affect scheduled customer products
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import path from "node:path";
|
||||
import { createTinybirdApi } from "@tinybirdco/sdk";
|
||||
|
||||
type ProfileName = "dev" | "prod" | "prod-legacy";
|
||||
type TinybirdTarget = "new" | "legacy";
|
||||
@@ -35,6 +36,7 @@ const usage = `Usage:
|
||||
bun tb info
|
||||
bun tb deploy:check
|
||||
bun tb deploy
|
||||
bun tb token:read <token_name>
|
||||
bun tb:prod <tinybird command...>
|
||||
bun tb:prod-legacy <tinybird command...>
|
||||
|
||||
@@ -70,15 +72,58 @@ const requireEnv = (name: string) => {
|
||||
return value;
|
||||
};
|
||||
|
||||
const requireEnvValue = (env: NodeJS.ProcessEnv, name: string) => {
|
||||
const value = env[name];
|
||||
if (!value) {
|
||||
console.error(`${name} is not set`);
|
||||
process.exit(1);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const resolveTinybirdArgs = (args: string[]) => {
|
||||
if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
|
||||
console.log(usage);
|
||||
process.exit(args.length === 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
if (args[0] === "token:read") {
|
||||
const tokenName = args[1];
|
||||
if (!tokenName || args.length > 2) {
|
||||
console.error("Usage: bun tb token:read <token_name>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
return commandAliases[args[0]] ?? args;
|
||||
};
|
||||
|
||||
const createReadToken = async (tokenName: string, env: NodeJS.ProcessEnv) => {
|
||||
const baseUrl = requireEnvValue(env, "TINYBIRD_API_URL");
|
||||
const api = createTinybirdApi({
|
||||
baseUrl,
|
||||
token: requireEnvValue(env, "TINYBIRD_TOKEN"),
|
||||
});
|
||||
|
||||
const url = new URL("/v0/tokens/", `${baseUrl}/`);
|
||||
url.searchParams.set("name", tokenName);
|
||||
url.searchParams.set("scope", "WORKSPACE:READ_ALL");
|
||||
|
||||
const response = await api.request(url.toString(), {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
throw new Error(`Failed to create Tinybird read token: ${body}`);
|
||||
}
|
||||
|
||||
const result = (await response.json()) as { token?: string };
|
||||
console.log(result.token ?? JSON.stringify(result));
|
||||
};
|
||||
|
||||
const executeTinybird = async () => {
|
||||
const target = requireEnv("AUTUMN_TINYBIRD_TARGET") as TinybirdTarget;
|
||||
const env = { ...process.env };
|
||||
@@ -92,6 +137,11 @@ const executeTinybird = async () => {
|
||||
}
|
||||
|
||||
const args = resolveTinybirdArgs(Bun.argv.slice(2));
|
||||
if (args[0] === "token:read") {
|
||||
await createReadToken(args[1], env);
|
||||
return;
|
||||
}
|
||||
|
||||
const exitCode = await run(["bunx", "tinybird", ...args], {
|
||||
cwd: serverDir,
|
||||
env,
|
||||
|
||||
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 { handleListAdminUsers } from "./handleListAdminUsers";
|
||||
import { handleListOAuthClients } from "./handleListOAuthClients";
|
||||
import { handleSendCustomerProductUpdatedWebhook } from "./handleSendCustomerProductUpdatedWebhook";
|
||||
import { handleUpsertAdminCustomerBlockConfig } from "./handleUpsertAdminCustomerBlockConfig";
|
||||
import { handleUpsertAdminFeatureFlagsConfig } from "./handleUpsertAdminFeatureFlagsConfig";
|
||||
import { handleUpsertAdminFullSubjectGateConfig } from "./handleUpsertAdminFullSubjectGateConfig";
|
||||
@@ -160,6 +161,10 @@ honoAdminRouter.get("/org-member", ...handleGetOrgMember);
|
||||
honoAdminRouter.get("/master-stripe-account", ...handleGetMasterStripeAccount);
|
||||
honoAdminRouter.get("/oauth-clients", ...handleListOAuthClients);
|
||||
honoAdminRouter.post("/invoice-line-items", ...handleGetInvoiceLineItems);
|
||||
honoAdminRouter.post(
|
||||
"/customer-products/:customer_product_id/send-updated-webhook",
|
||||
...handleSendCustomerProductUpdatedWebhook,
|
||||
);
|
||||
|
||||
honoAdminRouter.get("/rollouts", ...handleGetRollouts);
|
||||
honoAdminRouter.put("/rollouts/:rollout_id", ...handleUpdateRollout);
|
||||
|
||||
@@ -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,
|
||||
currentEpochMs: billingContext.currentEpochMs,
|
||||
externalId: productContext.externalId,
|
||||
isCustom:
|
||||
productContext.customPrices.length > 0 ||
|
||||
productContext.customEntitlements.length > 0,
|
||||
});
|
||||
insertCustomerProducts.push(customerProduct);
|
||||
phaseCustomerProductIds.push(customerProduct.id);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { computeDeleteCustomerProduct } from "@/internal/billing/v2/actions/upda
|
||||
import { computeCustomPlanNewCustomerProduct } from "@/internal/billing/v2/actions/updateSubscription/compute/customPlan/computeCustomPlanNewCustomerProduct";
|
||||
import { buildAutumnLineItems } from "@/internal/billing/v2/compute/computeAutumnUtils/buildAutumnLineItems";
|
||||
import { computePatchCustomerProductPlan } from "@/internal/billing/v2/compute/computePatchPlan";
|
||||
import { computeSchedulePhaseReplacements } from "@/internal/billing/v2/compute/computeSchedulePhaseReplacements";
|
||||
import { applyOneOffPrepaidCarryOvers } from "@/internal/billing/v2/utils/handleOneOffPrepaidCarryOvers/applyOneOffPrepaidCarryOvers";
|
||||
|
||||
export const computeCustomPlan = async ({
|
||||
@@ -57,6 +58,8 @@ export const computeCustomPlan = async ({
|
||||
newCustomerProduct: newFullCustomerProduct,
|
||||
fullCustomer,
|
||||
});
|
||||
const isUpdatingScheduledProduct =
|
||||
customerProduct.status === CusProductStatus.Scheduled;
|
||||
|
||||
const { allLineItems } = buildAutumnLineItems({
|
||||
ctx,
|
||||
@@ -77,13 +80,21 @@ export const computeCustomPlan = async ({
|
||||
return {
|
||||
customerId: fullCustomer?.id ?? "",
|
||||
insertCustomerProducts: [newFullCustomerProduct],
|
||||
updateCustomerProduct: {
|
||||
customerProduct,
|
||||
updates: {
|
||||
status: CusProductStatus.Expired,
|
||||
},
|
||||
},
|
||||
deleteCustomerProduct,
|
||||
updateCustomerProduct: isUpdatingScheduledProduct
|
||||
? undefined
|
||||
: {
|
||||
customerProduct,
|
||||
updates: {
|
||||
status: CusProductStatus.Expired,
|
||||
},
|
||||
},
|
||||
deleteCustomerProduct: isUpdatingScheduledProduct
|
||||
? customerProduct
|
||||
: deleteCustomerProduct,
|
||||
schedulePhaseCustomerProductReplacements: computeSchedulePhaseReplacements({
|
||||
oldCustomerProduct: customerProduct,
|
||||
newCustomerProduct: newFullCustomerProduct,
|
||||
}),
|
||||
customPrices,
|
||||
customEntitlements: [
|
||||
...(customEnts ?? []),
|
||||
|
||||
@@ -81,7 +81,8 @@ export const computeCustomPlanNewCustomerProduct = ({
|
||||
initOptions: {
|
||||
isCustom: updateSubscriptionContext.isCustom,
|
||||
subscriptionId: stripeSubscription?.id, // don't populate if it's starting in the future.
|
||||
subscriptionScheduleId: stripeSubscriptionSchedule?.id,
|
||||
subscriptionScheduleId:
|
||||
stripeSubscriptionSchedule?.id ?? currentCustomerProduct.scheduled_ids?.[0],
|
||||
externalId: currentCustomerProduct.external_id ?? undefined,
|
||||
startsAt: currentCustomerProduct.starts_at ?? undefined,
|
||||
...cancelFields,
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import { buildAutumnLineItems } from "@/internal/billing/v2/compute/computeAutumnUtils/buildAutumnLineItems";
|
||||
import { computeSchedulePhaseReplacements } from "@/internal/billing/v2/compute/computeSchedulePhaseReplacements";
|
||||
import { initPatchCustomerProduct } from "@/internal/billing/v2/utils/initFullCustomerProduct/initPatchedCustomerProduct";
|
||||
|
||||
export const computePatchCustomerProductPlan = ({
|
||||
@@ -58,18 +59,31 @@ export const computePatchCustomerProductPlan = ({
|
||||
} satisfies Partial<AutumnBillingPlan>;
|
||||
|
||||
if (patchContext.mode === "new") {
|
||||
const isUpdatingScheduledProduct =
|
||||
patchContext.originalCustomerProduct.status === CusProductStatus.Scheduled;
|
||||
|
||||
return {
|
||||
...basePlan,
|
||||
insertCustomerProducts: [finalCustomerProduct],
|
||||
updateCustomerProduct: {
|
||||
customerProduct: patchContext.originalCustomerProduct,
|
||||
updates: {
|
||||
status: CusProductStatus.Expired,
|
||||
ended_at: Date.now(),
|
||||
canceled: true,
|
||||
canceled_at: Date.now(),
|
||||
},
|
||||
},
|
||||
updateCustomerProduct: isUpdatingScheduledProduct
|
||||
? undefined
|
||||
: {
|
||||
customerProduct: patchContext.originalCustomerProduct,
|
||||
updates: {
|
||||
status: CusProductStatus.Expired,
|
||||
ended_at: Date.now(),
|
||||
canceled: true,
|
||||
canceled_at: Date.now(),
|
||||
},
|
||||
},
|
||||
deleteCustomerProduct: isUpdatingScheduledProduct
|
||||
? patchContext.originalCustomerProduct
|
||||
: undefined,
|
||||
schedulePhaseCustomerProductReplacements:
|
||||
computeSchedulePhaseReplacements({
|
||||
oldCustomerProduct: patchContext.originalCustomerProduct,
|
||||
newCustomerProduct: finalCustomerProduct,
|
||||
}),
|
||||
} satisfies AutumnBillingPlan;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
type AutumnBillingPlan,
|
||||
CusProductStatus,
|
||||
type FullCusProduct,
|
||||
} from "@autumn/shared";
|
||||
|
||||
export const computeSchedulePhaseReplacements = ({
|
||||
oldCustomerProduct,
|
||||
newCustomerProduct,
|
||||
}: {
|
||||
oldCustomerProduct: FullCusProduct;
|
||||
newCustomerProduct: FullCusProduct;
|
||||
}): AutumnBillingPlan["schedulePhaseCustomerProductReplacements"] => {
|
||||
if (oldCustomerProduct.status !== CusProductStatus.Scheduled) return undefined;
|
||||
|
||||
return [
|
||||
{
|
||||
oldCustomerProductId: oldCustomerProduct.id,
|
||||
newCustomerProductId: newCustomerProduct.id,
|
||||
internalCustomerId: oldCustomerProduct.internal_customer_id,
|
||||
internalEntityId: oldCustomerProduct.internal_entity_id,
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "@/internal/billing/v2/utils/billingPlan/customerProductPlanMutations";
|
||||
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService";
|
||||
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService";
|
||||
import { replaceScheduledPhaseCustomerProductIds } from "@/internal/customers/schedules/repos/replaceScheduledPhaseCustomerProductIds";
|
||||
import { invoiceActions } from "@/internal/invoices/actions";
|
||||
import { EntitlementService } from "@/internal/products/entitlements/EntitlementService";
|
||||
import { FreeTrialService } from "@/internal/products/free-trials/FreeTrialService";
|
||||
@@ -92,6 +93,11 @@ export const executeAutumnBillingPlan = async ({
|
||||
newCusProducts: insertCustomerProducts,
|
||||
});
|
||||
|
||||
await replaceScheduledPhaseCustomerProductIds({
|
||||
ctx,
|
||||
replacements: autumnBillingPlan.schedulePhaseCustomerProductReplacements,
|
||||
});
|
||||
|
||||
// 3. Update customer product (DB only)
|
||||
for (const { customerProduct, updates } of updateCustomerProducts) {
|
||||
// Skip empty updates — drizzle throws "No values to set" on empty SET.
|
||||
|
||||
@@ -11,6 +11,33 @@ import { toCustomerPlanSnapshot } from "./toCustomerPlanSnapshot";
|
||||
const getChangePlanId = (change: CustomerPlanChange): string | undefined =>
|
||||
change.subscription?.plan_id ?? change.purchase?.plan_id;
|
||||
|
||||
const getUpdatedChangeMergeKey = (
|
||||
change: CustomerPlanChange,
|
||||
): string | undefined => {
|
||||
if (change.subscription) {
|
||||
const subscription = change.subscription;
|
||||
return [
|
||||
"subscription",
|
||||
subscription.plan_id,
|
||||
subscription.status,
|
||||
subscription.started_at,
|
||||
subscription.expires_at,
|
||||
subscription.canceled_at,
|
||||
subscription.trial_ends_at,
|
||||
].join(":");
|
||||
}
|
||||
|
||||
if (change.purchase) {
|
||||
const purchase = change.purchase;
|
||||
return [
|
||||
"purchase",
|
||||
purchase.plan_id,
|
||||
purchase.status,
|
||||
purchase.expires_at,
|
||||
].join(":");
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* When a billing action updates a plan in-place, Autumn often creates a new
|
||||
* customer product (insertCustomerProducts) and expires the old one
|
||||
@@ -79,15 +106,15 @@ const mergeUpdatedPlanChanges = (
|
||||
const result: CustomerPlanChange[] = [];
|
||||
|
||||
for (const change of changes) {
|
||||
const planId = getChangePlanId(change);
|
||||
if (change.action !== "updated" || !planId) {
|
||||
const mergeKey = getUpdatedChangeMergeKey(change);
|
||||
if (change.action !== "updated" || !mergeKey) {
|
||||
result.push(change);
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = merged.get(planId);
|
||||
const existing = merged.get(mergeKey);
|
||||
if (!existing) {
|
||||
merged.set(planId, change);
|
||||
merged.set(mergeKey, change);
|
||||
result.push(change);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ export const initScheduledCustomerProduct = ({
|
||||
currentEpochMs,
|
||||
accessStartsAt,
|
||||
externalId,
|
||||
isCustom,
|
||||
subscriptionId,
|
||||
subscriptionScheduleId,
|
||||
internalEntityId,
|
||||
@@ -44,6 +45,7 @@ export const initScheduledCustomerProduct = ({
|
||||
accessStartsAt?: number;
|
||||
/** Customer-facing Autumn subscription API id, stored on customer_products.external_id. */
|
||||
externalId?: string;
|
||||
isCustom?: boolean;
|
||||
/** When syncing from an existing Stripe sub/schedule, link the resulting
|
||||
* scheduled cusProduct back to it so the customer-products view shows the
|
||||
* Stripe linkage and downstream actions (cancel, restore) can find it. */
|
||||
@@ -75,6 +77,7 @@ export const initScheduledCustomerProduct = ({
|
||||
status: accessStartsAt === undefined ? CusProductStatus.Scheduled : undefined,
|
||||
accessStartsAt,
|
||||
externalId,
|
||||
isCustom,
|
||||
subscriptionId,
|
||||
subscriptionScheduleId,
|
||||
internalEntityId,
|
||||
|
||||
@@ -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 = {
|
||||
migrationInternalId: string;
|
||||
executionFilter?: CustomerExecutionStatusFilter;
|
||||
};
|
||||
|
||||
export type CustomerExecutionStatus = MigrationItemRunStatus | "not_run";
|
||||
|
||||
export type CustomerExecutionStatusFilter = {
|
||||
statuses: CustomerExecutionStatus[];
|
||||
migrationRunId?: string;
|
||||
dryRun?: boolean;
|
||||
};
|
||||
|
||||
export type CustomerQueryArgs = {
|
||||
@@ -75,21 +84,103 @@ const buildProcessedIn = (includeProcessed: IncludeProcessed): SQL => sql`
|
||||
AND mir.dry_run = false
|
||||
)`;
|
||||
|
||||
const buildExecutionScope = (
|
||||
migrationInternalId: string,
|
||||
filter: CustomerExecutionStatusFilter | undefined,
|
||||
): SQL => {
|
||||
const dryRunScope =
|
||||
filter?.dryRun !== undefined
|
||||
? sql`AND mir.dry_run = ${filter.dryRun}`
|
||||
: sql`AND mir.dry_run = false`;
|
||||
const runScope = filter?.migrationRunId
|
||||
? sql`AND mir.migration_run_id = ${filter.migrationRunId}`
|
||||
: sql``;
|
||||
|
||||
return sql`
|
||||
mir.migration_internal_id = ${migrationInternalId}
|
||||
AND mir.item_kind = 'customer'
|
||||
${dryRunScope}
|
||||
${runScope}
|
||||
`;
|
||||
};
|
||||
|
||||
const buildExecutionStatusWhere = (
|
||||
includeProcessed: IncludeProcessed | undefined,
|
||||
{ includeNotRun = true }: { includeNotRun?: boolean } = {},
|
||||
): SQL => {
|
||||
const filter = includeProcessed?.executionFilter;
|
||||
if (!includeProcessed || !filter || filter.statuses.length === 0) return sql``;
|
||||
|
||||
const explicitStatuses = filter.statuses.filter(
|
||||
(status): status is MigrationItemRunStatus => status !== "not_run",
|
||||
);
|
||||
const clauses: SQL[] = [];
|
||||
|
||||
if (explicitStatuses.length > 0) {
|
||||
const statuses = sql.join(
|
||||
explicitStatuses.map((status) => sql`${status}`),
|
||||
sql`, `,
|
||||
);
|
||||
clauses.push(sql`
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM migration_item_runs mir
|
||||
WHERE ${buildExecutionScope(includeProcessed.migrationInternalId, filter)}
|
||||
AND mir.item_id = c.internal_id
|
||||
AND mir.status IN (${statuses})
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
if (includeNotRun && filter.statuses.includes("not_run")) {
|
||||
clauses.push(sql`
|
||||
NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM migration_item_runs mir
|
||||
WHERE ${buildExecutionScope(includeProcessed.migrationInternalId, filter)}
|
||||
AND mir.item_id = c.internal_id
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
if (clauses.length === 0) return sql`AND false`;
|
||||
return clauses.length === 1
|
||||
? sql`AND ${clauses[0]}`
|
||||
: sql`AND (${sql.join(clauses, sql` OR `)})`;
|
||||
};
|
||||
|
||||
const getExecutionFilterMode = (
|
||||
includeProcessed: IncludeProcessed,
|
||||
): "all" | "explicit_only" | "not_run_only" | "mixed" => {
|
||||
const statuses = includeProcessed.executionFilter?.statuses;
|
||||
if (!statuses || statuses.length === 0) return "all";
|
||||
|
||||
const hasNotRun = statuses.includes("not_run");
|
||||
const hasExplicit = statuses.some((status) => status !== "not_run");
|
||||
if (hasExplicit && hasNotRun) return "mixed";
|
||||
if (hasExplicit) return "explicit_only";
|
||||
return "not_run_only";
|
||||
};
|
||||
|
||||
// Predicates shared by both UNION branches (and the single-branch query).
|
||||
// Rebuilt per call so a branch never reuses another's SQL chunk instance.
|
||||
const buildCommonWhere = ({
|
||||
checkpoint,
|
||||
search,
|
||||
afterInternalId,
|
||||
includeProcessed,
|
||||
includeNotRun,
|
||||
}: {
|
||||
checkpoint?: CustomerCheckpointExclusion;
|
||||
search?: string;
|
||||
afterInternalId?: string;
|
||||
includeProcessed?: IncludeProcessed;
|
||||
includeNotRun?: boolean;
|
||||
}): SQL => {
|
||||
const cursor = afterInternalId
|
||||
? sql`AND c.internal_id < ${afterInternalId}`
|
||||
: sql``;
|
||||
return sql`${buildCheckpointWhere(checkpoint)} ${buildSearchWhere(search)} ${cursor}`;
|
||||
return sql`${buildCheckpointWhere(checkpoint)} ${buildSearchWhere(search)} ${buildExecutionStatusWhere(includeProcessed, { includeNotRun })} ${cursor}`;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -170,16 +261,38 @@ export const buildProcessedPreviewSelect = ({
|
||||
const where = compileWhere({ orgId, env, filter, ctx });
|
||||
const processed = buildProcessedIn(includeProcessed);
|
||||
const limitClause = limit !== undefined ? sql`LIMIT ${limit}` : sql``;
|
||||
const mode = getExecutionFilterMode(includeProcessed);
|
||||
|
||||
if (mode === "explicit_only") {
|
||||
return sql`
|
||||
SELECT c.internal_id, c.id, c.name, c.email
|
||||
FROM customers c
|
||||
WHERE (${processed}) ${buildCommonWhere({ checkpoint, search, afterInternalId, includeProcessed, includeNotRun: false })}
|
||||
ORDER BY c.internal_id DESC
|
||||
${limitClause}
|
||||
`;
|
||||
}
|
||||
|
||||
if (mode === "not_run_only") {
|
||||
return sql`
|
||||
SELECT c.internal_id, c.id, c.name, c.email
|
||||
FROM customers c
|
||||
WHERE (${where}) ${buildCommonWhere({ checkpoint, search, afterInternalId, includeProcessed })}
|
||||
ORDER BY c.internal_id DESC
|
||||
${limitClause}
|
||||
`;
|
||||
}
|
||||
|
||||
return sql`
|
||||
SELECT u.internal_id, u.id, u.name, u.email
|
||||
FROM (
|
||||
SELECT c.internal_id, c.id, c.name, c.email
|
||||
FROM customers c
|
||||
WHERE (${where}) ${buildCommonWhere({ checkpoint, search, afterInternalId })}
|
||||
WHERE (${where}) ${buildCommonWhere({ checkpoint, search, afterInternalId, includeProcessed })}
|
||||
UNION
|
||||
SELECT c.internal_id, c.id, c.name, c.email
|
||||
FROM customers c
|
||||
WHERE (${processed}) ${buildCommonWhere({ checkpoint, search, afterInternalId })}
|
||||
WHERE (${processed}) ${buildCommonWhere({ checkpoint, search, afterInternalId, includeProcessed, includeNotRun: false })}
|
||||
) u
|
||||
ORDER BY u.internal_id DESC
|
||||
${limitClause}
|
||||
@@ -197,16 +310,34 @@ export const buildProcessedPreviewCount = ({
|
||||
}: ProcessedPreviewArgs): SQL => {
|
||||
const where = compileWhere({ orgId, env, filter, ctx });
|
||||
const processed = buildProcessedIn(includeProcessed);
|
||||
const mode = getExecutionFilterMode(includeProcessed);
|
||||
|
||||
if (mode === "explicit_only") {
|
||||
return sql`
|
||||
SELECT COUNT(*)::bigint AS count
|
||||
FROM customers c
|
||||
WHERE (${processed}) ${buildCommonWhere({ checkpoint, search, includeProcessed, includeNotRun: false })}
|
||||
`;
|
||||
}
|
||||
|
||||
if (mode === "not_run_only") {
|
||||
return sql`
|
||||
SELECT COUNT(*)::bigint AS count
|
||||
FROM customers c
|
||||
WHERE (${where}) ${buildCommonWhere({ checkpoint, search, includeProcessed })}
|
||||
`;
|
||||
}
|
||||
|
||||
return sql`
|
||||
SELECT COUNT(*)::bigint AS count
|
||||
FROM (
|
||||
SELECT c.internal_id
|
||||
FROM customers c
|
||||
WHERE (${where}) ${buildCommonWhere({ checkpoint, search })}
|
||||
WHERE (${where}) ${buildCommonWhere({ checkpoint, search, includeProcessed })}
|
||||
UNION
|
||||
SELECT c.internal_id
|
||||
FROM customers c
|
||||
WHERE (${processed}) ${buildCommonWhere({ checkpoint, search })}
|
||||
WHERE (${processed}) ${buildCommonWhere({ checkpoint, search, includeProcessed, includeNotRun: false })}
|
||||
) u
|
||||
`;
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
migrationRepo,
|
||||
migrationRunRepo,
|
||||
} from "@/internal/migrations/v2/repos/index.js";
|
||||
import { clearOrgCache } from "@/internal/orgs/orgUtils/clearOrgCache.js";
|
||||
|
||||
const CancelMigrationRunBody = z.object({
|
||||
id: z.string(),
|
||||
@@ -70,6 +71,15 @@ export const handleCancelMigrationRun = createRoute({
|
||||
},
|
||||
});
|
||||
|
||||
if (activeRun.lazy_run) {
|
||||
await clearOrgCache({
|
||||
db: ctx.db,
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
logger: ctx.logger,
|
||||
});
|
||||
}
|
||||
|
||||
return c.json({
|
||||
migration_id: id,
|
||||
run_id: activeRun.internal_id,
|
||||
|
||||
@@ -24,6 +24,12 @@ const PreviewFilterBody = z.object({
|
||||
page: z.number().int().min(0).optional().default(0),
|
||||
pageSize: z.number().int().min(1).max(500).optional().default(DEFAULT_PAGE_SIZE),
|
||||
migrationId: z.string().optional(),
|
||||
executionStatuses: z
|
||||
.array(z.enum(["succeeded", "skipped", "failed", "not_run"]))
|
||||
.optional()
|
||||
.default([]),
|
||||
migrationRunId: z.string().optional(),
|
||||
migrationRunDryRun: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/** POST /migrations.filter.preview — count + enriched paginated customers. */
|
||||
@@ -32,14 +38,33 @@ export const handlePreviewMigrationFilter = createRoute({
|
||||
body: PreviewFilterBody,
|
||||
handler: async (c) => {
|
||||
const ctx = c.get("ctx");
|
||||
const { filter, search, page, pageSize, migrationId } = c.req.valid("json");
|
||||
const {
|
||||
filter,
|
||||
search,
|
||||
page,
|
||||
pageSize,
|
||||
migrationId,
|
||||
executionStatuses,
|
||||
migrationRunId,
|
||||
migrationRunDryRun,
|
||||
} = c.req.valid("json");
|
||||
|
||||
const searchTerm = search || undefined;
|
||||
|
||||
let includeProcessed: IncludeProcessed | undefined;
|
||||
if (migrationId) {
|
||||
const migration = await migrationRepo.find({ ctx, id: migrationId });
|
||||
includeProcessed = { migrationInternalId: migration.internal_id };
|
||||
includeProcessed = {
|
||||
migrationInternalId: migration.internal_id,
|
||||
executionFilter:
|
||||
executionStatuses.length > 0
|
||||
? {
|
||||
statuses: executionStatuses,
|
||||
migrationRunId,
|
||||
dryRun: migrationRunDryRun,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const [count, pageRows] = await Promise.all([
|
||||
|
||||
@@ -84,6 +84,7 @@ export const handleRunMigration = createRoute({
|
||||
migrationId: id,
|
||||
migrationRunId,
|
||||
dryRun,
|
||||
lazyRun,
|
||||
controls: { limit, only, concurrency, retryFailed },
|
||||
},
|
||||
getRunMigrationTriggerOptions({
|
||||
|
||||
@@ -39,6 +39,11 @@ export const mergeAutumnBillingPlans = ({
|
||||
...(incoming.deleteCustomerProducts ?? []),
|
||||
],
|
||||
}),
|
||||
schedulePhaseCustomerProductReplacements: mergeByKey({
|
||||
base: base.schedulePhaseCustomerProductReplacements,
|
||||
incoming: incoming.schedulePhaseCustomerProductReplacements,
|
||||
getKey: (replacement) => replacement.oldCustomerProductId,
|
||||
}),
|
||||
customPrices: mergeById({
|
||||
base: base.customPrices,
|
||||
incoming: incoming.customPrices,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { UpdateSubscriptionBillingContext } from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import { executeAutumnBillingPlan } from "@/internal/billing/v2/execute/executeAutumnBillingPlan.js";
|
||||
import { executeStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/execute/executeStripeBillingPlan.js";
|
||||
import { logStripeBillingResult } from "@/internal/billing/v2/providers/stripe/logs/logStripeBillingResult.js";
|
||||
import { sendBillingUpdatedWebhook } from "@/internal/billing/v2/workflows/sendBillingUpdatedWebhook/sendBillingUpdatedWebhook.js";
|
||||
import { billingPlanToSendProductsUpdated } from "@/internal/billing/v2/workflows/sendProductsUpdated/billingPlanToSendProductsUpdated.js";
|
||||
import { deleteCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/deleteCachedFullCustomer.js";
|
||||
import type { MigrateCustomerContext } from "@/internal/migrations/v2/operations/types/index.js";
|
||||
import { appendMigrationBillingLog } from "@/internal/migrations/v2/operations/utils/index.js";
|
||||
@@ -11,10 +14,12 @@ export const executeMigrateCustomerPlan = async ({
|
||||
ctx,
|
||||
context,
|
||||
billingPlan,
|
||||
billingContexts,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
context: MigrateCustomerContext;
|
||||
billingPlan: MigrateCustomerBillingPlan;
|
||||
billingContexts: UpdateSubscriptionBillingContext[];
|
||||
}): Promise<void> => {
|
||||
for (const stripeBillingPlan of billingPlan.stripeBillingPlans) {
|
||||
const stripeResult = await executeStripeBillingPlan({
|
||||
@@ -38,6 +43,21 @@ export const executeMigrateCustomerPlan = async ({
|
||||
autumnBillingPlan: billingPlan.autumn,
|
||||
});
|
||||
|
||||
const primaryBillingContext = billingContexts[0];
|
||||
if (primaryBillingContext) {
|
||||
await billingPlanToSendProductsUpdated({
|
||||
ctx,
|
||||
autumnBillingPlan: billingPlan.autumn,
|
||||
billingContext: primaryBillingContext,
|
||||
});
|
||||
}
|
||||
|
||||
await sendBillingUpdatedWebhook({
|
||||
ctx,
|
||||
autumnBillingPlan: billingPlan.autumn,
|
||||
originalFullCustomer: context.fullCustomer,
|
||||
});
|
||||
|
||||
const customerId =
|
||||
context.fullCustomer.id ?? context.fullCustomer.internal_id;
|
||||
await deleteCachedFullCustomer({
|
||||
|
||||
@@ -83,6 +83,7 @@ export const migrateCustomer = async ({
|
||||
ctx: migrationCtx,
|
||||
context,
|
||||
billingPlan,
|
||||
billingContexts,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { warmupRegionalRedis } from "@/external/redis/initUtils/redisWarmup.js";
|
||||
import { withMigrationRunTracking } from "@/internal/migrations/v2/actions/migrationRun/index.js";
|
||||
import { migrationRepo } from "@/internal/migrations/v2/repos/index.js";
|
||||
import { runMigration } from "@/internal/migrations/v2/run/runMigration.js";
|
||||
import { clearOrgCache } from "@/internal/orgs/orgUtils/clearOrgCache.js";
|
||||
import { createTriggerContext } from "@/trigger/utils/createTriggerContext.js";
|
||||
|
||||
const ControlsSchema = z.object({
|
||||
@@ -20,6 +21,7 @@ const PayloadSchema = z.object({
|
||||
migrationId: z.string(),
|
||||
migrationRunId: z.string(),
|
||||
dryRun: z.boolean().default(false),
|
||||
lazyRun: z.boolean().default(false),
|
||||
controls: ControlsSchema,
|
||||
});
|
||||
|
||||
@@ -35,7 +37,7 @@ export const runMigrationTask = task({
|
||||
machine: "medium-1x",
|
||||
maxDuration: 3600,
|
||||
run: async (rawPayload: unknown, { ctx: triggerCtx }) => {
|
||||
const { orgId, env, migrationId, migrationRunId, dryRun, controls } =
|
||||
const { orgId, env, migrationId, migrationRunId, dryRun, lazyRun, controls } =
|
||||
PayloadSchema.parse(rawPayload);
|
||||
|
||||
const { ctx, logger } = await createTriggerContext({
|
||||
@@ -69,40 +71,51 @@ export const runMigrationTask = task({
|
||||
},
|
||||
});
|
||||
|
||||
await withMigrationRunTracking({
|
||||
ctx,
|
||||
migrationRunId,
|
||||
run: async () => {
|
||||
const migration = await migrationRepo.find({ ctx, id: migrationId });
|
||||
try {
|
||||
await withMigrationRunTracking({
|
||||
ctx,
|
||||
migrationRunId,
|
||||
run: async () => {
|
||||
const migration = await migrationRepo.find({ ctx, id: migrationId });
|
||||
|
||||
// Default concurrency: 10 normally, 25 when no_billing_changes
|
||||
// because we're not hitting Stripe per customer. Caller can still
|
||||
// override via controls.concurrency.
|
||||
const defaultConcurrency =
|
||||
migration.no_billing_changes === true ? 25 : 10;
|
||||
const effectiveControls = {
|
||||
...(controls ?? {}),
|
||||
concurrency: controls?.concurrency ?? defaultConcurrency,
|
||||
};
|
||||
// Default concurrency: 10 normally, 25 when no_billing_changes
|
||||
// because we're not hitting Stripe per customer. Caller can still
|
||||
// override via controls.concurrency.
|
||||
const defaultConcurrency =
|
||||
migration.no_billing_changes === true ? 25 : 10;
|
||||
const effectiveControls = {
|
||||
...(controls ?? {}),
|
||||
concurrency: controls?.concurrency ?? defaultConcurrency,
|
||||
};
|
||||
|
||||
logger.info("run-migration: resolved controls", {
|
||||
data: {
|
||||
logger.info("run-migration: resolved controls", {
|
||||
data: {
|
||||
migrationRunId,
|
||||
noBillingChanges: migration.no_billing_changes === true,
|
||||
concurrency: effectiveControls.concurrency,
|
||||
concurrencyExplicit: controls?.concurrency !== undefined,
|
||||
},
|
||||
});
|
||||
|
||||
await runMigration({
|
||||
ctx,
|
||||
migration,
|
||||
dryRun,
|
||||
migrationRunId,
|
||||
noBillingChanges: migration.no_billing_changes === true,
|
||||
concurrency: effectiveControls.concurrency,
|
||||
concurrencyExplicit: controls?.concurrency !== undefined,
|
||||
},
|
||||
controls: effectiveControls,
|
||||
});
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
if (lazyRun && !dryRun) {
|
||||
await clearOrgCache({
|
||||
db: ctx.db,
|
||||
orgId,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
|
||||
await runMigration({
|
||||
ctx,
|
||||
migration,
|
||||
dryRun,
|
||||
migrationRunId,
|
||||
controls: effectiveControls,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("run-migration: done", {
|
||||
data: {
|
||||
|
||||
@@ -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,
|
||||
BillWhen,
|
||||
Infinite,
|
||||
PriceSchema,
|
||||
} from "@autumn/shared";
|
||||
import { pricesAreSame } from "@shared/utils/productUtils/priceUtils/comparePrice/pricesAreSame";
|
||||
|
||||
@@ -17,15 +18,15 @@ const fixedPrice = {
|
||||
is_custom: false,
|
||||
entitlement_id: null,
|
||||
proration_config: null,
|
||||
config: {
|
||||
type: PriceType.Fixed,
|
||||
amount: 10,
|
||||
interval: BillingInterval.Month,
|
||||
stripe_product_id: null,
|
||||
feature_id: null,
|
||||
internal_feature_id: null,
|
||||
},
|
||||
} satisfies Price;
|
||||
config: {
|
||||
type: PriceType.Fixed,
|
||||
amount: 10,
|
||||
interval: BillingInterval.Month,
|
||||
stripe_product_id: null,
|
||||
feature_id: null,
|
||||
internal_feature_id: null,
|
||||
},
|
||||
} satisfies Price;
|
||||
|
||||
const usagePrice = {
|
||||
id: "price_usage",
|
||||
@@ -48,6 +49,22 @@ const usagePrice = {
|
||||
} satisfies Price;
|
||||
|
||||
describe("pricesAreSame", () => {
|
||||
test("normalizes ignored fixed price metadata", () => {
|
||||
const parsed = PriceSchema.parse({
|
||||
...fixedPrice,
|
||||
config: {
|
||||
...fixedPrice.config,
|
||||
stripe_product_id: "prod_fixed",
|
||||
feature_id: "base",
|
||||
internal_feature_id: "internal_base",
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.config.stripe_product_id).toBeNull();
|
||||
expect(parsed.config.feature_id).toBeNull();
|
||||
expect(parsed.config.internal_feature_id).toBeNull();
|
||||
});
|
||||
|
||||
test("returns false instead of throwing for fixed vs usage prices", () => {
|
||||
expect(pricesAreSame(fixedPrice, usagePrice)).toBe(false);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { FullCustomer } from "../../../../models/cusModels/fullCusModel.js";
|
||||
import { customerProductHasActiveStatus } from "../../../../utils/index.js";
|
||||
import { customerProductHasRelevantStatus } from "../../../../utils/index.js";
|
||||
import type { CustomerFilter } from "../../../migrations/filters/customerFilter.js";
|
||||
import {
|
||||
arrayFilterMatches,
|
||||
@@ -12,8 +12,8 @@ import { planFilterMatchesCustomerProduct } from "../../../products/utils/match/
|
||||
*
|
||||
* JS-side mirror of the SQL compiler's `customerRegistry`. Used by the lazy
|
||||
* migration helper to skip non-matching customers without queueing work.
|
||||
* Mirrors the `cp.status IN ACTIVE_STATUSES` ambient predicate baked into
|
||||
* the SQL plan scope — non-active cusProducts are ignored.
|
||||
* Mirrors the `cp.status IN RELEVANT_STATUSES` ambient predicate baked into
|
||||
* the SQL plan scope — expired/paused cusProducts are ignored.
|
||||
*
|
||||
* Supports `customer_id` and `plan` (`$some` / `$every` / `$none` and the
|
||||
* implicit-`$some` bare form). `item` sugar throws to make the gap explicit,
|
||||
@@ -37,14 +37,14 @@ export const customerFilterMatchesFullCustomer = ({
|
||||
}
|
||||
|
||||
if (filter.plan !== undefined) {
|
||||
const activeProducts = fullCustomer.customer_products.filter(
|
||||
customerProductHasActiveStatus,
|
||||
const relevantProducts = fullCustomer.customer_products.filter(
|
||||
customerProductHasRelevantStatus,
|
||||
);
|
||||
const planFilter = filter.plan === "$none" ? { $none: {} } : filter.plan;
|
||||
if (
|
||||
!arrayFilterMatches({
|
||||
filter: planFilter,
|
||||
items: activeProducts,
|
||||
items: relevantProducts,
|
||||
matchesElement: ({ filter: planFilter, item: customerProduct }) =>
|
||||
planFilterMatchesCustomerProduct({
|
||||
filter: planFilter,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ACTIVE_STATUSES } from "../../../../utils/cusProductUtils/cusProductConstants.js";
|
||||
import { RELEVANT_STATUSES } from "../../../../utils/cusProductUtils/cusProductConstants.js";
|
||||
import type { NavScope, RootScope } from "./registryTypes.js";
|
||||
|
||||
/**
|
||||
@@ -27,8 +27,8 @@ import type { NavScope, RootScope } from "./registryTypes.js";
|
||||
* Ambient predicates push `org_id` / `env` down into every scope whose
|
||||
* table has those columns. Without this, multi-tenant scans bloat 10x+.
|
||||
*
|
||||
* `cp.status IN ACTIVE_STATUSES` is also baked in — customer-rooted
|
||||
* filters always operate on active plan instances.
|
||||
* `cp.status IN RELEVANT_STATUSES` is also baked in — customer-rooted
|
||||
* filters operate on active and scheduled plan instances.
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -81,7 +81,7 @@ const planScope: NavScope = {
|
||||
ambient: [
|
||||
{
|
||||
column: "cp.status",
|
||||
source: { kind: "values", values: ACTIVE_STATUSES },
|
||||
source: { kind: "values", values: RELEVANT_STATUSES },
|
||||
},
|
||||
],
|
||||
fields: {
|
||||
|
||||
@@ -12,8 +12,8 @@ import { PlanItemFilterSchema } from "./planItemFilter.js";
|
||||
* Filter over a plan. Migration-scoped: stable contract decoupled from
|
||||
* `ApiPlanV1`.
|
||||
*
|
||||
* Customer-rooted filters automatically scope to active customer-product
|
||||
* status (`cp.status IN ACTIVE_STATUSES`).
|
||||
* Customer-rooted filters automatically scope to relevant customer-product
|
||||
* status (`cp.status IN RELEVANT_STATUSES`).
|
||||
*
|
||||
* `price` is the plan's BASE price (customer_price linked to a price with
|
||||
* `entitlement_id IS NULL`). Use `price: null` for free plans,
|
||||
|
||||
@@ -83,6 +83,17 @@ export const AutumnBillingPlanSchema = z.object({
|
||||
})
|
||||
.optional(),
|
||||
|
||||
schedulePhaseCustomerProductReplacements: z
|
||||
.array(
|
||||
z.object({
|
||||
oldCustomerProductId: z.string(),
|
||||
newCustomerProductId: z.string(),
|
||||
internalCustomerId: z.string(),
|
||||
internalEntityId: z.string().nullish(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
|
||||
deleteCustomerProduct: FullCusProductSchema.optional(), // Scheduled product to delete (e.g., when updating while canceling)
|
||||
deleteCustomerProducts: z.array(FullCusProductSchema).optional(),
|
||||
|
||||
|
||||
@@ -2,6 +2,12 @@ import { z } from "zod/v4";
|
||||
import { BillingInterval } from "../../intervals/billingInterval";
|
||||
import { UsageTierSchema } from "./usagePriceConfig";
|
||||
|
||||
/** Imported fixed prices may carry usage metadata; fixed configs ignore it. */
|
||||
const IgnoredFixedPriceMetadataSchema = z.preprocess(
|
||||
(value) => (typeof value === "string" ? null : value),
|
||||
z.null().or(z.undefined()),
|
||||
);
|
||||
|
||||
export const FixedPriceConfigSchema = z.object({
|
||||
type: z.string(),
|
||||
amount: z.number().min(0),
|
||||
@@ -13,9 +19,9 @@ export const FixedPriceConfigSchema = z.object({
|
||||
usage_tiers: z.array(UsageTierSchema).nullish(),
|
||||
stripe_price_id: z.string().nullish(),
|
||||
stripe_empty_price_id: z.string().nullish(),
|
||||
stripe_product_id: z.null().or(z.undefined()),
|
||||
feature_id: z.null().or(z.undefined()),
|
||||
internal_feature_id: z.null().or(z.undefined()),
|
||||
stripe_product_id: IgnoredFixedPriceMetadataSchema,
|
||||
feature_id: IgnoredFixedPriceMetadataSchema,
|
||||
internal_feature_id: IgnoredFixedPriceMetadataSchema,
|
||||
});
|
||||
|
||||
export type FixedPriceConfig = z.infer<typeof FixedPriceConfigSchema>;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { CustomerFilter, CustomerWithProducts } from "@autumn/shared";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
import type { ExecutionStatus } from "@/views/migrations/migration/live/ExecutionStatusSubMenu";
|
||||
import { useQueryKeyFactory } from "@/hooks/common/useQueryKeyFactory";
|
||||
import { useAxiosInstance } from "@/services/useAxiosInstance";
|
||||
|
||||
@@ -19,24 +20,53 @@ export const useMigrationFilterPreview = ({
|
||||
page = 0,
|
||||
pageSize = DEFAULT_PAGE_SIZE,
|
||||
migrationId,
|
||||
executionStatuses = [],
|
||||
migrationRunId,
|
||||
migrationRunDryRun,
|
||||
}: {
|
||||
filter: CustomerFilter;
|
||||
search?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
migrationId?: string;
|
||||
executionStatuses?: ExecutionStatus[];
|
||||
migrationRunId?: string;
|
||||
migrationRunDryRun?: boolean;
|
||||
}) => {
|
||||
const axiosInstance = useAxiosInstance();
|
||||
const buildKey = useQueryKeyFactory();
|
||||
const filterKey = useMemo(() => JSON.stringify(filter), [filter]);
|
||||
const queryKey = buildKey(["migration-filter-preview", filterKey, search, page, pageSize, migrationId]);
|
||||
const executionKey = useMemo(
|
||||
() => executionStatuses.slice().sort().join(","),
|
||||
[executionStatuses],
|
||||
);
|
||||
const queryKey = buildKey([
|
||||
"migration-filter-preview",
|
||||
filterKey,
|
||||
search,
|
||||
page,
|
||||
pageSize,
|
||||
migrationId,
|
||||
executionKey,
|
||||
migrationRunId,
|
||||
migrationRunDryRun,
|
||||
]);
|
||||
|
||||
const query = useQuery<FilterPreviewResponse>({
|
||||
queryKey,
|
||||
queryFn: async () => {
|
||||
const { data } = await axiosInstance.post<FilterPreviewResponse>(
|
||||
"/migrations.filter.preview",
|
||||
{ filter, search, page, pageSize, migrationId },
|
||||
{
|
||||
filter,
|
||||
search,
|
||||
page,
|
||||
pageSize,
|
||||
migrationId,
|
||||
executionStatuses,
|
||||
migrationRunId,
|
||||
migrationRunDryRun,
|
||||
},
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
@@ -120,7 +120,7 @@ export class CusService {
|
||||
axios: AxiosInstance;
|
||||
customer_id: string;
|
||||
}): Promise<{ success: boolean }> {
|
||||
const { data } = await axios.post(`/customers/clear_cache`, {
|
||||
const { data } = await axios.post(`/v1/customers/clear_cache`, {
|
||||
customer_id,
|
||||
});
|
||||
return data;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { type FullCusProduct, isCustomerProductTrialing } from "@autumn/shared";
|
||||
import { FlaskIcon, PencilIcon } from "@phosphor-icons/react";
|
||||
import type { Row, Table } from "@tanstack/react-table";
|
||||
import { ArrowRightLeft, Delete, RotateCcw } from "lucide-react";
|
||||
import { ArrowRightLeft, Delete, RotateCcw, Send } from "lucide-react";
|
||||
import { TableDropdownMenuCell } from "@/components/general/table/table-dropdown-menu-cell";
|
||||
import {
|
||||
dateSkeleton,
|
||||
@@ -9,12 +9,110 @@ import {
|
||||
statusSkeleton,
|
||||
} from "@/components/general/table/table-skeleton-presets";
|
||||
import { DropdownMenuItem } from "@/components/v2/dropdowns/DropdownMenu";
|
||||
import { useAdmin } from "@/views/admin/hooks/useAdmin";
|
||||
import { createDateTimeColumn } from "@/views/customers2/utils/ColumnHelpers";
|
||||
import { AdminHover } from "../../../../../components/general/AdminHover";
|
||||
import { getCusProductHoverTexts } from "../../../../admin/adminUtils";
|
||||
import { CustomerProductPrice } from "./CustomerProductPrice";
|
||||
import { CustomerProductsStatus } from "./CustomerProductsStatus";
|
||||
|
||||
function CustomerProductActionsCell({
|
||||
row,
|
||||
table,
|
||||
}: {
|
||||
row: Row<FullCusProduct>;
|
||||
table: Table<FullCusProduct>;
|
||||
}) {
|
||||
const { isAdmin } = useAdmin();
|
||||
const meta = table.options.meta as {
|
||||
onCancelClick?: (product: FullCusProduct) => void;
|
||||
onUpdateClick?: (product: FullCusProduct) => void;
|
||||
onUncancelClick?: (product: FullCusProduct) => void;
|
||||
onTransferClick?: (product: FullCusProduct) => void;
|
||||
onTestSheetClick?: (product: FullCusProduct) => void;
|
||||
onSendWebhookClick?: (product: FullCusProduct) => void;
|
||||
hasEntities?: boolean;
|
||||
sendingWebhookProductId?: string | null;
|
||||
};
|
||||
|
||||
if (!meta?.onCancelClick) return null;
|
||||
|
||||
const isCanceling = row.original.canceled;
|
||||
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<TableDropdownMenuCell>
|
||||
{meta.onTestSheetClick && (
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2 text-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
meta.onTestSheetClick?.(row.original);
|
||||
}}
|
||||
>
|
||||
<FlaskIcon size={16} /> Test Sheet
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{meta.hasEntities && meta.onTransferClick && (
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2 text-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
meta.onTransferClick?.(row.original);
|
||||
}}
|
||||
>
|
||||
<ArrowRightLeft size={16} /> Transfer
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{meta.onUpdateClick && (
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2 text-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
meta.onUpdateClick?.(row.original);
|
||||
}}
|
||||
>
|
||||
<PencilIcon size={16} /> Update
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && meta.onSendWebhookClick && (
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2 text-xs"
|
||||
disabled={meta.sendingWebhookProductId === row.original.id}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
meta.onSendWebhookClick?.(row.original);
|
||||
}}
|
||||
>
|
||||
<Send size={16} /> Send CP updated webhook
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isCanceling ? (
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2 text-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
meta.onUncancelClick?.(row.original);
|
||||
}}
|
||||
>
|
||||
<RotateCcw size={16} /> Uncancel
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2 text-xs text-red-500 dark:text-red-400"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
meta.onCancelClick?.(row.original);
|
||||
}}
|
||||
>
|
||||
<Delete size={16} /> Cancel
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</TableDropdownMenuCell>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const CustomerProductsColumns = [
|
||||
{
|
||||
header: "Name",
|
||||
@@ -82,86 +180,6 @@ export const CustomerProductsColumns = [
|
||||
header: "",
|
||||
size: 40,
|
||||
meta: { skeleton: hiddenSkeleton },
|
||||
cell: ({
|
||||
row,
|
||||
table,
|
||||
}: {
|
||||
row: Row<FullCusProduct>;
|
||||
table: Table<FullCusProduct>;
|
||||
}) => {
|
||||
const meta = table.options.meta as {
|
||||
onCancelClick?: (product: FullCusProduct) => void;
|
||||
onUpdateClick?: (product: FullCusProduct) => void;
|
||||
onUncancelClick?: (product: FullCusProduct) => void;
|
||||
onTransferClick?: (product: FullCusProduct) => void;
|
||||
onTestSheetClick?: (product: FullCusProduct) => void;
|
||||
hasEntities?: boolean;
|
||||
};
|
||||
|
||||
if (!meta?.onCancelClick) return null;
|
||||
|
||||
const isCanceling = row.original.canceled;
|
||||
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<TableDropdownMenuCell>
|
||||
{meta.onTestSheetClick && (
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2 text-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
meta.onTestSheetClick?.(row.original);
|
||||
}}
|
||||
>
|
||||
<FlaskIcon size={16} /> Test Sheet
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{meta.hasEntities && meta.onTransferClick && (
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2 text-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
meta.onTransferClick?.(row.original);
|
||||
}}
|
||||
>
|
||||
<ArrowRightLeft size={16} /> Transfer
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{meta.onUpdateClick && (
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2 text-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
meta.onUpdateClick?.(row.original);
|
||||
}}
|
||||
>
|
||||
<PencilIcon size={16} /> Update
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isCanceling ? (
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2 text-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
meta.onUncancelClick?.(row.original);
|
||||
}}
|
||||
>
|
||||
<RotateCcw size={16} /> Uncancel
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2 text-xs text-red-500 dark:text-red-400"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
meta.onCancelClick?.(row.original);
|
||||
}}
|
||||
>
|
||||
<Delete size={16} /> Cancel
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</TableDropdownMenuCell>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: CustomerProductActionsCell,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -2,6 +2,7 @@ import { AppEnv, type Entity, type FullCusProduct } from "@autumn/shared";
|
||||
import { ArrowSquareOutIcon, PackageIcon } from "@phosphor-icons/react";
|
||||
import type { Row } from "@tanstack/react-table";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Table } from "@/components/general/table";
|
||||
import { SectionTag } from "@/components/v2/badges/SectionTag";
|
||||
@@ -9,7 +10,10 @@ import { Button } from "@/components/v2/buttons/Button";
|
||||
import { IconButton } from "@/components/v2/buttons/IconButton";
|
||||
import { useSheetStore } from "@/hooks/stores/useSheetStore";
|
||||
import { useEntity } from "@/hooks/stores/useSubscriptionStore";
|
||||
import { useAxiosInstance } from "@/services/useAxiosInstance";
|
||||
import { useEnv } from "@/utils/envUtils";
|
||||
import { getBackendErr } from "@/utils/genUtils";
|
||||
import { useAdmin } from "@/views/admin/hooks/useAdmin";
|
||||
import { useFullCusSearchQuery } from "@/views/customers/hooks/useFullCusSearchQuery";
|
||||
import { useSavedViewsQuery } from "@/views/customers/hooks/useSavedViewsQuery";
|
||||
import { useCustomerProductsData } from "@/views/customers2/hooks/useCustomerProductsData";
|
||||
@@ -81,6 +85,11 @@ export function CustomerProductsTable() {
|
||||
);
|
||||
const selectedItemId = useSheetStore((s) => s.itemId);
|
||||
const setSheet = useSheetStore((s) => s.setSheet);
|
||||
const axiosInstance = useAxiosInstance();
|
||||
const { isAdmin } = useAdmin();
|
||||
const [sendingWebhookProductId, setSendingWebhookProductId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
useSavedViewsQuery();
|
||||
useFullCusSearchQuery();
|
||||
@@ -124,6 +133,21 @@ export function CustomerProductsTable() {
|
||||
setSheet({ type: "subscription-update", itemId: product.id });
|
||||
};
|
||||
|
||||
const handleSendWebhookClick = async (product: FullCusProduct) => {
|
||||
if (!isAdmin) return;
|
||||
setSendingWebhookProductId(product.id);
|
||||
try {
|
||||
await axiosInstance.post(
|
||||
`/admin/customer-products/${product.id}/send-updated-webhook`,
|
||||
);
|
||||
toast.success("Customer product webhook sent");
|
||||
} catch (error) {
|
||||
toast.error(getBackendErr(error, "Failed to send webhook"));
|
||||
} finally {
|
||||
setSendingWebhookProductId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRowClick = (cusProduct: FullCusProduct) => {
|
||||
setSheet({
|
||||
type: "subscription-detail",
|
||||
@@ -136,7 +160,9 @@ export function CustomerProductsTable() {
|
||||
onUncancelClick: handleUncancelClick,
|
||||
onTransferClick: handleTransferClick,
|
||||
onUpdateClick: handleUpdateClick,
|
||||
onSendWebhookClick: handleSendWebhookClick,
|
||||
hasEntities,
|
||||
sendingWebhookProductId,
|
||||
nowMs: testClockFrozenTimeMs,
|
||||
};
|
||||
|
||||
|
||||
@@ -35,6 +35,12 @@ type MigrationPreview = {
|
||||
balance_changes?: (string | BalanceChange)[];
|
||||
flag_changes?: (string | FlagChange)[];
|
||||
};
|
||||
type ErrorPayload = {
|
||||
message?: unknown;
|
||||
error?: unknown;
|
||||
code?: unknown;
|
||||
path?: unknown;
|
||||
};
|
||||
|
||||
function parseJson<T>(raw: string | T): T | null {
|
||||
if (typeof raw !== "string") return raw;
|
||||
@@ -51,6 +57,30 @@ function parseList<T>(raw: (string | T)[] | undefined): T[] {
|
||||
.filter((c): c is T => c !== null);
|
||||
}
|
||||
|
||||
function formatUnknownError(value: unknown): string | null {
|
||||
if (value === null || value === undefined) return null;
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "number" || typeof value === "boolean")
|
||||
return String(value);
|
||||
if (Array.isArray(value))
|
||||
return value.map(formatUnknownError).filter(Boolean).join("\n");
|
||||
|
||||
if (typeof value === "object") {
|
||||
const payload = value as ErrorPayload;
|
||||
const message = formatUnknownError(payload.message ?? payload.error);
|
||||
const prefix = [payload.code, payload.path].filter(Boolean).join(" ");
|
||||
if (message) return prefix ? `${prefix}: ${message}` : message;
|
||||
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return "Unknown error";
|
||||
}
|
||||
}
|
||||
|
||||
return "Unknown error";
|
||||
}
|
||||
|
||||
const DOT_COLORS: Record<string, string> = {
|
||||
activated: "bg-green-500",
|
||||
scheduled: "bg-blue-500",
|
||||
@@ -308,13 +338,16 @@ export function EventResultDetail({ event }: { event: MigrationItemEvent }) {
|
||||
if (!response) return null;
|
||||
|
||||
if (event.status === "failed") {
|
||||
const error = response.error as { message?: string } | undefined;
|
||||
if (!error?.message) return null;
|
||||
const error = response.error as ErrorPayload | undefined;
|
||||
const message = formatUnknownError(error?.message ?? error);
|
||||
if (!message) return null;
|
||||
return (
|
||||
<div className="flex items-start gap-2 min-h-8 px-3 py-2 rounded-xl border border-red-500/20 bg-red-500/5 text-sm text-red-500">
|
||||
<span className="size-2 rounded-full bg-red-500 shrink-0 mt-1" />
|
||||
<span className="break-words min-w-0">{error.message}</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2 min-h-8 px-3 py-2 rounded-xl border border-red-500/20 bg-red-500/5 text-sm text-red-500">
|
||||
<span className="size-2 rounded-full bg-red-500 shrink-0 mt-1" />
|
||||
<span className="break-words min-w-0 whitespace-pre-wrap">
|
||||
{message}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -322,9 +355,9 @@ export function EventResultDetail({ event }: { event: MigrationItemEvent }) {
|
||||
if (preview) return <PreviewSummary preview={preview} />;
|
||||
|
||||
if (event.status === "skipped") {
|
||||
const skipped = response.skipped as { reason?: string } | undefined;
|
||||
const guard = response.guard as { reason?: string } | undefined;
|
||||
const reason = skipped?.reason ?? guard?.reason;
|
||||
const skipped = response.skipped as { reason?: unknown } | undefined;
|
||||
const guard = response.guard as { reason?: unknown } | undefined;
|
||||
const reason = formatUnknownError(skipped?.reason ?? guard?.reason);
|
||||
if (reason) return <span className="text-sm text-tertiary-foreground">{reason}</span>;
|
||||
}
|
||||
|
||||
|
||||
@@ -123,6 +123,7 @@ const statusColumn: ColumnDef<CustomerRow, unknown> = {
|
||||
status={event.status}
|
||||
dryRun={event.dry_run}
|
||||
response={event.response}
|
||||
timestamp={event.timestamp}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -223,6 +224,22 @@ export function MigrationLiveView({
|
||||
[debouncedSetSearch],
|
||||
);
|
||||
|
||||
const handleExecutionStatusesChange = useCallback(
|
||||
(statuses: ExecutionStatus[]) => {
|
||||
setExecutionStatuses(statuses);
|
||||
setPagination((p) => ({ ...p, pageIndex: 0 }));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const {
|
||||
itemEvents,
|
||||
runs,
|
||||
invalidate: invalidateRuns,
|
||||
} = useMigrationRunsQuery({ migrationId });
|
||||
|
||||
const latestRun = runs[0];
|
||||
|
||||
const {
|
||||
customers,
|
||||
count,
|
||||
@@ -233,14 +250,9 @@ export function MigrationLiveView({
|
||||
page: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
migrationId,
|
||||
executionStatuses,
|
||||
});
|
||||
|
||||
const {
|
||||
itemEvents,
|
||||
runs,
|
||||
invalidate: invalidateRuns,
|
||||
} = useMigrationRunsQuery({ migrationId });
|
||||
|
||||
const {
|
||||
subscriptions: realtimeSubscriptions,
|
||||
hasActive: hasRealtimeActive,
|
||||
@@ -254,7 +266,7 @@ export function MigrationLiveView({
|
||||
);
|
||||
|
||||
const eventsByCustomer = useMemo(
|
||||
() => buildEventsByCustomer(itemEvents),
|
||||
() => buildEventsByCustomer(itemEvents.filter((event) => !event.dry_run)),
|
||||
[itemEvents],
|
||||
);
|
||||
|
||||
@@ -305,20 +317,13 @@ export function MigrationLiveView({
|
||||
);
|
||||
|
||||
const filteredCustomers = useMemo(() => {
|
||||
const hasExecution = executionStatuses.length > 0;
|
||||
const hasStatus = customerFilters.status.length > 0;
|
||||
const hasVersion = customerFilters.version.length > 0;
|
||||
const hasProcessor = customerFilters.processor.length > 0;
|
||||
const hasNone = customerFilters.none;
|
||||
if (!hasExecution && !hasStatus && !hasVersion && !hasProcessor && !hasNone)
|
||||
if (!hasStatus && !hasVersion && !hasProcessor && !hasNone)
|
||||
return enrichedCustomers;
|
||||
return enrichedCustomers.filter((c) => {
|
||||
if (hasExecution) {
|
||||
const status = c._event?.status;
|
||||
if (!status && !executionStatuses.includes("not_run")) return false;
|
||||
if (status && !executionStatuses.includes(status as ExecutionStatus))
|
||||
return false;
|
||||
}
|
||||
const cusProducts = c.customer_products ?? [];
|
||||
if (hasNone && cusProducts.length === 0) return true;
|
||||
if (hasStatus) {
|
||||
@@ -348,7 +353,7 @@ export function MigrationLiveView({
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [enrichedCustomers, executionStatuses, customerFilters]);
|
||||
}, [enrichedCustomers, customerFilters]);
|
||||
|
||||
const pageCount =
|
||||
count !== null ? Math.max(Math.ceil(count / pagination.pageSize), 1) : 1;
|
||||
@@ -368,7 +373,6 @@ export function MigrationLiveView({
|
||||
const canPrev = pagination.pageIndex > 0;
|
||||
const canNext = count !== null && currentPage < pageCount;
|
||||
|
||||
const latestRun = runs[0];
|
||||
const latestFailedRun =
|
||||
latestRun?.status === "failed" && latestRun.error_message
|
||||
? latestRun
|
||||
@@ -701,11 +705,11 @@ export function MigrationLiveView({
|
||||
extraMenuItems={
|
||||
<ExecutionStatusSubMenu
|
||||
selected={executionStatuses}
|
||||
onChange={setExecutionStatuses}
|
||||
onChange={handleExecutionStatusesChange}
|
||||
/>
|
||||
}
|
||||
hasActiveExtraFilters={hasActiveExecutionFilters(executionStatuses)}
|
||||
onClearExtra={() => setExecutionStatuses([])}
|
||||
onClearExtra={() => handleExecutionStatusesChange([])}
|
||||
hideSavedViews
|
||||
/>
|
||||
<div className="relative flex items-center flex-1 min-w-0">
|
||||
@@ -846,23 +850,56 @@ function SampleCustomerPicker({
|
||||
c.email?.toLowerCase().includes(q),
|
||||
);
|
||||
}, [customers, search]);
|
||||
const selectedIdSet = new Set(selectedIds);
|
||||
const filteredIds = filtered.map((c) => c.id ?? c.internal_id);
|
||||
const allFilteredSelected =
|
||||
filteredIds.length > 0 && filteredIds.every((id) => selectedIdSet.has(id));
|
||||
|
||||
const toggle = (id: string) => {
|
||||
onChange(
|
||||
selectedIds.includes(id)
|
||||
? selectedIds.filter((v) => v !== id)
|
||||
: [...selectedIds, id],
|
||||
);
|
||||
const nextIds = new Set(selectedIds);
|
||||
if (nextIds.has(id)) {
|
||||
nextIds.delete(id);
|
||||
} else {
|
||||
nextIds.add(id);
|
||||
}
|
||||
onChange(Array.from(nextIds));
|
||||
};
|
||||
|
||||
const toggleFiltered = () => {
|
||||
const filteredIdSet = new Set(filteredIds);
|
||||
if (allFilteredSelected) {
|
||||
onChange(selectedIds.filter((id) => !filteredIdSet.has(id)));
|
||||
return;
|
||||
}
|
||||
|
||||
const nextIds = new Set(selectedIds);
|
||||
for (const id of filteredIds) nextIds.add(id);
|
||||
onChange(Array.from(nextIds));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search customers..."
|
||||
className="text-sm"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search customers..."
|
||||
className="text-sm"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={filteredIds.length === 0}
|
||||
onClick={toggleFiltered}
|
||||
className={cn(
|
||||
"h-input",
|
||||
allFilteredSelected && "bg-primary/10 text-primary",
|
||||
)}
|
||||
>
|
||||
{allFilteredSelected ? "Clear all" : "Select all"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="h-48 overflow-y-auto rounded-xl border border-border">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="px-3 py-4 text-center text-xs text-subtle">
|
||||
@@ -870,7 +907,7 @@ function SampleCustomerPicker({
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((c) => {
|
||||
const isSelected = selectedIds.includes(c.id ?? c.internal_id);
|
||||
const isSelected = selectedIdSet.has(c.id ?? c.internal_id);
|
||||
return (
|
||||
<button
|
||||
key={c.internal_id}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { format } from "date-fns";
|
||||
import { Badge } from "@/components/v2/badges/Badge";
|
||||
import type { MigrationItemEventStatus } from "@/hooks/queries/useMigrationRunsQuery";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -50,10 +51,12 @@ export function ItemEventStatusBadge({
|
||||
status,
|
||||
dryRun = false,
|
||||
response = null,
|
||||
timestamp,
|
||||
}: {
|
||||
status: MigrationItemEventStatus;
|
||||
dryRun?: boolean;
|
||||
response?: Record<string, unknown> | null;
|
||||
timestamp?: string;
|
||||
}) {
|
||||
if (status === "skipped" && isNoOpResponse(response))
|
||||
return (
|
||||
@@ -68,12 +71,17 @@ export function ItemEventStatusBadge({
|
||||
</Badge>
|
||||
);
|
||||
|
||||
const label =
|
||||
status === "succeeded" && timestamp
|
||||
? `${STATUS_LABELS[status]} (${format(new Date(timestamp), "d MMM yyyy")})`
|
||||
: STATUS_LABELS[status];
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant="muted"
|
||||
className={(dryRun ? DRY_STYLES : LIVE_STYLES)[status]}
|
||||
>
|
||||
{STATUS_LABELS[status]}
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user