chore: add reset migration to version

This commit is contained in:
Charlie Lamb
2026-06-08 18:30:35 +01:00
parent 1d365a5429
commit 4aac366aaa
20 changed files with 1006 additions and 142 deletions

View File

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

View File

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

View File

@@ -105,6 +105,7 @@ export const setupUpdatePlanProductContext = async ({
fullCustomer: productFullCustomer,
params,
reusePricesAndEntitlements,
resetToCatalogVersion: typeof preparedOp.version === "number",
});
const operationBillingContext = await setupMigrationOperationBillingContext({

View File

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

View File

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

View File

@@ -76,18 +76,15 @@ const retireOrDeleteRows = async ({
db,
priceIds,
});
for (const entitlementId of entitlementIds) {
if (referencedEnts.has(entitlementId)) {
await EntitlementService.update({
db,
id: entitlementId,
updates: { is_custom: true },
});
} else {
await EntitlementService.deleteInIds({ db, ids: [entitlementId] });
}
}
const priceRows = await PriceService.getInIds({ db, ids: priceIds });
const entitlementsReferencedByRetainedPrices = new Set(
priceRows
.flatMap((price) =>
referencedPrices.has(price.id) && price.entitlement_id
? [price.entitlement_id]
: [],
),
);
for (const priceId of priceIds) {
if (referencedPrices.has(priceId)) {
@@ -100,6 +97,21 @@ const retireOrDeleteRows = async ({
await PriceService.deleteInIds({ db, ids: [priceId] });
}
}
for (const entitlementId of entitlementIds) {
if (
referencedEnts.has(entitlementId) ||
entitlementsReferencedByRetainedPrices.has(entitlementId)
) {
await EntitlementService.update({
db,
id: entitlementId,
updates: { is_custom: true },
});
} else {
await EntitlementService.deleteInIds({ db, ids: [entitlementId] });
}
}
};
/**

View File

@@ -14,9 +14,24 @@
*/
import { expect, test } from "bun:test";
import type { ApiCustomerV3, ApiCustomerV5 } from "@autumn/shared";
import {
type ApiCustomerV3,
type ApiCustomerV5,
CusProductStatus,
customerEntitlements,
customerPrices,
customerProducts,
customers,
entitlements,
features,
prices,
ResetInterval,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect";
import { expectFlagCorrect } from "@tests/integration/utils/expectFlagCorrect";
import { TestFeature } from "@tests/setup/v2Features";
import { items } from "@tests/utils/fixtures/items";
@@ -24,8 +39,155 @@ 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 { and, eq, isNull } from "drizzle-orm";
import { runUpdatePlanMigration } from "../../utils/runUpdatePlanMigration";
const getActiveCustomerProductIsCustom = async ({
ctx,
customerId,
productId,
}: {
ctx: AutumnContext;
customerId: string;
productId: string;
}): Promise<boolean | undefined> => {
const [row] = await ctx.db
.select({ isCustom: customerProducts.is_custom })
.from(customerProducts)
.innerJoin(
customers,
eq(customerProducts.internal_customer_id, customers.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),
eq(customerProducts.status, CusProductStatus.Active),
),
);
return row?.isCustom;
};
const getActiveCustomerProductFeatureIds = async ({
ctx,
customerId,
productId,
}: {
ctx: AutumnContext;
customerId: string;
productId: string;
}): Promise<string[]> => {
const rows = await ctx.db
.select({ featureId: features.id })
.from(customerProducts)
.innerJoin(
customers,
eq(customerProducts.internal_customer_id, customers.internal_id),
)
.innerJoin(
customerEntitlements,
eq(customerEntitlements.customer_product_id, customerProducts.id),
)
.innerJoin(
entitlements,
eq(customerEntitlements.entitlement_id, entitlements.id),
)
.innerJoin(features, eq(entitlements.internal_feature_id, features.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),
eq(customerProducts.status, CusProductStatus.Active),
),
);
return rows.map((row) => row.featureId);
};
const getActiveBasePriceAmount = async ({
ctx,
customerId,
productId,
}: {
ctx: AutumnContext;
customerId: string;
productId: string;
}): Promise<number | undefined> => {
const [row] = await ctx.db
.select({ config: prices.config })
.from(customerProducts)
.innerJoin(
customers,
eq(customerProducts.internal_customer_id, customers.internal_id),
)
.innerJoin(
customerPrices,
eq(customerPrices.customer_product_id, customerProducts.id),
)
.innerJoin(prices, eq(customerPrices.price_id, prices.id))
.where(
and(
eq(customers.org_id, ctx.org.id),
eq(customers.env, ctx.env),
eq(customers.id, customerId),
eq(customerProducts.product_id, productId),
eq(customerProducts.status, CusProductStatus.Active),
isNull(prices.entitlement_id),
),
);
const config = row?.config;
return config && "amount" in config && typeof config.amount === "number"
? config.amount
: undefined;
};
const getActiveFeatureResetInterval = async ({
ctx,
customerId,
productId,
featureId,
}: {
ctx: AutumnContext;
customerId: string;
productId: string;
featureId: string;
}): Promise<string | null | undefined> => {
const [row] = await ctx.db
.select({ interval: entitlements.interval })
.from(customerProducts)
.innerJoin(
customers,
eq(customerProducts.internal_customer_id, customers.internal_id),
)
.innerJoin(
customerEntitlements,
eq(customerEntitlements.customer_product_id, customerProducts.id),
)
.innerJoin(
entitlements,
eq(customerEntitlements.entitlement_id, entitlements.id),
)
.innerJoin(features, eq(entitlements.internal_feature_id, features.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),
eq(customerProducts.status, CusProductStatus.Active),
eq(features.id, featureId),
),
);
return row?.interval;
};
test.concurrent(`${chalk.yellowBright("update_plan custom: customer with is_custom plan is skipped")}`, async () => {
const customerId = "migration-v2-custom-skip";
@@ -403,3 +565,183 @@ test.concurrent(`${chalk.yellowBright("update_plan custom: explicit `custom: tru
usage: 0,
});
});
test.concurrent(`${chalk.yellowBright("update_plan reset: same-version custom plan resets to catalog")}`, async () => {
const customerId = "migration-v2-same-version-custom-reset";
const catalogBasePrice = 20;
const customBasePrice = 30;
const customMessages = {
...itemsV2.monthlyMessages({ included: 850 }),
reset: { interval: ResetInterval.Hour },
};
const pro = products.pro({
id: "v2-same-version-reset-pro",
items: [
items.monthlyMessages({ includedUsage: 500 }),
items.adminRights(),
],
});
const { autumnV1, autumnV2_2, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [s.billing.attach({ productId: pro.id })],
});
await autumnV2_2.subscriptions.update({
customer_id: customerId,
plan_id: pro.id,
customize: {
price: itemsV2.monthlyPrice({ amount: customBasePrice }),
items: [customMessages, itemsV2.dashboard()],
},
});
let customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
expectFlagCorrect({
customer,
featureId: TestFeature.Dashboard,
present: true,
});
expectFlagCorrect({
customer,
featureId: TestFeature.AdminRights,
present: false,
});
expect(
await getActiveCustomerProductIsCustom({ ctx, customerId, productId: pro.id }),
).toBe(true);
expect(
await getActiveBasePriceAmount({ ctx, customerId, productId: pro.id }),
).toBe(customBasePrice);
expect(
await getActiveFeatureResetInterval({
ctx,
customerId,
productId: pro.id,
featureId: TestFeature.Messages,
}),
).toBe(ResetInterval.Hour);
expectBalanceCorrect({
customer,
featureId: TestFeature.Messages,
remaining: 850,
usage: 0,
planId: pro.id,
});
const invoiceCountBefore =
(await autumnV1.customers.get<ApiCustomerV3>(customerId)).invoices
?.length ?? 0;
await runUpdatePlanMigration({
ctx,
migrationClient: autumnV2_2,
migrationId: `${customerId}-mig`,
customerId,
filter: { customer: { customer_id: customerId } },
operations: {
customer: [
{
type: "update_plan",
plan_filter: { plan_id: pro.id, version: 1 },
version: 1,
},
],
},
runOnServer: false,
noBillingChanges: true,
});
customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
const featureIds = await getActiveCustomerProductFeatureIds({
ctx,
customerId,
productId: pro.id,
});
expect(featureIds).not.toContain(TestFeature.Dashboard);
expect(featureIds).toContain(TestFeature.AdminRights);
expect(
await getActiveBasePriceAmount({ ctx, customerId, productId: pro.id }),
).toBe(catalogBasePrice);
expect(
await getActiveFeatureResetInterval({
ctx,
customerId,
productId: pro.id,
featureId: TestFeature.Messages,
}),
).toBe(ResetInterval.Month);
expectBalanceCorrect({
customer,
featureId: TestFeature.Messages,
remaining: 500,
usage: 0,
planId: pro.id,
});
await expectCustomerInvoiceCorrect({
customer: await autumnV1.customers.get<ApiCustomerV3>(customerId),
count: invoiceCountBefore,
});
});
test.concurrent(`${chalk.yellowBright("update_plan reset: same-version regular plan stays non-custom")}`, async () => {
const customerId = "migration-v2-same-version-regular-reset";
const pro = products.pro({
id: "v2-same-version-regular-pro",
items: [items.monthlyMessages({ includedUsage: 500 })],
});
const { autumnV1, autumnV2_2, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [
s.billing.attach({ productId: pro.id }),
s.track({ featureId: TestFeature.Messages, value: 100, timeout: 2000 }),
],
});
const invoiceCountBefore =
(await autumnV1.customers.get<ApiCustomerV3>(customerId)).invoices
?.length ?? 0;
await runUpdatePlanMigration({
ctx,
migrationClient: autumnV2_2,
migrationId: `${customerId}-mig`,
customerId,
filter: { customer: { plan: { plan_id: pro.id, version: 1 } } },
operations: {
customer: [
{
type: "update_plan",
plan_filter: { plan_id: pro.id, version: 1 },
version: 1,
},
],
},
runOnServer: false,
noBillingChanges: true,
});
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
expectBalanceCorrect({
customer,
featureId: TestFeature.Messages,
remaining: 400,
usage: 100,
planId: pro.id,
});
expect(
await getActiveCustomerProductIsCustom({ ctx, customerId, productId: pro.id }),
).toBe(false);
await expectCustomerInvoiceCorrect({
customer: await autumnV1.customers.get<ApiCustomerV3>(customerId),
count: invoiceCountBefore,
});
});

