Merge branch 'main' into fix/tax-rate-id-preview

This commit is contained in:
John Yeo
2026-06-09 16:44:22 +01:00
committed by GitHub
320 changed files with 33087 additions and 4853 deletions

2
ai

Submodule ai updated: 0e52f71fbd...db7737aca7

View File

@@ -20,6 +20,15 @@ const nextConfig = {
// falling back to WebP. Next.js negotiates via Accept header automatically.
formats: ["image/avif", "image/webp"],
},
async redirects() {
return [
{
source: "/docs",
destination: "https://docs.useautumn.com",
permanent: false,
},
];
},
async headers() {
if (!isProd) return [];

1210
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -50,9 +50,7 @@
}
},
"overrides": {
"@better-auth/core": "1.6.5",
"@better-auth/passkey": "1.6.5",
"better-auth": "1.6.5",
"@modelcontextprotocol/sdk": "1.29.0",
"@isaacs/brace-expansion": "5.0.1",
"fast-xml-parser": "5.3.4",

View File

@@ -13,11 +13,13 @@ import { resolveIdentity } from "./resolveIdentity";
const buildSdkArgs = ({
body,
identity,
route,
}: {
body: unknown;
identity: ResolvedIdentity;
route: RouteDefinition;
}): Record<string, unknown> => {
const args = sanitizeBody(body);
const args = sanitizeBody(body, route.protectedBodyFields);
if (identity.customerId) {
args.customerId = identity.customerId;
@@ -71,7 +73,7 @@ export const executeRoute = async ({
}
// 3. Build args and call SDK
const sdkArgs = buildSdkArgs({ body, identity });
const sdkArgs = buildSdkArgs({ body, identity, route });
try {
const result = await route.sdkMethod(autumn, sdkArgs);

View File

@@ -16,7 +16,12 @@ import {
updateSubscriptionParamsSchema,
} from "../../../generated";
import type { RouteDefinition, RouteName } from "../types";
import { backendError, backendSuccess, sanitizeBody } from "../utils";
import {
backendError,
backendSuccess,
CUSTOMER_PROTECTED_BODY_FIELDS,
sanitizeBody,
} from "../utils";
const getEntityBodySchema = z.object({
entityId: z.string(),
@@ -33,8 +38,9 @@ export const routeConfigs: RouteDefinition<RouteName>[] = [
// expand: z.array(z.enum(CustomerExpand)).optional(),
expand: z.array(z.string()).optional(),
}),
protectedBodyFields: CUSTOMER_PROTECTED_BODY_FIELDS,
customHandler: async ({ autumn, identity, body }) => {
const sanitizedBody = sanitizeBody(body);
const sanitizedBody = sanitizeBody(body, CUSTOMER_PROTECTED_BODY_FIELDS);
// Special case: if no customer and errorOnNotFound is false, return 204
if (!identity?.customerId && sanitizedBody.errorOnNotFound === false) {

View File

@@ -1,5 +1,6 @@
import type { Autumn } from "@useautumn/sdk";
import type { z } from "zod/v4";
import type { ProtectedBodyField } from "../utils/sanitizeBody";
import type { ResolvedIdentity } from "./authTypes";
import type { BackendResult } from "./responseTypes";
@@ -48,6 +49,8 @@ export type RouteDefinition<T extends RouteName = RouteName> = {
customHandler?: CustomHandlerFn;
/** Whether customer ID is required (default: true) */
requireCustomer?: boolean;
/** Body fields that must come from identity, not frontend */
protectedBodyFields?: readonly ProtectedBodyField[];
/** Zod schema for request body validation (used by better-auth plugin) */
bodySchema?: z.ZodTypeAny;
};

View File

@@ -1,3 +1,8 @@
export { secretKeyCheck } from "./secretKeyCheck";
export { backendSuccess, backendError, isBackendResult } from "./backendRes";
export { sanitizeBody } from "./sanitizeBody";
export {
CUSTOMER_PROTECTED_BODY_FIELDS,
DEFAULT_PROTECTED_BODY_FIELDS,
sanitizeBody,
} from "./sanitizeBody";
export type { ProtectedBodyField } from "./sanitizeBody";

View File

@@ -1,19 +1,31 @@
/** Fields that must come from identity, not frontend */
const PROTECTED_FIELDS = [
export const DEFAULT_PROTECTED_BODY_FIELDS = [
"customerId",
"customerData",
"name",
"email",
"metadata",
"stripeId",
];
] as const;
export const CUSTOMER_PROTECTED_BODY_FIELDS = [
...DEFAULT_PROTECTED_BODY_FIELDS,
"metadata",
] as const;
export type ProtectedBodyField =
| (typeof DEFAULT_PROTECTED_BODY_FIELDS)[number]
| (typeof CUSTOMER_PROTECTED_BODY_FIELDS)[number];
/** Strip protected fields from body to prevent spoofing */
export const sanitizeBody = (body: unknown): Record<string, unknown> => {
export const sanitizeBody = (
body: unknown,
protectedFields: readonly ProtectedBodyField[] = DEFAULT_PROTECTED_BODY_FIELDS,
): Record<string, unknown> => {
const rawBody = (body as Record<string, unknown>) || {};
const sanitized: Record<string, unknown> = {};
for (const [key, value] of Object.entries(rawBody)) {
if (!PROTECTED_FIELDS.includes(key)) {
if (!protectedFields.includes(key as ProtectedBodyField)) {
sanitized[key] = value;
}
}

View File

@@ -67,30 +67,6 @@ function getEnvVariable(filePath: string, key: string): string | null {
return null;
}
function getPackageDependencyVersion({
projectRoot,
packageName,
}: {
projectRoot: string;
packageName: string;
}): string {
const packageJson = JSON.parse(
readFileSync(join(projectRoot, "package.json"), "utf-8"),
) as {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
};
const version =
packageJson.dependencies?.[packageName] ??
packageJson.devDependencies?.[packageName];
if (!version) {
throw new Error(`Missing ${packageName} in package.json`);
}
return version;
}
function killPorts({ ports }: { ports: number[] }) {
if (process.platform === "win32") {
return;
@@ -180,10 +156,6 @@ async function startDev() {
// Use cmd on Windows, sh on Unix
const isWindows = process.platform === "win32";
const triggerDevVersion = getPackageDependencyVersion({
projectRoot,
packageName: "trigger.dev",
});
let shellArgs: string[];
if (serverOnly) {
@@ -227,11 +199,10 @@ async function startDev() {
if (worktreeNum === 1) {
names.push("trigger");
colors.push("cyan");
cmds.push(
isWindows
? `"bunx trigger.dev@${triggerDevVersion} dev"`
: `"bunx trigger.dev@${triggerDevVersion} dev"`,
);
// Use the locally-installed (pinned) trigger.dev CLI. Passing
// `@<version>` makes bunx fetch a fresh copy into a temp dir,
// which can be broken/incomplete (ERR_MODULE_NOT_FOUND).
cmds.push(isWindows ? `"bunx trigger.dev dev"` : `"bunx trigger.dev dev"`);
}
names.push("vite", "checkout");

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,349 @@
import { AppEnv } from "@autumn/shared";
import { compileFilter } from "@autumn/shared/api/migrations/compiler/compileFilter.js";
import type { CustomerFilter } from "@autumn/shared/api/migrations/filters/customerFilter.js";
import { type SQL, sql } from "drizzle-orm";
import { PgDialect } from "drizzle-orm/pg-core";
import {
buildCustomerCount,
buildCustomerSelect,
buildProcessedPreviewCount,
buildProcessedPreviewSelect,
} from "@/internal/migrations/v2/filters/customers/buildCustomerSelect.js";
import { rawWithParamsToDrizzle } from "@/internal/migrations/v2/filters/rawWithParamsToDrizzle.js";
// Import initDrizzle directly — avoid `experimentEnv` because its
// `loadLocalEnv()` reads `server/.env` and clobbers env vars injected by
// `infisical run --env=staging` (e.g. DATABASE_URL).
import { initDrizzle } from "../src/db/initDrizzle";
import { FeatureService } from "../src/internal/features/FeatureService.js";
// Why this experiment exists: the "include processed customers" preview
// (handlePreviewMigrationFilter + buildCustomerSelect) ORs the org/env-scoped
// compiled filter with `c.internal_id IN (<processed subquery>)`. The OR strips
// org/env scoping from the second branch, so the planner can't use
// idx_customers_org_env_internal_id and may seq-scan ALL customers. This script
// EXPLAINs the current OR query against an equivalent UNION rewrite to confirm
// the bottleneck and decide whether a new index is needed.
//
// Run against a remote env (e.g. staging) via infisical:
// infisical run --env=staging --recursive -- \
// bun run server/experiments/explainIncludeProcessedFilter.ts
const prodTestOrgId = (() => {
const v = process.env.PROD_TEST_ORG_ID;
if (!v) throw new Error("PROD_TEST_ORG_ID env var is required");
return v;
})();
const dbUrl = process.env.DATABASE_URL ?? "";
console.log(
"DATABASE URL host:",
dbUrl.replace(/:\/\/[^@]+@/, "://***:***@") || "(empty)",
);
// ─── Configuration ──────────────────────────────────────────────────
const ORG_ID = prodTestOrgId;
const ENV = AppEnv.Live;
const SAMPLE_LIMIT = 10; // matches the default preview page size
const TRUNCATE_EXPLAIN = true;
const EXPLAIN_MAX_LINES = 40;
// Optional override. When unset, the script auto-discovers the migration in
// this org/env with the most live (dry_run = false) customer item runs.
const MIGRATION_INTERNAL_ID = process.env.MIGRATION_INTERNAL_ID || undefined;
// User-facing migration id (the `id` column, resolved to internal_id like the
// production handler does). Takes precedence over auto-discovery.
const MIGRATION_ID = process.env.MIGRATION_ID || "plan_pro-update";
// Filter the live preview applies. Keep it representative of a real migration
// selection. An empty `{}` matches all customers in the org/env.
const FILTER: CustomerFilter = {
plan: { plan_id: "free" },
};
// ═════════════════════════════════════════════════════════════════════
const truncateExplainText = (text: string, maxLines: number): string => {
const lines = text.split("\n");
if (lines.length <= maxLines) return text;
const omitted = lines.length - maxLines;
return [...lines.slice(0, maxLines), `... (${omitted} more lines truncated)`].join(
"\n",
);
};
const printExplainPlan = async ({
db,
query,
label,
}: {
db: ReturnType<typeof initDrizzle>["db"];
query: SQL;
label: string;
}) => {
console.log(`\n--- EXPLAIN ANALYZE: ${label} ---`);
const explainResult = await db.execute(
sql`EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) ${query}`,
);
const lines: string[] = [];
for (const row of explainResult)
lines.push(String((row as Record<string, unknown>)["QUERY PLAN"]));
const joined = lines.join("\n");
console.log(
TRUNCATE_EXPLAIN ? truncateExplainText(joined, EXPLAIN_MAX_LINES) : joined,
);
};
const dialect = new PgDialect();
const inlineParams = (text: string, params: readonly unknown[]): string =>
text.replace(/\$(\d+)/g, (_, n) => {
const v = params[Number(n) - 1];
if (v === null || v === undefined) return "NULL";
if (typeof v === "number" || typeof v === "boolean") return String(v);
return `'${String(v).replace(/'/g, "''")}'`;
});
const printSqlQuery = ({ query, label }: { query: SQL; label: string }) => {
const { sql: text, params } = dialect.sqlToQuery(query);
console.log(`\n--- SQL: ${label} ---`);
console.log(inlineParams(text, params));
};
const runMeasured = async ({
db,
query,
label,
}: {
db: ReturnType<typeof initDrizzle>["db"];
query: SQL;
label: string;
}) => {
console.log(`\n=== ${label} ===`);
printSqlQuery({ query, label });
const startedAt = performance.now();
const result = await db.execute(query);
const elapsedMs = performance.now() - startedAt;
console.log(`Rows returned: ${result.length}`);
console.log(`Wall-clock: ${elapsedMs.toFixed(2)}ms`);
if (label.startsWith("COUNT") && result.length > 0)
console.log(`Count: ${(result[0] as Record<string, unknown>).count}`);
await printExplainPlan({ db, query, label });
};
const compiledWhere = ({
filter,
features,
}: {
filter: CustomerFilter;
features: Awaited<ReturnType<typeof FeatureService.list>>;
}): SQL =>
rawWithParamsToDrizzle(
compileFilter({
filter,
ctx: { features },
ambient: { orgId: ORG_ID, env: ENV },
}),
);
// The processed-customers subquery, identical to buildIncludeProcessedOr.
const processedSubquery = (migrationInternalId: string): SQL => sql`
SELECT mir.item_id FROM migration_item_runs mir
WHERE mir.migration_internal_id = ${migrationInternalId}
AND mir.item_kind = 'customer'
AND mir.dry_run = false
`;
// Proposed UNION rewrite: each branch keeps its own scoping so the planner can
// use an index per branch instead of seq-scanning all customers.
const buildUnionSelect = ({
where,
migrationInternalId,
limit,
}: {
where: SQL;
migrationInternalId: string;
limit: number;
}): SQL => 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})
UNION
SELECT c.internal_id, c.id, c.name, c.email
FROM customers c
WHERE c.internal_id IN (${processedSubquery(migrationInternalId)})
) u
ORDER BY u.internal_id DESC
LIMIT ${limit}
`;
const buildUnionCount = ({
where,
migrationInternalId,
}: {
where: SQL;
migrationInternalId: string;
}): SQL => sql`
SELECT COUNT(*)::bigint AS count
FROM (
SELECT c.internal_id
FROM customers c
WHERE (${where})
UNION
SELECT c.internal_id
FROM customers c
WHERE c.internal_id IN (${processedSubquery(migrationInternalId)})
) u
`;
// Resolve a user-facing migration `id` to its `internal_id`, scoped to org/env
// — mirrors migrationRepo.find used by handlePreviewMigrationFilter.
const resolveMigrationInternalId = async (
db: ReturnType<typeof initDrizzle>["db"],
id: string,
): Promise<string | undefined> => {
const rows = (await db.execute(sql`
SELECT internal_id FROM migrations
WHERE org_id = ${ORG_ID} AND env = ${ENV} AND id = ${id}
LIMIT 1
`)) as Array<{ internal_id: string }>;
return rows[0]?.internal_id;
};
const discoverMigrationInternalId = async (
db: ReturnType<typeof initDrizzle>["db"],
): Promise<string | undefined> => {
const rows = (await db.execute(sql`
SELECT mir.migration_internal_id AS migration_internal_id, COUNT(*) AS n
FROM migration_item_runs mir
JOIN migration_runs mr ON mr.migration_internal_id = mir.migration_internal_id
WHERE mr.org_id = ${ORG_ID}
AND mr.env = ${ENV}
AND mir.item_kind = 'customer'
AND mir.dry_run = false
GROUP BY mir.migration_internal_id
ORDER BY n DESC
LIMIT 5
`)) as Array<{ migration_internal_id: string; n: bigint | number }>;
if (rows.length === 0) return undefined;
console.log("\nMigrations with live customer item runs (top 5):");
for (const r of rows)
console.log(` ${r.migration_internal_id}${Number(r.n)} processed`);
return rows[0].migration_internal_id;
};
const main = async () => {
const replicaUrl = process.env.DATABASE_REPLICA_URL;
const usingReplica = Boolean(replicaUrl);
if (!usingReplica)
console.warn(
"DATABASE_REPLICA_URL not set — falling back to DATABASE_URL (primary). Set the replica URL to test against the read replica.",
);
const { db } = initDrizzle({ replica: usingReplica });
console.log(
`=== INCLUDE-PROCESSED FILTER EXPERIMENT (${usingReplica ? "REPLICA" : "PRIMARY"}) ===`,
);
console.log(JSON.stringify({ ORG_ID, ENV, FILTER }, null, 2));
let migrationInternalId = MIGRATION_INTERNAL_ID;
if (!migrationInternalId && MIGRATION_ID) {
migrationInternalId = await resolveMigrationInternalId(db, MIGRATION_ID);
if (migrationInternalId)
console.log(`\nResolved MIGRATION_ID '${MIGRATION_ID}' → ${migrationInternalId}`);
else
console.warn(
`\nMIGRATION_ID '${MIGRATION_ID}' not found for this org/env — falling back to auto-discovery.`,
);
}
migrationInternalId ??= await discoverMigrationInternalId(db);
if (!migrationInternalId) {
console.error(
"\nNo migration with live customer item runs found for this org/env. " +
"Set MIGRATION_INTERNAL_ID or MIGRATION_ID explicitly to test a specific migration.",
);
process.exit(1);
}
console.log(`\nUsing migration_internal_id: ${migrationInternalId}`);
const orgFeatures = await FeatureService.list({ db, orgId: ORG_ID, env: ENV });
console.log(`\nLoaded ${orgFeatures.length} features for resolution context.`);
const ctx = { features: orgFeatures };
const where = compiledWhere({ filter: FILTER, features: orgFeatures });
const includeProcessed = { migrationInternalId };
// 1. Isolated processed subquery — confirms migration_item_runs index coverage.
await runMeasured({
db,
query: sql`SELECT mir.item_id FROM migration_item_runs mir
WHERE mir.migration_internal_id = ${migrationInternalId}
AND mir.item_kind = 'customer'
AND mir.dry_run = false`,
label: "SUBQUERY (processed item_ids only)",
});
// 2. Pure filter — exactly what the FILTER STEP (no migrationId) runs.
// Baseline to prove the customer filter alone is fast; only the live
// view's includeProcessed OR is slow.
await runMeasured({
db,
query: buildCustomerCount({ orgId: ORG_ID, env: ENV, filter: FILTER, ctx }),
label: "COUNT [filter only — filter step]",
});
await runMeasured({
db,
query: buildCustomerSelect({
orgId: ORG_ID,
env: ENV,
filter: FILTER,
ctx,
limit: SAMPLE_LIMIT,
}),
label: `SELECT [filter only — filter step] (limit ${SAMPLE_LIMIT})`,
});
// 3. Live-view path: the dedicated preview builders (filter processed).
await runMeasured({
db,
query: buildProcessedPreviewCount({
orgId: ORG_ID,
env: ENV,
filter: FILTER,
ctx,
includeProcessed,
}),
label: "COUNT [preview builder]",
});
await runMeasured({
db,
query: buildProcessedPreviewSelect({
orgId: ORG_ID,
env: ENV,
filter: FILTER,
ctx,
includeProcessed,
limit: SAMPLE_LIMIT,
}),
label: `SELECT [preview builder] (limit ${SAMPLE_LIMIT})`,
});
// 4. Hand-written UNION reference (sanity check the builder matches this).
await runMeasured({
db,
query: buildUnionCount({ where, migrationInternalId }),
label: "COUNT [UNION — proposed]",
});
await runMeasured({
db,
query: buildUnionSelect({ where, migrationInternalId, limit: SAMPLE_LIMIT }),
label: `SELECT [UNION — proposed] (limit ${SAMPLE_LIMIT})`,
});
process.exit(0);
};
await main();

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

@@ -988,6 +988,7 @@ export class AutumnInt {
id: string;
filter?: MigrationFilter | null;
operations?: Operations | null;
no_billing_changes?: boolean;
}): Promise<Migration> => {
const data = await this.post(`/migrations.create`, params);
return data as Migration;
@@ -1002,7 +1003,8 @@ export class AutumnInt {
id?: string;
filter?: MigrationFilter | null;
operations?: Operations | null;
retry_failed?: boolean;
no_billing_changes?: boolean;
archived?: boolean;
};
}): Promise<Migration> => {
const data = await this.post(`/migrations.update`, params);
@@ -1016,6 +1018,7 @@ export class AutumnInt {
id: string;
filter?: MigrationFilter | null;
operations?: Operations | null;
no_billing_changes?: boolean;
}): Promise<Migration> => {
try {
await this.post(`/migrations.delete`, { id: params.id });
@@ -1035,11 +1038,14 @@ export class AutumnInt {
dry_run?: boolean;
only?: string[];
limit?: number;
concurrency?: number;
lazy_run?: boolean;
retry_item_statuses?: ("failed" | "skipped")[];
}): Promise<{
migration_id: string;
dry_run: boolean;
lazy_run: boolean;
concurrency?: number;
run_id: string;
}> => {
const data = await this.post(`/migrations.run`, params);
@@ -1047,6 +1053,7 @@ export class AutumnInt {
migration_id: string;
dry_run: boolean;
lazy_run: boolean;
concurrency?: number;
run_id: string;
};
},
@@ -1059,6 +1066,20 @@ export class AutumnInt {
const data = await this.post(`/migrations.lazy_run`, params);
return data as { migration_id: string; run_id: string };
},
cancelRun: async (params: {
id: string;
}): Promise<{
migration_id: string;
run_id: string;
canceled: boolean;
}> => {
const data = await this.post(`/migrations.cancel_run`, params);
return data as {
migration_id: string;
run_id: string;
canceled: boolean;
};
},
listRuns: async (params: {
migrationId: string;
}): Promise<{ list: MigrationRun[] }> => {

View File

@@ -74,6 +74,7 @@ export const listMigrationItemEventsEndpoint = defineEndpoint(
env: p.string(),
migration_internal_id: p.string(),
migration_run_id: p.string().optional(""),
item_ids: p.array(p.string()).optional(),
limit: p.int32().optional(1000),
},
nodes: [
@@ -99,6 +100,9 @@ export const listMigrationItemEventsEndpoint = defineEndpoint(
{% if defined(migration_run_id) and String(migration_run_id, '') != '' %}
AND migration_run_id = {{String(migration_run_id)}}
{% end %}
{% if defined(item_ids) and length(item_ids) > 0 %}
AND item_id IN {{Array(item_ids, 'String')}}
{% end %}
ORDER BY timestamp DESC, item_kind ASC, item_id ASC
LIMIT {{Int32(limit, 1000)}}
`,

View File

@@ -68,8 +68,7 @@ const getRedirectUriFromFields = (fields: RequestFields) =>
getNestedOAuthField(fields.oauth_query, "redirect_uri");
const getScopesFromFields = (fields: RequestFields) => {
const rawScope =
getString(fields.scope) ?? getNestedOAuthField(fields.oauth_query, "scope");
const rawScope = getNestedOAuthField(fields.oauth_query, "scope");
return rawScope?.split(/\s+/).filter(Boolean) ?? null;
};
@@ -80,23 +79,7 @@ const getFieldsWithScope = ({
fields: RequestFields;
scope: string;
}) => {
const next: RequestFields = { ...fields, scope };
const oauthQuery = fields.oauth_query;
if (typeof oauthQuery === "string") {
try {
next.oauth_query = JSON.stringify({ ...JSON.parse(oauthQuery), scope });
} catch {
const params = new URLSearchParams(oauthQuery);
params.set("scope", scope);
next.oauth_query = params.toString();
}
} else if (typeof oauthQuery === "object" && oauthQuery !== null) {
next.oauth_query = {
...(oauthQuery as Record<string, unknown>),
scope,
};
}
return next;
return { ...fields, scope };
};
const withScope = ({

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

@@ -3,7 +3,6 @@ import {
type FullCustomer,
isCustomerProductFree,
isFreeProduct,
notNullish,
type UpdateSubscriptionBillingContextOverride,
type UpdateSubscriptionV1Params,
} from "@autumn/shared";
@@ -22,12 +21,14 @@ export const setupUpdateSubscriptionProductContext = async ({
params,
contextOverride = {},
reusePricesAndEntitlements,
resetToCatalogVersion = false,
}: {
ctx: AutumnContext;
fullCustomer: FullCustomer;
params: UpdateSubscriptionV1Params;
contextOverride?: UpdateSubscriptionBillingContextOverride;
reusePricesAndEntitlements?: ReusePricesAndEntitlements;
resetToCatalogVersion?: boolean;
}) => {
const { productContext } = contextOverride;
@@ -50,17 +51,22 @@ export const setupUpdateSubscriptionProductContext = async ({
});
let fullProduct = cusProductToProduct({ cusProduct: targetCustomerProduct });
const requestedVersion = params.version;
const targetVersion = targetCustomerProduct.product.version;
const hasRequestedVersion = typeof requestedVersion === "number";
const changesVersion =
hasRequestedVersion &&
(requestedVersion < targetVersion || requestedVersion > targetVersion);
const shouldLoadCatalogVersion =
hasRequestedVersion && (resetToCatalogVersion || changesVersion);
if (
notNullish(params.version) &&
params.version !== targetCustomerProduct.product.version
) {
if (shouldLoadCatalogVersion) {
fullProduct = await ProductService.getFull({
db: ctx.db,
idOrInternalId: targetCustomerProduct.product.id,
orgId: ctx.org.id,
env: ctx.env,
version: params.version,
version: requestedVersion,
});
}

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 = ({
@@ -24,7 +25,11 @@ export const computePatchCustomerProductPlan = ({
throw new Error("Patch context is required to compute patch customer plan");
}
const { finalCustomerProduct, customerProductUpdates } =
const {
finalCustomerProduct,
customerProductUpdates,
oneOffPrepaidCarryOverCustomerEntitlements,
} =
initPatchCustomerProduct({
ctx,
billingContext: updateSubscriptionContext,
@@ -46,6 +51,7 @@ export const computePatchCustomerProductPlan = ({
customEntitlements: patchContext.customEntitlements,
customFreeTrial: trialContext?.customFreeTrial,
lineItems: allLineItems,
insertCustomerEntitlements: oneOffPrepaidCarryOverCustomerEntitlements,
updateCustomerEntitlements: computeAnchorResetEntitlementUpdates({
updateSubscriptionContext,
finalCustomerProduct,
@@ -53,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

@@ -1,3 +1,9 @@
import {
ErrCode,
RecaseError,
ResetInterval,
resetIntvToEntIntv,
} from "@autumn/shared";
import type {
CustomizePlanV1,
Entitlement,
@@ -12,6 +18,7 @@ import type {
import { planItemFilterMatchesCustomerPair } from "@shared/api/products/items/utils/match";
import { cusEntToCusPrice } from "@shared/utils/cusEntUtils/convertCusEntUtils/cusEntToCusPrice";
import { customerPriceToCustomerEntitlement } from "@shared/utils/cusPriceUtils/convertCustomerPrice/customerPriceToCustomerEntitlement";
import { StatusCodes } from "http-status-codes";
import { generateId } from "@/utils/genUtils";
type CustomerProductItemPair = {
@@ -49,20 +56,47 @@ const getCustomerProductItemPairs = ({
return pairs;
};
const assertAllowedIntervalUpdate = ({
customerPrice,
overrides,
}: {
customerPrice?: FullCustomerPrice;
overrides: UpdatePlanItemParamsV1;
}) => {
if (overrides.interval === undefined || !customerPrice) return;
throw new RecaseError({
message:
"update_items cannot change intervals for paid items. Use remove_items and add_items instead.",
code: ErrCode.InvalidProductItem,
statusCode: StatusCodes.BAD_REQUEST,
});
};
const applyOverridesToEntitlement = ({
source,
customerPrice,
overrides,
}: {
source: Entitlement;
customerPrice?: FullCustomerPrice;
overrides: UpdatePlanItemParamsV1;
}): Entitlement => ({
...source,
id: generateId("ent"),
is_custom: true,
created_at: Date.now(),
allowance:
overrides.included !== undefined ? overrides.included : source.allowance,
});
}): Entitlement => {
assertAllowedIntervalUpdate({ customerPrice, overrides });
return {
...source,
id: generateId("ent"),
is_custom: true,
created_at: Date.now(),
allowance:
overrides.included !== undefined ? overrides.included : source.allowance,
interval:
overrides.interval !== undefined
? resetIntvToEntIntv({ resetIntv: overrides.interval })
: source.interval,
};
};
const applyOverridesToPrice = ({
source,
@@ -78,13 +112,8 @@ const applyOverridesToPrice = ({
entitlement_id: newEntitlementId,
});
/**
* Patch existing items in place. For each `update_items[i]`, find matching
* customer-entitlement / customer-price pairs on the target customer product,
* clone the underlying entitlement (and price, if any) with the overrides
* applied, and emit them as delete + add buckets. Existing usage and rollovers
* carry forward via the shared patch carry plumbing.
*/
/** Patch existing items in place by emitting matched items as delete + add buckets.
* Existing usage and rollovers carry forward via patch carry links. */
export const handleCustomizeUpdateItems = ({
customize,
targetCustomerProduct,
@@ -98,6 +127,10 @@ export const handleCustomizeUpdateItems = ({
customerEntitlements: FullCustomerEntitlement[];
prices: Price[];
entitlements: Entitlement[];
carryLinks: {
fromCustomerEntitlementId: string;
toEntitlementId: string;
}[];
} => {
const updateItems = customize.update_items ?? [];
if (updateItems.length === 0) {
@@ -106,6 +139,7 @@ export const handleCustomizeUpdateItems = ({
customerEntitlements: [],
prices: [],
entitlements: [],
carryLinks: [],
};
}
@@ -115,6 +149,10 @@ export const handleCustomizeUpdateItems = ({
const deleteCustomerEntitlements: FullCustomerEntitlement[] = [];
const newPrices: Price[] = [];
const newEntitlements: Entitlement[] = [];
const carryLinks: {
fromCustomerEntitlementId: string;
toEntitlementId: string;
}[] = [];
const pairs = getCustomerProductItemPairs({ targetCustomerProduct });
@@ -134,9 +172,14 @@ export const handleCustomizeUpdateItems = ({
const newEntitlement = applyOverridesToEntitlement({
source: pair.customerEntitlement.entitlement,
customerPrice: pair.customerPrice,
overrides: update,
});
newEntitlements.push(newEntitlement);
carryLinks.push({
fromCustomerEntitlementId: pair.customerEntitlement.id,
toEntitlementId: newEntitlement.id,
});
deleteCustomerEntitlements.push(pair.customerEntitlement);
deleteCustomerEntitlementIds.add(pair.customerEntitlement.id);
@@ -182,5 +225,6 @@ export const handleCustomizeUpdateItems = ({
customerEntitlements: deleteCustomerEntitlements,
prices: newPrices,
entitlements: newEntitlements,
carryLinks,
};
};

View File

@@ -129,6 +129,7 @@ export const setupPatchContext = ({
customerEntitlements: updateDeleteCustomerEntitlements,
prices: updateNewPrices,
entitlements: updateNewEntitlements,
carryLinks: updateItemCarryLinks,
} = handleCustomizeUpdateItems({
customize: params.customize ?? {},
targetCustomerProduct: finalCustomerProduct,
@@ -198,6 +199,7 @@ export const setupPatchContext = ({
...customItemPrices,
],
customEntitlements: [...updateNewEntitlements, ...customEntitlements],
updateItemCarryLinks,
};
return patchContext;

View File

@@ -2,15 +2,100 @@ import {
type AutumnBillingPlan,
CusProductStatus,
type CustomerPlanChange,
customerEntitlementToFeatureId,
type FullCusProduct,
} from "@autumn/shared";
import { buildPlanItemChanges } from "./buildPlanItemChanges";
import { buildPreviousAttributes } from "./buildPreviousAttributes";
import { cusProductStatusToPublicStatus } from "./cusProductStatusMapping";
import { toCustomerPlanSnapshot } from "./toCustomerPlanSnapshot";
type PlanChangeEntry = {
change: CustomerPlanChange;
customerProduct?: FullCusProduct;
};
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(":");
}
};
const entitlementFeatureIds = (customerProduct: FullCusProduct) =>
new Set(
customerProduct.customer_entitlements.map((customerEntitlement) =>
customerEntitlementToFeatureId(customerEntitlement),
),
);
const buildReplacementItemChanges = ({
activated,
expired,
}: {
activated: PlanChangeEntry;
expired: PlanChangeEntry;
}): CustomerPlanChange["item_changes"] => {
const activatedProduct = activated.customerProduct;
const expiredProduct = expired.customerProduct;
if (activatedProduct === undefined || expiredProduct === undefined) {
return [
...(activated.change.item_changes ?? []),
...(expired.change.item_changes ?? []),
];
}
const activatedFeatureIds = entitlementFeatureIds(activatedProduct);
const expiredFeatureIds = entitlementFeatureIds(expiredProduct);
return [
...buildPlanItemChanges({
customerProduct: activatedProduct,
insertCustomerEntitlements:
activatedProduct.customer_entitlements.filter(
(customerEntitlement) =>
expiredFeatureIds.has(
customerEntitlementToFeatureId(customerEntitlement),
) === false,
),
insertCustomerPrices: activatedProduct.customer_prices,
}),
...buildPlanItemChanges({
customerProduct: expiredProduct,
deleteCustomerEntitlements: expiredProduct.customer_entitlements.filter(
(customerEntitlement) =>
activatedFeatureIds.has(
customerEntitlementToFeatureId(customerEntitlement),
) === false,
),
deleteCustomerPrices: expiredProduct.customer_prices,
}),
];
};
/**
* When a billing action updates a plan in-place, Autumn often creates a new
* customer product (insertCustomerProducts) and expires the old one
@@ -20,17 +105,20 @@ const getChangePlanId = (change: CustomerPlanChange): string | undefined =>
* reflects the logical operation.
*/
const collapseSamePlanIdPairs = (
changes: CustomerPlanChange[],
): CustomerPlanChange[] => {
entries: PlanChangeEntry[],
): PlanChangeEntry[] => {
const consumed = new Set<number>();
const result: CustomerPlanChange[] = [];
const result: PlanChangeEntry[] = [];
for (let i = 0; i < changes.length; i++) {
for (let i = 0; i < entries.length; i++) {
if (consumed.has(i)) continue;
const change = changes[i];
const entry = entries[i];
const { change } = entry;
if (change.action !== "activated" && change.action !== "expired") {
result.push(change);
const canCollapse =
change.action === "activated" || change.action === "expired";
if (canCollapse === false) {
result.push(entry);
continue;
}
@@ -38,16 +126,17 @@ const collapseSamePlanIdPairs = (
const counterpartAction =
change.action === "activated" ? "expired" : "activated";
const pairIdx = changes.findIndex(
(other, j) =>
j !== i &&
!consumed.has(j) &&
other.action === counterpartAction &&
getChangePlanId(other) === planId,
);
const pairIdx = entries.findIndex((other, j) => {
if (j === i) return false;
if (consumed.has(j)) return false;
return (
other.change.action === counterpartAction &&
getChangePlanId(other.change) === planId
);
});
if (pairIdx < 0) {
result.push(change);
result.push(entry);
continue;
}
@@ -57,38 +146,85 @@ const collapseSamePlanIdPairs = (
// the iterator, not as a pairing candidate).
consumed.add(i);
consumed.add(pairIdx);
const activatedChange = change.action === "activated" ? change : changes[pairIdx];
const expiredChange = change.action === "expired" ? change : changes[pairIdx];
const pair = entries[pairIdx];
const activated = change.action === "activated" ? entry : pair;
const expired = change.action === "expired" ? entry : pair;
result.push({
action: "updated",
subscription: activatedChange.subscription,
purchase: activatedChange.purchase,
previous_attributes: expiredChange.previous_attributes,
item_changes: activatedChange.item_changes,
customerProduct: activated.customerProduct,
change: {
action: "updated",
subscription: activated.change.subscription,
purchase: activated.change.purchase,
previous_attributes: expired.change.previous_attributes,
item_changes: buildReplacementItemChanges({
activated,
expired,
}),
},
});
}
return result;
};
const mergeUpdatedPlanChanges = (
entries: PlanChangeEntry[],
): PlanChangeEntry[] => {
const merged = new Map<string, PlanChangeEntry>();
const result: PlanChangeEntry[] = [];
for (const entry of entries) {
const { change } = entry;
const mergeKey = getUpdatedChangeMergeKey(change);
if (change.action === "updated" && mergeKey) {
const existing = merged.get(mergeKey);
if (existing) {
existing.change.subscription =
existing.change.subscription ?? change.subscription;
existing.change.purchase = existing.change.purchase ?? change.purchase;
existing.change.previous_attributes = {
...(existing.change.previous_attributes ?? {}),
...(change.previous_attributes ?? {}),
};
existing.change.item_changes = [
...(existing.change.item_changes ?? []),
...(change.item_changes ?? []),
];
continue;
}
merged.set(mergeKey, entry);
result.push(entry);
continue;
}
result.push(entry);
}
return result;
};
export const buildPlanChanges = ({
autumnBillingPlan,
}: {
autumnBillingPlan: AutumnBillingPlan;
}): CustomerPlanChange[] => {
const changes: CustomerPlanChange[] = [];
const entries: PlanChangeEntry[] = [];
for (const cusProduct of autumnBillingPlan.insertCustomerProducts ?? []) {
const action =
cusProduct.status === CusProductStatus.Scheduled
? "scheduled"
: "activated";
changes.push({
action,
...toCustomerPlanSnapshot({ cusProduct }),
previous_attributes: null,
item_changes: [],
entries.push({
customerProduct: cusProduct,
change: {
action,
...toCustomerPlanSnapshot({ cusProduct }),
previous_attributes: null,
item_changes: [],
},
});
}
@@ -126,33 +262,44 @@ export const buildPlanChanges = ({
action = "updated";
}
changes.push({
action,
...toCustomerPlanSnapshot({
cusProduct: originalCusProduct,
overrides: {
status: update.updates.status,
canceled_at: update.updates.canceled_at,
ended_at: update.updates.ended_at,
trial_ends_at: update.updates.trial_ends_at,
},
}),
previous_attributes: previousAttributes,
item_changes: [],
entries.push({
customerProduct: originalCusProduct,
change: {
action,
...toCustomerPlanSnapshot({
cusProduct: originalCusProduct,
overrides: {
status: update.updates.status,
canceled_at: update.updates.canceled_at,
ended_at: update.updates.ended_at,
trial_ends_at: update.updates.trial_ends_at,
},
}),
previous_attributes: previousAttributes,
item_changes: [],
},
});
}
for (const patch of autumnBillingPlan.patchCustomerProducts ?? []) {
changes.push({
action: "updated",
...toCustomerPlanSnapshot({ cusProduct: patch.customerProduct }),
previous_attributes: {},
item_changes: buildPlanItemChanges({
insertCustomerEntitlements: patch.insertCustomerEntitlements,
deleteCustomerEntitlements: patch.deleteCustomerEntitlements,
}),
entries.push({
customerProduct: patch.customerProduct,
change: {
action: "updated",
...toCustomerPlanSnapshot({ cusProduct: patch.customerProduct }),
previous_attributes: {},
item_changes: buildPlanItemChanges({
customerProduct: patch.customerProduct,
insertCustomerEntitlements: patch.insertCustomerEntitlements,
deleteCustomerEntitlements: patch.deleteCustomerEntitlements,
insertCustomerPrices: patch.insertCustomerPrices,
deleteCustomerPrices: patch.deleteCustomerPrices,
}),
},
});
}
return collapseSamePlanIdPairs(changes);
return mergeUpdatedPlanChanges(collapseSamePlanIdPairs(entries)).map(
(entry) => entry.change,
);
};

View File

@@ -1,23 +1,79 @@
import type {
CustomerPlanItemChange,
FullCusProduct,
FullCustomerEntitlement,
FullCustomerPrice,
} from "@autumn/shared";
import type { ApiPlanItemV1 } from "@autumn/shared/api/products/items/apiPlanItemV1.js";
import {
customerEntitlementToFeatureId,
customerEntitlementToPlanItemV1,
} from "@autumn/shared";
export const buildPlanItemChanges = ({
insertCustomerEntitlements,
deleteCustomerEntitlements,
export type InternalPlanItemChange = {
action: "created" | "deleted";
feature_id: string;
item: ApiPlanItemV1;
previous_attributes: Record<string, unknown>;
};
export const buildInternalPlanItemChanges = ({
customerProduct,
insertCustomerEntitlements = [],
deleteCustomerEntitlements = [],
insertCustomerPrices = [],
deleteCustomerPrices = [],
}: {
customerProduct: FullCusProduct;
insertCustomerEntitlements?: FullCustomerEntitlement[];
deleteCustomerEntitlements?: FullCustomerEntitlement[];
insertCustomerPrices?: FullCustomerPrice[];
deleteCustomerPrices?: FullCustomerPrice[];
}): InternalPlanItemChange[] => [
...insertCustomerEntitlements.map((customerEntitlement) => ({
action: "created" as const,
feature_id: customerEntitlementToFeatureId(customerEntitlement),
item: customerEntitlementToPlanItemV1({
customerEntitlement,
customerProduct,
customerPrices: insertCustomerPrices,
}),
previous_attributes: {},
})),
...deleteCustomerEntitlements.map((customerEntitlement) => ({
action: "deleted" as const,
feature_id: customerEntitlementToFeatureId(customerEntitlement),
item: customerEntitlementToPlanItemV1({
customerEntitlement,
customerProduct,
customerPrices: deleteCustomerPrices,
}),
previous_attributes: {},
})),
];
export const buildPlanItemChanges = ({
customerProduct,
insertCustomerEntitlements,
deleteCustomerEntitlements,
insertCustomerPrices,
deleteCustomerPrices,
}: {
customerProduct: FullCusProduct;
insertCustomerEntitlements?: FullCustomerEntitlement[];
deleteCustomerEntitlements?: FullCustomerEntitlement[];
insertCustomerPrices?: FullCustomerPrice[];
deleteCustomerPrices?: FullCustomerPrice[];
}): CustomerPlanItemChange[] => {
const changes: CustomerPlanItemChange[] = [];
for (const ent of insertCustomerEntitlements ?? []) {
changes.push({ action: "created", feature_id: ent.feature_id });
}
for (const ent of deleteCustomerEntitlements ?? []) {
changes.push({ action: "deleted", feature_id: ent.feature_id });
}
return changes;
return buildInternalPlanItemChanges({
customerProduct,
insertCustomerEntitlements,
deleteCustomerEntitlements,
insertCustomerPrices,
deleteCustomerPrices,
}).map(({ action, feature_id, item }) => ({
action,
feature_id,
item,
}));
};

View File

@@ -0,0 +1,21 @@
import type { FullCustomerEntitlement } from "@autumn/shared";
export type CustomerEntitlementCarryIdentity = {
internalFeatureId: string;
};
export const carryIdentityToKey = (
identity: CustomerEntitlementCarryIdentity,
) => identity.internalFeatureId;
export const customerEntitlementToCarryIdentity = ({
customerEntitlement,
}: {
customerEntitlement: FullCustomerEntitlement;
}): CustomerEntitlementCarryIdentity => {
const entitlement = customerEntitlement.entitlement;
return {
internalFeatureId: entitlement.internal_feature_id,
};
};

View File

@@ -0,0 +1,164 @@
import {
type FullCusProduct,
type FullCustomerEntitlement,
} from "@autumn/shared";
import {
carryIdentityToKey,
customerEntitlementToCarryIdentity,
} from "./carryIdentity";
import { customerProductWithOnlyEntitlements } from "./projectCustomerProductForCarry";
export type CustomerProductCarryGroup = {
fromCustomerProduct: FullCusProduct;
toCustomerProduct: FullCusProduct;
};
/** Resolved replacement pair used when an updated item no longer identity-matches its source. */
export type CustomerProductCarryLink = {
fromCustomerEntitlement: FullCustomerEntitlement;
toCustomerEntitlement: FullCustomerEntitlement;
};
const addToGroup = <T>(groups: Map<string, T[]>, key: string, value: T) => {
const group = groups.get(key);
if (group) {
group.push(value);
return;
}
groups.set(key, [value]);
};
const groupCustomerEntitlementsByCarryIdentity = ({
customerEntitlements,
}: {
customerEntitlements: FullCustomerEntitlement[];
}) => {
const customerEntitlementsByKey = new Map<
string,
FullCustomerEntitlement[]
>();
for (const customerEntitlement of customerEntitlements) {
const key = carryIdentityToKey(
customerEntitlementToCarryIdentity({
customerEntitlement,
}),
);
addToGroup(customerEntitlementsByKey, key, customerEntitlement);
}
return customerEntitlementsByKey;
};
const getLinkedCustomerProductCarryGroups = ({
fromCustomerProduct,
toCustomerProduct,
links,
}: {
fromCustomerProduct: FullCusProduct;
toCustomerProduct: FullCusProduct;
links: CustomerProductCarryLink[];
}): CustomerProductCarryGroup[] =>
links.map((link) => ({
fromCustomerProduct: customerProductWithOnlyEntitlements({
customerProduct: fromCustomerProduct,
customerEntitlements: [link.fromCustomerEntitlement],
}),
toCustomerProduct: customerProductWithOnlyEntitlements({
customerProduct: toCustomerProduct,
customerEntitlements: [link.toCustomerEntitlement],
}),
}));
const getIdentityCustomerProductCarryGroups = ({
fromCustomerProduct,
toCustomerProduct,
fromCustomerEntitlements,
}: {
fromCustomerProduct: FullCusProduct;
toCustomerProduct: FullCusProduct;
fromCustomerEntitlements: FullCustomerEntitlement[];
}): CustomerProductCarryGroup[] => {
const toEntitlementsByKey = groupCustomerEntitlementsByCarryIdentity({
customerEntitlements: toCustomerProduct.customer_entitlements,
});
const fromEntitlementsByKey = groupCustomerEntitlementsByCarryIdentity({
customerEntitlements: fromCustomerEntitlements,
});
return Array.from(fromEntitlementsByKey.entries()).flatMap(
([key, fromEntitlements]) => {
const toEntitlements = toEntitlementsByKey.get(key);
if (!toEntitlements) return [];
return {
fromCustomerProduct: customerProductWithOnlyEntitlements({
customerProduct: fromCustomerProduct,
customerEntitlements: fromEntitlements,
}),
toCustomerProduct: customerProductWithOnlyEntitlements({
customerProduct: toCustomerProduct,
customerEntitlements: toEntitlements,
}),
};
},
);
};
const getUnlinkedCustomerEntitlements = ({
customerEntitlements,
linkedCustomerEntitlementIds,
}: {
customerEntitlements: FullCustomerEntitlement[];
linkedCustomerEntitlementIds: Set<string>;
}) => {
const unlinkedCustomerEntitlements: FullCustomerEntitlement[] = [];
for (const customerEntitlement of customerEntitlements) {
if (linkedCustomerEntitlementIds.has(customerEntitlement.id)) continue;
unlinkedCustomerEntitlements.push(customerEntitlement);
}
return unlinkedCustomerEntitlements;
};
export const getCustomerProductCarryGroups = ({
fromCustomerProduct,
toCustomerProduct,
fromCustomerEntitlements,
links,
}: {
fromCustomerProduct: FullCusProduct;
toCustomerProduct: FullCusProduct;
fromCustomerEntitlements: FullCustomerEntitlement[];
links?: CustomerProductCarryLink[];
}): CustomerProductCarryGroup[] => {
const linkedFromCustomerEntitlementIds = new Set(
links?.map((link) => link.fromCustomerEntitlement.id),
);
const linkedToCustomerEntitlementIds = new Set(
links?.map((link) => link.toCustomerEntitlement.id),
);
const linkedCarryGroups = getLinkedCustomerProductCarryGroups({
fromCustomerProduct,
toCustomerProduct,
links: links ?? [],
});
const identityCarryGroups = getIdentityCustomerProductCarryGroups({
fromCustomerProduct,
toCustomerProduct: {
...toCustomerProduct,
customer_entitlements: getUnlinkedCustomerEntitlements({
customerEntitlements: toCustomerProduct.customer_entitlements,
linkedCustomerEntitlementIds: linkedToCustomerEntitlementIds,
}),
},
fromCustomerEntitlements: getUnlinkedCustomerEntitlements({
customerEntitlements: fromCustomerEntitlements,
linkedCustomerEntitlementIds: linkedFromCustomerEntitlementIds,
}),
});
return [...linkedCarryGroups, ...identityCarryGroups];
};

View File

@@ -0,0 +1,3 @@
export * from "./carryIdentity";
export * from "./customerProductCarryGroups";
export * from "./projectCustomerProductForCarry";

View File

@@ -0,0 +1,46 @@
import {
type FullCusEntWithFullCusProduct,
type FullCusProduct,
type FullCustomerEntitlement,
type FullCustomerPrice,
} from "@autumn/shared";
import { cusEntToCusPrice } from "@shared/utils/cusEntUtils/convertCusEntUtils/cusEntToCusPrice";
const customerPricesForCustomerEntitlements = ({
customerProduct,
customerEntitlements,
}: {
customerProduct: FullCusProduct;
customerEntitlements: FullCustomerEntitlement[];
}): FullCustomerPrice[] => {
const customerPricesById = new Map<string, FullCustomerPrice>();
for (const customerEntitlement of customerEntitlements) {
const customerPrice = cusEntToCusPrice({
cusEnt: {
...customerEntitlement,
customer_product: customerProduct,
} satisfies FullCusEntWithFullCusProduct,
});
if (!customerPrice) continue;
customerPricesById.set(customerPrice.id, customerPrice);
}
return Array.from(customerPricesById.values());
};
export const customerProductWithOnlyEntitlements = ({
customerProduct,
customerEntitlements,
}: {
customerProduct: FullCusProduct;
customerEntitlements: FullCustomerEntitlement[];
}): FullCusProduct => ({
...customerProduct,
customer_prices: customerPricesForCustomerEntitlements({
customerProduct,
customerEntitlements,
}),
customer_entitlements: customerEntitlements,
});

View File

@@ -1,29 +0,0 @@
import type { FullCusProduct, PatchContext } from "@autumn/shared";
export const getPatchCarryCustomerProduct = ({
patchContext,
}: {
patchContext: PatchContext;
}): FullCusProduct => {
const deletedEntitlementIds = new Set(
patchContext.deleteCustomerEntitlements.map(
(customerEntitlement) => customerEntitlement.entitlement.id,
),
);
const deletedCustomerPriceIds = new Set(
patchContext.deleteCustomerPrices.map((customerPrice) => customerPrice.id),
);
return {
...patchContext.originalCustomerProduct,
customer_prices:
patchContext.originalCustomerProduct.customer_prices.filter(
(customerPrice) =>
deletedCustomerPriceIds.has(customerPrice.id) ||
(customerPrice.price.entitlement_id
? deletedEntitlementIds.has(customerPrice.price.entitlement_id)
: false),
),
customer_entitlements: patchContext.deleteCustomerEntitlements,
};
};

View File

@@ -1,4 +1,3 @@
export * from "./applyCustomerProductItemsPatch";
export * from "./getPatchCarryCustomerProduct";
export * from "./initPatchCustomerProduct";
export * from "./initPatchedCustomerEntitlementsAndPrices";

View File

@@ -1,6 +1,7 @@
import {
type AutumnBillingPlan,
cusProductToProduct,
type InsertCustomerEntitlement,
type PatchContext,
type TrialContext,
type UpdateSubscriptionBillingContext,
@@ -68,8 +69,14 @@ export const initPatchCustomerProduct = ({
}): {
finalCustomerProduct: PatchContext["finalCustomerProduct"];
customerProductUpdates: CustomerProductUpdates;
oneOffPrepaidCarryOverCustomerEntitlements: InsertCustomerEntitlement[];
} => {
const { customerPrices, customerEntitlements } =
const {
customerPrices,
customerEntitlements,
oneOffPrepaidCarryOverEntitlements,
oneOffPrepaidCarryOverCustomerEntitlements,
} =
initPatchedCustomerEntitlementsAndPrices({
ctx,
billingContext,
@@ -95,6 +102,7 @@ export const initPatchCustomerProduct = ({
});
patchContext.insertCustomerPrices = customerPrices;
patchContext.insertCustomerEntitlements = customerEntitlements;
patchContext.customEntitlements.push(...oneOffPrepaidCarryOverEntitlements);
patchContext.fullProduct = cusProductToProduct({
cusProduct: patchContext.finalCustomerProduct,
});
@@ -116,5 +124,6 @@ export const initPatchCustomerProduct = ({
...trialUpdates,
...customUpdates,
},
oneOffPrepaidCarryOverCustomerEntitlements,
};
};

View File

@@ -1,15 +1,18 @@
import type {
Entitlement,
FullCustomerEntitlement,
FullCustomerPrice,
InsertCustomerEntitlement,
PatchContext,
UpdateSubscriptionBillingContext,
} from "@autumn/shared";
import { enrichEntitlementsWithFeatures } from "@shared/utils/productUtils/entUtils/enrichEntitlement";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { getCustomerProductCarryGroups } from "@/internal/billing/v2/utils/initFullCustomerProduct/carryExisting";
import { applyExistingStatesToCustomerProduct } from "@/internal/billing/v2/utils/initFullCustomerProduct/applyExisting/applyExistingStatesToCustomerProduct";
import { initCustomerEntitlement } from "@/internal/billing/v2/utils/initFullCustomerProduct/initCustomerEntitlement/initCustomerEntitlement";
import { initCustomerPrice } from "@/internal/billing/v2/utils/initFullCustomerProduct/initCustomerPrice";
import { getPatchCarryCustomerProduct } from "./getPatchCarryCustomerProduct";
import { applyOneOffPrepaidCarryOvers } from "../../handleOneOffPrepaidCarryOvers/applyOneOffPrepaidCarryOvers";
type PatchInitBillingContext = Pick<
UpdateSubscriptionBillingContext,
@@ -32,6 +35,8 @@ export const initPatchedCustomerEntitlementsAndPrices = ({
}): {
customerPrices: FullCustomerPrice[];
customerEntitlements: FullCustomerEntitlement[];
oneOffPrepaidCarryOverEntitlements: Entitlement[];
oneOffPrepaidCarryOverCustomerEntitlements: InsertCustomerEntitlement[];
} => {
const {
fullCustomer,
@@ -86,25 +91,71 @@ export const initPatchedCustomerEntitlementsAndPrices = ({
customer_prices: customerPrices,
customer_entitlements: customerEntitlements,
};
const carryCustomerProduct = getPatchCarryCustomerProduct({ patchContext });
const deletedEntitlementsById = new Map(
patchContext.deleteCustomerEntitlements.map((customerEntitlement) => [
customerEntitlement.id,
customerEntitlement,
]),
);
const customerEntitlementsByEntitlementId = new Map(
customerEntitlements.map((customerEntitlement) => [
customerEntitlement.entitlement.id,
customerEntitlement,
]),
);
const carryGroups = getCustomerProductCarryGroups({
fromCustomerProduct: patchContext.originalCustomerProduct,
toCustomerProduct: customerProductWithNewItemsOnly,
fromCustomerEntitlements: patchContext.deleteCustomerEntitlements,
links: patchContext.updateItemCarryLinks.flatMap((link) => {
const fromCustomerEntitlement = deletedEntitlementsById.get(
link.fromCustomerEntitlementId,
);
const toCustomerEntitlement = customerEntitlementsByEntitlementId.get(
link.toEntitlementId,
);
applyExistingStatesToCustomerProduct({
ctx,
fullCustomer,
customerProduct: customerProductWithNewItemsOnly,
existingUsagesConfig: skipExistingUsageCarry
? undefined
: {
fromCustomerProduct: carryCustomerProduct,
carryAllConsumableFeatures: true,
},
existingRolloversConfig: {
fromCustomerProduct: carryCustomerProduct,
},
if (!fromCustomerEntitlement || !toCustomerEntitlement) return [];
return { fromCustomerEntitlement, toCustomerEntitlement };
}),
});
const oneOffPrepaidCarryOverEntitlements: Entitlement[] = [];
const oneOffPrepaidCarryOverCustomerEntitlements: InsertCustomerEntitlement[] =
[];
for (const carryGroup of carryGroups) {
applyExistingStatesToCustomerProduct({
ctx,
fullCustomer,
customerProduct: carryGroup.toCustomerProduct,
existingUsagesConfig: skipExistingUsageCarry
? undefined
: {
fromCustomerProduct: carryGroup.fromCustomerProduct,
carryAllConsumableFeatures: true,
},
existingRolloversConfig: {
fromCustomerProduct: carryGroup.fromCustomerProduct,
},
});
const oneOffPrepaidCarryOvers = applyOneOffPrepaidCarryOvers({
oldCustomerProduct: carryGroup.fromCustomerProduct,
newCustomerProduct: carryGroup.toCustomerProduct,
fullCustomer,
});
oneOffPrepaidCarryOverEntitlements.push(
...oneOffPrepaidCarryOvers.entitlements,
);
oneOffPrepaidCarryOverCustomerEntitlements.push(
...oneOffPrepaidCarryOvers.customerEntitlements,
);
}
return {
customerPrices: customerProductWithNewItemsOnly.customer_prices,
customerEntitlements: customerProductWithNewItemsOnly.customer_entitlements,
oneOffPrepaidCarryOverEntitlements,
oneOffPrepaidCarryOverCustomerEntitlements,
};
};

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

@@ -23,30 +23,16 @@ import { CusSearchService } from "./CusSearchService.js";
import { getCursorPaginatedFullCusQuery } from "./cursorPaginatedFullCusQuery.js";
import { getApiCustomerBase } from "./cusUtils/apiCusUtils/getApiCustomerBase.js";
import {
type DashboardProductVersionFilter,
type DashboardStatusFilter,
getPaginatedFullCusQuery,
parseDashboardProcessorFilter,
parseDashboardStatusFilter,
parseDashboardVersionFilter,
} from "./getFullCusQuery.js";
const parseDashboardVersionFilter = (
raw: string[] | undefined,
): DashboardProductVersionFilter[] => {
if (!raw?.length) return [];
return raw
.filter(Boolean)
.map((s) => {
const [productId, version] = s.split(":");
return { productId, version: parseInt(version, 10) };
})
.filter(
(v): v is DashboardProductVersionFilter =>
!!v.productId && !Number.isNaN(v.version),
);
};
import {
type FlattenedCustomerRow,
reassembleFlattenedCustomer,
} from "./reassembleFlattenedCustomer/index.js";
import type { CustomerListFilters } from "./customerListFilters.js";
export class CusBatchService {
static async getByInternalIds({
@@ -311,12 +297,7 @@ export class CusBatchService {
}: {
ctx: RequestContext;
search: string;
filters?: {
status?: string[];
version?: string[];
none?: boolean;
processor?: string[];
};
filters?: CustomerListFilters;
cursor: { t: number; id: string } | null;
limit: number;
}): Promise<{
@@ -332,14 +313,7 @@ export class CusBatchService {
orgSlug: ctx.org.slug,
});
const statusFilters = (filters?.status ?? []).filter(
(s): s is DashboardStatusFilter =>
s === "active" ||
s === "past_due" ||
s === "canceled" ||
s === "free_trial" ||
s === "expired",
);
const statusFilters = parseDashboardStatusFilter(filters?.status);
const productVersionFilters = parseDashboardVersionFilter(filters?.version);
@@ -392,7 +366,7 @@ export class CusBatchService {
search: requiresResolveStep ? undefined : search,
processors: requiresResolveStep
? undefined
: (filters?.processor as ListCustomersV2Params["processors"]),
: parseDashboardProcessorFilter(filters?.processor),
cusProductLimit,
});

View File

@@ -24,6 +24,13 @@ import {
import { alias } from "drizzle-orm/pg-core";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { getOrgCusProductLimit } from "../misc/edgeConfig/orgLimitsStore.js";
import type { CustomerListFilters } from "./customerListFilters.js";
import {
type DashboardProductVersionFilter,
isCustomDashboardProductFilter,
isVersionDashboardProductFilter,
parseDashboardVersionFilter,
} from "./getFullCusQuery.js";
// Create alias for subquery
const customerProductsAlias = alias(customerProducts, "cp_alias");
@@ -54,12 +61,26 @@ const productFields = {
is_add_on: products.is_add_on,
};
interface SearchFilters {
status?: string[];
version?: string[];
none?: boolean;
processor?: string[];
}
const dashboardProductFilterToDrizzleSql = (
filter: DashboardProductVersionFilter,
) =>
and(
isCustomDashboardProductFilter(filter)
? and(
eq(customerProducts.product_id, filter.productId),
eq(customerProducts.is_custom, true),
)
: and(eq(products.id, filter.productId), eq(products.version, filter.version)),
);
const dashboardProductFilterToRawSql = (
filter: DashboardProductVersionFilter,
) =>
isCustomDashboardProductFilter(filter)
? sql`(${customerProducts.product_id} = ${filter.productId} AND ${customerProducts.is_custom} = true)`
: sql`(${products.id} = ${filter.productId} AND ${products.version} = ${filter.version})`;
type SearchFilters = CustomerListFilters;
export class CusSearchService {
static getProcessorFilterSql({
@@ -153,29 +174,13 @@ export class CusSearchService {
statuses = [];
}
// Handle product:version combinations
let productVersionFilters: Array<{ productId: string; version: number }> =
[];
// Parse version field which now contains "productId:version,productId2:version2"
if (filters.version && filters.version.length > 0) {
const versionSelections = filters.version.filter(Boolean);
productVersionFilters = versionSelections.map((selection) => {
const [productId, version] = selection.split(":");
return { productId, version: parseInt(version) };
});
}
const productVersionFilters = parseDashboardVersionFilter(filters.version);
const filtersDrizzle = and(
// New product:version filtering
productVersionFilters.length > 0
? or(
...productVersionFilters.map((pv) =>
and(
eq(customerProducts.product_id, pv.productId),
eq(products.version, pv.version),
),
),
...productVersionFilters.map(dashboardProductFilterToDrizzleSql),
)
: undefined,
// Legacy product filtering (fallback)
@@ -958,11 +963,10 @@ const buildSearchPredicates = ({
filters?.status && filters.status.length > 0 && !filters.status.includes("")
? filters.status
: [];
const versions = filters?.version?.filter(Boolean) ?? [];
const productVersionFilters = versions.map((selection) => {
const [productId, version] = selection.split(":");
return { productId, version: parseInt(version, 10) };
});
const productVersionFilters = parseDashboardVersionFilter(filters?.version);
const hasNumberedVersion = productVersionFilters.some(
isVersionDashboardProductFilter,
);
if (statuses.length === 0 && productVersionFilters.length === 0) {
return {
@@ -1005,10 +1009,7 @@ const buildSearchPredicates = ({
const versionRaw =
productVersionFilters.length > 0
? sql`(${sql.join(
productVersionFilters.map(
(pv) =>
sql`(${customerProducts.product_id} = ${pv.productId} AND ${products.version} = ${pv.version})`,
),
productVersionFilters.map(dashboardProductFilterToRawSql),
sql` OR `,
)})`
: null;
@@ -1038,12 +1039,7 @@ const buildSearchPredicates = ({
const filtersDrizzle = and(
productVersionFilters.length > 0
? or(
...productVersionFilters.map((pv) =>
and(
eq(customerProducts.product_id, pv.productId),
eq(products.version, pv.version),
),
),
...productVersionFilters.map(dashboardProductFilterToDrizzleSql),
)
: undefined,
statuses.length > 0
@@ -1093,7 +1089,7 @@ const buildSearchPredicates = ({
return {
kind: "productMode",
useInnerJoin: productVersionFilters.length > 0,
useInnerJoin: hasNumberedVersion,
where: and(
shouldApplyActiveFilter ? activeDrizzle : undefined,
filtersDrizzle,

View File

@@ -68,6 +68,8 @@ export const getCursorPaginatedFullCusQuery = ({
const customerListFilterSql = getCustomerListFilterSql({
internalCustomerIds,
orgId,
env,
inStatuses,
plans,
processors,

View File

@@ -140,24 +140,7 @@ export class CusProdReadService {
orgId: string;
env: AppEnv;
}) {
const internalProductIds = await db
.select({
internal_id: products.internal_id,
})
.from(products)
.where(
and(
eq(products.id, productId),
eq(products.org_id, orgId),
eq(products.env, env),
),
);
const internalProductIdsArray = internalProductIds.map(
(item) => item.internal_id,
);
const result = await db
const rows = await db
.select({
active: countDistinct(customerProducts.internal_customer_id).as(
"active",
@@ -173,17 +156,82 @@ export class CusProdReadService {
).as("trialing"),
all: countDistinct(customerProducts.internal_customer_id).as("all"),
})
.from(customerProducts)
.from(products)
.leftJoin(
customerProducts,
and(
eq(customerProducts.internal_product_id, products.internal_id),
inArray(customerProducts.status, activeStatuses),
),
)
.where(
and(
inArray(
customerProducts.internal_product_id,
internalProductIdsArray,
),
inArray(customerProducts.status, activeStatuses),
eq(products.id, productId),
eq(products.org_id, orgId),
eq(products.env, env),
),
);
return result[0];
return rows[0];
}
static async getCountsPerVersion({
db,
productId,
orgId,
env,
}: {
db: DrizzleCli;
productId: string;
orgId: string;
env: AppEnv;
}) {
const rows = await db
.select({
version: products.version,
active: countDistinct(customerProducts.internal_customer_id).as(
"active",
),
canceled: countDistinct(
sql`CASE WHEN ${isNotNull(customerProducts.canceled_at)} THEN ${customerProducts.internal_customer_id} END`,
).as("canceled"),
custom: countDistinct(
sql`CASE WHEN ${eq(customerProducts.is_custom, true)} THEN ${customerProducts.internal_customer_id} END`,
).as("custom"),
trialing: countDistinct(
sql`CASE WHEN ${isNotNull(customerProducts.trial_ends_at)} AND ${sql`${customerProducts.trial_ends_at} > (EXTRACT(EPOCH FROM NOW()) * 1000)::bigint`} THEN ${customerProducts.internal_customer_id} END`,
).as("trialing"),
all: countDistinct(customerProducts.internal_customer_id).as("all"),
})
.from(products)
.leftJoin(
customerProducts,
and(
eq(customerProducts.internal_product_id, products.internal_id),
inArray(customerProducts.status, activeStatuses),
),
)
.where(
and(
eq(products.id, productId),
eq(products.org_id, orgId),
eq(products.env, env),
),
)
.groupBy(products.version);
const result: Record<
number,
{ active: number; canceled: number; custom: number; trialing: number }
> = {};
for (const row of rows) {
result[row.version] = {
active: row.active,
canceled: row.canceled,
custom: row.custom,
trialing: row.trialing,
};
}
return result;
}
}

View File

@@ -31,6 +31,26 @@ import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js";
import RecaseError from "@/utils/errorUtils.js";
export class CusEntService {
/**
* Which of these catalog entitlements are referenced by any
* customer_entitlements row — across every status, including loose,
* scheduled and canceled.
*/
static async getReferencedEntitlementIds({
db,
entitlementIds,
}: {
db: DrizzleCli;
entitlementIds: string[];
}): Promise<Set<string>> {
if (entitlementIds.length === 0) return new Set();
const rows = await db
.select({ entitlement_id: customerEntitlements.entitlement_id })
.from(customerEntitlements)
.where(inArray(customerEntitlements.entitlement_id, entitlementIds));
return new Set(rows.map((row) => row.entitlement_id));
}
static async get({
ctx,
externalId,

View File

@@ -4,10 +4,28 @@ import {
type FullCustomerEntitlement,
type FullCustomerPrice,
} from "@autumn/shared";
import { eq } from "drizzle-orm";
import { eq, inArray } from "drizzle-orm";
import type { DrizzleCli } from "@/db/initDrizzle.js";
export class CusPriceService {
/** Which of these catalog prices are referenced by any customer_prices row. */
static async getReferencedPriceIds({
db,
priceIds,
}: {
db: DrizzleCli;
priceIds: string[];
}): Promise<Set<string>> {
if (priceIds.length === 0) return new Set();
const rows = await db
.select({ price_id: customerPrices.price_id })
.from(customerPrices)
.where(inArray(customerPrices.price_id, priceIds));
return new Set(
rows.map((row) => row.price_id).filter((id): id is string => id !== null),
);
}
static async getRelatedToCusEnt({
db,
cusEnt,

View File

@@ -0,0 +1,10 @@
import { z } from "zod/v4";
export const CustomerListFiltersSchema = z.object({
status: z.array(z.string()).optional(),
version: z.array(z.string()).optional(),
none: z.boolean().optional(),
processor: z.array(z.string()).optional(),
});
export type CustomerListFilters = z.infer<typeof CustomerListFiltersSchema>;

View File

@@ -13,10 +13,75 @@ export type DashboardStatusFilter =
| "free_trial"
| "expired";
export type DashboardProductVersionFilter = {
productId: string;
version: number;
};
export const parseDashboardStatusFilter = (
raw: string[] | undefined,
): DashboardStatusFilter[] =>
(raw ?? []).filter(
(s): s is DashboardStatusFilter =>
s === "active" ||
s === "past_due" ||
s === "canceled" ||
s === "free_trial" ||
s === "expired",
);
type DashboardProcessorFilter = NonNullable<
ListCustomersV2Params["processors"]
>[number];
export const parseDashboardProcessorFilter = (
raw: string[] | undefined,
): ListCustomersV2Params["processors"] =>
(raw ?? []).filter(
(p): p is DashboardProcessorFilter =>
p === "stripe" || p === "revenuecat" || p === "vercel",
);
export type DashboardProductVersionFilter =
| { productId: string; version: number; custom?: never }
| { productId: string; custom: true; version?: never };
export const isCustomDashboardProductFilter = (
filter: DashboardProductVersionFilter,
): filter is Extract<DashboardProductVersionFilter, { custom: true }> =>
"custom" in filter;
export const isVersionDashboardProductFilter = (
filter: DashboardProductVersionFilter,
): filter is Extract<DashboardProductVersionFilter, { version: number }> =>
"version" in filter;
export const parseDashboardVersionFilter = (
raw: string[] | undefined,
): DashboardProductVersionFilter[] =>
(raw ?? []).flatMap((value): DashboardProductVersionFilter[] => {
if (!value) return [];
const [productId, version] = value.split(":");
if (!productId || !version) return [];
if (version === "custom") return [{ productId, custom: true }];
const parsedVersion = Number.parseInt(version, 10);
if (Number.isNaN(parsedVersion)) return [];
return [{ productId, version: parsedVersion }];
});
const dashboardProductFilterToCustomerListSql = (
filter: DashboardProductVersionFilter,
{ orgId, env }: { orgId?: string; env?: string } = {},
): SQL =>
isCustomDashboardProductFilter(filter)
? sql`(cp_dash.product_id = ${filter.productId} AND cp_dash.is_custom = true)`
: orgId && env
? sql`cp_dash.internal_product_id IN (
SELECT p_lookup.internal_id
FROM products p_lookup
WHERE p_lookup.org_id = ${orgId}
AND p_lookup.env = ${env}
AND p_lookup.id = ${filter.productId}
AND p_lookup.version = ${filter.version}
)`
: sql`(p_dash.id = ${filter.productId} AND p_dash.version = ${filter.version})`;
const buildOptimizedCusProductsCTE = ({
inStatuses,
@@ -557,6 +622,8 @@ export const getPaginatedFullCusQuery = ({
const customerListFilterSql = getCustomerListFilterSql({
internalCustomerIds,
orgId,
env,
inStatuses,
plans,
processors,
@@ -865,6 +932,8 @@ export const hasCustomerListFilters = ({
export const getCustomerListFilterSql = ({
internalCustomerIds,
orgId,
env,
inStatuses,
plans,
processors,
@@ -874,6 +943,8 @@ export const getCustomerListFilterSql = ({
productVersionFilters,
}: {
internalCustomerIds?: string[];
orgId?: string;
env?: string;
inStatuses?: CusProductStatus[];
plans?: ListCustomersV2Params["plans"];
processors?: ListCustomersV2Params["processors"];
@@ -963,10 +1034,13 @@ export const getCustomerListFilterSql = ({
)`);
}
const productFilters = productVersionFilters ?? [];
const hasStatus = statusFilters && statusFilters.length > 0;
const hasVersion =
productVersionFilters && productVersionFilters.length > 0;
if (hasStatus || hasVersion) {
const hasProductFilter = productFilters.length > 0;
const hasVersion = productFilters.some(isVersionDashboardProductFilter);
const canUseProductCandidateSet =
orgId && env && productFilters.every(isVersionDashboardProductFilter);
if (hasStatus || hasProductFilter) {
const innerClauses: SQL[] = [];
// Mirrors CusSearchService.buildSearchPredicates productMode:
@@ -1006,15 +1080,24 @@ export const getCustomerListFilterSql = ({
innerClauses.push(sql`(${sql.join(statusClauses, sql` OR `)})`);
}
if (hasVersion) {
const versionClauses = productVersionFilters!.map(
(pv) =>
sql`(cp_dash.product_id = ${pv.productId} AND p_dash.version = ${pv.version})`,
if (hasProductFilter) {
const versionClauses = productFilters.map(
(filter) => dashboardProductFilterToCustomerListSql(filter, { orgId, env }),
);
innerClauses.push(sql`(${sql.join(versionClauses, sql` OR `)})`);
}
if (canUseProductCandidateSet) {
filters.push(sql`AND c.internal_id IN (
SELECT cp_dash.internal_customer_id
FROM customer_products cp_dash
WHERE ${sql.join(innerClauses, sql` AND `)}
)`);
return sql.join(filters, sql` `);
}
const joinProducts = hasVersion
&& !(orgId && env)
? sql`JOIN products p_dash ON cp_dash.internal_product_id = p_dash.internal_id`
: sql``;

View File

@@ -1,20 +1,14 @@
import { Scopes } from "@autumn/shared";
import { z } from "zod/v4";
import { createRoute } from "@/honoMiddlewares/routeHandler";
import { CustomerListFiltersSchema } from "../customerListFilters";
import { CusSearchService } from "../CusSearchService";
export const handleCountCustomers = createRoute({
scopes: [Scopes.Customers.Read],
body: z.object({
search: z.string().optional(),
filters: z
.object({
status: z.array(z.string()).optional(),
version: z.array(z.string()).optional(),
none: z.boolean().optional(),
processor: z.array(z.string()).optional(),
})
.optional(),
filters: CustomerListFiltersSchema.optional(),
}),
handler: async (c) => {
const { db, org, env } = c.get("ctx");

View File

@@ -1,6 +1,7 @@
import { Scopes, StandardCursor } from "@autumn/shared";
import { z } from "zod/v4";
import { createRoute } from "@/honoMiddlewares/routeHandler";
import { CustomerListFiltersSchema } from "../customerListFilters";
import { CusBatchService } from "../CusBatchService";
export const handleGetFullCustomers = createRoute({
@@ -9,14 +10,7 @@ export const handleGetFullCustomers = createRoute({
search: z.string().optional(),
limit: z.number().int().min(1).max(1000).optional().default(50),
cursor: z.string().optional().default(""),
filters: z
.object({
status: z.array(z.string()).optional(),
version: z.array(z.string()).optional(),
none: z.boolean().optional(),
processor: z.array(z.string()).optional(),
})
.optional(),
filters: CustomerListFiltersSchema.optional(),
}),
handler: async (c) => {
const ctx = c.get("ctx");

View File

@@ -1,6 +1,7 @@
import { type FullCusProduct, Scopes, StandardCursor } from "@autumn/shared";
import { z } from "zod/v4";
import { createRoute } from "@/honoMiddlewares/routeHandler";
import { CustomerListFiltersSchema } from "../customerListFilters";
import { CusBatchService } from "../CusBatchService";
export const handleSearchCustomers = createRoute({
@@ -9,14 +10,7 @@ export const handleSearchCustomers = createRoute({
search: z.string().optional(),
limit: z.number().int().min(1).max(1000).optional().default(50),
cursor: z.string().optional().default(""),
filters: z
.object({
status: z.array(z.string()).optional(),
version: z.array(z.string()).optional(),
none: z.boolean().optional(),
processor: z.array(z.string()).optional(),
})
.optional(),
filters: CustomerListFiltersSchema.optional(),
}),
handler: async (c) => {
const ctx = c.get("ctx");

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

@@ -9,6 +9,10 @@ import {
migrationItemRunRepo,
} from "../../repos/index.js";
import type { RunScopeItem } from "../../run/types/runScope.js";
import {
normalizeRetryItemStatuses,
type RetryableMigrationItemRunStatus,
} from "../../run/utils/retryItemStatuses.js";
export type MigrationItemTrackingResult = {
itemPreview: MigrationItemPreview | null;
@@ -169,7 +173,7 @@ export const withMigrationItemTracking = async <
item,
dryRun,
claimItemRun = false,
retryFailed = false,
retryItemStatuses,
run,
}: {
ctx: AutumnContext;
@@ -178,10 +182,13 @@ export const withMigrationItemTracking = async <
item: RunScopeItem;
dryRun: boolean;
claimItemRun?: boolean;
retryFailed?: boolean;
retryItemStatuses?: RetryableMigrationItemRunStatus[];
run: () => Promise<T>;
}): Promise<T | undefined> => {
if (claimItemRun) {
const retryStatuses = normalizeRetryItemStatuses({
retryItemStatuses,
});
const claim = await migrationItemRunRepo.claim({
ctx,
migrationInternalId,
@@ -189,7 +196,8 @@ export const withMigrationItemTracking = async <
dryRun,
itemKind: item.kind,
itemId: item.internal_id,
claimBehavior: retryFailed ? "retry_failed" : "claim_new",
claimBehavior: retryStatuses.length > 0 ? "retry_statuses" : "claim_new",
retryStatuses,
});
if (!claim.claimed) {

View File

@@ -56,13 +56,6 @@ export const withMigrationRunClaim = async ({
});
}
// Lazy-mode runs need to land on `ctx.org.pendingMigrations` for every
// authed request, so bust the cached api-key payload here. Non-lazy runs
// have no effect on the hot path until the trigger task starts mutating.
if (lazyRun) {
await clearOrgCache({ db: ctx.db, orgId: ctx.org.id, env: ctx.env });
}
let result: { triggerRunId?: string } | undefined;
try {
result = await claimed(migrationRun.internal_id);
@@ -106,6 +99,12 @@ export const withMigrationRunClaim = async ({
}
}
// Publish lazy-mode runs only after claim setup succeeds, so customer
// request-path tasks cannot observe a migration before prepare completes.
if (lazyRun) {
await clearOrgCache({ db: ctx.db, orgId: ctx.org.id, env: ctx.env });
}
return {
migrationRunId: migrationRun.internal_id,
triggerRunId: result?.triggerRunId,

View File

@@ -1,6 +1,10 @@
import { MigrationRunStatus } from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { migrationRunRepo } from "../../repos/index.js";
import {
clearMigrationCancelRequested,
isMigrationCancelRequested,
} from "../../run/utils/migrationCancelToken.js";
export const withMigrationRunTracking = async <T>({
ctx,
@@ -22,14 +26,29 @@ export const withMigrationRunTracking = async <T>({
try {
const result = await run();
// In-flight items have drained. If cancellation was requested mid-run,
// settle as `canceled` rather than `succeeded`.
const cancelRequested = await isMigrationCancelRequested({
migrationRunId,
});
await migrationRunRepo.update({
ctx,
internalId: migrationRunId,
updates: {
status: MigrationRunStatus.Succeeded,
finished_at: Date.now(),
},
updates: cancelRequested
? {
status: MigrationRunStatus.Canceled,
error_message: "Canceled by user",
finished_at: Date.now(),
}
: {
status: MigrationRunStatus.Succeeded,
finished_at: Date.now(),
},
});
if (cancelRequested) {
await clearMigrationCancelRequested({ migrationRunId });
}
return result;
} catch (error) {
await migrationRunRepo.update({

View File

@@ -1,5 +1,6 @@
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import type { RunScopeItem } from "../run/types/runScope.js";
import type { RetryableMigrationItemRunStatus } from "../run/utils/retryItemStatuses.js";
export type MigrationRunControls = {
concurrency?: number;
@@ -7,6 +8,7 @@ export type MigrationRunControls = {
only?: string[] | null;
checkpoint?: boolean;
checkpointDryRun?: boolean;
retryItemStatuses?: RetryableMigrationItemRunStatus[];
};
export type MigrationBatchResult<Row extends Record<string, unknown>> = {

View File

@@ -1,21 +1,64 @@
import type { CustomerFilter, MigrationItemRunStatus } from "@autumn/shared";
import { compileFilter } from "@autumn/shared/api/migrations/compiler/compileFilter.js";
import type { ResolutionContext } from "@autumn/shared/api/migrations/compiler/filterToIr/resolutionContext.js";
import { buildCustomerCandidateQuery } from "@autumn/shared/api/migrations/filters/planner/buildCustomerCandidateQuery.js";
import { type SQL, sql } from "drizzle-orm";
import type { CustomerListFilters } from "@/internal/customers/customerListFilters.js";
import {
getCustomerListFilterSql,
parseDashboardProcessorFilter,
parseDashboardStatusFilter,
parseDashboardVersionFilter,
} from "@/internal/customers/getFullCusQuery.js";
import { rawWithParamsToDrizzle } from "../rawWithParamsToDrizzle.js";
export type IncludeProcessed = {
migrationInternalId: string;
executionFilter?: CustomerExecutionStatusFilter;
};
export type CustomerExecutionStatus =
| MigrationItemRunStatus
| "not_run"
| "queued";
export type CustomerExecutionStatusFilter = {
statuses: CustomerExecutionStatus[];
migrationRunId?: string;
dryRun?: boolean;
queuedRun?: {
migrationRunId: string;
dryRun: boolean;
onlyIds?: string[];
targetLimit?: number;
};
};
export type CustomerQueryArgs = {
orgId: string;
env: string;
filter: CustomerFilter;
ctx: ResolutionContext;
checkpoint?: CustomerCheckpointExclusion;
search?: string;
customerFilters?: CustomerListFilters;
};
const compileWhere = ({ orgId, env, filter, ctx }: CustomerQueryArgs): SQL =>
rawWithParamsToDrizzle(
compileFilter({ filter, ctx, ambient: { orgId, env } }),
);
const compileCustomerCandidate = ({
orgId,
env,
filter,
ctx,
}: CustomerQueryArgs): { source: SQL; where: SQL } => {
const candidate = buildCustomerCandidateQuery({
filter,
ctx,
ambient: { orgId, env },
});
return {
source: rawWithParamsToDrizzle(candidate.source),
where: rawWithParamsToDrizzle(candidate.where),
};
};
export type CustomerCheckpointExclusion = {
migrationInternalId: string;
@@ -56,10 +99,201 @@ const buildCheckpointWhere = (
`;
};
const buildCustomerListWhere = ({
orgId,
env,
search,
customerFilters,
}: {
orgId: string;
env: string;
search?: string;
customerFilters?: CustomerListFilters;
}): SQL =>
getCustomerListFilterSql({
orgId,
env,
search,
statusFilters: parseDashboardStatusFilter(customerFilters?.status),
noneFilter: customerFilters?.none,
productVersionFilters: parseDashboardVersionFilter(customerFilters?.version),
processors: parseDashboardProcessorFilter(customerFilters?.processor),
});
const buildProcessedIn = (includeProcessed: IncludeProcessed): SQL => sql`
c.internal_id IN (
SELECT mir.item_id FROM migration_item_runs mir
WHERE mir.migration_internal_id = ${includeProcessed.migrationInternalId}
AND mir.item_kind = 'customer'
AND mir.dry_run = false
)`;
const buildExecutionScope = (
migrationInternalId: string,
filter: Pick<
CustomerExecutionStatusFilter,
"migrationRunId" | "dryRun"
> | 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 buildQueuedTargetWhere = (
queuedRun: CustomerExecutionStatusFilter["queuedRun"],
): SQL => {
if (!queuedRun) return sql`false`;
if (queuedRun.targetLimit !== undefined) return sql`false`;
if (queuedRun.onlyIds && queuedRun.onlyIds.length > 0) {
const ids = sql.join(
queuedRun.onlyIds.map((id) => sql`${id}`),
sql`, `,
);
return sql`(c.internal_id IN (${ids}) OR c.id IN (${ids}))`;
}
return sql`true`;
};
const buildQueuedWhere = (
includeProcessed: IncludeProcessed,
filter: CustomerExecutionStatusFilter,
): SQL => {
const claimedScope = filter.queuedRun?.dryRun
? {
migrationRunId: filter.queuedRun.migrationRunId,
dryRun: true,
}
: { dryRun: false };
return sql`
${buildQueuedTargetWhere(filter.queuedRun)}
AND NOT EXISTS (
SELECT 1
FROM migration_item_runs mir
WHERE ${buildExecutionScope(
includeProcessed.migrationInternalId,
claimedScope,
)}
AND mir.item_id = c.internal_id
)
`;
};
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" && status !== "queued",
);
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
)
AND NOT (${buildQueuedTargetWhere(filter.queuedRun)})
`);
}
if (includeNotRun && filter.statuses.includes("queued")) {
clauses.push(buildQueuedWhere(includeProcessed, filter));
}
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 hasQueued = statuses.includes("queued");
const hasPending = hasNotRun || hasQueued;
const hasExplicit = statuses.some(
(status) => status !== "not_run" && status !== "queued",
);
if (hasExplicit && hasPending) 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,
orgId,
env,
search,
customerFilters,
afterInternalId,
includeProcessed,
includeNotRun,
}: {
checkpoint?: CustomerCheckpointExclusion;
orgId: string;
env: string;
search?: string;
customerFilters?: CustomerListFilters;
afterInternalId?: string;
includeProcessed?: IncludeProcessed;
includeNotRun?: boolean;
}): SQL => {
const cursor = afterInternalId
? sql`AND c.internal_id < ${afterInternalId}`
: sql``;
return sql`${buildCheckpointWhere(checkpoint)} ${buildCustomerListWhere({ orgId, env, search, customerFilters })} ${buildExecutionStatusWhere(includeProcessed, { includeNotRun })} ${cursor}`;
};
/**
* Full SELECT. Returns `{ internal_id, id }` rows newest-first via keyset
* pagination on `c.internal_id DESC`, so successive iterations over an
* unchanged customer set yield rows in the same order.
*
* Pure filter set only — the run path. To also surface already-processed
* customers (preview live view), use `buildProcessedPreviewSelect`.
*/
export const buildCustomerSelect = ({
orgId,
@@ -67,22 +301,20 @@ export const buildCustomerSelect = ({
filter,
ctx,
checkpoint,
search,
customerFilters,
limit,
afterInternalId,
}: CustomerQueryArgs & {
limit?: number;
afterInternalId?: string;
}): SQL => {
const where = compileWhere({ orgId, env, filter, ctx });
const checkpointWhere = buildCheckpointWhere(checkpoint);
const cursor = afterInternalId
? sql`AND c.internal_id < ${afterInternalId}`
: sql``;
const candidate = compileCustomerCandidate({ orgId, env, filter, ctx });
const limitClause = limit !== undefined ? sql`LIMIT ${limit}` : sql``;
return sql`
SELECT c.internal_id, c.id, c.name, c.email
FROM customers c
WHERE (${where}) ${checkpointWhere} ${cursor}
FROM ${candidate.source}
WHERE (${candidate.where}) ${buildCommonWhere({ checkpoint, orgId, env, search, customerFilters, afterInternalId })}
ORDER BY c.internal_id DESC
${limitClause}
`;
@@ -95,12 +327,147 @@ export const buildCustomerCount = ({
filter,
ctx,
checkpoint,
search,
customerFilters,
}: CustomerQueryArgs): SQL => {
const where = compileWhere({ orgId, env, filter, ctx });
const checkpointWhere = buildCheckpointWhere(checkpoint);
const candidate = compileCustomerCandidate({ orgId, env, filter, ctx });
return sql`
SELECT COUNT(*)::bigint AS count
FROM customers c
WHERE (${where}) ${checkpointWhere}
FROM ${candidate.source}
WHERE (${candidate.where}) ${buildCommonWhere({ checkpoint, orgId, env, search, customerFilters })}
`;
};
export const buildLimitedCustomerCount = ({
limit,
...args
}: CustomerQueryArgs & { limit: number }): SQL => {
const candidate = compileCustomerCandidate(args);
return sql`
SELECT COUNT(*)::bigint AS count
FROM (
SELECT 1
FROM ${candidate.source}
WHERE (${candidate.where}) ${buildCommonWhere({
checkpoint: args.checkpoint,
orgId: args.orgId,
env: args.env,
search: args.search,
customerFilters: args.customerFilters,
})}
LIMIT ${limit}
) limited
`;
};
// ─── Preview-only: filter set already-processed set ────────────────
// The live view surfaces customers an in-flight migration already ran for,
// which the live filter no longer matches. We UNION the two scoped sets
// rather than OR them: an `OR ... IN (...)` strips org/env scoping from the
// customers scan and forces a full-table seq scan, whereas each UNION branch
// keeps its own index. Equivalent to `(filter OR processed) AND <common>`
// because `<common>` (checkpoint/search/cursor) is applied per branch.
type ProcessedPreviewArgs = CustomerQueryArgs & {
includeProcessed: IncludeProcessed;
};
export const buildProcessedPreviewSelect = ({
orgId,
env,
filter,
ctx,
checkpoint,
search,
customerFilters,
includeProcessed,
limit,
afterInternalId,
}: ProcessedPreviewArgs & {
limit?: number;
afterInternalId?: string;
}): SQL => {
const candidate = compileCustomerCandidate({ 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, orgId, env, search, customerFilters, 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 ${candidate.source}
WHERE (${candidate.where}) ${buildCommonWhere({ checkpoint, orgId, env, search, customerFilters, 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 ${candidate.source}
WHERE (${candidate.where}) ${buildCommonWhere({ checkpoint, orgId, env, search, customerFilters, afterInternalId, includeProcessed })}
UNION
SELECT c.internal_id, c.id, c.name, c.email
FROM customers c
WHERE (${processed}) ${buildCommonWhere({ checkpoint, orgId, env, search, customerFilters, afterInternalId, includeProcessed, includeNotRun: false })}
) u
ORDER BY u.internal_id DESC
${limitClause}
`;
};
export const buildProcessedPreviewCount = ({
orgId,
env,
filter,
ctx,
checkpoint,
search,
customerFilters,
includeProcessed,
}: ProcessedPreviewArgs): SQL => {
const candidate = compileCustomerCandidate({ 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, orgId, env, search, customerFilters, includeProcessed, includeNotRun: false })}
`;
}
if (mode === "not_run_only") {
return sql`
SELECT COUNT(*)::bigint AS count
FROM ${candidate.source}
WHERE (${candidate.where}) ${buildCommonWhere({ checkpoint, orgId, env, search, customerFilters, includeProcessed })}
`;
}
return sql`
SELECT COUNT(*)::bigint AS count
FROM (
SELECT c.internal_id
FROM ${candidate.source}
WHERE (${candidate.where}) ${buildCommonWhere({ checkpoint, orgId, env, search, customerFilters, includeProcessed })}
UNION
SELECT c.internal_id
FROM customers c
WHERE (${processed}) ${buildCommonWhere({ checkpoint, orgId, env, search, customerFilters, includeProcessed, includeNotRun: false })}
) u
`;
};

View File

@@ -1,10 +1,15 @@
import type { CustomerFilter } from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import type { CustomerListFilters } from "@/internal/customers/customerListFilters.js";
import { iterateOverFilterResults } from "../iterateOverFilterResults.js";
import {
buildCustomerCount,
buildCustomerSelect,
buildLimitedCustomerCount,
buildProcessedPreviewCount,
buildProcessedPreviewSelect,
type CustomerCheckpointExclusion,
type IncludeProcessed,
} from "./buildCustomerSelect.js";
export type CustomerRow = {
@@ -14,6 +19,50 @@ export type CustomerRow = {
email: string | null;
};
const buildArgs = ({
ctx,
filter,
checkpoint,
search,
customerFilters,
}: {
ctx: AutumnContext;
filter: CustomerFilter;
checkpoint?: CustomerCheckpointExclusion;
search?: string;
customerFilters?: CustomerListFilters;
}) => ({
orgId: ctx.org.id,
env: ctx.env,
filter,
checkpoint,
search,
customerFilters,
ctx: { features: ctx.features },
});
type CustomerSelectArgs = ReturnType<typeof buildArgs>;
const buildRowsSelect = ({
args,
includeProcessed,
limit,
afterInternalId,
}: {
args: CustomerSelectArgs;
includeProcessed?: IncludeProcessed;
limit?: number;
afterInternalId?: string;
}) =>
includeProcessed
? buildProcessedPreviewSelect({
...args,
includeProcessed,
limit,
afterInternalId,
})
: buildCustomerSelect({ ...args, limit, afterInternalId });
/**
* Pure inner: takes a CustomerFilter directly. Used by `runFilter` shim
* (Migration-fed) and reusable from scripts that don't have a Migration.
@@ -22,26 +71,68 @@ export const filterCustomers = ({
ctx,
filter,
checkpoint,
search,
customerFilters,
includeProcessed,
batchSize,
limit,
}: {
ctx: AutumnContext;
filter: CustomerFilter;
checkpoint?: CustomerCheckpointExclusion;
search?: string;
customerFilters?: CustomerListFilters;
includeProcessed?: IncludeProcessed;
batchSize?: number;
limit?: number;
}): AsyncGenerator<CustomerRow[]> => {
const args = {
orgId: ctx.org.id,
env: ctx.env,
filter,
checkpoint,
ctx: { features: ctx.features },
};
return iterateOverFilterResults<CustomerRow>({
const args = buildArgs({ ctx, filter, checkpoint, search, customerFilters });
const source = iterateOverFilterResults<CustomerRow>({
db: ctx.db,
buildSelect: ({ limit, afterInternalId }) =>
buildCustomerSelect({ ...args, limit, afterInternalId }),
batchSize,
buildRowsSelect({ args, includeProcessed, limit, afterInternalId }),
batchSize:
limit === undefined ? batchSize : Math.min(batchSize ?? limit, limit),
});
return limit === undefined ? source : takeRows(source, limit);
};
export const getCustomerPage = async ({
ctx,
filter,
checkpoint,
search,
customerFilters,
includeProcessed,
pageSize,
cursor,
}: {
ctx: AutumnContext;
filter: CustomerFilter;
checkpoint?: CustomerCheckpointExclusion;
search?: string;
customerFilters?: CustomerListFilters;
includeProcessed?: IncludeProcessed;
pageSize: number;
cursor?: string;
}): Promise<{ rows: CustomerRow[]; nextCursor: string | null }> => {
const args = buildArgs({ ctx, filter, checkpoint, search, customerFilters });
const rows = (await ctx.db.execute(
buildRowsSelect({
args,
includeProcessed,
limit: pageSize + 1,
afterInternalId: cursor || undefined,
}),
)) as CustomerRow[];
const pageRows = rows.slice(0, pageSize);
return {
rows: pageRows,
nextCursor:
rows.length > pageSize
? (pageRows[pageRows.length - 1]?.internal_id ?? null)
: null,
};
};
/** Count of customers matching `filter`. */
@@ -49,19 +140,42 @@ export const countCustomers = async ({
ctx,
filter,
checkpoint,
search,
customerFilters,
includeProcessed,
limit,
}: {
ctx: AutumnContext;
filter: CustomerFilter;
checkpoint?: CustomerCheckpointExclusion;
search?: string;
customerFilters?: CustomerListFilters;
includeProcessed?: IncludeProcessed;
limit?: number;
}): Promise<number> => {
const [{ count }] = (await ctx.db.execute(
buildCustomerCount({
orgId: ctx.org.id,
env: ctx.env,
filter,
checkpoint,
ctx: { features: ctx.features },
}),
)) as Array<{ count: bigint | number }>;
const args = buildArgs({ ctx, filter, checkpoint, search, customerFilters });
const query = includeProcessed
? buildProcessedPreviewCount({ ...args, includeProcessed })
: limit === undefined
? buildCustomerCount(args)
: buildLimitedCustomerCount({ ...args, limit });
const [{ count }] = (await ctx.db.execute(query)) as Array<{
count: bigint | number;
}>;
return Number(count);
};
async function* takeRows<TRow>(
source: AsyncGenerator<TRow[]>,
limit: number,
): AsyncGenerator<TRow[]> {
let remaining = limit;
if (remaining <= 0) return;
for await (const batch of source) {
const next = batch.slice(0, remaining);
if (next.length > 0) yield next;
remaining -= next.length;
if (remaining <= 0) return;
}
}

View File

@@ -1,7 +1,11 @@
import { MigrationItemRunStatus } from "@autumn/shared";
import {
MigrationItemRunStatus,
type MigrationItemRunStatus as MigrationItemRunStatusType,
} from "@autumn/shared";
import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js";
import type { MigrationRunControls } from "../cloudAdapter/types.js";
import type { RunScopeItem, RunScopeKind } from "../run/types/runScope.js";
import { normalizeRetryItemStatuses } from "../run/utils/retryItemStatuses.js";
import type {
MigrationRuntime,
MigrationRuntimeWithEventId,
@@ -51,14 +55,20 @@ export const runFilter = async ({
dryRun,
controls,
});
const count = await countCustomers({ ctx, filter, checkpoint });
const limit = controls?.limit ?? undefined;
const count = await countCustomers({
ctx,
filter,
checkpoint,
limit,
});
ctx.logger.info("runFilter: customer scope resolved", {
data: {
migrationRunId,
matchedCount: count,
only: controls?.only,
retryFailed: migration.retry_failed === true,
retryItemStatuses: controls?.retryItemStatuses,
effectiveFilter: filter,
checkpointExcludedStatuses: checkpoint?.excludedStatuses,
},
@@ -67,7 +77,7 @@ export const runFilter = async ({
ctx.logger.warn(
"runFilter: no customers matched — nothing to migrate. " +
"Common causes: customer is excluded by a previous item_run " +
"(set retry_failed=true to re-run failed items), or the customer " +
"(set retry_item_statuses to re-run checkpointed items), or the customer " +
"does not match other filter clauses (plan, addon, etc.)",
{
data: {
@@ -79,7 +89,12 @@ export const runFilter = async ({
}
const iterate = async function* () {
for await (const batch of filterCustomers({ ctx, filter, checkpoint })) {
for await (const batch of filterCustomers({
ctx,
filter,
checkpoint,
limit,
})) {
yield batch.map(
(row): RunScopeItem => ({
kind: "customer",
@@ -109,11 +124,19 @@ const getCustomerCheckpointExclusion = ({
(!dryRun || controls?.checkpointDryRun === true);
if (!enabled) return undefined;
const excludedStatuses = [
const retryItemStatuses = normalizeRetryItemStatuses({
retryItemStatuses: controls?.retryItemStatuses,
});
const retryItemStatusSet = new Set(retryItemStatuses);
const excludedStatuses: MigrationItemRunStatusType[] = [
MigrationItemRunStatus.Running,
MigrationItemRunStatus.Succeeded,
MigrationItemRunStatus.Skipped,
...(migration.retry_failed ? [] : [MigrationItemRunStatus.Failed]),
...(retryItemStatusSet.has(MigrationItemRunStatus.Skipped)
? []
: [MigrationItemRunStatus.Skipped]),
...(retryItemStatusSet.has(MigrationItemRunStatus.Failed)
? []
: [MigrationItemRunStatus.Failed]),
];
return {

View File

@@ -4,21 +4,25 @@ import {
RecaseError,
Scopes,
} from "@autumn/shared";
import { runs } from "@trigger.dev/sdk/v3";
import { z } from "zod/v4";
import { createRoute } from "@/honoMiddlewares/routeHandler";
import {
migrationRepo,
migrationRunRepo,
} from "@/internal/migrations/v2/repos/index.js";
import { setMigrationCancelRequested } from "@/internal/migrations/v2/run/utils/migrationCancelToken.js";
import { clearOrgCache } from "@/internal/orgs/orgUtils/clearOrgCache.js";
const CancelMigrationRunBody = z.object({
id: z.string(),
});
/** POST /migrations.cancel_run — cancel the active migration_run for a
* migration, if any. Marks the run as `canceled` and best-effort
* cancels the trigger.dev task. Errors if no active run exists. */
/** POST /migrations.cancel_run — request cancellation of the active
* migration_run for a migration, if any. Sets a cache token so in-flight
* items finish but no new items start. Lazy runs are marked `canceled`
* immediately (and the org cache cleared) so no further per-customer tasks
* are enqueued; batch runs settle to `canceled` once their runner drains.
* Errors if no active run exists. */
export const handleCancelMigrationRun = createRoute({
scopes: [Scopes.Migrations.Write],
body: CancelMigrationRunBody,
@@ -43,32 +47,31 @@ export const handleCancelMigrationRun = createRoute({
});
}
if (activeRun.trigger_run_id) {
try {
await runs.cancel(activeRun.trigger_run_id);
} catch (error) {
ctx.logger.warn(
"cancel-migration-run: trigger.dev cancel failed (continuing to mark canceled)",
{
data: {
runId: activeRun.internal_id,
triggerRunId: activeRun.trigger_run_id,
error: error instanceof Error ? error.message : String(error),
},
},
);
}
}
await setMigrationCancelRequested({ migrationRunId: activeRun.internal_id });
await migrationRunRepo.update({
ctx,
internalId: activeRun.internal_id,
updates: {
status: MigrationRunStatus.Canceled,
error_message: "Canceled by user",
finished_at: Date.now(),
},
});
// Lazy runs have no batch loop to drain. Mark them canceled now and clear
// the org cache so `pendingMigrations` drops this run and the customer
// hot path stops enqueuing per-customer tasks. Batch runs are settled to
// `canceled` by their own runner (withMigrationRunTracking) after the
// in-flight items finish.
if (activeRun.lazy_run) {
await migrationRunRepo.update({
ctx,
internalId: activeRun.internal_id,
updates: {
status: MigrationRunStatus.Canceled,
error_message: "Canceled by user",
finished_at: Date.now(),
},
});
await clearOrgCache({
db: ctx.db,
orgId: ctx.org.id,
env: ctx.env,
logger: ctx.logger,
});
}
return c.json({
migration_id: id,

View File

@@ -9,6 +9,7 @@ const CreateMigrationBody = z.object({
id: z.string().min(1).max(200),
filter: MigrationFilterSchema.nullable().optional(),
operations: OperationsSchema.nullable().optional(),
no_billing_changes: z.boolean().optional(),
});
/** POST /migrations.create — create a draft migration. */

View File

@@ -6,6 +6,7 @@ import { migrationItemEventRepo } from "../repos/index.js";
const ListMigrationItemEventsBody = z.object({
migrationId: z.string(),
migrationRunId: z.string().optional(),
itemIds: z.array(z.string()).optional(),
});
export const handleListMigrationItemEvents = createRoute({
@@ -13,11 +14,12 @@ export const handleListMigrationItemEvents = createRoute({
body: ListMigrationItemEventsBody,
handler: async (c) => {
const ctx = c.get("ctx");
const { migrationId, migrationRunId } = c.req.valid("json");
const { migrationId, migrationRunId, itemIds } = c.req.valid("json");
const events = await migrationItemEventRepo.list({
ctx,
migrationId,
migrationRunId,
itemIds,
});
return c.json({ list: events });

View File

@@ -1,7 +1,11 @@
import { Scopes } from "@autumn/shared";
import { z } from "zod/v4";
import { createRoute } from "@/honoMiddlewares/routeHandler";
import { migrationRepo, migrationRunRepo } from "../repos/index.js";
import {
migrationItemRunRepo,
migrationRepo,
migrationRunRepo,
} from "../repos/index.js";
const ListMigrationRunsBody = z.object({
migrationId: z.string(),
@@ -18,7 +22,47 @@ export const handleListMigrationRuns = createRoute({
ctx,
migrationInternalId: migration.internal_id,
});
const dryRunIds = runs
.filter((run) => run.dry_run)
.map((run) => run.internal_id);
const hasLiveRuns = runs.some((run) => !run.dry_run);
return c.json({ list: runs });
const countRows = await migrationItemRunRepo.listCountsByRun({
ctx,
migrationInternalId: migration.internal_id,
migrationRunIds: dryRunIds,
});
const liveCounts = hasLiveRuns
? await migrationItemRunRepo.getCounts({
ctx,
migrationInternalId: migration.internal_id,
dryRun: false,
})
: null;
const countsByRunId = new Map(
countRows.map((row) => [row.migration_run_id, row]),
);
const runsWithCounts = runs.map((run) => {
const counts = run.dry_run
? countsByRunId.get(run.internal_id)
: liveCounts;
const succeeded = counts?.succeeded ?? 0;
const skipped = counts?.skipped ?? 0;
const failed = counts?.failed ?? 0;
return {
...run,
item_run_counts: {
total: counts?.total ?? 0,
running: counts?.running ?? 0,
succeeded,
skipped,
failed,
completed: succeeded + skipped + failed,
},
};
});
return c.json({ list: runsWithCounts });
},
});

View File

@@ -1,4 +1,9 @@
import { Scopes } from "@autumn/shared";
import {
MigrationItemKind,
migrationItemRuns,
Scopes,
} from "@autumn/shared";
import { and, eq, inArray, sql } from "drizzle-orm";
import { createRoute } from "@/honoMiddlewares/routeHandler";
import { migrationRepo } from "@/internal/migrations/v2/repos/index.js";
@@ -8,6 +13,33 @@ export const handleListMigrations = createRoute({
handler: async (c) => {
const ctx = c.get("ctx");
const migrations = await migrationRepo.get({ ctx });
return c.json({ list: migrations });
if (migrations.length === 0) return c.json({ list: [] });
const internalIds = migrations.map((m) => m.internal_id);
const rows = await ctx.db
.select({
migration_internal_id: migrationItemRuns.migration_internal_id,
count: sql<number>`count(*)::int`,
})
.from(migrationItemRuns)
.where(
and(
inArray(migrationItemRuns.migration_internal_id, internalIds),
eq(migrationItemRuns.item_kind, MigrationItemKind.Customer),
eq(migrationItemRuns.dry_run, false),
),
)
.groupBy(migrationItemRuns.migration_internal_id);
const liveRunSet = new Set(rows.map((r) => r.migration_internal_id));
const enriched = migrations.map((m) => ({
...m,
has_live_runs: liveRunSet.has(m.internal_id),
}));
return c.json({ list: enriched });
},
});

View File

@@ -11,7 +11,8 @@ const PatchMigrationBody = z.object({
id: z.string().min(1).max(200).optional(),
filter: MigrationFilterSchema.nullable().optional(),
operations: OperationsSchema.nullable().optional(),
retry_failed: z.boolean().optional(),
no_billing_changes: z.boolean().nullable().optional(),
archived: z.boolean().optional(),
}),
});

View File

@@ -2,25 +2,52 @@ import {
CustomerFilterSchema,
customerProducts,
customers,
MigrationItemKind,
products,
RELEVANT_STATUSES,
Scopes,
} from "@autumn/shared";
import { eq, inArray } from "drizzle-orm";
import { and, eq, inArray } from "drizzle-orm";
import { z } from "zod/v4";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
import { CustomerListFiltersSchema } from "@/internal/customers/customerListFilters.js";
import {
countCustomers,
filterCustomers,
getCustomerPage,
} from "@/internal/migrations/v2/filters/customers/filterCustomers.js";
import type { IncludeProcessed } from "../filters/customers/buildCustomerSelect.js";
import {
migrationItemRunRepo,
migrationRepo,
migrationRunRepo,
} from "../repos/index.js";
const DEFAULT_PAGE_SIZE = 10;
const DEFAULT_PAGE_SIZE = 50;
const PreviewFilterBody = z.object({
filter: CustomerFilterSchema.optional().default({}),
search: z.string().optional().default(""),
page: z.number().int().min(0).optional().default(0),
pageSize: z.number().int().min(1).max(500).optional().default(DEFAULT_PAGE_SIZE),
customerFilters: CustomerListFiltersSchema.optional(),
cursor: z.string().optional().default(""),
includeCount: z.boolean().optional().default(true),
countOnly: z.boolean().optional().default(false),
pageSize: z
.number()
.int()
.min(1)
.max(500)
.optional()
.default(DEFAULT_PAGE_SIZE),
migrationId: z.string().optional(),
executionStatuses: z
.array(
z.enum(["queued", "running", "succeeded", "skipped", "failed", "not_run"]),
)
.optional()
.default([]),
migrationRunId: z.string().optional(),
migrationRunDryRun: z.boolean().optional(),
});
/** POST /migrations.filter.preview — count + enriched paginated customers. */
@@ -29,39 +56,130 @@ export const handlePreviewMigrationFilter = createRoute({
body: PreviewFilterBody,
handler: async (c) => {
const ctx = c.get("ctx");
const { filter, search, page, pageSize } = c.req.valid("json");
const {
filter,
search,
customerFilters,
cursor,
includeCount,
countOnly,
pageSize,
migrationId,
executionStatuses,
migrationRunId,
migrationRunDryRun,
} = c.req.valid("json");
const [count, pageRows] = await Promise.all([
countCustomers({ ctx, filter }),
collectPage(
filterCustomers({ ctx, filter, batchSize: pageSize }),
page * pageSize,
pageSize,
),
]);
const searchTerm = search || undefined;
// An empty customer scope compiles to nothing (wrapAnd throws). Treat "no
// active filter" as selecting nobody rather than 500ing the preview.
const hasAnyField = Object.values(filter ?? {}).some(
(v) => v !== undefined,
);
if (!hasAnyField) {
return c.json({
count: includeCount ? 0 : null,
customers: [],
next_cursor: null,
});
}
let includeProcessed: IncludeProcessed | undefined;
let migrationInternalId: string | undefined;
if (migrationId) {
const migration = await migrationRepo.find({ ctx, id: migrationId });
migrationInternalId = migration.internal_id;
const needsActiveRun = executionStatuses.some((status) =>
["queued", "not_run"].includes(status),
);
const [activeRun] = needsActiveRun
? await migrationRunRepo.list({
ctx,
migrationInternalId: migration.internal_id,
active: true,
})
: [];
includeProcessed = {
migrationInternalId: migration.internal_id,
executionFilter:
executionStatuses.length > 0
? {
statuses: executionStatuses,
migrationRunId,
dryRun: migrationRunDryRun,
queuedRun: activeRun
? {
migrationRunId: activeRun.internal_id,
dryRun: activeRun.dry_run,
onlyIds: activeRun.only_ids ?? undefined,
targetLimit: activeRun.target_limit ?? undefined,
}
: undefined,
}
: undefined,
};
}
const countPromise = includeCount
? countCustomers({
ctx,
filter,
search: searchTerm,
customerFilters,
includeProcessed,
})
: Promise.resolve(null);
const pagePromise = countOnly
? Promise.resolve({ rows: [], nextCursor: null })
: getCustomerPage({
ctx,
filter,
search: searchTerm,
customerFilters,
includeProcessed,
pageSize,
cursor,
});
const [count, pageResult] = await Promise.all([countPromise, pagePromise]);
const pageRows = pageResult.rows;
if (pageRows.length === 0) {
return c.json({ count, customers: [], page, pageSize });
return c.json({
count,
customers: [],
next_cursor: null,
});
}
const enriched = await enrichCustomers(
ctx.db,
pageRows.map((r) => r.internal_id),
);
const itemRuns = migrationInternalId
? await migrationItemRunRepo.listForItems({
ctx,
migrationInternalId,
itemKind: MigrationItemKind.Customer,
itemIds: pageRows.map((r) => r.internal_id),
dryRun: false,
})
: [];
const itemRunsByCustomer = new Map(
itemRuns.map((run) => [run.item_id, run]),
);
let grouped = groupByCustomer(enriched);
const grouped = groupByCustomer(enriched).map((customer) => ({
...customer,
migration_item_run:
itemRunsByCustomer.get(customer.internal_id as string) ?? null,
}));
if (search) {
const q = search.toLowerCase();
grouped = grouped.filter((row) => {
const name = (row.name as string | null)?.toLowerCase() ?? "";
const email = (row.email as string | null)?.toLowerCase() ?? "";
const id = (row.id as string | null)?.toLowerCase() ?? "";
return name.includes(q) || email.includes(q) || id.includes(q);
});
}
return c.json({ count, customers: grouped, page, pageSize });
return c.json({
count,
customers: grouped,
next_cursor: pageResult.nextCursor,
});
},
});
@@ -103,8 +221,17 @@ async function enrichCustomers(db: DrizzleCli, ids: string[]) {
},
})
.from(customers)
.leftJoin(customerProducts, eq(customers.internal_id, customerProducts.internal_customer_id))
.leftJoin(products, eq(customerProducts.internal_product_id, products.internal_id))
.leftJoin(
customerProducts,
and(
eq(customers.internal_id, customerProducts.internal_customer_id),
inArray(customerProducts.status, RELEVANT_STATUSES),
),
)
.leftJoin(
products,
eq(customerProducts.internal_product_id, products.internal_id),
)
.where(inArray(customers.internal_id, ids));
}
@@ -113,10 +240,17 @@ function groupByCustomer(rows: Array<Record<string, unknown>>) {
for (const row of rows) {
const id = row.internal_id as string;
if (!map.has(id)) {
const { customer_product, product, ...customer } = row;
const {
customer_product: _customerProduct,
product: _product,
...customer
} = row;
map.set(id, { ...customer, customer_products: [] });
}
if (row.customer_product && (row.customer_product as Record<string, unknown>).id) {
if (
row.customer_product &&
(row.customer_product as Record<string, unknown>).id
) {
const entry = map.get(id)!;
(entry.customer_products as unknown[]).push({
...(row.customer_product as Record<string, unknown>),
@@ -126,23 +260,3 @@ function groupByCustomer(rows: Array<Record<string, unknown>>) {
}
return Array.from(map.values());
}
async function collectPage<T>(
gen: AsyncGenerator<T[]>,
skip: number,
take: number,
): Promise<T[]> {
const rows: T[] = [];
let skipped = 0;
for await (const batch of gen) {
for (const row of batch) {
if (skipped < skip) {
skipped++;
continue;
}
rows.push(row);
if (rows.length >= take) return rows;
}
}
return rows;
}

View File

@@ -3,15 +3,22 @@ import { auth } from "@trigger.dev/sdk/v3";
import { z } from "zod/v4";
import { createRoute } from "@/honoMiddlewares/routeHandler";
import { withMigrationRunClaim } from "@/internal/migrations/v2/actions/migrationRun/index.js";
import { prepare } from "@/internal/migrations/v2/prepare/index.js";
import { migrationRepo } from "@/internal/migrations/v2/repos/index.js";
import { RETRYABLE_MIGRATION_ITEM_RUN_STATUSES } from "@/internal/migrations/v2/run/utils/retryItemStatuses.js";
import { runMigrationTask } from "@/trigger/migrations/runMigrationTask.js";
const MAX_CONCURRENCY = 5;
const RunMigrationBody = z.object({
id: z.string(),
dry_run: z.boolean().default(false),
limit: z.number().int().min(1).optional(),
only: z.array(z.string()).optional(),
concurrency: z.number().int().min(1).optional(),
concurrency: z.number().int().min(1).max(MAX_CONCURRENCY).optional(),
retry_item_statuses: z
.array(z.enum(RETRYABLE_MIGRATION_ITEM_RUN_STATUSES))
.optional(),
/** When true, claim a lazy run alongside the background sweeper. Customers
* hit on the request path get migrated lazily via `runMigrationCustomerTask`
* before the sweeper reaches them. Background and lazy run on the same
@@ -21,13 +28,15 @@ const RunMigrationBody = z.object({
const getRunMigrationTriggerOptions = ({
orgId,
migrationId,
isDev,
}: {
orgId: string;
migrationId: string;
isDev: boolean;
}) => ({
...(isDev ? { region: "eu-central-1" } : {}),
concurrencyKey: orgId,
concurrencyKey: `${orgId}:${migrationId}`,
});
export const handleRunMigration = createRoute({
@@ -41,6 +50,7 @@ export const handleRunMigration = createRoute({
limit,
only,
concurrency,
retry_item_statuses: retryItemStatuses,
lazy_run: lazyRun,
} = c.req.valid("json");
@@ -53,6 +63,15 @@ export const handleRunMigration = createRoute({
statusCode: 400,
});
if (lazyRun && only && only.length > 0) {
throw new RecaseError({
message:
"Migration lazy_run cannot be combined with only. Run targeted customers without lazy_run.",
code: ErrCode.InvalidRequest,
statusCode: 400,
});
}
const isDev = process.env.NODE_ENV === "development";
const { migrationRunId, triggerRunId } = await withMigrationRunClaim({
ctx,
@@ -62,6 +81,9 @@ export const handleRunMigration = createRoute({
onlyIds: only,
targetLimit: limit,
claimed: async (migrationRunId) => {
if (lazyRun && !dryRun) {
await prepare({ ctx, migration, dryRun: false });
}
const handle = await runMigrationTask.trigger(
{
orgId: ctx.org.id,
@@ -69,10 +91,17 @@ export const handleRunMigration = createRoute({
migrationId: id,
migrationRunId,
dryRun,
controls: { limit, only, concurrency },
lazyRun,
controls: {
limit,
only,
concurrency,
retryItemStatuses,
},
},
getRunMigrationTriggerOptions({
orgId: ctx.org.id,
migrationId: id,
isDev,
}),
);
@@ -96,6 +125,7 @@ export const handleRunMigration = createRoute({
migration_id: id,
dry_run: dryRun,
lazy_run: lazyRun,
concurrency,
run_id: migrationRunId,
trigger_run_id: triggerRunId,
public_access_token: publicAccessToken,

View File

@@ -2,7 +2,6 @@ import {
BillingVersion,
type FullCusProduct,
type FullCustomer,
hasCustomItems,
orgDisableStripeWrites,
type UpdateSubscriptionBillingContext,
UpdateSubscriptionIntent,
@@ -106,6 +105,7 @@ export const setupUpdatePlanProductContext = async ({
fullCustomer: productFullCustomer,
params,
reusePricesAndEntitlements,
resetToCatalogVersion: typeof preparedOp.version === "number",
});
const operationBillingContext = await setupMigrationOperationBillingContext({
@@ -154,7 +154,7 @@ export const setupUpdatePlanProductContext = async ({
customPrices,
customEnts,
trialContext: operationBillingContext.trialContext,
isCustom: hasCustomItems(params.customize),
isCustom: targetCustomerProduct.is_custom,
billingVersion: BillingVersion.V2,
actionSource: "migration",
skipBillingChanges,

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,137 +1,12 @@
import type {
AutumnBillingPlan,
FullCusProduct,
FullCustomerEntitlement,
} from "@autumn/shared";
import {
getDeleteCustomerProducts,
getPatchCustomerProducts,
} from "@/internal/billing/v2/utils/billingPlan/customerProductPlanMutations.js";
import type {
PreviewPlanChange,
PreviewPlanItemChange,
} from "./types/index.js";
const customerProductToPlanChange = ({
customerProduct,
action,
itemChanges = [],
}: {
customerProduct: FullCusProduct;
action: PreviewPlanChange["action"];
itemChanges?: PreviewPlanItemChange[];
}): PreviewPlanChange => ({
action,
plan_id: customerProduct.product.id,
entity_id: customerProduct.entity_id ?? null,
item_changes: itemChanges,
});
const buildUpdatedPreviousAttributes = ({
oldCustomerEntitlement,
newCustomerEntitlement,
}: {
oldCustomerEntitlement: FullCustomerEntitlement;
newCustomerEntitlement: FullCustomerEntitlement;
}): Record<string, unknown> => {
const previous: Record<string, unknown> = {};
const oldIncluded = oldCustomerEntitlement.entitlement.allowance ?? null;
const newIncluded = newCustomerEntitlement.entitlement.allowance ?? null;
if (oldIncluded !== newIncluded) previous.included = oldIncluded;
const oldUnlimited = Boolean(oldCustomerEntitlement.unlimited);
const newUnlimited = Boolean(newCustomerEntitlement.unlimited);
if (oldUnlimited !== newUnlimited) previous.unlimited = oldUnlimited;
return previous;
};
/**
* Pair up patch-level insert/delete customer_entitlements that share a
* `feature_id` and emit a single `"updated"` item_change for each pair.
* Unpaired inserts/deletes stay as their own `"created"` / `"deleted"`
* entries. When multiple cusEnts for the same feature are touched (e.g.
* monthly + lifetime), they pair in arrival order; the dashboard sees N
* `"updated"` entries for that feature.
*/
const buildPatchItemChanges = ({
patch,
}: {
patch: NonNullable<AutumnBillingPlan["patchCustomerProducts"]>[number];
}): PreviewPlanItemChange[] => {
const changes: PreviewPlanItemChange[] = [];
const insertsByFeature = new Map<string, FullCustomerEntitlement[]>();
for (const insert of patch.insertCustomerEntitlements) {
const featureId = insert.entitlement.feature.id;
const existing = insertsByFeature.get(featureId) ?? [];
existing.push(insert);
insertsByFeature.set(featureId, existing);
}
const remainingDeletes: FullCustomerEntitlement[] = [];
for (const deleted of patch.deleteCustomerEntitlements) {
const featureId = deleted.entitlement.feature.id;
const matchingInserts = insertsByFeature.get(featureId);
const paired = matchingInserts?.shift();
if (paired) {
changes.push({
action: "updated",
feature_id: featureId,
previous_attributes: buildUpdatedPreviousAttributes({
oldCustomerEntitlement: deleted,
newCustomerEntitlement: paired,
}),
});
continue;
}
remainingDeletes.push(deleted);
}
for (const inserts of insertsByFeature.values()) {
for (const insert of inserts) {
changes.push({
action: "created",
feature_id: insert.entitlement.feature.id,
previous_attributes: {},
});
}
}
for (const deleted of remainingDeletes) {
changes.push({
action: "deleted",
feature_id: deleted.entitlement.feature.id,
previous_attributes: {},
});
}
return changes;
};
import type { AutumnBillingPlan } from "@autumn/shared";
import { buildPlanChanges as buildBillingUpdatedPlanChanges } from "@/internal/billing/v2/utils/billingChangeResponse/buildPlanChanges.js";
import type { PreviewPlanChange } from "./types/index.js";
export const buildPlanChanges = ({
autumnBillingPlan,
}: {
autumnBillingPlan: AutumnBillingPlan;
}): PreviewPlanChange[] => [
...autumnBillingPlan.insertCustomerProducts.map((customerProduct) =>
customerProductToPlanChange({
customerProduct,
action: "created",
}),
),
...getDeleteCustomerProducts({ autumnBillingPlan }).map((customerProduct) =>
customerProductToPlanChange({
customerProduct,
action: "deleted",
}),
),
...getPatchCustomerProducts({ autumnBillingPlan }).map((patch) =>
customerProductToPlanChange({
customerProduct: patch.customerProduct,
action: "updated",
itemChanges: buildPatchItemChanges({ patch }),
}),
),
];
}): PreviewPlanChange[] =>
buildBillingUpdatedPlanChanges({
autumnBillingPlan,
});

View File

@@ -1,18 +1,14 @@
import { z } from "zod/v4";
import {
CustomerPlanChangeSchema,
CustomerPlanItemChangeSchema,
type CustomerPlanChange,
type CustomerPlanItemChange,
} from "@autumn/shared/api/billing/common/customerPlanChange.js";
export const PreviewPlanItemChangeSchema = z.object({
action: z.enum(["created", "updated", "deleted"]),
feature_id: z.string(),
previous_attributes: z.record(z.string(), z.unknown()).default({}),
});
export const PreviewPlanItemChangeSchema = CustomerPlanItemChangeSchema;
export const PreviewPlanChangeSchema = z.object({
action: z.enum(["created", "updated", "deleted"]),
plan_id: z.string(),
entity_id: z.string().nullable().optional(),
item_changes: z.array(PreviewPlanItemChangeSchema).default([]),
});
export const PreviewPlanChangeSchema = CustomerPlanChangeSchema;
export type PreviewPlanItemChange = z.infer<typeof PreviewPlanItemChangeSchema>;
export type PreviewPlanItemChange = CustomerPlanItemChange;
export type PreviewPlanChange = z.infer<typeof PreviewPlanChangeSchema>;
export type PreviewPlanChange = CustomerPlanChange;

View File

@@ -1,4 +1,11 @@
import { type Migration, migrationItemRuns, migrations } from "@autumn/shared";
import {
ErrCode,
type Migration,
MigrationItemKind,
migrationItemRuns,
migrations,
RecaseError,
} from "@autumn/shared";
import { and, eq } from "drizzle-orm";
import type { RepoContext } from "@/db/repoContext.js";
@@ -10,15 +17,44 @@ export const deleteMigration = async ({
ctx: RepoContext;
id: string;
}): Promise<Migration | null> => {
const [row] = await ctx.db
.delete(migrations)
const [migration] = await ctx.db
.select()
.from(migrations)
.where(
and(
eq(migrations.id, id),
eq(migrations.org_id, ctx.org.id),
eq(migrations.env, ctx.env),
eq(migrations.archived, false),
),
)
.limit(1);
if (!migration) return null;
const [customerRun] = await ctx.db
.select({ id: migrationItemRuns.migration_item_run_id })
.from(migrationItemRuns)
.where(
and(
eq(migrationItemRuns.migration_internal_id, migration.internal_id),
eq(migrationItemRuns.item_kind, MigrationItemKind.Customer),
eq(migrationItemRuns.dry_run, false),
),
)
.limit(1);
if (customerRun) {
throw new RecaseError({
message: `Migration ${id} has customer run history and cannot be deleted. Archive it instead.`,
code: ErrCode.InvalidRequest,
statusCode: 400,
});
}
const [row] = await ctx.db
.delete(migrations)
.where(eq(migrations.internal_id, migration.internal_id))
.returning();
if (row) {
await ctx.db

View File

@@ -24,6 +24,7 @@ export const findMigration = async ({
and(
eq(m.org_id, ctx.org.id),
eq(m.env, ctx.env),
eq(m.archived, false),
id !== undefined ? eq(m.id, id) : eq(m.internal_id, internalId!),
),
});

View File

@@ -16,7 +16,10 @@ export const insertMigration = async ({
insert,
}: {
ctx: RepoContext;
insert: Pick<MigrationInsert, "id" | "filter" | "operations">;
insert: Pick<
MigrationInsert,
"id" | "filter" | "operations" | "no_billing_changes"
>;
}): Promise<Migration> => {
const row: MigrationInsert = {
internal_id: generateId("mig"),
@@ -25,7 +28,9 @@ export const insertMigration = async ({
env: ctx.env,
filter: insert.filter ?? null,
operations: insert.operations ?? null,
no_billing_changes: insert.no_billing_changes ?? null,
retry_failed: false,
archived: false,
created_at: Date.now(),
updated_at: null,
};

View File

@@ -3,6 +3,7 @@ import {
migrationTinybird,
type TinybirdMigrationItemEvent,
} from "@/external/tinybird/migrations/migrationItemEventsDataSource.js";
import { normalizeMigrationItemEventJson } from "./listMigrationItemEvents.js";
export const listLatestMigrationItemEvents = async ({
ctx,
@@ -33,7 +34,9 @@ export const listLatestMigrationItemEvents = async ({
});
const latestByItem = new Map<string, TinybirdMigrationItemEvent>();
for (const event of result.data as TinybirdMigrationItemEvent[]) {
for (const event of (result.data as TinybirdMigrationItemEvent[]).map(
normalizeMigrationItemEventJson,
)) {
if (event.dry_run !== dryRun) continue;
const key = `${event.item_kind}:${event.item_id}`;
if (!latestByItem.has(key)) latestByItem.set(key, event);

View File

@@ -5,14 +5,45 @@ import {
} from "@/external/tinybird/migrations/migrationItemEventsDataSource.js";
import { findMigration } from "../findMigration.js";
const parseJsonish = (value: unknown): unknown => {
if (typeof value !== "string") {
if (Array.isArray(value)) return value.map(parseJsonish);
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value).map(([key, entry]) => [key, parseJsonish(entry)]),
);
}
return value;
}
const trimmed = value.trim();
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return value;
try {
return parseJsonish(JSON.parse(value));
} catch {
return value;
}
};
export const normalizeMigrationItemEventJson = (
event: TinybirdMigrationItemEvent,
): TinybirdMigrationItemEvent => ({
...event,
item_preview: parseJsonish(event.item_preview) as TinybirdMigrationItemEvent["item_preview"],
response: parseJsonish(event.response) as TinybirdMigrationItemEvent["response"],
});
export const listMigrationItemEvents = async ({
ctx,
migrationId,
migrationRunId,
itemIds,
}: {
ctx: RepoContext;
migrationId: string;
migrationRunId?: string;
itemIds?: string[];
}): Promise<TinybirdMigrationItemEvent[]> => {
if (!migrationTinybird) {
ctx.logger.debug(
@@ -22,6 +53,18 @@ export const listMigrationItemEvents = async ({
}
const migration = await findMigration({ ctx, id: migrationId });
if (itemIds && itemIds.length > 0) {
return listMigrationItemEventsBySql({
ctx,
orgId: ctx.org.id,
env: ctx.env,
migrationInternalId: migration.internal_id,
migrationRunId,
itemIds,
});
}
const queryParams = {
org_id: ctx.org.id,
env: ctx.env,
@@ -37,5 +80,73 @@ export const listMigrationItemEvents = async ({
`listMigrationItemEvents: got ${result.data.length} results`,
);
return result.data as TinybirdMigrationItemEvent[];
return (result.data as TinybirdMigrationItemEvent[]).map(
normalizeMigrationItemEventJson,
);
};
const escapeString = (s: string) => s.replace(/'/g, "\\'");
const listMigrationItemEventsBySql = async ({
ctx,
orgId,
env,
migrationInternalId,
migrationRunId,
itemIds,
}: {
ctx: RepoContext;
orgId: string;
env: string;
migrationInternalId: string;
migrationRunId?: string;
itemIds: string[];
}): Promise<TinybirdMigrationItemEvent[]> => {
const conditions = [
`org_id = '${escapeString(orgId)}'`,
`env = '${escapeString(env)}'`,
`migration_internal_id = '${escapeString(migrationInternalId)}'`,
];
if (migrationRunId) {
conditions.push(
`migration_run_id = '${escapeString(migrationRunId)}'`,
);
}
const idList = itemIds.map((id) => `'${escapeString(id)}'`).join(",");
conditions.push(`item_id IN (${idList})`);
const sql = `
SELECT
timestamp,
org_id,
env,
migration_internal_id,
migration_run_id,
dry_run,
item_kind,
item_id,
item_preview,
status,
response
FROM migration_item_events
WHERE ${conditions.join(" AND ")}
ORDER BY timestamp DESC, item_kind ASC, item_id ASC
LIMIT 1000
FORMAT JSON
`;
ctx.logger.info(
`listMigrationItemEventsBySql: querying ${itemIds.length} item_ids for migration=${migrationInternalId}`,
);
const result = await migrationTinybird!.sql<TinybirdMigrationItemEvent>(sql);
const rows = result.data ?? [];
ctx.logger.info(
`listMigrationItemEventsBySql: got ${rows.length} results`,
);
return rows.map(normalizeMigrationItemEventJson);
};

View File

@@ -4,16 +4,17 @@ import {
MigrationItemRunStatus,
migrationItemRuns,
} from "@autumn/shared";
import { eq, sql } from "drizzle-orm";
import { inArray, sql } from "drizzle-orm";
import type { RepoContext } from "@/db/repoContext.js";
import { generateId } from "@/utils/genUtils.js";
import type { RetryableMigrationItemRunStatus } from "../../run/utils/retryItemStatuses.js";
import { getMigrationItemRun } from "./getMigrationItemRun.js";
type MigrationItemRunRepoContext = RepoContext & {
dbGeneral?: RepoContext["db"];
};
export type MigrationItemRunClaimBehavior = "claim_new" | "retry_failed";
export type MigrationItemRunClaimBehavior = "claim_new" | "retry_statuses";
export type MigrationItemRunClaimResult =
| { claimed: true; itemRun: MigrationItemRun }
@@ -27,6 +28,7 @@ export const claimMigrationItemRun = async ({
itemKind,
itemId,
claimBehavior,
retryStatuses = [],
}: {
ctx: MigrationItemRunRepoContext;
migrationInternalId: string;
@@ -35,6 +37,7 @@ export const claimMigrationItemRun = async ({
itemKind: MigrationItemKind;
itemId: string;
claimBehavior: MigrationItemRunClaimBehavior;
retryStatuses?: RetryableMigrationItemRunStatus[];
}): Promise<MigrationItemRunClaimResult> => {
if (dryRun && !migrationRunId)
throw new Error(
@@ -70,29 +73,29 @@ export const claimMigrationItemRun = async ({
? sql`${migrationItemRuns.dry_run} = true`
: sql`${migrationItemRuns.dry_run} = false`;
const [claimed] =
claimBehavior === "retry_failed"
? await db
.insert(migrationItemRuns)
.values(values)
.onConflictDoUpdate({
target,
targetWhere,
set: {
status: MigrationItemRunStatus.Running,
updated_at: now,
},
setWhere: eq(
migrationItemRuns.status,
MigrationItemRunStatus.Failed,
),
})
.returning()
: await db
.insert(migrationItemRuns)
.values(values)
.onConflictDoNothing({ target, where: targetWhere })
.returning();
const shouldRetry =
claimBehavior === "retry_statuses" && retryStatuses.length > 0;
const [claimed] = shouldRetry
? await db
.insert(migrationItemRuns)
.values(values)
.onConflictDoUpdate({
target,
targetWhere,
set: {
migration_run_id: migrationRunId ?? null,
status: MigrationItemRunStatus.Running,
updated_at: now,
},
setWhere: inArray(migrationItemRuns.status, retryStatuses),
})
.returning()
: await db
.insert(migrationItemRuns)
.values(values)
.onConflictDoNothing({ target, where: targetWhere })
.returning();
if (claimed) return { claimed: true, itemRun: claimed };

View File

@@ -3,6 +3,11 @@ import {
getCustomerMigrationItemRun,
getMigrationItemRun,
} from "./getMigrationItemRun.js";
import {
getMigrationItemRunCounts,
listMigrationItemRunCountsByRun,
} from "./listMigrationItemRunCountsByRun.js";
import { listMigrationItemRunsForItems } from "./listMigrationItemRunsForItems.js";
import {
markMigrationItemRunFailed,
markMigrationItemRunSkipped,
@@ -13,9 +18,16 @@ export const migrationItemRunRepo = {
claim: claimMigrationItemRun,
get: getMigrationItemRun,
getCustomer: getCustomerMigrationItemRun,
getCounts: getMigrationItemRunCounts,
listCountsByRun: listMigrationItemRunCountsByRun,
listForItems: listMigrationItemRunsForItems,
markSucceeded: markMigrationItemRunSucceeded,
markSkipped: markMigrationItemRunSkipped,
markFailed: markMigrationItemRunFailed,
};
export type { MigrationItemRunClaimBehavior } from "./claimMigrationItemRun.js";
export type {
MigrationItemRunCounts,
MigrationItemRunCountsByRun,
} from "./listMigrationItemRunCountsByRun.js";

View File

@@ -0,0 +1,94 @@
import {
MigrationItemKind,
MigrationItemRunStatus,
migrationItemRuns,
} from "@autumn/shared";
import { and, eq, inArray, type SQL, sql } from "drizzle-orm";
import type { RepoContext } from "@/db/repoContext.js";
export type MigrationItemRunCounts = {
total: number;
running: number;
succeeded: number;
skipped: number;
failed: number;
};
export type MigrationItemRunCountsByRun = MigrationItemRunCounts & {
migration_run_id: string | null;
};
const countSelection = {
total: sql<number>`count(*)::int`,
running: sql<number>`count(*) filter (where ${migrationItemRuns.status} = ${MigrationItemRunStatus.Running})::int`,
succeeded: sql<number>`count(*) filter (where ${migrationItemRuns.status} = ${MigrationItemRunStatus.Succeeded})::int`,
skipped: sql<number>`count(*) filter (where ${migrationItemRuns.status} = ${MigrationItemRunStatus.Skipped})::int`,
failed: sql<number>`count(*) filter (where ${migrationItemRuns.status} = ${MigrationItemRunStatus.Failed})::int`,
};
const emptyCounts: MigrationItemRunCounts = {
total: 0,
running: 0,
succeeded: 0,
skipped: 0,
failed: 0,
};
export const listMigrationItemRunCountsByRun = async ({
ctx,
migrationInternalId,
migrationRunIds,
itemKind = MigrationItemKind.Customer,
}: {
ctx: RepoContext;
migrationInternalId: string;
migrationRunIds: string[];
itemKind?: MigrationItemKind;
}): Promise<MigrationItemRunCountsByRun[]> => {
if (migrationRunIds.length === 0) return [];
return ctx.db
.select({
migration_run_id: migrationItemRuns.migration_run_id,
...countSelection,
})
.from(migrationItemRuns)
.where(
and(
eq(migrationItemRuns.migration_internal_id, migrationInternalId),
eq(migrationItemRuns.item_kind, itemKind),
inArray(migrationItemRuns.migration_run_id, migrationRunIds),
),
)
.groupBy(migrationItemRuns.migration_run_id);
};
export const getMigrationItemRunCounts = async ({
ctx,
migrationInternalId,
itemKind = MigrationItemKind.Customer,
dryRun,
migrationRunId,
}: {
ctx: RepoContext;
migrationInternalId: string;
itemKind?: MigrationItemKind;
dryRun?: boolean;
migrationRunId?: string;
}): Promise<MigrationItemRunCounts> => {
const where: SQL[] = [
eq(migrationItemRuns.migration_internal_id, migrationInternalId),
eq(migrationItemRuns.item_kind, itemKind),
];
if (dryRun !== undefined) where.push(eq(migrationItemRuns.dry_run, dryRun));
if (migrationRunId !== undefined)
where.push(eq(migrationItemRuns.migration_run_id, migrationRunId));
const [counts] = await ctx.db
.select(countSelection)
.from(migrationItemRuns)
.where(and(...where));
return counts ?? emptyCounts;
};

View File

@@ -0,0 +1,37 @@
import {
type MigrationItemKind,
type MigrationItemRun,
migrationItemRuns,
} from "@autumn/shared";
import { and, eq, inArray } from "drizzle-orm";
import type { RepoContext } from "@/db/repoContext.js";
export const listMigrationItemRunsForItems = async ({
ctx,
migrationInternalId,
itemKind,
itemIds,
dryRun,
}: {
ctx: RepoContext;
migrationInternalId: string;
itemKind: MigrationItemKind;
itemIds: string[];
dryRun?: boolean;
}): Promise<MigrationItemRun[]> => {
if (itemIds.length === 0) return [];
return ctx.db
.select()
.from(migrationItemRuns)
.where(
and(
eq(migrationItemRuns.migration_internal_id, migrationInternalId),
eq(migrationItemRuns.item_kind, itemKind),
inArray(migrationItemRuns.item_id, itemIds),
...(dryRun === undefined
? []
: [eq(migrationItemRuns.dry_run, dryRun)]),
),
);
};

View File

@@ -22,20 +22,31 @@ export const updateMigration = async ({
updates: Partial<
Pick<
MigrationInsert,
"id" | "filter" | "operations" | "prepared_state" | "retry_failed"
| "id"
| "filter"
| "operations"
| "prepared_state"
| "retry_failed"
| "no_billing_changes"
| "archived"
>
>;
}): Promise<Migration | null> => {
const where = [
eq(migrations.id, id),
eq(migrations.org_id, ctx.org.id),
eq(migrations.env, ctx.env),
];
// Only restrict to non-archived rows when we're NOT toggling the archive flag
if (updates.archived === undefined) {
where.push(eq(migrations.archived, false));
}
const [row] = await ctx.db
.update(migrations)
.set({ ...updates, updated_at: Date.now() })
.where(
and(
eq(migrations.id, id),
eq(migrations.org_id, ctx.org.id),
eq(migrations.env, ctx.env),
),
)
.where(and(...where))
.returning();
return row ?? null;

View File

@@ -6,10 +6,7 @@ import type {
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { evaluateStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/actionBuilders/evaluateStripeBillingPlan.js";
import {
assertStripePlanNoCharges,
hasStripePlanActions,
} from "@/internal/billing/v2/providers/stripe/errors/assertStripePlanNoCharges.js";
import { assertStripePlanNoCharges } from "@/internal/billing/v2/providers/stripe/errors/assertStripePlanNoCharges.js";
import { logStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/logs/logStripeBillingPlan.js";
import { MigrationOperationError } from "@/internal/migrations/v2/operations/errors/index.js";
import type { MigrateCustomerContext } from "@/internal/migrations/v2/operations/types/index.js";
@@ -61,20 +58,22 @@ export const evaluateMigrateCustomerStripe = async ({
billingContexts: UpdateSubscriptionBillingContext[];
autumnBillingPlan: AutumnBillingPlan;
}): Promise<MigrateCustomerBillingPlan> => {
if (context.migration.no_billing_changes === true) {
return {
autumn: autumnBillingPlan,
stripe: {},
stripeBillingPlans: [],
};
}
const stripeBillingPlans: MigrateCustomerStripeBillingPlan[] = [];
for (const [subscriptionId, billingContext] of contextBySubscriptionId({
billingContexts,
})) {
const shouldValidateForcedNoBillingChanges =
context.migration.no_billing_changes === true;
const evaluationContext = shouldValidateForcedNoBillingChanges
? { ...billingContext, skipBillingChanges: false }
: billingContext;
const stripeBillingPlan = await evaluateStripeBillingPlan({
ctx,
billingContext: evaluationContext,
billingContext,
autumnBillingPlan,
});
appendMigrationBillingLog({
@@ -84,7 +83,7 @@ export const evaluateMigrateCustomerStripe = async ({
logStripeBillingPlan({
ctx: logCtx,
stripeBillingPlan,
billingContext: evaluationContext,
billingContext,
}),
});
@@ -101,20 +100,6 @@ export const evaluateMigrateCustomerStripe = async ({
}),
});
if (
shouldValidateForcedNoBillingChanges &&
hasStripePlanActions(stripeBillingPlan)
) {
throw new MigrationOperationError({
code: "unsupported_operation_input",
operationType: "update_plan",
field: "no_billing_changes",
message:
"Migration no_billing_changes=true was set, but update_plan produced Stripe mutations",
details: { subscriptionId },
});
}
stripeBillingPlans.push({
subscriptionId,
billingContext,

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

@@ -13,6 +13,7 @@ import {
} from "../../types/migrationDefinition.js";
import { migrateCustomer } from "../migrateCustomer/index.js";
import type { RunScopeItem, RunScopeKind } from "../types/runScope.js";
import { isMigrationCancelRequested } from "../utils/migrationCancelToken.js";
import { iterateScope } from "./iterateScope.js";
/** Runs one filtered migration scope iteration. */
@@ -50,6 +51,10 @@ export const runScopeIteration = async ({
controls?.checkpoint !== false &&
(!dryRun || controls?.checkpointDryRun === true);
// In-memory latch so we hit Redis only until the first cancel detection;
// every later item short-circuits without a cache roundtrip.
let cancelRequested = false;
const perItem = async ({
item,
itemCtx,
@@ -62,6 +67,19 @@ export const runScopeIteration = async ({
`runMigration: per-item handler missing for kind "${item.kind}"`,
);
if (!cancelRequested && (await isMigrationCancelRequested({ migrationRunId })))
cancelRequested = true;
if (cancelRequested) {
itemCtx.logger.info("run-migration: skipping item, cancel requested", {
data: {
migrationRunId,
customerId: item.id ?? item.internal_id,
internalId: item.internal_id,
},
});
return undefined;
}
itemCtx.logger.info("run-migration: processing customer", {
data: {
migrationRunId,
@@ -89,7 +107,7 @@ export const runScopeIteration = async ({
item,
dryRun,
claimItemRun: checkpointReadEnabled,
retryFailed: migration.retry_failed === true,
retryItemStatuses: controls?.retryItemStatuses,
run,
});
};

View File

@@ -17,7 +17,10 @@ export const preProcessMigration = <M extends MigrationRuntime>(
migration: M,
): M => {
const operations = migration.operations
? preProcessMigrationOperations({ operations: migration.operations })
? preProcessMigrationOperations({
operations: migration.operations,
filter: migration.filter,
})
: migration.operations;
const filter = preProcessMigrationFilter({
operations: operations ?? undefined,

View File

@@ -40,7 +40,7 @@ export const preProcessMigrationFilter = ({
if (!filter.customer) return filter;
const planRule = filter.customer.plan;
if (planRule === undefined || planRule === "$none") return filter;
if (planRule === undefined) return filter;
const nextPlan: PlanFilter | PlanQuantifier = isQuantifierObject(planRule)
? {

View File

@@ -2,8 +2,42 @@ import type {
CustomerOperation,
CustomerOperations,
} from "@autumn/shared/api/migrations/operations/customer/customerOperations.js";
import type { MigrationFilter } from "@autumn/shared/api/migrations/filters/migrationFilter.js";
import type { PlanFilter } from "@autumn/shared/api/migrations/filters/planFilter.js";
import type { Operations } from "@autumn/shared/api/migrations/operations/operations.js";
type PlanQuantifier = {
$some?: PlanFilter;
$every?: PlanFilter;
$none?: PlanFilter;
};
const isPlanQuantifier = (
plan: PlanFilter | PlanQuantifier,
): plan is PlanQuantifier =>
"$some" in plan || "$every" in plan || "$none" in plan;
const planFilterTargetsCustom = (plan: PlanFilter): boolean =>
plan.custom === true || (plan.$or ?? []).some(planFilterTargetsCustom);
const planTargetsCustom = (plan: PlanFilter | PlanQuantifier): boolean => {
if (isPlanQuantifier(plan)) {
return [plan.$some, plan.$every, plan.$none].some((inner) => {
if (inner === undefined) return false;
return planFilterTargetsCustom(inner);
});
}
return planFilterTargetsCustom(plan);
};
const filterTargetsCustom = (filter: MigrationFilter | null | undefined) => {
const customer = filter?.customer;
if (customer?.customer_id) return true;
if (customer?.plan === undefined) return false;
return planTargetsCustom(customer.plan);
};
/**
* Op-level guard. Any `update_plan` op that bumps `version` automatically
* gets `plan_filter.custom: false` so admin-customized customer_products
@@ -15,24 +49,37 @@ import type { Operations } from "@autumn/shared/api/migrations/operations/operat
*/
export const preProcessMigrationOperations = ({
operations,
filter,
}: {
operations: Operations;
filter?: MigrationFilter | null;
}): Operations => {
if (!operations.customer) return operations;
if (operations.customer === undefined) return operations;
const targetsCustom = filterTargetsCustom(filter);
const customerOps: CustomerOperations = operations.customer.map(
(op): CustomerOperation => {
if (op.type !== "update_plan") return op;
if (op.version === undefined) return op;
if (op.plan_filter.custom !== undefined) return op;
if (op.type === "update_plan") {
if (op.version === undefined) return op;
if (
op.plan_filter.custom === true ||
op.plan_filter.custom === false
) {
return op;
}
if (targetsCustom) return op;
return {
...op,
plan_filter: {
...op.plan_filter,
custom: false,
},
};
return {
...op,
plan_filter: {
...op.plan_filter,
custom: false,
},
};
}
return op;
},
);

View File

@@ -0,0 +1,37 @@
import { CacheManager } from "@/utils/cacheUtils/CacheManager.js";
/** "Cancellation requested" signal for a migration run. Set by the cancel
* handler; read by the batch per-item gate and the lazy enqueue/task gates so
* in-flight work finishes while no new items start. Best-effort: a degraded
* cache makes the gate a no-op. */
const TOKEN_TTL_SECONDS = 3600;
const cancelTokenKey = (migrationRunId: string) =>
`migration_run_cancel:${migrationRunId}`;
export const setMigrationCancelRequested = async ({
migrationRunId,
}: {
migrationRunId: string;
}): Promise<void> => {
await CacheManager.setJson(cancelTokenKey(migrationRunId), true, TOKEN_TTL_SECONDS);
};
export const isMigrationCancelRequested = async ({
migrationRunId,
}: {
migrationRunId: string;
}): Promise<boolean> => {
const value = await CacheManager.getJson<boolean>(
cancelTokenKey(migrationRunId),
);
return value === true;
};
export const clearMigrationCancelRequested = async ({
migrationRunId,
}: {
migrationRunId: string;
}): Promise<void> => {
await CacheManager.del(cancelTokenKey(migrationRunId));
};

View File

@@ -0,0 +1,27 @@
import {
MigrationItemRunStatus,
type MigrationItemRunStatus as MigrationItemRunStatusType,
} from "@autumn/shared";
export const RETRYABLE_MIGRATION_ITEM_RUN_STATUSES = [
MigrationItemRunStatus.Failed,
MigrationItemRunStatus.Skipped,
] as const;
export type RetryableMigrationItemRunStatus =
(typeof RETRYABLE_MIGRATION_ITEM_RUN_STATUSES)[number];
export const normalizeRetryItemStatuses = ({
retryItemStatuses,
}: {
retryItemStatuses?: RetryableMigrationItemRunStatus[];
}): RetryableMigrationItemRunStatus[] => {
const statuses = new Set(retryItemStatuses ?? []);
return [...statuses];
};
export const isRetryableMigrationItemRunStatus = (
status: MigrationItemRunStatusType,
): status is RetryableMigrationItemRunStatus =>
status === MigrationItemRunStatus.Failed ||
status === MigrationItemRunStatus.Skipped;

View File

@@ -0,0 +1,190 @@
import type { Feature, FullProduct, ProductItem } from "@autumn/shared";
import {
findSimilarItem,
itemsAreSame,
mapToProductItems,
} from "@autumn/shared";
import type { DrizzleCli } from "@server/db/initDrizzle";
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js";
import { CusPriceService } from "@/internal/customers/cusProducts/cusPrices/CusPriceService.js";
import { EntitlementService } from "@/internal/products/entitlements/EntitlementService.js";
import { PriceService } from "@/internal/products/prices/PriceService.js";
// Includes the base price: a base-price edit must retire the old shared row too,
// not mutate it in place under existing customers.
const currentItemsOf = ({
currentFullProduct,
features,
}: {
currentFullProduct: FullProduct;
features: Feature[];
}): ProductItem[] =>
mapToProductItems({
prices: currentFullProduct.prices,
entitlements: currentFullProduct.entitlements,
features,
});
/**
* Callers rarely echo back entitlement_id / price_id, so without this match the
* unchanged items look new and the old rows get deleted (cascading the
* customers' rows). Match incoming items to the current catalog by feature +
* interval and carry their ids forward.
*/
const backfillExistingItemIds = ({
items,
currentFullProduct,
features,
}: {
items: ProductItem[];
currentFullProduct: FullProduct;
features: Feature[];
}): ProductItem[] => {
const currentItems = currentItemsOf({ currentFullProduct, features });
return items.map((item) => {
if (item.entitlement_id || item.price_id) return item;
const match = findSimilarItem({ item, items: currentItems });
if (!match) return item;
return {
...item,
...(match.entitlement_id ? { entitlement_id: match.entitlement_id } : {}),
...(match.price_id ? { price_id: match.price_id } : {}),
};
});
};
/**
* Retire (vs mutate/delete) a catalog ent/price so existing customers that
* reference it keep their definition. Referenced rows flip to is_custom:true
* (hidden from the catalog, FK still valid); unreferenced rows are deleted.
*/
const retireOrDeleteRows = async ({
db,
entitlementIds,
priceIds,
}: {
db: DrizzleCli;
entitlementIds: string[];
priceIds: string[];
}) => {
const referencedEnts = await CusEntService.getReferencedEntitlementIds({
db,
entitlementIds,
});
const referencedPrices = await CusPriceService.getReferencedPriceIds({
db,
priceIds,
});
const priceRows = await PriceService.getInIds({ db, ids: priceIds });
const entitlementsReferencedByRetainedPrices = new Set(
priceRows
.flatMap((price) =>
referencedPrices.has(price.id) && price.entitlement_id
? [price.entitlement_id]
: [],
),
);
for (const priceId of priceIds) {
if (referencedPrices.has(priceId)) {
await PriceService.update({
db,
id: priceId,
update: { is_custom: true },
});
} else {
await PriceService.deleteInIds({ db, ids: [priceId] });
}
}
for (const entitlementId of entitlementIds) {
if (
referencedEnts.has(entitlementId) ||
entitlementsReferencedByRetainedPrices.has(entitlementId)
) {
await EntitlementService.update({
db,
id: entitlementId,
updates: { is_custom: true },
});
} else {
await EntitlementService.deleteInIds({ db, ids: [entitlementId] });
}
}
};
/**
* Resolve an in-place edit (disable_version + customers) against the current
* catalog. Carries forward unchanged ids, retires the rows behind UPDATE/DELETE
* (is_custom flip when referenced, else delete) so existing customers are
* untouched, and returns the items to insert plus the catalog prices/ents with
* the retired rows removed — handed to `handleNewProductItems` so it does not
* re-delete them.
*/
export const resolveInPlaceEdit = async ({
db,
items,
currentFullProduct,
features,
}: {
db: DrizzleCli;
items: ProductItem[];
currentFullProduct: FullProduct;
features: Feature[];
}): Promise<{
items: ProductItem[];
curPrices: FullProduct["prices"];
curEnts: FullProduct["entitlements"];
}> => {
const backfilledItems = backfillExistingItemIds({
items,
currentFullProduct,
features,
});
const currentItems = currentItemsOf({ currentFullProduct, features });
const retiredEntitlementIds: string[] = [];
const retiredPriceIds: string[] = [];
for (const currentItem of currentItems) {
const match = findSimilarItem({
item: currentItem,
items: backfilledItems,
});
const isDeleted = !match;
const isUpdated =
match &&
!itemsAreSame({ item1: match, item2: currentItem, features }).same;
if (!(isDeleted || isUpdated)) continue;
if (currentItem.entitlement_id)
retiredEntitlementIds.push(currentItem.entitlement_id);
if (currentItem.price_id) retiredPriceIds.push(currentItem.price_id);
}
await retireOrDeleteRows({
db,
entitlementIds: retiredEntitlementIds,
priceIds: retiredPriceIds,
});
const retired = new Set([...retiredEntitlementIds, ...retiredPriceIds]);
// Updated items must mint fresh is_custom:false rows, so drop the backfilled
// ids that now point at retired rows.
const preparedItems = backfilledItems.map((item) => {
const retiresEnt = item.entitlement_id && retired.has(item.entitlement_id);
const retiresPrice = item.price_id && retired.has(item.price_id);
if (!(retiresEnt || retiresPrice)) return item;
return { ...item, entitlement_id: undefined, price_id: undefined };
});
return {
items: preparedItems,
curPrices: currentFullProduct.prices.filter(
(price) => !retired.has(price.id),
),
curEnts: currentFullProduct.entitlements.filter(
(ent) => !retired.has(ent.id),
),
};
};

View File

@@ -10,6 +10,7 @@ import {
UpdateProductSchema,
type UpdateProductV2Params,
} from "@autumn/shared";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
import {
@@ -25,6 +26,7 @@ import { initProductInStripe } from "@/internal/products/productUtils.js";
import { rewardProgramRepo } from "@/internal/rewards/repos/index.js";
import { JobName } from "@/queue/JobName.js";
import { addTaskToQueue } from "@/queue/queueUtils.js";
import { resolveInPlaceEdit } from "./inPlaceUpdateUtils.js";
import { validateDefaultFlag } from "./validateDefaultFlag.js";
interface UpdateProductParams {
@@ -55,6 +57,7 @@ export const updateProduct = async ({
idOrInternalId: productId,
orgId: org.id,
env,
version,
});
};
@@ -121,13 +124,11 @@ export const updateProduct = async ({
// Check if versioning is needed (customers exist AND items or free trial changed)
const freeTrialProvided = "free_trial" in updates;
if (cusProductExists && (itemsExist || freeTrialProvided)) {
if (disable_version) {
throw new RecaseError({
message: "Cannot auto save product as there are existing customers",
});
}
if (
cusProductExists &&
!disable_version &&
(itemsExist || freeTrialProvided)
) {
const { itemsSame, freeTrialsSame } = productsAreSame({
newProductV2: newProductV2,
curProductV1: fullProduct,
@@ -154,16 +155,42 @@ export const updateProduct = async ({
const { free_trial } = updates;
if (updates.items) {
await handleNewProductItems({
db,
curPrices: fullProduct.prices,
curEnts: fullProduct.entitlements,
newItems: updates.items,
features,
product: fullProduct,
logger: ctx.logger,
isCustom: false,
});
const newItems = updates.items;
if (cusProductExists && disable_version) {
// Retire the shared catalog rows + insert their replacements atomically:
// a failure between the two must not leave the plan with retired rows
// and no replacement.
await db.transaction(async (transaction) => {
const tx = transaction as unknown as DrizzleCli;
const inPlace = await resolveInPlaceEdit({
db: tx,
items: newItems,
currentFullProduct: fullProduct,
features,
});
await handleNewProductItems({
db: tx,
curPrices: inPlace.curPrices,
curEnts: inPlace.curEnts,
newItems: inPlace.items,
features,
product: fullProduct,
logger: ctx.logger,
isCustom: false,
});
});
} else {
await handleNewProductItems({
db,
curPrices: fullProduct.prices,
curEnts: fullProduct.entitlements,
newItems,
features,
product: fullProduct,
logger: ctx.logger,
isCustom: false,
});
}
}
const latestProductId = updates.id || fullProduct.id;
@@ -174,6 +201,7 @@ export const updateProduct = async ({
idOrInternalId: latestProductId,
orgId: org.id,
env,
version: fullProduct.version,
});
if (free_trial !== undefined) {

View File

@@ -145,13 +145,7 @@ export const handleUpdatePlanV1 = createRoute({
// Check if versioning is needed (customers exist AND items or free trial changed)
const freeTrialProvided = "free_trial" in body;
if (cusProductExists && (itemsExist || freeTrialProvided)) {
if (disable_version) {
throw new RecaseError({
message: "Cannot auto save product as there are existing customers",
});
}
if (cusProductExists && !disable_version && (itemsExist || freeTrialProvided)) {
const { itemsSame, freeTrialsSame } = productsAreSame({
newProductV2: newProductV2,
curProductV1: fullProduct,

View File

@@ -1,9 +1,9 @@
import {
AffectedResource,
apiPlan,
Scopes,
UpdatePlanParamsV2Schema,
type UpdateProductV2Params,
Scopes,
} from "@autumn/shared";
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
import { updateProduct } from "../../../product/actions/updateProduct.js";
@@ -17,7 +17,8 @@ export const handleUpdatePlanV2 = createRoute({
handler: async (c) => {
const body = c.req.valid("json");
const { plan_id, new_plan_id, ...planParams } = body;
const { plan_id, new_plan_id, disable_version, version, ...planParams } =
body;
const ctx = c.get("ctx");
const initialFullProduct = await ProductService.getFull({
@@ -25,6 +26,7 @@ export const handleUpdatePlanV2 = createRoute({
idOrInternalId: plan_id,
orgId: ctx.org.id,
env: ctx.env,
version,
});
const updateProductV2Params = apiPlan.map.paramsV1ToProductV2({
@@ -39,7 +41,7 @@ export const handleUpdatePlanV2 = createRoute({
await updateProduct({
ctx,
productId: plan_id,
query: {},
query: { version, disable_version },
updates: updateProductV2Params,
initialFullProduct,
});
@@ -50,6 +52,7 @@ export const handleUpdatePlanV2 = createRoute({
idOrInternalId: latestPlanId,
orgId: ctx.org.id,
env: ctx.env,
version,
});
const latestPlan = await getPlanResponse({

View File

@@ -41,7 +41,13 @@ export const handleVersionProductV2 = async ({
}) => {
const { db, features } = ctx;
const curVersion = latestProduct.version;
const latestForVersioning = await ProductService.getFull({
db,
idOrInternalId: latestProduct.id,
orgId: org.id,
env,
});
const curVersion = latestForVersioning.version;
const newVersion = curVersion + 1;
console.log(

View File

@@ -1,6 +1,7 @@
import { mapToProductV2, queryInteger, Scopes } from "@autumn/shared";
import { z } from "zod/v4";
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
import { CusProdReadService } from "@/internal/customers/cusProducts/CusProdReadService.js";
import { ProductService } from "../ProductService.js";
const GetProductInternalQuerySchema = z.object({
@@ -15,7 +16,7 @@ export const handleGetProductInternal = createRoute({
const { version } = c.req.valid("query");
const { db, org, env, features } = c.get("ctx");
const [product, latestProduct] = await Promise.all([
const [product, latestProduct, versionCounts] = await Promise.all([
ProductService.getFull({
db,
idOrInternalId: productId,
@@ -29,6 +30,12 @@ export const handleGetProductInternal = createRoute({
orgId: org.id,
env,
}),
CusProdReadService.getCountsPerVersion({
db,
productId,
orgId: org.id,
env,
}),
]);
const productV2 = mapToProductV2({
@@ -36,6 +43,10 @@ export const handleGetProductInternal = createRoute({
features: features,
});
return c.json({ product: productV2, numVersions: latestProduct.version });
return c.json({
product: productV2,
numVersions: latestProduct.version,
versionCounts,
});
},
});

View File

@@ -6,6 +6,7 @@ import { deleteCachedFullCustomer } from "@/internal/customers/cusUtils/fullCust
import { withMigrationItemTracking } from "@/internal/migrations/v2/actions/migrationItem/index.js";
import { migrationRepo } from "@/internal/migrations/v2/repos/index.js";
import { migrateCustomer } from "@/internal/migrations/v2/run/migrateCustomer/index.js";
import { isMigrationCancelRequested } from "@/internal/migrations/v2/run/utils/migrationCancelToken.js";
import { createTriggerContext } from "@/trigger/utils/createTriggerContext.js";
const PayloadSchema = z.object({
@@ -60,6 +61,13 @@ export const runMigrationCustomerTask = task({
data: { migrationInternalId, migrationRunId, customerInternalId },
});
if (await isMigrationCancelRequested({ migrationRunId })) {
logger.info("run-migration-customer: skipping, cancel requested", {
data: { migrationInternalId, migrationRunId, customerInternalId },
});
return;
}
const migration = await migrationRepo.find({
ctx,
internalId: migrationInternalId,

View File

@@ -5,13 +5,22 @@ 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 { RETRYABLE_MIGRATION_ITEM_RUN_STATUSES } from "@/internal/migrations/v2/run/utils/retryItemStatuses.js";
import { clearOrgCache } from "@/internal/orgs/orgUtils/clearOrgCache.js";
import { createTriggerContext } from "@/trigger/utils/createTriggerContext.js";
const ControlsSchema = z.object({
limit: z.number().int().min(1).optional(),
only: z.array(z.string()).optional(),
concurrency: z.number().int().min(1).optional(),
}).optional();
const MAX_CONCURRENCY = 5;
const ControlsSchema = z
.object({
limit: z.number().int().min(1).optional(),
only: z.array(z.string()).optional(),
concurrency: z.number().int().min(1).max(MAX_CONCURRENCY).optional(),
retryItemStatuses: z
.array(z.enum(RETRYABLE_MIGRATION_ITEM_RUN_STATUSES))
.optional(),
})
.optional();
const PayloadSchema = z.object({
orgId: z.string(),
@@ -19,6 +28,7 @@ const PayloadSchema = z.object({
migrationId: z.string(),
migrationRunId: z.string(),
dryRun: z.boolean().default(false),
lazyRun: z.boolean().default(false),
controls: ControlsSchema,
});
@@ -32,10 +42,18 @@ export const runMigrationTask = task({
id: "run-migration",
queue: runMigrationTaskQueue,
machine: "medium-1x",
maxDuration: 3600,
// Trigger.dev has no true "disable" — set very high to effectively remove the timeout.
maxDuration: 86400,
run: async (rawPayload: unknown, { ctx: triggerCtx }) => {
const { orgId, env, migrationId, migrationRunId, dryRun, controls } =
PayloadSchema.parse(rawPayload);
const {
orgId,
env,
migrationId,
migrationRunId,
dryRun,
lazyRun,
controls,
} = PayloadSchema.parse(rawPayload);
const { ctx, logger } = await createTriggerContext({
orgId,
@@ -65,43 +83,50 @@ export const runMigrationTask = task({
onlyCount: controls?.only?.length,
limit: controls?.limit,
concurrency: controls?.concurrency,
retryItemStatuses: controls?.retryItemStatuses,
},
});
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,
};
const effectiveControls = {
...(controls ?? {}),
concurrency: controls?.concurrency ?? MAX_CONCURRENCY,
};
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,5 @@
`update_items` is retired for now.
These files are reference-only and are excluded from active test discovery and
server typecheck. If `update_items` returns, move them back under the active
migration integration tests and rename `*.deprecated.ts` back to `*.test.ts`.

View File

@@ -0,0 +1,235 @@
import { expect, test } from "bun:test";
import {
type ApiCustomerV3,
type ApiCustomerV5,
ResetInterval,
} from "@autumn/shared";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect";
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 { addMonths } from "date-fns";
import { runUpdatePlanMigration } from "../../../utils/runUpdatePlanMigration";
import { lifetimeCredits } from "./updateIntervalTestUtils";
test.concurrent(`${chalk.yellowBright("migrations update_items interval: monthly credits become one-off with and without usage")}`, async () => {
for (const scenario of [
{
customerId: "migration-update-items-interval-usage",
usage: 40,
remaining: 110,
},
{
customerId: "migration-update-items-interval-no-usage",
usage: 0,
remaining: 150,
},
]) {
const base = products.base({
id: `${scenario.customerId}-plan`,
items: [items.monthlyCredits({ includedUsage: 100 })],
});
const { autumnV1, autumnV2_2, ctx } = await initScenario({
customerId: scenario.customerId,
setup: [s.customer(), s.products({ list: [base] })],
actions: [
s.billing.attach({ productId: base.id }),
...(scenario.usage > 0
? [
s.track({
featureId: TestFeature.Credits,
value: scenario.usage,
timeout: 2000,
}),
]
: []),
],
});
await runUpdatePlanMigration({
ctx,
migrationClient: autumnV2_2,
migrationId: `${scenario.customerId}-mig`,
customerId: scenario.customerId,
filter: { customer: { plan: { plan_id: base.id } } },
operations: {
customer: [
{
type: "update_plan",
plan_filter: { plan_id: base.id },
customize: {
update_items: [
{
filter: { feature_id: TestFeature.Credits },
included: 150,
interval: ResetInterval.OneOff,
},
],
},
},
],
},
runOnServer: false,
});
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(
scenario.customerId,
);
expectBalanceCorrect({
customer,
featureId: TestFeature.Credits,
remaining: scenario.remaining,
usage: scenario.usage,
nextResetAt: null,
planId: base.id,
breakdown: {
[ResetInterval.OneOff]: {
included_grant: 150,
remaining: scenario.remaining,
usage: scenario.usage,
},
},
});
await expectCustomerInvoiceCorrect({
customer: await autumnV1.customers.get<ApiCustomerV3>(
scenario.customerId,
),
count: 0,
});
}
});
test.concurrent(`${chalk.yellowBright("migrations update_items interval: mixed included and interval update carries usage")}`, async () => {
const customerId = "migration-update-items-mixed-included-interval";
const base = products.base({
id: "migration-update-items-mixed-included-interval-plan",
items: [items.monthlyCredits({ includedUsage: 100 })],
});
const { autumnV1, autumnV2_2, ctx } = await initScenario({
customerId,
setup: [s.customer(), s.products({ list: [base] })],
actions: [
s.billing.attach({ productId: base.id }),
s.track({ featureId: TestFeature.Credits, value: 45, timeout: 2000 }),
],
});
await runUpdatePlanMigration({
ctx,
migrationClient: autumnV2_2,
migrationId: `${customerId}-mig`,
customerId,
filter: { customer: { plan: { plan_id: base.id } } },
operations: {
customer: [
{
type: "update_plan",
plan_filter: { plan_id: base.id },
customize: {
update_items: [
{
filter: { feature_id: TestFeature.Credits },
included: 180,
interval: ResetInterval.OneOff,
},
],
},
},
],
},
runOnServer: false,
});
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
expectBalanceCorrect({
customer,
featureId: TestFeature.Credits,
remaining: 135,
usage: 45,
nextResetAt: null,
planId: base.id,
breakdown: {
[ResetInterval.OneOff]: {
included_grant: 180,
remaining: 135,
usage: 45,
},
},
});
await expectCustomerInvoiceCorrect({
customer: await autumnV1.customers.get<ApiCustomerV3>(customerId),
count: 0,
});
});
test.concurrent(`${chalk.yellowBright("migrations update_items interval: free one-off to monthly preserves plan anchor")}`, async () => {
const customerId = "migration-update-items-one-off-to-month-free";
const base = products.base({
id: "migration-update-items-one-off-to-month-free-plan",
items: [lifetimeCredits({ includedUsage: 100 })],
});
const { autumnV2_2, ctx } = await initScenario({
customerId,
setup: [s.customer(), s.products({ list: [base] })],
actions: [
s.billing.attach({ productId: base.id }),
s.track({ featureId: TestFeature.Credits, value: 40, timeout: 2000 }),
],
});
const before = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
const startedAt =
before.subscriptions.find((subscription) => subscription.plan_id === base.id)
?.started_at ??
before.purchases.find((purchase) => purchase.plan_id === base.id)
?.started_at;
expect(startedAt).toBeDefined();
await runUpdatePlanMigration({
ctx,
migrationClient: autumnV2_2,
migrationId: `${customerId}-mig`,
customerId,
filter: { customer: { plan: { plan_id: base.id } } },
operations: {
customer: [
{
type: "update_plan",
plan_filter: { plan_id: base.id },
customize: {
update_items: [
{
filter: { feature_id: TestFeature.Credits },
included: 150,
interval: ResetInterval.Month,
},
],
},
},
],
},
runOnServer: false,
});
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
expectBalanceCorrect({
customer,
featureId: TestFeature.Credits,
remaining: 110,
usage: 40,
nextResetAt: addMonths(startedAt!, 1).getTime(),
planId: base.id,
breakdown: {
[ResetInterval.Month]: {
included_grant: 150,
remaining: 110,
usage: 40,
},
},
});
});

View File

@@ -0,0 +1,463 @@
import { expect, test } from "bun:test";
import {
type ApiCustomerV3,
type ApiCustomerV5,
type ApiEntityV2,
BillingInterval,
BillingMethod,
ResetInterval,
} from "@autumn/shared";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect";
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 { runUpdatePlanMigration } from "../../../utils/runUpdatePlanMigration";
import { getCreditBucket, lifetimeCredits } from "./updateIntervalTestUtils";
test.concurrent(`${chalk.yellowBright("migrations update_items interval: mixed update carries per entity with same-feature cusEnts")}`, async () => {
const customerId = "migration-update-items-mixed-entity-same-feature";
const base = products.base({
id: "migration-update-items-mixed-entity-same-feature-plan",
items: [
items.monthlyCredits({ includedUsage: 100 }),
lifetimeCredits({ includedUsage: 50 }),
],
});
const { autumnV1, autumnV2_2, ctx, entities } = await initScenario({
customerId,
setup: [
s.customer(),
s.products({ list: [base] }),
s.entities({ count: 2, featureId: TestFeature.Users }),
],
actions: [
s.billing.attach({ productId: base.id, entityIndex: 0 }),
s.billing.attach({ productId: base.id, entityIndex: 1 }),
s.track({
featureId: TestFeature.Credits,
value: 30,
entityIndex: 0,
timeout: 2000,
}),
s.track({
featureId: TestFeature.Credits,
value: 60,
entityIndex: 1,
timeout: 2000,
}),
],
});
await runUpdatePlanMigration({
ctx,
migrationClient: autumnV2_2,
migrationId: `${customerId}-mig`,
customerId,
filter: { customer: { plan: { plan_id: base.id } } },
operations: {
customer: [
{
type: "update_plan",
plan_filter: { plan_id: base.id },
customize: {
update_items: [
{
filter: {
feature_id: TestFeature.Credits,
interval: BillingInterval.Month,
},
included: 200,
interval: ResetInterval.OneOff,
},
],
},
},
],
},
runOnServer: false,
});
for (const scenario of [
{ entityId: entities[0].id, usage: 30, remaining: 220 },
{ entityId: entities[1].id, usage: 60, remaining: 190 },
]) {
const entity = await autumnV2_2.entities.get<ApiEntityV2>(
customerId,
scenario.entityId,
);
expectBalanceCorrect({
customer: entity,
featureId: TestFeature.Credits,
remaining: scenario.remaining,
usage: scenario.usage,
nextResetAt: null,
planId: base.id,
});
const oneOffBuckets = entity.balances[
TestFeature.Credits
].breakdown?.filter(
(bucket) => bucket.reset?.interval === ResetInterval.OneOff,
);
expect(oneOffBuckets).toHaveLength(2);
expect(oneOffBuckets).toEqual(
expect.arrayContaining([
expect.objectContaining({
included_grant: 50,
remaining: 50,
usage: 0,
}),
expect.objectContaining({
included_grant: 200,
remaining: scenario.remaining - 50,
usage: scenario.usage,
}),
]),
);
}
await expectCustomerInvoiceCorrect({
customer: await autumnV1.customers.get<ApiCustomerV3>(customerId),
count: 0,
});
});
test.concurrent(`${chalk.yellowBright("migrations update_items interval: monthly to one-off preserves existing lifetime usage")}`, async () => {
const customerId = "migration-update-items-lifetime-usage";
const base = products.base({
id: "migration-update-items-lifetime-usage-plan",
items: [
items.monthlyCredits({ includedUsage: 100 }),
lifetimeCredits({ includedUsage: 80 }),
],
});
const { autumnV1, autumnV2, autumnV2_2, ctx } = await initScenario({
customerId,
setup: [s.customer(), s.products({ list: [base] })],
actions: [s.billing.attach({ productId: base.id })],
});
const initialCustomer =
await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
await autumnV2.balances.update({
customer_id: customerId,
feature_id: TestFeature.Credits,
current_balance: 70,
balance_id: getCreditBucket({
subject: initialCustomer,
resetInterval: ResetInterval.Month,
includedGrant: 100,
}).id,
});
await autumnV2.balances.update({
customer_id: customerId,
feature_id: TestFeature.Credits,
current_balance: 50,
balance_id: getCreditBucket({
subject: initialCustomer,
resetInterval: ResetInterval.OneOff,
includedGrant: 80,
}).id,
});
await runUpdatePlanMigration({
ctx,
migrationClient: autumnV2_2,
migrationId: `${customerId}-mig`,
customerId,
filter: { customer: { plan: { plan_id: base.id } } },
operations: {
customer: [
{
type: "update_plan",
plan_filter: { plan_id: base.id },
customize: {
update_items: [
{
filter: {
feature_id: TestFeature.Credits,
interval: BillingInterval.Month,
},
included: 200,
interval: ResetInterval.OneOff,
},
],
},
},
],
},
runOnServer: false,
});
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
expectBalanceCorrect({
customer,
featureId: TestFeature.Credits,
remaining: 220,
usage: 60,
nextResetAt: null,
planId: base.id,
});
expect(getCreditBucket({
subject: customer,
resetInterval: ResetInterval.OneOff,
includedGrant: 80,
})).toMatchObject({ remaining: 50, usage: 30 });
expect(getCreditBucket({
subject: customer,
resetInterval: ResetInterval.OneOff,
includedGrant: 200,
})).toMatchObject({ remaining: 170, usage: 30 });
await expectCustomerInvoiceCorrect({
customer: await autumnV1.customers.get<ApiCustomerV3>(customerId),
count: 0,
});
});
test.concurrent(`${chalk.yellowBright("migrations update_items interval: prepaid and usage-based one-off carry stays separated")}`, async () => {
const customerId = "migration-update-items-interval-billing-methods";
const pro = products.pro({
id: "migration-update-items-interval-billing-methods-plan",
items: [
items.prepaid({
featureId: TestFeature.Credits,
includedUsage: 100,
billingUnits: 100,
price: 10,
}),
items.consumable({
featureId: TestFeature.Credits,
includedUsage: 50,
price: 0.1,
}),
],
});
const { autumnV1, autumnV2, autumnV2_2, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [
s.billing.attach({
productId: pro.id,
options: [{ feature_id: TestFeature.Credits, quantity: 300 }],
}),
],
});
const initialCustomer =
await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
await autumnV2.balances.update({
customer_id: customerId,
feature_id: TestFeature.Credits,
current_balance: 250,
balance_id: getCreditBucket({
subject: initialCustomer,
resetInterval: ResetInterval.Month,
billingMethod: BillingMethod.Prepaid,
}).id,
});
await autumnV2.balances.update({
customer_id: customerId,
feature_id: TestFeature.Credits,
current_balance: 30,
balance_id: getCreditBucket({
subject: initialCustomer,
resetInterval: ResetInterval.Month,
billingMethod: BillingMethod.UsageBased,
}).id,
});
const invoiceCountBefore =
(await autumnV1.customers.get<ApiCustomerV3>(customerId)).invoices?.length ??
0;
await runUpdatePlanMigration({
ctx,
migrationClient: autumnV2_2,
migrationId: `${customerId}-mig`,
customerId,
filter: { customer: { plan: { plan_id: pro.id } } },
operations: {
customer: [
{
type: "update_plan",
plan_filter: { plan_id: pro.id },
customize: {
update_items: [
{
filter: {
feature_id: TestFeature.Credits,
billing_method: BillingMethod.Prepaid,
interval: BillingInterval.Month,
},
included: 200,
interval: ResetInterval.OneOff,
},
{
filter: {
feature_id: TestFeature.Credits,
billing_method: BillingMethod.UsageBased,
interval: BillingInterval.Month,
},
included: 100,
interval: ResetInterval.OneOff,
},
],
},
},
],
},
noBillingChanges: true,
runOnServer: false,
});
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
expectBalanceCorrect({
customer,
featureId: TestFeature.Credits,
remaining: 330,
usage: 70,
nextResetAt: null,
planId: pro.id,
});
expect(getCreditBucket({
subject: customer,
resetInterval: ResetInterval.OneOff,
billingMethod: BillingMethod.Prepaid,
})).toMatchObject({
included_grant: 200,
prepaid_grant: 100,
remaining: 250,
usage: 50,
});
expect(getCreditBucket({
subject: customer,
resetInterval: ResetInterval.OneOff,
billingMethod: BillingMethod.UsageBased,
})).toMatchObject({
included_grant: 100,
remaining: 80,
usage: 20,
});
await expectCustomerInvoiceCorrect({
customer: await autumnV1.customers.get<ApiCustomerV3>(customerId),
count: invoiceCountBefore,
});
});
test.concurrent(`${chalk.yellowBright("migrations update_items interval: carry links do not leak across add-ons")}`, async () => {
const customerId = "migration-update-items-interval-addon-isolation";
const pro = products.pro({
id: "migration-update-items-interval-addon-isolation-pro",
items: [items.monthlyCredits({ includedUsage: 100 })],
});
const addon = products.recurringAddOn({
id: "migration-update-items-interval-addon-isolation-addon",
items: [items.monthlyCredits({ includedUsage: 500 })],
});
const { autumnV1, autumnV2, autumnV2_2, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, addon] }),
],
actions: [
s.billing.attach({ productId: pro.id }),
s.billing.attach({ productId: addon.id }),
],
});
const initialCustomer =
await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
await autumnV2.balances.update({
customer_id: customerId,
feature_id: TestFeature.Credits,
current_balance: 70,
balance_id: getCreditBucket({
subject: initialCustomer,
planId: pro.id,
resetInterval: ResetInterval.Month,
}).id,
});
await autumnV2.balances.update({
customer_id: customerId,
feature_id: TestFeature.Credits,
current_balance: 450,
balance_id: getCreditBucket({
subject: initialCustomer,
planId: addon.id,
resetInterval: ResetInterval.Month,
}).id,
});
const invoiceCountBefore =
(await autumnV1.customers.get<ApiCustomerV3>(customerId)).invoices?.length ??
0;
await runUpdatePlanMigration({
ctx,
migrationClient: autumnV2_2,
migrationId: `${customerId}-mig`,
customerId,
filter: { customer: { plan: { plan_id: pro.id } } },
operations: {
customer: [
{
type: "update_plan",
plan_filter: { plan_id: pro.id },
customize: {
update_items: [
{
filter: {
feature_id: TestFeature.Credits,
interval: BillingInterval.Month,
},
included: 200,
interval: ResetInterval.OneOff,
},
],
},
},
],
},
noBillingChanges: true,
runOnServer: false,
});
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
expectBalanceCorrect({
customer,
featureId: TestFeature.Credits,
remaining: 620,
usage: 80,
});
expect(getCreditBucket({
subject: customer,
planId: pro.id,
resetInterval: ResetInterval.OneOff,
})).toMatchObject({
included_grant: 200,
remaining: 170,
usage: 30,
});
expect(getCreditBucket({
subject: customer,
planId: addon.id,
resetInterval: ResetInterval.Month,
})).toMatchObject({
included_grant: 500,
remaining: 450,
usage: 50,
});
await expectCustomerInvoiceCorrect({
customer: await autumnV1.customers.get<ApiCustomerV3>(customerId),
count: invoiceCountBefore,
});
});

View File

@@ -0,0 +1,210 @@
import { expect, test } from "bun:test";
import {
type ApiCustomerV5,
BillingMethod,
ResetInterval,
} from "@autumn/shared";
import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect";
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 { constructPrepaidItem } from "@/utils/scriptUtils/constructItem";
import { runUpdatePlanMigration } from "../../../utils/runUpdatePlanMigration";
import { lifetimeCredits } from "./updateIntervalTestUtils";
test.concurrent(`${chalk.yellowBright("migrations update_items interval: subscription one-off to monthly uses subscription cycle")}`, async () => {
const customerId = "migration-update-items-one-off-to-month-sub";
const pro = products.pro({
id: "migration-update-items-one-off-to-month-sub-plan",
items: [lifetimeCredits({ includedUsage: 100 })],
});
const { autumnV2_2, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [
s.billing.attach({ productId: pro.id }),
s.advanceTestClock({ days: 10 }),
s.track({ featureId: TestFeature.Credits, value: 40, timeout: 2000 }),
],
});
const before = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
const currentPeriodEnd = before.subscriptions.find(
(subscription) => subscription.plan_id === pro.id,
)?.current_period_end;
expect(currentPeriodEnd).not.toBeNull();
await runUpdatePlanMigration({
ctx,
migrationClient: autumnV2_2,
migrationId: `${customerId}-mig`,
customerId,
filter: { customer: { plan: { plan_id: pro.id } } },
operations: {
customer: [
{
type: "update_plan",
plan_filter: { plan_id: pro.id },
customize: {
update_items: [
{
filter: { feature_id: TestFeature.Credits },
included: 150,
interval: ResetInterval.Month,
},
],
},
},
],
},
runOnServer: false,
noBillingChanges: true,
});
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
expectBalanceCorrect({
customer,
featureId: TestFeature.Credits,
remaining: 110,
usage: 40,
nextResetAt: currentPeriodEnd!,
planId: pro.id,
breakdown: {
[ResetInterval.Month]: {
included_grant: 150,
remaining: 110,
usage: 40,
},
},
});
});
test.concurrent(`${chalk.yellowBright("migrations update_items interval: monthly paid item interval changes are rejected")}`, async () => {
const customerId = "migration-update-items-monthly-paid-rejected";
const base = products.base({
id: "migration-update-items-monthly-paid-rejected-plan",
items: [
items.prepaid({
featureId: TestFeature.Credits,
includedUsage: 100,
billingUnits: 100,
price: 10,
}),
items.consumableMessages({ includedUsage: 50, price: 0.1 }),
],
});
const { autumnV2_2, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [base] }),
],
actions: [s.billing.attach({ productId: base.id })],
});
const cases = [
{
name: "prepaid",
filter: {
feature_id: TestFeature.Credits,
billing_method: BillingMethod.Prepaid,
},
},
{
name: "usage-based",
filter: {
feature_id: TestFeature.Messages,
billing_method: BillingMethod.UsageBased,
},
},
];
for (const testCase of cases) {
await expect(
runUpdatePlanMigration({
ctx,
migrationClient: autumnV2_2,
migrationId: `${customerId}-${testCase.name}-mig`,
customerId,
filter: { customer: { plan: { plan_id: base.id } } },
operations: {
customer: [
{
type: "update_plan",
plan_filter: { plan_id: base.id },
customize: {
update_items: [
{
filter: testCase.filter,
interval: ResetInterval.OneOff,
},
],
},
},
],
},
runOnServer: false,
}),
).rejects.toThrow(/paid items/i);
}
});
test.concurrent(`${chalk.yellowBright("migrations update_items interval: one-off prepaid interval changes are rejected")}`, async () => {
const customerId = "migration-update-items-one-off-prepaid-rejected";
const oneOffPrepaid = constructPrepaidItem({
featureId: TestFeature.Credits,
includedUsage: 100,
billingUnits: 100,
price: 10,
isOneOff: true,
});
const base = products.base({
id: "migration-update-items-one-off-prepaid-rejected-plan",
items: [oneOffPrepaid],
});
const { autumnV2_2, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [base] }),
],
actions: [s.billing.attach({ productId: base.id })],
});
await expect(
runUpdatePlanMigration({
ctx,
migrationClient: autumnV2_2,
migrationId: `${customerId}-mig`,
customerId,
filter: { customer: { plan: { plan_id: base.id } } },
operations: {
customer: [
{
type: "update_plan",
plan_filter: { plan_id: base.id },
customize: {
update_items: [
{
filter: {
feature_id: TestFeature.Credits,
billing_method: BillingMethod.Prepaid,
},
interval: ResetInterval.Month,
},
],
},
},
],
},
runOnServer: false,
}),
).rejects.toThrow(/paid items/i);
});

View File

@@ -0,0 +1,25 @@
import type { ApiCustomerV5, ApiEntityV2 } from "@autumn/shared";
import {
getBalanceBucket,
getBalanceBuckets,
} from "@tests/integration/utils/getBalanceBucket";
import { TestFeature } from "@tests/setup/v2Features";
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem";
export const lifetimeCredits = ({
includedUsage = 50,
}: {
includedUsage?: number;
} = {}) =>
constructFeatureItem({
featureId: TestFeature.Credits,
includedUsage,
interval: null,
});
export const getCreditBuckets = (subject: ApiCustomerV5 | ApiEntityV2) =>
getBalanceBuckets({ subject, featureId: TestFeature.Credits });
export const getCreditBucket = (
params: Omit<Parameters<typeof getBalanceBucket>[0], "featureId">,
) => getBalanceBucket({ ...params, featureId: TestFeature.Credits });

Some files were not shown because too many files have changed in this diff Show More