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 { createTinybirdApi } from "@tinybirdco/sdk";
type ProfileName = "dev" | "prod" | "prod-legacy";
type TinybirdTarget = "new" | "legacy";
@@ -35,6 +36,7 @@ const usage = `Usage:
bun tb info
bun tb deploy:check
bun tb deploy
bun tb token:read <token_name>
bun tb:prod <tinybird command...>
bun tb:prod-legacy <tinybird command...>
@@ -70,15 +72,58 @@ const requireEnv = (name: string) => {
return value;
};
const requireEnvValue = (env: NodeJS.ProcessEnv, name: string) => {
const value = env[name];
if (!value) {
console.error(`${name} is not set`);
process.exit(1);
}
return value;
};
const resolveTinybirdArgs = (args: string[]) => {
if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
console.log(usage);
process.exit(args.length === 0 ? 1 : 0);
}
if (args[0] === "token:read") {
const tokenName = args[1];
if (!tokenName || args.length > 2) {
console.error("Usage: bun tb token:read <token_name>");
process.exit(1);
}
return args;
}
return commandAliases[args[0]] ?? args;
};
const createReadToken = async (tokenName: string, env: NodeJS.ProcessEnv) => {
const baseUrl = requireEnvValue(env, "TINYBIRD_API_URL");
const api = createTinybirdApi({
baseUrl,
token: requireEnvValue(env, "TINYBIRD_TOKEN"),
});
const url = new URL("/v0/tokens/", `${baseUrl}/`);
url.searchParams.set("name", tokenName);
url.searchParams.set("scope", "WORKSPACE:READ_ALL");
const response = await api.request(url.toString(), {
method: "POST",
});
if (!response.ok) {
const body = await response.text();
throw new Error(`Failed to create Tinybird read token: ${body}`);
}
const result = (await response.json()) as { token?: string };
console.log(result.token ?? JSON.stringify(result));
};
const executeTinybird = async () => {
const target = requireEnv("AUTUMN_TINYBIRD_TARGET") as TinybirdTarget;
const env = { ...process.env };
@@ -92,6 +137,11 @@ const executeTinybird = async () => {
}
const args = resolveTinybirdArgs(Bun.argv.slice(2));
if (args[0] === "token:read") {
await createReadToken(args[1], env);
return;
}
const exitCode = await run(["bunx", "tinybird", ...args], {
cwd: serverDir,
env,

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

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

View File

@@ -81,7 +81,8 @@ export const computeCustomPlanNewCustomerProduct = ({
initOptions: {
isCustom: updateSubscriptionContext.isCustom,
subscriptionId: stripeSubscription?.id, // don't populate if it's starting in the future.
subscriptionScheduleId: stripeSubscriptionSchedule?.id,
subscriptionScheduleId:
stripeSubscriptionSchedule?.id ?? currentCustomerProduct.scheduled_ids?.[0],
externalId: currentCustomerProduct.external_id ?? undefined,
startsAt: currentCustomerProduct.starts_at ?? undefined,
...cancelFields,

View File

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

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

View File

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

View File

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

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 = {
migrationInternalId: string;
executionFilter?: CustomerExecutionStatusFilter;
};
export type CustomerExecutionStatus = MigrationItemRunStatus | "not_run";
export type CustomerExecutionStatusFilter = {
statuses: CustomerExecutionStatus[];
migrationRunId?: string;
dryRun?: boolean;
};
export type CustomerQueryArgs = {
@@ -75,21 +84,103 @@ const buildProcessedIn = (includeProcessed: IncludeProcessed): SQL => sql`
AND mir.dry_run = false
)`;
const buildExecutionScope = (
migrationInternalId: string,
filter: CustomerExecutionStatusFilter | undefined,
): SQL => {
const dryRunScope =
filter?.dryRun !== undefined
? sql`AND mir.dry_run = ${filter.dryRun}`
: sql`AND mir.dry_run = false`;
const runScope = filter?.migrationRunId
? sql`AND mir.migration_run_id = ${filter.migrationRunId}`
: sql``;
return sql`
mir.migration_internal_id = ${migrationInternalId}
AND mir.item_kind = 'customer'
${dryRunScope}
${runScope}
`;
};
const buildExecutionStatusWhere = (
includeProcessed: IncludeProcessed | undefined,
{ includeNotRun = true }: { includeNotRun?: boolean } = {},
): SQL => {
const filter = includeProcessed?.executionFilter;
if (!includeProcessed || !filter || filter.statuses.length === 0) return sql``;
const explicitStatuses = filter.statuses.filter(
(status): status is MigrationItemRunStatus => status !== "not_run",
);
const clauses: SQL[] = [];
if (explicitStatuses.length > 0) {
const statuses = sql.join(
explicitStatuses.map((status) => sql`${status}`),
sql`, `,
);
clauses.push(sql`
EXISTS (
SELECT 1
FROM migration_item_runs mir
WHERE ${buildExecutionScope(includeProcessed.migrationInternalId, filter)}
AND mir.item_id = c.internal_id
AND mir.status IN (${statuses})
)
`);
}
if (includeNotRun && filter.statuses.includes("not_run")) {
clauses.push(sql`
NOT EXISTS (
SELECT 1
FROM migration_item_runs mir
WHERE ${buildExecutionScope(includeProcessed.migrationInternalId, filter)}
AND mir.item_id = c.internal_id
)
`);
}
if (clauses.length === 0) return sql`AND false`;
return clauses.length === 1
? sql`AND ${clauses[0]}`
: sql`AND (${sql.join(clauses, sql` OR `)})`;
};
const getExecutionFilterMode = (
includeProcessed: IncludeProcessed,
): "all" | "explicit_only" | "not_run_only" | "mixed" => {
const statuses = includeProcessed.executionFilter?.statuses;
if (!statuses || statuses.length === 0) return "all";
const hasNotRun = statuses.includes("not_run");
const hasExplicit = statuses.some((status) => status !== "not_run");
if (hasExplicit && hasNotRun) return "mixed";
if (hasExplicit) return "explicit_only";
return "not_run_only";
};
// Predicates shared by both UNION branches (and the single-branch query).
// Rebuilt per call so a branch never reuses another's SQL chunk instance.
const buildCommonWhere = ({
checkpoint,
search,
afterInternalId,
includeProcessed,
includeNotRun,
}: {
checkpoint?: CustomerCheckpointExclusion;
search?: string;
afterInternalId?: string;
includeProcessed?: IncludeProcessed;
includeNotRun?: boolean;
}): SQL => {
const cursor = afterInternalId
? sql`AND c.internal_id < ${afterInternalId}`
: sql``;
return sql`${buildCheckpointWhere(checkpoint)} ${buildSearchWhere(search)} ${cursor}`;
return sql`${buildCheckpointWhere(checkpoint)} ${buildSearchWhere(search)} ${buildExecutionStatusWhere(includeProcessed, { includeNotRun })} ${cursor}`;
};
/**
@@ -170,16 +261,38 @@ export const buildProcessedPreviewSelect = ({
const where = compileWhere({ orgId, env, filter, ctx });
const processed = buildProcessedIn(includeProcessed);
const limitClause = limit !== undefined ? sql`LIMIT ${limit}` : sql``;
const mode = getExecutionFilterMode(includeProcessed);
if (mode === "explicit_only") {
return sql`
SELECT c.internal_id, c.id, c.name, c.email
FROM customers c
WHERE (${processed}) ${buildCommonWhere({ checkpoint, search, afterInternalId, includeProcessed, includeNotRun: false })}
ORDER BY c.internal_id DESC
${limitClause}
`;
}
if (mode === "not_run_only") {
return sql`
SELECT c.internal_id, c.id, c.name, c.email
FROM customers c
WHERE (${where}) ${buildCommonWhere({ checkpoint, search, afterInternalId, includeProcessed })}
ORDER BY c.internal_id DESC
${limitClause}
`;
}
return sql`
SELECT u.internal_id, u.id, u.name, u.email
FROM (
SELECT c.internal_id, c.id, c.name, c.email
FROM customers c
WHERE (${where}) ${buildCommonWhere({ checkpoint, search, afterInternalId })}
WHERE (${where}) ${buildCommonWhere({ checkpoint, search, afterInternalId, includeProcessed })}
UNION
SELECT c.internal_id, c.id, c.name, c.email
FROM customers c
WHERE (${processed}) ${buildCommonWhere({ checkpoint, search, afterInternalId })}
WHERE (${processed}) ${buildCommonWhere({ checkpoint, search, afterInternalId, includeProcessed, includeNotRun: false })}
) u
ORDER BY u.internal_id DESC
${limitClause}
@@ -197,16 +310,34 @@ export const buildProcessedPreviewCount = ({
}: ProcessedPreviewArgs): SQL => {
const where = compileWhere({ orgId, env, filter, ctx });
const processed = buildProcessedIn(includeProcessed);
const mode = getExecutionFilterMode(includeProcessed);
if (mode === "explicit_only") {
return sql`
SELECT COUNT(*)::bigint AS count
FROM customers c
WHERE (${processed}) ${buildCommonWhere({ checkpoint, search, includeProcessed, includeNotRun: false })}
`;
}
if (mode === "not_run_only") {
return sql`
SELECT COUNT(*)::bigint AS count
FROM customers c
WHERE (${where}) ${buildCommonWhere({ checkpoint, search, includeProcessed })}
`;
}
return sql`
SELECT COUNT(*)::bigint AS count
FROM (
SELECT c.internal_id
FROM customers c
WHERE (${where}) ${buildCommonWhere({ checkpoint, search })}
WHERE (${where}) ${buildCommonWhere({ checkpoint, search, includeProcessed })}
UNION
SELECT c.internal_id
FROM customers c
WHERE (${processed}) ${buildCommonWhere({ checkpoint, search })}
WHERE (${processed}) ${buildCommonWhere({ checkpoint, search, includeProcessed, includeNotRun: false })}
) u
`;
};