View File

@@ -19,6 +19,7 @@ import {
type ApiPlanV1,
ApiVersion,
BillingInterval,
BillingMethod,
ResetInterval,
type UpdatePlanParamsV2Input,
} from "@autumn/shared";
@@ -33,6 +34,8 @@ import { AutumnRpcCli } from "@/external/autumn/autumnRpcCli.js";
import { ProductService } from "@/internal/products/ProductService.js";
import { snapshotCustomerState } from "./utils/snapshotCustomerState";
type RpcInput = Omit<UpdatePlanParamsV2Input, "plan_id">;
const messagesEnt = async ({
ctx,
planId,
@@ -183,3 +186,44 @@ test(`${chalk.yellowBright("plans.update disable_version: respects requested ver
200,
);
});
test(`${chalk.yellowBright("plans.update disable_version: UPDATE price-linked item keeps FK order valid")}`, async () => {
const customerId = "plan-in-place-update-priced-item";
const pro = products.pro({
id: "pro_in_place_update_priced_item",
items: [items.consumableMessages({ includedUsage: 0, price: 10 })],
});
const { ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [s.billing.attach({ productId: pro.id })],
});
const autumnRpc = new AutumnRpcCli({
secretKey: ctx.orgSecretKey,
version: ApiVersion.V2_1,
});
const before = await snapshotCustomerState({ ctx, customerId });
await autumnRpc.plans.update<ApiPlanV1, RpcInput>(pro.id, {
disable_version: true,
price: { amount: 20, interval: BillingInterval.Month },
items: [
{
feature_id: TestFeature.Messages,
price: {
amount: 12,
interval: BillingInterval.Month,
billing_method: BillingMethod.UsageBased,
billing_units: 1,
},
},
],
});
expect(await snapshotCustomerState({ ctx, customerId })).toBe(before);
});

View File

@@ -0,0 +1,47 @@
import { test } from "bun:test";
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";
/**
* Migration setup: users entitlement with existing usage.
*
* v1 $20/mo · 5 included users → cus migusers-v1 (used 4)
* v2 $20/mo · 10 included users (latest, no customer)
*/
test(`${chalk.yellowBright("migration-setup: users included with usage")}`, async () => {
const team = products.base({
id: "team-users",
items: [
items.monthlyPrice({ price: 20 }),
items.monthlyUsers({ includedUsage: 5 }),
],
});
const { autumnV1 } = await initScenario({
customerId: "migusers-v1",
setup: [
s.customer({ paymentMethod: "success", testClock: false }),
s.products({ list: [team], prefix: "migusers" }),
],
actions: [
s.billing.attach({ productId: team.id }),
s.track({ featureId: TestFeature.Users, value: 4, timeout: 2000 }),
],
});
await autumnV1.products.update(team.id, {
items: [
items.monthlyPrice({ price: 20 }),
items.monthlyUsers({ includedUsage: 10 }),
],
});
console.log(
chalk.green(
`[migration-setup] plan "${team.id}" has v1-v2. migusers-v1 is on v1 with 5 users included and 4 users used; latest is v2.`,
),
);
}, 20_000);

View File

@@ -326,4 +326,47 @@ describe("buildBillingChangeResponse — updateSubscription", () => {
expired: ["pro"],
});
});
test("collapse same-plan_id pairs preserves replacement item changes", () => {
const newPro = makeFullCusProduct({
planId: "pro",
status: CusProductStatus.Active,
startedAt: NOW,
id: "cp_pro_new",
});
newPro.customer_entitlements = [
makeCustomerEntitlement({ featureId: "api_calls" }),
];
const oldPro = makeFullCusProduct({
planId: "pro",
startedAt: NOW - 30_000,
id: "cp_pro_old",
});
oldPro.customer_entitlements = [
makeCustomerEntitlement({ featureId: "legacy_feature" }),
];
const response = buildBillingChangeResponse({
ctx,
originalFullCustomer: makeFullCustomer({ customerProducts: [oldPro] }),
autumnBillingPlan: makeAutumnBillingPlan({
inserts: [newPro],
update: makeUpdate({
customerProduct: oldPro,
updates: { status: CusProductStatus.Expired },
}),
}),
});
expectBillingChangeResponse(response, { updated: ["pro"] });
expectPlanChange(findPlanChange(response, { action: "updated", planId: "pro" }), {
action: "updated",
planId: "pro",
itemChanges: [
{ action: "created", feature_id: "api_calls" },
{ action: "deleted", feature_id: "legacy_feature" },
],
});
});
});

View File

@@ -0,0 +1,69 @@
import { describe, expect, test } from "bun:test";
import type { MigrationFilter, Operations, UpdatePlanOp } from "@autumn/shared";
import { preProcessMigrationOperations } from "@/internal/migrations/v2/run/preProcess/preProcessMigrationOperations";
const operations: Operations = {
customer: [
{
type: "update_plan",
plan_filter: { plan_id: "pro", version: 1 },
version: 1,
},
],
};
const firstUpdatePlan = (ops: Operations): UpdatePlanOp => {
const op = ops.customer?.[0];
if (op?.type === "update_plan") return op;
throw new Error("Expected first operation to update a plan");
};
const process = (filter?: MigrationFilter) =>
firstUpdatePlan(preProcessMigrationOperations({ operations, filter }));
describe("preProcessMigrationOperations custom guard", () => {
test("defaults version migrations to non-custom plans", () => {
expect(process().plan_filter).toEqual({
plan_id: "pro",
version: 1,
custom: false,
});
});
test("keeps custom plans eligible when the migration targets one customer", () => {
expect(
process({ customer: { customer_id: "cus_1" } }).plan_filter,
).toEqual({
plan_id: "pro",
version: 1,
});
});
test("keeps custom plans eligible when the filter explicitly targets custom", () => {
expect(
process({ customer: { plan: { plan_id: "pro", custom: true } } })
.plan_filter,
).toEqual({
plan_id: "pro",
version: 1,
});
});
test("keeps custom plans eligible through plan quantifiers and OR filters", () => {
expect(
process({
customer: {
plan: {
$some: {
plan_id: "pro",
$or: [{ version: 1 }, { custom: true }],
},
},
},
}).plan_filter,
).toEqual({
plan_id: "pro",
version: 1,
});
});
});

View File

@@ -19,6 +19,7 @@ interface SubTab {
value: string;
icon?: ReactNode;
path?: string;
badge?: ReactNode;
}
interface CollapsibleNavGroupProps {
@@ -117,6 +118,7 @@ export const CollapsibleNavGroup = ({
subValue={subTab.path ? undefined : subTab.value}
icon={subTab.icon}
title={keyToTitle(subTab.title)}
badge={subTab.badge}
isSubNav
/>
))}

View File

@@ -9,6 +9,7 @@ import {
KeyIcon,
LegoIcon,
TerminalWindowIcon,
TestTubeIcon,
TriangleIcon,
UserCircleIcon,
UsersIcon,
@@ -18,6 +19,11 @@ import { PanelLeft } from "lucide-react";
import { useHotkeys } from "react-hotkeys-hook";
import { Button } from "@/components/v2/buttons/Button";
import { RevenueCatIcon, StripeIcon } from "@/components/v2/icons/AutumnIcons";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/v2/tooltips/Tooltip";
import { useAutumnFlags } from "@/hooks/common/useAutumnFlags";
import { useLocalStorage } from "@/hooks/common/useLocalStorage";
import { useScopes } from "@/hooks/useScopes";
@@ -213,6 +219,23 @@ export const MainSidebar = ({
value: "migrations",
path: "/migrations",
icon: <ArrowsClockwiseIcon size={16} weight="fill" />,
badge: (
<Tooltip>
<TooltipTrigger
render={
<TestTubeIcon
size={14}
weight="fill"
className="ml-auto text-amber-500"
/>
}
/>
<TooltipContent side="right">
Migrations are in beta. Get in touch with the team for
more complex migrations.
</TooltipContent>
</Tooltip>
),
},
]}
/>

View File

@@ -1,6 +1,6 @@
import type { AppEnv } from "@autumn/shared";
import { ChevronRight } from "lucide-react";
import { useState } from "react";
import { type ReactNode, useState } from "react";
import { Link, useSearchParams } from "react-router";
import { useTab } from "@/hooks/common/useTab";
import { cn } from "@/lib/utils";
@@ -21,6 +21,7 @@ export const NavButton = ({
isOpen,
isSubNav = false,
isGroup = false,
badge,
}: {
value?: string;
subValue?: string;
@@ -34,6 +35,7 @@ export const NavButton = ({
isOpen?: boolean;
isSubNav?: boolean;
isGroup?: boolean;
badge?: ReactNode;
}) => {
// Get window path
const finalEnv = useEnv();
@@ -67,6 +69,7 @@ export const NavButton = ({
>
{title}
</span>
{badge && expanded && badge}
</div>
{online && (
<span className="relative flex h-2 w-2 ml-2">

View File

@@ -60,10 +60,10 @@ export function SidebarContact() {
<DropdownMenuTrigger render={<div />} nativeButton={false}>
<NavButton
env={env}
value="chat"
icon={<QuestionIcon size={16} weight="duotone" />}
title="Contact us"
onClick={() => {}}
isGroup
/>
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start">

View File

@@ -1,7 +1,13 @@
import { ArrowsClockwiseIcon } from "@phosphor-icons/react";
import { ArrowsClockwiseIcon, TestTubeIcon } from "@phosphor-icons/react";
import { useMemo } from "react";
import { Table } from "@/components/general/table";
import { Badge } from "@/components/v2/badges/Badge";
import { EmptyState } from "@/components/v2/empty-states/EmptyState";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/v2/tooltips/Tooltip";
import {
useMigrationsQuery,
type MigrationWithRunInfo,
@@ -71,6 +77,21 @@ export function MigrationListTable() {
className="text-subtle"
/>
Migrations
<Tooltip>
<TooltipTrigger className="inline-flex cursor-default">
<Badge
variant="muted"
className="text-[10px] px-1.5 py-0 gap-1 cursor-default text-amber-600 dark:text-amber-400"
>
<TestTubeIcon size={12} weight="fill" />
Beta
</Badge>
</TooltipTrigger>
<TooltipContent side="right">
Migrations are in beta. Get in touch with the team for more
complex migrations.
</TooltipContent>
</Tooltip>
</Table.Heading>
<Table.Actions>
<div className="flex items-center gap-2">

View File

@@ -111,6 +111,7 @@ export function UpdatePlanOpForm({
const customize = value.customize;
const addItems = customize?.add_items ?? [];
const planVersionActionLabel = getPlanVersionActionLabel(value);
const openSheet = (mode: OperationSheetMode, itemIndex?: number) => {
setSheetMode(mode);
@@ -312,9 +313,9 @@ export function UpdatePlanOpForm({
<DropdownMenuItem
closeOnClick
onClick={() => update({ version: 1 })}
>
Set Plan Version
</DropdownMenuItem>
>
{planVersionActionLabel}
</DropdownMenuItem>
)}
{(!customize || customize.price === undefined) && (
<DropdownMenuItem
@@ -372,6 +373,21 @@ export function extractPlanIds(
return [];
}
export function isSameVersionReset(value: UpdatePlanOp): boolean {
const filteredVersion = value.plan_filter.version;
const selectedVersion = value.version ?? 1;
return (
typeof filteredVersion === "number" && filteredVersion === selectedVersion
);
}
export function getPlanVersionActionLabel(value: UpdatePlanOp): string {
return isSameVersionReset(value)
? "Reset to Plan Version"
: "Set Plan Version";
}
function toPlanIdMatcher(
ids: string[],
): UpdatePlanOp["plan_filter"]["plan_id"] {

View File

@@ -2,6 +2,7 @@ import type {
ApiPlanV1,
Feature,
FrontendProduct,
UpdatePlanOp,
UpdatePlanParamsV2Input,
} from "@autumn/shared";
import {
@@ -156,25 +157,28 @@ export function buildVersionMigrationDraft({
const versions = scope === "all" ? pastVersions : [scope];
const versionMatcher =
versions.length === 1 ? versions[0] : { $in: versions };
const planFilter = {
const basePlanFilter = {
plan_id: productId,
version: versionMatcher,
...(!includeCustom ? { custom: false } : {}),
};
const planFilter = includeCustom
? basePlanFilter
: { ...basePlanFilter, custom: false };
const versionOp = (custom: boolean): UpdatePlanOp => ({
type: "update_plan",
plan_filter: { ...basePlanFilter, custom },
version: latestVersion,
});
const filter: MigrationFilter = {
customer: { plan: planFilter },
};
const operations: Operations = {
customer: [
{
type: "update_plan",
plan_filter: planFilter,
version: latestVersion,
},
],
} as unknown as Operations;
customer: includeCustom
? [versionOp(false), versionOp(true)]
: [versionOp(false)],
};
const suffix = scope === "all" ? "migrate-all" : `migrate-v${scope}`;
@@ -206,18 +210,20 @@ export function buildMigrationDraft({
const hasCustomize = Object.keys(diff).length > 0;
const customize = hasCustomize ? diff : undefined;
const planFilter = {
const basePlanFilter = {
plan_id: baseProduct.id,
...(scope === "this_version"
? { version: baseProduct.version }
: {}),
...(!includeCustom ? { custom: false } : {}),
};
const updatePlanOp = {
type: "update_plan" as const,
plan_filter: planFilter,
const planFilter = includeCustom
? basePlanFilter
: { ...basePlanFilter, custom: false };
const updatePlanOp = (custom: boolean): UpdatePlanOp => ({
type: "update_plan",
plan_filter: { ...basePlanFilter, custom },
...(customize ? { customize } : {}),
};
});
const filter: MigrationFilter = {
customer: { plan: planFilter },
@@ -229,7 +235,11 @@ export function buildMigrationDraft({
return {
id: `${baseProduct.id}-${suffix}-${migrationUid()}`,
filter,
operations: { customer: [updatePlanOp] } as unknown as Operations,
no_billing_changes: !diffHasBillingChanges(diff),
operations: {
customer: includeCustom
? [updatePlanOp(false), updatePlanOp(true)]
: [updatePlanOp(false)],
},
no_billing_changes: diffHasBillingChanges(diff) === false,
};
}

View File

@@ -0,0 +1,33 @@
import { describe, expect, test } from "bun:test";
import type { UpdatePlanOp } from "@autumn/shared";
import { getPlanVersionActionLabel } from "@/views/migrations/migration/operations/UpdatePlanOpForm";
const op = (patch: Partial<UpdatePlanOp>): UpdatePlanOp => ({
type: "update_plan",
plan_filter: {},
...patch,
});
describe("UpdatePlanOpForm", () => {
test("labels same-version version operations as reset", () => {
expect(
getPlanVersionActionLabel(
op({ plan_filter: { version: 2 }, version: 2 }),
),
).toBe("Reset to Plan Version");
});
test("keeps set label when operation version differs from filter version", () => {
expect(
getPlanVersionActionLabel(
op({ plan_filter: { version: 1 }, version: 2 }),
),
).toBe("Set Plan Version");
});
test("uses the menu default version before a version is selected", () => {
expect(getPlanVersionActionLabel(op({ plan_filter: { version: 1 } }))).toBe(
"Reset to Plan Version",
);
});
});

View File

@@ -9,10 +9,12 @@ import {
UsageModel,
type Feature,
type FrontendProduct,
type UpdatePlanOp,
} from "@autumn/shared";
import {
buildMigrationDraft,
buildVersionMigrationDraft,
type MigrationDraft,
} from "@/views/products/plan/versioning/buildMigrationDraft";
const features: Feature[] = [
@@ -48,6 +50,17 @@ const baseProduct: FrontendProduct = {
basePriceType: "free",
};
const updatePlanFilters = (draft: MigrationDraft) =>
(draft.operations.customer ?? [])
.filter((op): op is UpdatePlanOp => op.type === "update_plan")
.map((op) => op.plan_filter);
const firstUpdatePlan = (draft: MigrationDraft): UpdatePlanOp => {
const op = draft.operations.customer?.[0];
if (op?.type === "update_plan") return op;
throw new Error("Expected first migration operation to update a plan");
};
describe("buildMigrationDraft", () => {
test("excludes custom plans by default", () => {
const draft = buildMigrationDraft({
@@ -62,14 +75,14 @@ describe("buildMigrationDraft", () => {
version: 2,
custom: false,
});
expect(draft.operations.customer?.[0]?.plan_filter).toMatchObject({
expect(firstUpdatePlan(draft).plan_filter).toMatchObject({
plan_id: "pro",
version: 2,
custom: false,
});
});
test("includes custom plans when enabled", () => {
test("targets both regular and custom plans when custom plans are included", () => {
const draft = buildMigrationDraft({
baseProduct,
editedProduct: { ...baseProduct, name: "Pro updated" },
@@ -82,9 +95,56 @@ describe("buildMigrationDraft", () => {
plan_id: "pro",
version: 2,
});
expect(draft.operations.customer?.[0]?.plan_filter).toEqual({
expect(updatePlanFilters(draft)).toEqual([
{
plan_id: "pro",
version: 2,
custom: false,
},
{
plan_id: "pro",
version: 2,
custom: true,
},
]);
});
test("keeps custom targeting explicit for version reset migrations", () => {
const draft = buildMigrationDraft({
baseProduct,
editedProduct: baseProduct,
features,
scope: "this_version",
includeCustom: true,
});
expect(updatePlanFilters(draft)).toEqual([
{
plan_id: "pro",
version: 2,
custom: false,
},
{
plan_id: "pro",
version: 2,
custom: true,
},
]);
});
test("keeps a single operation when custom plans are excluded", () => {
const draft = buildMigrationDraft({
baseProduct,
editedProduct: baseProduct,
features,
scope: "this_version",
});
expect(draft.operations.customer).toHaveLength(1);
expect(firstUpdatePlan(draft).plan_filter).toEqual({
plan_id: "pro",
version: 2,
custom: false,
});
});
@@ -113,7 +173,7 @@ describe("buildMigrationDraft", () => {
scope: "this_version",
});
const updatePlan = draft.operations.customer?.[0];
const updatePlan = firstUpdatePlan(draft);
const addItem = updatePlan?.customize?.add_items?.[0];
const price = JSON.parse(JSON.stringify(addItem?.price));
@@ -143,14 +203,14 @@ describe("buildVersionMigrationDraft", () => {
version: { $in: [1, 2] },
custom: false,
});
expect(draft.operations.customer?.[0]?.plan_filter).toMatchObject({
expect(firstUpdatePlan(draft).plan_filter).toMatchObject({
plan_id: "pro",
version: { $in: [1, 2] },
custom: false,
});
});
test("omits custom filters when custom plans are included", () => {
test("targets both regular and custom versions when custom plans are included", () => {
const draft = buildVersionMigrationDraft({
productId: "pro",
latestVersion: 3,
@@ -163,9 +223,9 @@ describe("buildVersionMigrationDraft", () => {
plan_id: "pro",
version: 2,
});
expect(draft.operations.customer?.[0]?.plan_filter).toEqual({
plan_id: "pro",
version: 2,
});
expect(updatePlanFilters(draft)).toEqual([
{ plan_id: "pro", version: 2, custom: false },
{ plan_id: "pro", version: 2, custom: true },
]);
});
});