chore: add reset migration to version
This commit is contained in:
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -105,6 +105,7 @@ export const setupUpdatePlanProductContext = async ({
|
||||
fullCustomer: productFullCustomer,
|
||||
params,
|
||||
reusePricesAndEntitlements,
|
||||
resetToCatalogVersion: typeof preparedOp.version === "number",
|
||||
});
|
||||
|
||||
const operationBillingContext = await setupMigrationOperationBillingContext({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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] });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
@@ -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" },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"] {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user