fix: migration bugs

This commit is contained in:
johnyeo
2026-05-28 11:39:58 +01:00
parent ff0ef7cd2b
commit c3ae562b5b
18 changed files with 1179 additions and 91 deletions

View File

@@ -985,6 +985,7 @@ export class AutumnInt {
id: string;
filter?: MigrationFilter | null;
operations?: Operations | null;
no_billing_changes?: boolean;
}): Promise<Migration> => {
const data = await this.post(`/migrations.create`, params);
return data as Migration;
@@ -1000,6 +1001,7 @@ export class AutumnInt {
filter?: MigrationFilter | null;
operations?: Operations | null;
retry_failed?: boolean;
no_billing_changes?: boolean;
};
}): Promise<Migration> => {
const data = await this.post(`/migrations.update`, params);
@@ -1013,6 +1015,7 @@ export class AutumnInt {
id: string;
filter?: MigrationFilter | null;
operations?: Operations | null;
no_billing_changes?: boolean;
}): Promise<Migration> => {
try {
await this.post(`/migrations.delete`, { id: params.id });

View File

@@ -0,0 +1,55 @@
import {
EntInterval,
type FullCusEntWithFullCusProduct,
type FullCusProduct,
type FullCustomerEntitlement,
} from "@autumn/shared";
import { cusEntToCusPrice } from "@shared/utils/cusEntUtils/convertCusEntUtils/cusEntToCusPrice";
import { priceToBillingMethod } from "@shared/utils/productUtils/priceUtils/convertPriceUtils";
export type CustomerEntitlementCarryIdentity = {
internalFeatureId: string;
interval: string;
intervalCount: number;
entityFeatureId: string | null;
billingMethod: string | null;
};
export const carryIdentityToKey = (
identity: CustomerEntitlementCarryIdentity,
) =>
[
identity.internalFeatureId,
identity.interval,
identity.intervalCount,
identity.entityFeatureId ?? "",
identity.billingMethod ?? "",
].join(":");
export const customerEntitlementToCarryIdentity = ({
customerEntitlement,
customerProduct,
}: {
customerEntitlement: FullCustomerEntitlement;
customerProduct: FullCusProduct;
}): CustomerEntitlementCarryIdentity => {
const customerEntitlementWithProduct = {
...customerEntitlement,
customer_product: customerProduct,
} satisfies FullCusEntWithFullCusProduct;
const customerPrice = cusEntToCusPrice({
cusEnt: customerEntitlementWithProduct,
});
const entitlement = customerEntitlement.entitlement;
return {
internalFeatureId: entitlement.internal_feature_id,
interval:
customerPrice?.price.config.interval ??
entitlement.interval ??
EntInterval.Lifetime,
intervalCount: entitlement.interval_count ?? 1,
entityFeatureId: entitlement.entity_feature_id ?? null,
billingMethod: priceToBillingMethod({ price: customerPrice?.price }) ?? null,
};
};

View File

@@ -0,0 +1,86 @@
import {
type FullCusProduct,
type FullCustomerEntitlement,
} from "@autumn/shared";
import {
carryIdentityToKey,
customerEntitlementToCarryIdentity,
} from "./carryIdentity";
import { customerProductWithOnlyEntitlements } from "./projectCustomerProductForCarry";
export type CustomerProductCarryGroup = {
fromCustomerProduct: FullCusProduct;
toCustomerProduct: FullCusProduct;
};
const addToGroup = <T>(groups: Map<string, T[]>, key: string, value: T) => {
const group = groups.get(key);
if (group) {
group.push(value);
return;
}
groups.set(key, [value]);
};
const groupCustomerEntitlementsByCarryIdentity = ({
customerProduct,
customerEntitlements,
}: {
customerProduct: FullCusProduct;
customerEntitlements: FullCustomerEntitlement[];
}) => {
const customerEntitlementsByKey = new Map<
string,
FullCustomerEntitlement[]
>();
for (const customerEntitlement of customerEntitlements) {
const key = carryIdentityToKey(
customerEntitlementToCarryIdentity({
customerEntitlement,
customerProduct,
}),
);
addToGroup(customerEntitlementsByKey, key, customerEntitlement);
}
return customerEntitlementsByKey;
};
export const getCustomerProductCarryGroups = ({
fromCustomerProduct,
toCustomerProduct,
fromCustomerEntitlements,
}: {
fromCustomerProduct: FullCusProduct;
toCustomerProduct: FullCusProduct;
fromCustomerEntitlements: FullCustomerEntitlement[];
}): CustomerProductCarryGroup[] => {
const toEntitlementsByKey = groupCustomerEntitlementsByCarryIdentity({
customerProduct: toCustomerProduct,
customerEntitlements: toCustomerProduct.customer_entitlements,
});
const fromEntitlementsByKey = groupCustomerEntitlementsByCarryIdentity({
customerProduct: fromCustomerProduct,
customerEntitlements: fromCustomerEntitlements,
});
return Array.from(fromEntitlementsByKey.entries()).flatMap(
([key, fromEntitlements]) => {
const toEntitlements = toEntitlementsByKey.get(key);
if (!toEntitlements) return [];
return {
fromCustomerProduct: customerProductWithOnlyEntitlements({
customerProduct: fromCustomerProduct,
customerEntitlements: fromEntitlements,
}),
toCustomerProduct: customerProductWithOnlyEntitlements({
customerProduct: toCustomerProduct,
customerEntitlements: toEntitlements,
}),
};
},
);
};

View File

@@ -0,0 +1,3 @@
export * from "./carryIdentity";
export * from "./customerProductCarryGroups";
export * from "./projectCustomerProductForCarry";

View File

@@ -0,0 +1,46 @@
import {
type FullCusEntWithFullCusProduct,
type FullCusProduct,
type FullCustomerEntitlement,
type FullCustomerPrice,
} from "@autumn/shared";
import { cusEntToCusPrice } from "@shared/utils/cusEntUtils/convertCusEntUtils/cusEntToCusPrice";
const customerPricesForCustomerEntitlements = ({
customerProduct,
customerEntitlements,
}: {
customerProduct: FullCusProduct;
customerEntitlements: FullCustomerEntitlement[];
}): FullCustomerPrice[] => {
const customerPricesById = new Map<string, FullCustomerPrice>();
for (const customerEntitlement of customerEntitlements) {
const customerPrice = cusEntToCusPrice({
cusEnt: {
...customerEntitlement,
customer_product: customerProduct,
} satisfies FullCusEntWithFullCusProduct,
});
if (!customerPrice) continue;
customerPricesById.set(customerPrice.id, customerPrice);
}
return Array.from(customerPricesById.values());
};
export const customerProductWithOnlyEntitlements = ({
customerProduct,
customerEntitlements,
}: {
customerProduct: FullCusProduct;
customerEntitlements: FullCustomerEntitlement[];
}): FullCusProduct => ({
...customerProduct,
customer_prices: customerPricesForCustomerEntitlements({
customerProduct,
customerEntitlements,
}),
customer_entitlements: customerEntitlements,
});

View File

@@ -1,29 +0,0 @@
import type { FullCusProduct, PatchContext } from "@autumn/shared";
export const getPatchCarryCustomerProduct = ({
patchContext,
}: {
patchContext: PatchContext;
}): FullCusProduct => {
const deletedEntitlementIds = new Set(
patchContext.deleteCustomerEntitlements.map(
(customerEntitlement) => customerEntitlement.entitlement.id,
),
);
const deletedCustomerPriceIds = new Set(
patchContext.deleteCustomerPrices.map((customerPrice) => customerPrice.id),
);
return {
...patchContext.originalCustomerProduct,
customer_prices:
patchContext.originalCustomerProduct.customer_prices.filter(
(customerPrice) =>
deletedCustomerPriceIds.has(customerPrice.id) ||
(customerPrice.price.entitlement_id
? deletedEntitlementIds.has(customerPrice.price.entitlement_id)
: false),
),
customer_entitlements: patchContext.deleteCustomerEntitlements,
};
};

View File

@@ -1,4 +1,3 @@
export * from "./applyCustomerProductItemsPatch";
export * from "./getPatchCarryCustomerProduct";
export * from "./initPatchCustomerProduct";
export * from "./initPatchedCustomerEntitlementsAndPrices";

View File

@@ -6,10 +6,11 @@ import type {
} from "@autumn/shared";
import { enrichEntitlementsWithFeatures } from "@shared/utils/productUtils/entUtils/enrichEntitlement";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { getCustomerProductCarryGroups } from "@/internal/billing/v2/utils/initFullCustomerProduct/carryExisting";
import { applyExistingStatesToCustomerProduct } from "@/internal/billing/v2/utils/initFullCustomerProduct/applyExisting/applyExistingStatesToCustomerProduct";
import { initCustomerEntitlement } from "@/internal/billing/v2/utils/initFullCustomerProduct/initCustomerEntitlement/initCustomerEntitlement";
import { initCustomerPrice } from "@/internal/billing/v2/utils/initFullCustomerProduct/initCustomerPrice";
import { getPatchCarryCustomerProduct } from "./getPatchCarryCustomerProduct";
import { applyOneOffPrepaidCarryOvers } from "../../handleOneOffPrepaidCarryOvers/applyOneOffPrepaidCarryOvers";
type PatchInitBillingContext = Pick<
UpdateSubscriptionBillingContext,
@@ -86,23 +87,35 @@ export const initPatchedCustomerEntitlementsAndPrices = ({
customer_prices: customerPrices,
customer_entitlements: customerEntitlements,
};
const carryCustomerProduct = getPatchCarryCustomerProduct({ patchContext });
applyExistingStatesToCustomerProduct({
ctx,
fullCustomer,
customerProduct: customerProductWithNewItemsOnly,
existingUsagesConfig: skipExistingUsageCarry
? undefined
: {
fromCustomerProduct: carryCustomerProduct,
carryAllConsumableFeatures: true,
},
existingRolloversConfig: {
fromCustomerProduct: carryCustomerProduct,
},
const carryGroups = getCustomerProductCarryGroups({
fromCustomerProduct: patchContext.originalCustomerProduct,
toCustomerProduct: customerProductWithNewItemsOnly,
fromCustomerEntitlements: patchContext.deleteCustomerEntitlements,
});
for (const carryGroup of carryGroups) {
applyExistingStatesToCustomerProduct({
ctx,
fullCustomer,
customerProduct: carryGroup.toCustomerProduct,
existingUsagesConfig: skipExistingUsageCarry
? undefined
: {
fromCustomerProduct: carryGroup.fromCustomerProduct,
carryAllConsumableFeatures: true,
},
existingRolloversConfig: {
fromCustomerProduct: carryGroup.fromCustomerProduct,
},
});
applyOneOffPrepaidCarryOvers({
oldCustomerProduct: carryGroup.fromCustomerProduct,
newCustomerProduct: carryGroup.toCustomerProduct,
fullCustomer,
});
}
return {
customerPrices: customerProductWithNewItemsOnly.customer_prices,
customerEntitlements: customerProductWithNewItemsOnly.customer_entitlements,

View File

@@ -12,6 +12,7 @@ const PatchMigrationBody = z.object({
filter: MigrationFilterSchema.nullable().optional(),
operations: OperationsSchema.nullable().optional(),
retry_failed: z.boolean().optional(),
no_billing_changes: z.boolean().nullable().optional(),
}),
});

View File

@@ -22,7 +22,12 @@ export const updateMigration = async ({
updates: Partial<
Pick<
MigrationInsert,
"id" | "filter" | "operations" | "prepared_state" | "retry_failed"
| "id"
| "filter"
| "operations"
| "prepared_state"
| "retry_failed"
| "no_billing_changes"
>
>;
}): Promise<Migration | null> => {

View File

@@ -6,10 +6,7 @@ import type {
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { evaluateStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/actionBuilders/evaluateStripeBillingPlan.js";
import {
assertStripePlanNoCharges,
hasStripePlanActions,
} from "@/internal/billing/v2/providers/stripe/errors/assertStripePlanNoCharges.js";
import { assertStripePlanNoCharges } from "@/internal/billing/v2/providers/stripe/errors/assertStripePlanNoCharges.js";
import { logStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/logs/logStripeBillingPlan.js";
import { MigrationOperationError } from "@/internal/migrations/v2/operations/errors/index.js";
import type { MigrateCustomerContext } from "@/internal/migrations/v2/operations/types/index.js";
@@ -61,20 +58,22 @@ export const evaluateMigrateCustomerStripe = async ({
billingContexts: UpdateSubscriptionBillingContext[];
autumnBillingPlan: AutumnBillingPlan;
}): Promise<MigrateCustomerBillingPlan> => {
if (context.migration.no_billing_changes === true) {
return {
autumn: autumnBillingPlan,
stripe: {},
stripeBillingPlans: [],
};
}
const stripeBillingPlans: MigrateCustomerStripeBillingPlan[] = [];
for (const [subscriptionId, billingContext] of contextBySubscriptionId({
billingContexts,
})) {
const shouldValidateForcedNoBillingChanges =
context.migration.no_billing_changes === true;
const evaluationContext = shouldValidateForcedNoBillingChanges
? { ...billingContext, skipBillingChanges: false }
: billingContext;
const stripeBillingPlan = await evaluateStripeBillingPlan({
ctx,
billingContext: evaluationContext,
billingContext,
autumnBillingPlan,
});
appendMigrationBillingLog({
@@ -84,7 +83,7 @@ export const evaluateMigrateCustomerStripe = async ({
logStripeBillingPlan({
ctx: logCtx,
stripeBillingPlan,
billingContext: evaluationContext,
billingContext,
}),
});
@@ -101,20 +100,6 @@ export const evaluateMigrateCustomerStripe = async ({
}),
});
if (
shouldValidateForcedNoBillingChanges &&
hasStripePlanActions(stripeBillingPlan)
) {
throw new MigrationOperationError({
code: "unsupported_operation_input",
operationType: "update_plan",
field: "no_billing_changes",
message:
"Migration no_billing_changes=true was set, but update_plan produced Stripe mutations",
details: { subscriptionId },
});
}
stripeBillingPlans.push({
subscriptionId,
billingContext,

View File

@@ -0,0 +1,34 @@
/**
* TDD coverage for migration draft CRUD used by the dashboard.
*
* Red-failure mode: PATCH strips `updates.no_billing_changes`, so the
* saved migration does not match the dashboard toggle.
*
* Green-success criteria: PATCH persists `no_billing_changes` like create.
*/
import { expect, test } from "bun:test";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
test.concurrent(
`${chalk.yellowBright("migrations.update: persists no_billing_changes from dashboard PATCH")}`,
async () => {
const customerId = "migrations-update-no-billing";
const migrationId = `${customerId}-mig`;
const { autumnV2_2 } = await initScenario({
customerId,
setup: [s.customer()],
actions: [],
});
await autumnV2_2.migrationsV2.deleteAndCreate({ id: migrationId });
const updated = await autumnV2_2.migrationsV2.update({
id: migrationId,
updates: { no_billing_changes: true },
});
expect(updated.no_billing_changes).toBe(true);
},
);

View File

@@ -0,0 +1,400 @@
import { expect, test } from "bun:test";
import {
type ApiCustomerV3,
type ApiCustomerV5,
BillingInterval,
BillingMethod,
ProductItemInterval,
ResetInterval,
} from "@autumn/shared";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect";
import { TestFeature } from "@tests/setup/v2Features";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
import {
constructFeatureItem,
constructPrepaidItem,
} from "@/utils/scriptUtils/constructItem";
import { runUpdatePlanMigration } from "../../utils/runUpdatePlanMigration";
type BalanceBreakdown = NonNullable<
ApiCustomerV5["balances"][string]["breakdown"]
>[number];
const dailyCredits = ({ includedUsage = 50 }: { includedUsage?: number } = {}) =>
constructFeatureItem({
featureId: TestFeature.Credits,
includedUsage,
interval: ProductItemInterval.Day,
});
const oneOffPrepaidCredits = ({
includedUsage = 0,
billingUnits = 100,
price = 10,
}: {
includedUsage?: number;
billingUnits?: number;
price?: number;
} = {}) =>
constructPrepaidItem({
featureId: TestFeature.Credits,
includedUsage,
billingUnits,
price,
isOneOff: true,
});
const getBucket = ({
customer,
billingMethod,
resetInterval,
}: {
customer: ApiCustomerV5;
billingMethod?: BillingMethod;
resetInterval?: ResetInterval | null;
}): BalanceBreakdown => {
const bucket = customer.balances[TestFeature.Credits]?.breakdown?.find(
(candidate) => {
if (
billingMethod &&
candidate.price?.billing_method !== billingMethod
) {
return false;
}
if (resetInterval === null) return candidate.reset === null;
if (resetInterval) return candidate.reset?.interval === resetInterval;
return true;
},
);
expect(bucket).toBeDefined();
return bucket!;
};
test.concurrent(`${chalk.yellowBright("migrations update_items: daily and monthly credits carry separately when both are updated")}`, async () => {
const customerId = "migration-update-items-daily-monthly-carry";
const base = products.base({
id: "migration-update-items-daily-monthly-carry-plan",
items: [
dailyCredits({ includedUsage: 50 }),
items.monthlyCredits({ includedUsage: 100 }),
constructFeatureItem({
featureId: TestFeature.Credits,
includedUsage: 100,
interval: null,
}),
],
});
const { autumnV1, autumnV2, autumnV2_2, ctx } = await initScenario({
customerId,
setup: [s.customer(), s.products({ list: [base] })],
actions: [s.billing.attach({ productId: base.id })],
});
await autumnV2.balances.update({
customer_id: customerId,
feature_id: TestFeature.Credits,
current_balance: 20,
interval: ResetInterval.Day,
});
await autumnV2.balances.update({
customer_id: customerId,
feature_id: TestFeature.Credits,
current_balance: 60,
interval: ResetInterval.Month,
});
await autumnV2.balances.update({
customer_id: customerId,
feature_id: TestFeature.Credits,
current_balance: 70,
interval: ResetInterval.OneOff,
});
await runUpdatePlanMigration({
ctx,
migrationClient: autumnV2_2,
migrationId: `${customerId}-mig`,
customerId,
filter: { customer: { plan: { plan_id: base.id } } },
operations: {
customer: [
{
type: "update_plan",
plan_filter: { plan_id: base.id },
customize: {
update_items: [
{
filter: {
feature_id: TestFeature.Credits,
interval: ProductItemInterval.Day,
},
included: 80,
},
{
filter: {
feature_id: TestFeature.Credits,
interval: BillingInterval.Month,
},
included: 200,
},
],
},
},
],
},
});
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
expectBalanceCorrect({
customer,
featureId: TestFeature.Credits,
remaining: 280,
usage: 100,
breakdown: {
[ResetInterval.Day]: { included_grant: 80, remaining: 50, usage: 30 },
[ResetInterval.Month]: { included_grant: 200, remaining: 160, usage: 40 },
[ResetInterval.OneOff]: { included_grant: 100, remaining: 70, usage: 30 },
},
});
await expectCustomerInvoiceCorrect({
customer: await autumnV1.customers.get<ApiCustomerV3>(customerId),
count: 0,
});
});
test.concurrent(`${chalk.yellowBright("migrations update_items: prepaid and usage-based credits carry by billing method")}`, async () => {
const customerId = "migration-update-items-billing-method-carry";
const pro = products.pro({
id: "migration-update-items-billing-method-carry-plan",
items: [
items.prepaid({
featureId: TestFeature.Credits,
includedUsage: 100,
billingUnits: 100,
price: 10,
}),
items.consumable({
featureId: TestFeature.Credits,
includedUsage: 50,
price: 0.1,
}),
],
});
const { autumnV1, autumnV2, autumnV2_2, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [
s.billing.attach({
productId: pro.id,
options: [{ feature_id: TestFeature.Credits, quantity: 300 }],
}),
],
});
const initialCustomer =
await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
const prepaidBucket = getBucket({
customer: initialCustomer,
billingMethod: BillingMethod.Prepaid,
});
const usageBasedBucket = getBucket({
customer: initialCustomer,
billingMethod: BillingMethod.UsageBased,
});
await autumnV2.balances.update({
customer_id: customerId,
feature_id: TestFeature.Credits,
current_balance: 250,
balance_id: prepaidBucket.id,
});
await autumnV2.balances.update({
customer_id: customerId,
feature_id: TestFeature.Credits,
current_balance: 30,
balance_id: usageBasedBucket.id,
});
const invoiceCountBefore =
(await autumnV1.customers.get<ApiCustomerV3>(customerId)).invoices?.length ??
0;
await runUpdatePlanMigration({
ctx,
migrationClient: autumnV2_2,
migrationId: `${customerId}-mig`,
customerId,
filter: { customer: { plan: { plan_id: pro.id } } },
operations: {
customer: [
{
type: "update_plan",
plan_filter: { plan_id: pro.id },
customize: {
update_items: [
{
filter: {
feature_id: TestFeature.Credits,
billing_method: BillingMethod.Prepaid,
interval: BillingInterval.Month,
},
included: 200,
},
{
filter: {
feature_id: TestFeature.Credits,
billing_method: BillingMethod.UsageBased,
interval: BillingInterval.Month,
},
included: 100,
},
],
},
},
],
},
noBillingChanges: true,
runOnServer: false,
});
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
expectBalanceCorrect({
customer,
featureId: TestFeature.Credits,
remaining: 330,
usage: 70,
breakdown: {
[BillingMethod.Prepaid]: {
included_grant: 200,
prepaid_grant: 100,
remaining: 250,
usage: 50,
},
[BillingMethod.UsageBased]: {
included_grant: 100,
remaining: 80,
usage: 20,
},
},
});
await expectCustomerInvoiceCorrect({
customer: await autumnV1.customers.get<ApiCustomerV3>(customerId),
count: invoiceCountBefore,
});
});
test.concurrent(`${chalk.yellowBright("migrations update_items: one-off prepaid balance survives alongside monthly carry")}`, async () => {
const customerId = "migration-update-items-one-off-prepaid-carry";
const pro = products.pro({
id: "migration-update-items-one-off-prepaid-carry-plan",
items: [
items.monthlyCredits({ includedUsage: 100 }),
oneOffPrepaidCredits({ includedUsage: 0, billingUnits: 100 }),
],
});
const { autumnV1, autumnV2, autumnV2_2, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [
s.billing.attach({
productId: pro.id,
options: [{ feature_id: TestFeature.Credits, quantity: 200 }],
}),
],
});
const initialCustomer =
await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
const monthlyBucket = getBucket({
customer: initialCustomer,
resetInterval: ResetInterval.Month,
});
const oneOffBucket = getBucket({
customer: initialCustomer,
billingMethod: BillingMethod.Prepaid,
resetInterval: ResetInterval.OneOff,
});
await autumnV2.balances.update({
customer_id: customerId,
feature_id: TestFeature.Credits,
current_balance: 60,
balance_id: monthlyBucket.id,
});
await autumnV2.balances.update({
customer_id: customerId,
feature_id: TestFeature.Credits,
current_balance: 150,
balance_id: oneOffBucket.id,
});
const invoiceCountBefore =
(await autumnV1.customers.get<ApiCustomerV3>(customerId)).invoices?.length ??
0;
await runUpdatePlanMigration({
ctx,
migrationClient: autumnV2_2,
migrationId: `${customerId}-mig`,
customerId,
filter: { customer: { plan: { plan_id: pro.id } } },
operations: {
customer: [
{
type: "update_plan",
plan_filter: { plan_id: pro.id },
customize: {
update_items: [
{
filter: {
feature_id: TestFeature.Credits,
interval: BillingInterval.Month,
},
included: 200,
},
{
filter: {
feature_id: TestFeature.Credits,
billing_method: BillingMethod.Prepaid,
interval: BillingInterval.OneOff,
},
included: 25,
},
],
},
},
],
},
noBillingChanges: true,
runOnServer: false,
});
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
expectBalanceCorrect({
customer,
featureId: TestFeature.Credits,
remaining: 335,
usage: 40,
breakdown: {
[ResetInterval.Month]: { included_grant: 200, remaining: 160, usage: 40 },
[BillingMethod.Prepaid]: {
included_grant: 175,
prepaid_grant: 0,
remaining: 175,
usage: 0,
},
},
});
await expectCustomerInvoiceCorrect({
customer: await autumnV1.customers.get<ApiCustomerV3>(customerId),
count: invoiceCountBefore,
});
});

View File

@@ -0,0 +1,416 @@
import { expect, test } from "bun:test";
import {
type ApiCustomerV3,
type ApiCustomerV5,
type ApiEntityV2,
BillingInterval,
BillingMethod,
ProductItemInterval,
ResetInterval,
} from "@autumn/shared";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect";
import { TestFeature } from "@tests/setup/v2Features";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem";
import { runUpdatePlanMigration } from "../../utils/runUpdatePlanMigration";
const dailyCredits = ({ includedUsage = 50 }: { includedUsage?: number } = {}) =>
constructFeatureItem({
featureId: TestFeature.Credits,
includedUsage,
interval: ProductItemInterval.Day,
});
const lifetimeCredits = ({
includedUsage = 50,
}: {
includedUsage?: number;
} = {}) =>
constructFeatureItem({
featureId: TestFeature.Credits,
includedUsage,
interval: null,
});
test.concurrent(`${chalk.yellowBright("migrations update_items: removes daily credits while monthly usage carry stays scoped")}`, async () => {
const customerId = "migration-update-items-credits-daily-remove";
const base = products.base({
id: "migration-update-items-credits-daily-remove-plan",
items: [
dailyCredits({ includedUsage: 50 }),
items.monthlyCredits({ includedUsage: 100 }),
lifetimeCredits({ includedUsage: 100 }),
],
});
const { autumnV1, autumnV2, autumnV2_2, ctx } = await initScenario({
customerId,
setup: [s.customer(), s.products({ list: [base] })],
actions: [s.billing.attach({ productId: base.id })],
});
await autumnV2.balances.update({
customer_id: customerId,
feature_id: TestFeature.Credits,
current_balance: 20,
interval: ResetInterval.Day,
});
await autumnV2.balances.update({
customer_id: customerId,
feature_id: TestFeature.Credits,
current_balance: 60,
interval: ResetInterval.Month,
});
await autumnV2.balances.update({
customer_id: customerId,
feature_id: TestFeature.Credits,
current_balance: 70,
interval: ResetInterval.OneOff,
});
await runUpdatePlanMigration({
ctx,
migrationClient: autumnV2_2,
migrationId: `${customerId}-mig`,
customerId,
filter: { customer: { plan: { plan_id: base.id } } },
operations: {
customer: [
{
type: "update_plan",
plan_filter: { plan_id: base.id },
customize: {
remove_items: [
{
feature_id: TestFeature.Credits,
interval: ResetInterval.Day,
},
],
update_items: [
{
filter: {
feature_id: TestFeature.Credits,
interval: BillingInterval.Month,
},
included: 200,
},
],
},
},
],
},
});
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
await expectCustomerProducts({ customer, active: [base.id] });
expectBalanceCorrect({
customer,
featureId: TestFeature.Credits,
remaining: 230,
usage: 70,
planId: base.id,
breakdown: {
[ResetInterval.Month]: { included_grant: 200, remaining: 160, usage: 40 },
[ResetInterval.OneOff]: { included_grant: 100, remaining: 70, usage: 30 },
},
});
expect(
customer.balances[TestFeature.Credits]?.breakdown?.some(
(bucket) => bucket.reset?.interval === ResetInterval.Day,
),
).toBe(false);
await expectCustomerInvoiceCorrect({
customer: await autumnV1.customers.get<ApiCustomerV3>(customerId),
count: 0,
});
});
test.concurrent(`${chalk.yellowBright("migrations update_items: prepaid credits keep prepaid bucket beside lifetime credits")}`, async () => {
const customerId = "migration-update-items-prepaid-credits";
const pro = products.pro({
id: "migration-update-items-prepaid-credits-plan",
items: [
items.prepaid({
featureId: TestFeature.Credits,
includedUsage: 100,
billingUnits: 100,
price: 10,
}),
lifetimeCredits({ includedUsage: 50 }),
],
});
const { autumnV1, autumnV2_2, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [
s.billing.attach({
productId: pro.id,
options: [{ feature_id: TestFeature.Credits, quantity: 300 }],
}),
s.track({ featureId: TestFeature.Credits, value: 125, 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 } } },
operations: {
customer: [
{
type: "update_plan",
plan_filter: { plan_id: pro.id },
customize: {
update_items: [
{
filter: {
feature_id: TestFeature.Credits,
interval: BillingInterval.Month,
},
included: 200,
},
],
},
},
],
},
noBillingChanges: true,
runOnServer: false,
});
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
expectBalanceCorrect({
customer,
featureId: TestFeature.Credits,
remaining: 225,
usage: 125,
planId: pro.id,
breakdown: {
[BillingMethod.Prepaid]: {
included_grant: 200,
prepaid_grant: 100,
remaining: 175,
usage: 125,
},
[ResetInterval.OneOff]: { included_grant: 50, remaining: 50, usage: 0 },
},
});
expect(
customer.balances[TestFeature.Credits]?.breakdown?.filter(
(bucket) => bucket.reset?.interval === ResetInterval.OneOff,
).length,
).toBe(1);
await expectCustomerInvoiceCorrect({
customer: await autumnV1.customers.get<ApiCustomerV3>(customerId),
count: invoiceCountBefore,
});
});
test.concurrent(`${chalk.yellowBright("migrations update_items: customer plan monthly credits and addon lifetime credits stay separate")}`, async () => {
const customerId = "migration-update-items-addon-lifetime-credits";
const pro = products.pro({
id: "migration-update-items-addon-lifetime-credits-pro",
items: [items.monthlyCredits({ includedUsage: 100 })],
});
const addon = products.recurringAddOn({
id: "migration-update-items-addon-lifetime-credits-addon",
items: [lifetimeCredits({ includedUsage: 500 })],
});
const { autumnV1, autumnV2, autumnV2_2, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, addon] }),
],
actions: [
s.billing.attach({ productId: pro.id }),
s.billing.attach({ productId: addon.id }),
],
});
const invoiceCountBefore =
(await autumnV1.customers.get<ApiCustomerV3>(customerId)).invoices?.length ??
0;
await autumnV2.balances.update({
customer_id: customerId,
feature_id: TestFeature.Credits,
current_balance: 80,
interval: ResetInterval.Month,
});
await autumnV2.balances.update({
customer_id: customerId,
feature_id: TestFeature.Credits,
current_balance: 400,
interval: ResetInterval.OneOff,
});
await runUpdatePlanMigration({
ctx,
migrationClient: autumnV2_2,
migrationId: `${customerId}-mig`,
customerId,
filter: { customer: { plan: { plan_id: pro.id } } },
operations: {
customer: [
{
type: "update_plan",
plan_filter: { plan_id: pro.id },
customize: {
update_items: [
{
filter: {
feature_id: TestFeature.Credits,
interval: BillingInterval.Month,
},
included: 200,
},
],
},
},
],
},
noBillingChanges: true,
runOnServer: false,
});
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
await expectCustomerProducts({ customer, active: [pro.id, addon.id] });
expectBalanceCorrect({
customer,
featureId: TestFeature.Credits,
remaining: 580,
usage: 120,
breakdown: {
[ResetInterval.Month]: { included_grant: 200, remaining: 180, usage: 20 },
[ResetInterval.OneOff]: {
included_grant: 500,
remaining: 400,
usage: 100,
},
},
});
await expectCustomerInvoiceCorrect({
customer: await autumnV1.customers.get<ApiCustomerV3>(customerId),
count: invoiceCountBefore,
});
});
test.concurrent(`${chalk.yellowBright("migrations update_items: entity-level credits are migrated per entity product")}`, async () => {
const customerId = "migration-update-items-entity-credits";
const pro = products.pro({
id: "migration-update-items-entity-credits-plan",
items: [
items.monthlyCredits({ includedUsage: 100 }),
lifetimeCredits({ includedUsage: 50 }),
],
});
const { autumnV1, autumnV2_2, ctx, entities } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
s.entities({ count: 2, featureId: TestFeature.Users }),
],
actions: [
s.billing.attach({ productId: pro.id, entityIndex: 0 }),
s.billing.attach({ productId: pro.id, entityIndex: 1 }),
s.track({
featureId: TestFeature.Credits,
value: 30,
entityIndex: 0,
timeout: 2000,
}),
s.track({
featureId: TestFeature.Credits,
value: 60,
entityIndex: 1,
timeout: 2000,
}),
],
});
const invoiceCountBefore =
(await autumnV1.customers.get<ApiCustomerV3>(customerId)).invoices?.length ??
0;
await runUpdatePlanMigration({
ctx,
migrationClient: autumnV2_2,
migrationId: `${customerId}-mig`,
customerId,
filter: { customer: { plan: { plan_id: pro.id } } },
operations: {
customer: [
{
type: "update_plan",
plan_filter: { plan_id: pro.id },
customize: {
update_items: [
{
filter: {
feature_id: TestFeature.Credits,
interval: BillingInterval.Month,
},
included: 200,
},
],
},
},
],
},
noBillingChanges: true,
runOnServer: false,
});
const firstEntity = await autumnV2_2.entities.get<ApiEntityV2>(
customerId,
entities[0].id,
);
const secondEntity = await autumnV2_2.entities.get<ApiEntityV2>(
customerId,
entities[1].id,
);
expectBalanceCorrect({
customer: firstEntity,
featureId: TestFeature.Credits,
remaining: 220,
usage: 30,
planId: pro.id,
breakdown: {
[ResetInterval.Month]: { included_grant: 200, remaining: 170, usage: 30 },
[ResetInterval.OneOff]: { included_grant: 50, remaining: 50, usage: 0 },
},
});
expectBalanceCorrect({
customer: secondEntity,
featureId: TestFeature.Credits,
remaining: 190,
usage: 60,
planId: pro.id,
breakdown: {
[ResetInterval.Month]: { included_grant: 200, remaining: 140, usage: 60 },
[ResetInterval.OneOff]: { included_grant: 50, remaining: 50, usage: 0 },
},
});
await expectCustomerInvoiceCorrect({
customer: await autumnV1.customers.get<ApiCustomerV3>(customerId),
count: invoiceCountBefore,
});
});

View File

@@ -1,20 +1,4 @@
/**
* TDD coverage for update_items targeting one of several customer entitlements
* for the same feature (monthly + lifetime case).
*
* Contract under test:
* New behaviors:
* - A `PlanItemFilter` that includes `interval` only matches entitlements
* with that interval. Untouched entitlements (different interval) keep
* their balance and reset state exactly as-is.
* - Usage carried via update_items only applies to the entitlement(s) it
* replaced — sibling entitlements for the same feature with usage of
* their own do not get double-deducted.
* - When a single update_items[i].filter matches multiple customer
* entitlements (e.g. feature_id only), all matches are updated.
*/
import { expect, test } from "bun:test";
import { test } from "bun:test";
import {
type ApiCustomerV3,
type ApiCustomerV5,

View File

@@ -8,13 +8,14 @@
* - Existing customer products are patched, not replaced or expired.
*/
import { test } from "bun:test";
import { expect, test } from "bun:test";
import type { ApiCustomerV3, ApiCustomerV5 } from "@autumn/shared";
import { BillingMethod } from "@autumn/shared";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import { expectNoExpiredCustomerProducts } from "@tests/integration/billing/utils/expectNoExpiredCustomerProducts";
import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect";
import { expectStripeSubscriptionUnchanged } from "@tests/integration/billing/utils/stripe/expectStripeSubscriptionUnchanged";
import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect";
import { expectFlagCorrect } from "@tests/integration/utils/expectFlagCorrect";
import { TestFeature } from "@tests/setup/v2Features";
@@ -106,3 +107,64 @@ test.concurrent(`${chalk.yellowBright("migrations update_plan: consumable paid f
await expectNoExpiredCustomerProducts({ ctx, customerId, productId: pro.id });
await expectStripeSubscriptionCorrect({ ctx, customerId });
});
test.concurrent(`${chalk.yellowBright("migrations update_plan: no_billing_changes remove paid feature stays DB-only")}`, async () => {
const customerId = "migration-update-paid-remove-no-billing";
const pro = products.pro({
id: "migration-update-paid-remove-no-billing-plan",
items: [items.consumableMessages({ includedUsage: 100, price: 0.1 })],
});
const { autumnV1, autumnV2_2, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [s.billing.attach({ productId: pro.id })],
});
const customerBefore = await autumnV1.customers.get<ApiCustomerV3>(customerId);
const invoiceCountBefore = customerBefore.invoices?.length ?? 0;
const subsBefore = await ctx.stripeCli.subscriptions.list({
customer: customerBefore.stripe_id as string,
status: "all",
});
const subBefore = subsBefore.data.find(
(sub) => sub.status === "active" || sub.status === "trialing",
);
expect(subBefore).toBeDefined();
await runUpdatePlanMigration({
ctx,
migrationClient: autumnV2_2,
migrationId: `${customerId}-mig`,
customerId,
filter: { customer: { plan: { plan_id: pro.id } } },
operations: {
customer: [
{
type: "update_plan",
plan_filter: { plan_id: pro.id },
customize: {
remove_items: [{ feature_id: TestFeature.Messages }],
},
},
],
},
noBillingChanges: true,
runOnServer: false,
});
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
await expectCustomerProducts({ customer, active: [pro.id] });
expect(customer.balances[TestFeature.Messages]).toBeUndefined();
await expectNoExpiredCustomerProducts({ ctx, customerId, productId: pro.id });
const subAfter = await ctx.stripeCli.subscriptions.retrieve(subBefore!.id);
expectStripeSubscriptionUnchanged({ before: subBefore!, after: subAfter });
await expectCustomerInvoiceCorrect({
customer: await autumnV1.customers.get<ApiCustomerV3>(customerId),
count: invoiceCountBefore,
});
});

View File

@@ -12,6 +12,7 @@ type MigrationClient = {
id: string;
filter?: MigrationFilter | null;
operations?: Operations | null;
no_billing_changes?: boolean;
}) => Promise<Migration>;
run: (params: { id: string; dry_run?: boolean }) => Promise<{
migration_id: string;
@@ -60,6 +61,7 @@ export const runUpdatePlanMigration = async ({
customerId,
filter,
operations,
noBillingChanges,
runOnServer = true,
waitFor,
timeoutMs = 30_000,
@@ -71,6 +73,7 @@ export const runUpdatePlanMigration = async ({
customerId: string;
filter: MigrationFilter;
operations: Operations;
noBillingChanges?: boolean;
runOnServer?: boolean;
waitFor?: () => Promise<unknown>;
timeoutMs?: number;
@@ -80,6 +83,7 @@ export const runUpdatePlanMigration = async ({
id: migrationId,
filter,
operations,
no_billing_changes: noBillingChanges,
});
if (runOnServer) {

View File

@@ -1,7 +1,9 @@
import { InternalError } from "@api/errors/base/InternalError";
import { BillingMethod } from "@api/products/components/billingMethod";
import type { Feature } from "@models/featureModels/featureModels";
import type { EntitlementWithFeature } from "@models/productModels/entModels/entModels";
import type { UsagePriceConfig } from "@models/productModels/priceModels/priceConfig/usagePriceConfig";
import { BillingType } from "@models/productModels/priceModels/priceEnums";
import type { Price } from "@models/productModels/priceModels/priceModels";
import {
OnDecrease,
@@ -13,6 +15,7 @@ import {
shouldProrate,
shouldSkipLineItems,
} from "@utils/billingUtils";
import { getBillingType } from "@utils/productUtils/priceUtils";
import { priceToEnt } from "@utils/productUtils/convertProductUtils";
// Overload: errorOnNotFound = true → guaranteed Feature
@@ -94,3 +97,21 @@ export const priceToProrationConfig = ({
shouldCreateReplaceables: shouldCreateReplaceables(prorationBehaviorConfig),
};
};
export const priceToBillingMethod = ({
price,
}: {
price?: Price;
}): BillingMethod | undefined => {
if (!price) return undefined;
const billingType = getBillingType(price.config);
if (billingType === BillingType.UsageInAdvance) return BillingMethod.Prepaid;
if (
billingType === BillingType.UsageInArrear ||
billingType === BillingType.InArrearProrated
)
return BillingMethod.UsageBased;
return undefined;
};