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