View File

@@ -11,6 +11,7 @@ import {
migrationRepo,
migrationRunRepo,
} from "@/internal/migrations/v2/repos/index.js";
import { clearOrgCache } from "@/internal/orgs/orgUtils/clearOrgCache.js";
const CancelMigrationRunBody = z.object({
id: z.string(),
@@ -70,6 +71,15 @@ export const handleCancelMigrationRun = createRoute({
},
});
if (activeRun.lazy_run) {
await clearOrgCache({
db: ctx.db,
orgId: ctx.org.id,
env: ctx.env,
logger: ctx.logger,
});
}
return c.json({
migration_id: id,
run_id: activeRun.internal_id,

View File

@@ -24,6 +24,12 @@ const PreviewFilterBody = z.object({
page: z.number().int().min(0).optional().default(0),
pageSize: z.number().int().min(1).max(500).optional().default(DEFAULT_PAGE_SIZE),
migrationId: z.string().optional(),
executionStatuses: z
.array(z.enum(["succeeded", "skipped", "failed", "not_run"]))
.optional()
.default([]),
migrationRunId: z.string().optional(),
migrationRunDryRun: z.boolean().optional(),
});
/** POST /migrations.filter.preview — count + enriched paginated customers. */
@@ -32,14 +38,33 @@ export const handlePreviewMigrationFilter = createRoute({
body: PreviewFilterBody,
handler: async (c) => {
const ctx = c.get("ctx");
const { filter, search, page, pageSize, migrationId } = c.req.valid("json");
const {
filter,
search,
page,
pageSize,
migrationId,
executionStatuses,
migrationRunId,
migrationRunDryRun,
} = c.req.valid("json");
const searchTerm = search || undefined;
let includeProcessed: IncludeProcessed | undefined;
if (migrationId) {
const migration = await migrationRepo.find({ ctx, id: migrationId });
includeProcessed = { migrationInternalId: migration.internal_id };
includeProcessed = {
migrationInternalId: migration.internal_id,
executionFilter:
executionStatuses.length > 0
? {
statuses: executionStatuses,
migrationRunId,
dryRun: migrationRunDryRun,
}
: undefined,
};
}
const [count, pageRows] = await Promise.all([

View File

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

View File

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

View File

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

View File

@@ -83,6 +83,7 @@ export const migrateCustomer = async ({
ctx: migrationCtx,
context,
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 { migrationRepo } from "@/internal/migrations/v2/repos/index.js";
import { runMigration } from "@/internal/migrations/v2/run/runMigration.js";
import { clearOrgCache } from "@/internal/orgs/orgUtils/clearOrgCache.js";
import { createTriggerContext } from "@/trigger/utils/createTriggerContext.js";
const ControlsSchema = z.object({
@@ -20,6 +21,7 @@ const PayloadSchema = z.object({
migrationId: z.string(),
migrationRunId: z.string(),
dryRun: z.boolean().default(false),
lazyRun: z.boolean().default(false),
controls: ControlsSchema,
});
@@ -35,7 +37,7 @@ export const runMigrationTask = task({
machine: "medium-1x",
maxDuration: 3600,
run: async (rawPayload: unknown, { ctx: triggerCtx }) => {
const { orgId, env, migrationId, migrationRunId, dryRun, controls } =
const { orgId, env, migrationId, migrationRunId, dryRun, lazyRun, controls } =
PayloadSchema.parse(rawPayload);
const { ctx, logger } = await createTriggerContext({
@@ -69,40 +71,51 @@ export const runMigrationTask = task({
},
});
await withMigrationRunTracking({
ctx,
migrationRunId,
run: async () => {
const migration = await migrationRepo.find({ ctx, id: migrationId });
try {
await withMigrationRunTracking({
ctx,
migrationRunId,
run: async () => {
const migration = await migrationRepo.find({ ctx, id: migrationId });
// Default concurrency: 10 normally, 25 when no_billing_changes
// because we're not hitting Stripe per customer. Caller can still
// override via controls.concurrency.
const defaultConcurrency =
migration.no_billing_changes === true ? 25 : 10;
const effectiveControls = {
...(controls ?? {}),
concurrency: controls?.concurrency ?? defaultConcurrency,
};
// Default concurrency: 10 normally, 25 when no_billing_changes
// because we're not hitting Stripe per customer. Caller can still
// override via controls.concurrency.
const defaultConcurrency =
migration.no_billing_changes === true ? 25 : 10;
const effectiveControls = {
...(controls ?? {}),
concurrency: controls?.concurrency ?? defaultConcurrency,
};
logger.info("run-migration: resolved controls", {
data: {
logger.info("run-migration: resolved controls", {
data: {
migrationRunId,
noBillingChanges: migration.no_billing_changes === true,
concurrency: effectiveControls.concurrency,
concurrencyExplicit: controls?.concurrency !== undefined,
},
});
await runMigration({
ctx,
migration,
dryRun,
migrationRunId,
noBillingChanges: migration.no_billing_changes === true,
concurrency: effectiveControls.concurrency,
concurrencyExplicit: controls?.concurrency !== undefined,
},
controls: effectiveControls,
});
},
});
} finally {
if (lazyRun && !dryRun) {
await clearOrgCache({
db: ctx.db,
orgId,
env,
logger,
});
await runMigration({
ctx,
migration,
dryRun,
migrationRunId,
controls: effectiveControls,
});
},
});
}
}
logger.info("run-migration: done", {
data: {

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,
BillWhen,
Infinite,
PriceSchema,
} from "@autumn/shared";
import { pricesAreSame } from "@shared/utils/productUtils/priceUtils/comparePrice/pricesAreSame";
@@ -17,15 +18,15 @@ const fixedPrice = {
is_custom: false,
entitlement_id: null,
proration_config: null,
config: {
type: PriceType.Fixed,
amount: 10,
interval: BillingInterval.Month,
stripe_product_id: null,
feature_id: null,
internal_feature_id: null,
},
} satisfies Price;
config: {
type: PriceType.Fixed,
amount: 10,
interval: BillingInterval.Month,
stripe_product_id: null,
feature_id: null,
internal_feature_id: null,
},
} satisfies Price;
const usagePrice = {
id: "price_usage",
@@ -48,6 +49,22 @@ const usagePrice = {
} satisfies Price;
describe("pricesAreSame", () => {
test("normalizes ignored fixed price metadata", () => {
const parsed = PriceSchema.parse({
...fixedPrice,
config: {
...fixedPrice.config,
stripe_product_id: "prod_fixed",
feature_id: "base",
internal_feature_id: "internal_base",
},
});
expect(parsed.config.stripe_product_id).toBeNull();
expect(parsed.config.feature_id).toBeNull();
expect(parsed.config.internal_feature_id).toBeNull();
});
test("returns false instead of throwing for fixed vs usage prices", () => {
expect(pricesAreSame(fixedPrice, usagePrice)).toBe(false);
});

View File

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

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

View File

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

View File

@@ -83,6 +83,17 @@ export const AutumnBillingPlanSchema = z.object({
})
.optional(),
schedulePhaseCustomerProductReplacements: z
.array(
z.object({
oldCustomerProductId: z.string(),
newCustomerProductId: z.string(),
internalCustomerId: z.string(),
internalEntityId: z.string().nullish(),
}),
)
.optional(),
deleteCustomerProduct: FullCusProductSchema.optional(), // Scheduled product to delete (e.g., when updating while canceling)
deleteCustomerProducts: z.array(FullCusProductSchema).optional(),

View File

@@ -2,6 +2,12 @@ import { z } from "zod/v4";
import { BillingInterval } from "../../intervals/billingInterval";
import { UsageTierSchema } from "./usagePriceConfig";
/** Imported fixed prices may carry usage metadata; fixed configs ignore it. */
const IgnoredFixedPriceMetadataSchema = z.preprocess(
(value) => (typeof value === "string" ? null : value),
z.null().or(z.undefined()),
);
export const FixedPriceConfigSchema = z.object({
type: z.string(),
amount: z.number().min(0),
@@ -13,9 +19,9 @@ export const FixedPriceConfigSchema = z.object({
usage_tiers: z.array(UsageTierSchema).nullish(),
stripe_price_id: z.string().nullish(),
stripe_empty_price_id: z.string().nullish(),
stripe_product_id: z.null().or(z.undefined()),
feature_id: z.null().or(z.undefined()),
internal_feature_id: z.null().or(z.undefined()),
stripe_product_id: IgnoredFixedPriceMetadataSchema,
feature_id: IgnoredFixedPriceMetadataSchema,
internal_feature_id: IgnoredFixedPriceMetadataSchema,
});
export type FixedPriceConfig = z.infer<typeof FixedPriceConfigSchema>;

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import { type FullCusProduct, isCustomerProductTrialing } from "@autumn/shared";
import { FlaskIcon, PencilIcon } from "@phosphor-icons/react";
import type { Row, Table } from "@tanstack/react-table";
import { ArrowRightLeft, Delete, RotateCcw } from "lucide-react";
import { ArrowRightLeft, Delete, RotateCcw, Send } from "lucide-react";
import { TableDropdownMenuCell } from "@/components/general/table/table-dropdown-menu-cell";
import {
dateSkeleton,
@@ -9,12 +9,110 @@ import {
statusSkeleton,
} from "@/components/general/table/table-skeleton-presets";
import { DropdownMenuItem } from "@/components/v2/dropdowns/DropdownMenu";
import { useAdmin } from "@/views/admin/hooks/useAdmin";
import { createDateTimeColumn } from "@/views/customers2/utils/ColumnHelpers";
import { AdminHover } from "../../../../../components/general/AdminHover";
import { getCusProductHoverTexts } from "../../../../admin/adminUtils";
import { CustomerProductPrice } from "./CustomerProductPrice";
import { CustomerProductsStatus } from "./CustomerProductsStatus";
function CustomerProductActionsCell({
row,
table,
}: {
row: Row<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 = [
{
header: "Name",
@@ -82,86 +180,6 @@ export const CustomerProductsColumns = [
header: "",
size: 40,
meta: { skeleton: hiddenSkeleton },
cell: ({
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>
);
},
cell: CustomerProductActionsCell,
},
];

View File

@@ -2,6 +2,7 @@ import { AppEnv, type Entity, type FullCusProduct } from "@autumn/shared";
import { ArrowSquareOutIcon, PackageIcon } from "@phosphor-icons/react";
import type { Row } from "@tanstack/react-table";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { Table } from "@/components/general/table";
import { SectionTag } from "@/components/v2/badges/SectionTag";
@@ -9,7 +10,10 @@ import { Button } from "@/components/v2/buttons/Button";
import { IconButton } from "@/components/v2/buttons/IconButton";
import { useSheetStore } from "@/hooks/stores/useSheetStore";
import { useEntity } from "@/hooks/stores/useSubscriptionStore";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { useEnv } from "@/utils/envUtils";
import { getBackendErr } from "@/utils/genUtils";
import { useAdmin } from "@/views/admin/hooks/useAdmin";
import { useFullCusSearchQuery } from "@/views/customers/hooks/useFullCusSearchQuery";
import { useSavedViewsQuery } from "@/views/customers/hooks/useSavedViewsQuery";
import { useCustomerProductsData } from "@/views/customers2/hooks/useCustomerProductsData";
@@ -81,6 +85,11 @@ export function CustomerProductsTable() {
);
const selectedItemId = useSheetStore((s) => s.itemId);
const setSheet = useSheetStore((s) => s.setSheet);
const axiosInstance = useAxiosInstance();
const { isAdmin } = useAdmin();
const [sendingWebhookProductId, setSendingWebhookProductId] = useState<
string | null
>(null);
useSavedViewsQuery();
useFullCusSearchQuery();
@@ -124,6 +133,21 @@ export function CustomerProductsTable() {
setSheet({ type: "subscription-update", itemId: product.id });
};
const handleSendWebhookClick = async (product: FullCusProduct) => {
if (!isAdmin) return;
setSendingWebhookProductId(product.id);
try {
await axiosInstance.post(
`/admin/customer-products/${product.id}/send-updated-webhook`,
);
toast.success("Customer product webhook sent");
} catch (error) {
toast.error(getBackendErr(error, "Failed to send webhook"));
} finally {
setSendingWebhookProductId(null);
}
};
const handleRowClick = (cusProduct: FullCusProduct) => {
setSheet({
type: "subscription-detail",
@@ -136,7 +160,9 @@ export function CustomerProductsTable() {
onUncancelClick: handleUncancelClick,
onTransferClick: handleTransferClick,
onUpdateClick: handleUpdateClick,
onSendWebhookClick: handleSendWebhookClick,
hasEntities,
sendingWebhookProductId,
nowMs: testClockFrozenTimeMs,
};

View File

@@ -35,6 +35,12 @@ type MigrationPreview = {
balance_changes?: (string | BalanceChange)[];
flag_changes?: (string | FlagChange)[];
};
type ErrorPayload = {
message?: unknown;
error?: unknown;
code?: unknown;
path?: unknown;
};
function parseJson<T>(raw: string | T): T | null {
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);
}
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> = {
activated: "bg-green-500",
scheduled: "bg-blue-500",
@@ -308,13 +338,16 @@ export function EventResultDetail({ event }: { event: MigrationItemEvent }) {
if (!response) return null;
if (event.status === "failed") {
const error = response.error as { message?: string } | undefined;
if (!error?.message) return null;
const error = response.error as ErrorPayload | undefined;
const message = formatUnknownError(error?.message ?? error);
if (!message) return null;
return (
<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="break-words min-w-0">{error.message}</span>
</div>
<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="break-words min-w-0 whitespace-pre-wrap">
{message}
</span>
</div>
);
}
@@ -322,9 +355,9 @@ export function EventResultDetail({ event }: { event: MigrationItemEvent }) {
if (preview) return <PreviewSummary preview={preview} />;
if (event.status === "skipped") {
const skipped = response.skipped as { reason?: string } | undefined;
const guard = response.guard as { reason?: string } | undefined;
const reason = skipped?.reason ?? guard?.reason;
const skipped = response.skipped as { reason?: unknown } | undefined;
const guard = response.guard as { reason?: unknown } | undefined;
const reason = formatUnknownError(skipped?.reason ?? guard?.reason);
if (reason) return <span className="text-sm text-tertiary-foreground">{reason}</span>;
}

View File

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

View File

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