Merge branch 'main' into fix/tax-rate-id-preview
This commit is contained in:
2
ai
2
ai
Submodule ai updated: 0e52f71fbd...db7737aca7
@@ -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 [];
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
349
server/experiments/explainIncludeProcessedFilter.ts
Normal file
349
server/experiments/explainIncludeProcessedFilter.ts
Normal 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();
|
||||
245
server/experiments/explainMigrationFilterPreview.ts
Normal file
245
server/experiments/explainMigrationFilterPreview.ts
Normal 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();
|
||||
23
server/src/external/autumn/autumnCli.ts
vendored
23
server/src/external/autumn/autumnCli.ts
vendored
@@ -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[] }> => {
|
||||
|
||||
@@ -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)}}
|
||||
`,
|
||||
|
||||
@@ -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 = ({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ?? []),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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];
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./carryIdentity";
|
||||
export * from "./customerProductCarryGroups";
|
||||
export * from "./projectCustomerProductForCarry";
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
export * from "./applyCustomerProductItemsPatch";
|
||||
export * from "./getPatchCarryCustomerProduct";
|
||||
export * from "./initPatchCustomerProduct";
|
||||
export * from "./initPatchedCustomerEntitlementsAndPrices";
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -68,6 +68,8 @@ export const getCursorPaginatedFullCusQuery = ({
|
||||
|
||||
const customerListFilterSql = getCustomerListFilterSql({
|
||||
internalCustomerIds,
|
||||
orgId,
|
||||
env,
|
||||
inStatuses,
|
||||
plans,
|
||||
processors,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
10
server/src/internal/customers/customerListFilters.ts
Normal file
10
server/src/internal/customers/customerListFilters.ts
Normal 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>;
|
||||
@@ -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``;
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
);
|
||||
}));
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>> = {
|
||||
|
||||
@@ -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
|
||||
`;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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!),
|
||||
),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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)]),
|
||||
),
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -83,6 +83,7 @@ export const migrateCustomer = async ({
|
||||
ctx: migrationCtx,
|
||||
context,
|
||||
billingPlan,
|
||||
billingContexts,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
? {
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
@@ -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;
|
||||
190
server/src/internal/product/actions/inPlaceUpdateUtils.ts
Normal file
190
server/src/internal/product/actions/inPlaceUpdateUtils.ts
Normal 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),
|
||||
),
|
||||
};
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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`.
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user