updated add + delete tests
This commit is contained in:
@@ -21,7 +21,11 @@ export const computePatchCustomerProductPlan = ({
|
||||
throw new Error("Patch context is required to compute patch customer plan");
|
||||
}
|
||||
|
||||
const { finalCustomerProduct, customerProductUpdates } =
|
||||
const {
|
||||
finalCustomerProduct,
|
||||
customerProductUpdates,
|
||||
oneOffPrepaidCarryOverCustomerEntitlements,
|
||||
} =
|
||||
initPatchCustomerProduct({
|
||||
ctx,
|
||||
billingContext: updateSubscriptionContext,
|
||||
@@ -43,6 +47,7 @@ export const computePatchCustomerProductPlan = ({
|
||||
customEntitlements: patchContext.customEntitlements,
|
||||
customFreeTrial: trialContext?.customFreeTrial,
|
||||
lineItems: allLineItems,
|
||||
insertCustomerEntitlements: oneOffPrepaidCarryOverCustomerEntitlements,
|
||||
} satisfies Partial<AutumnBillingPlan>;
|
||||
|
||||
if (patchContext.mode === "new") {
|
||||
|
||||
@@ -18,7 +18,6 @@ import type {
|
||||
import { planItemFilterMatchesCustomerPair } from "@shared/api/products/items/utils/match";
|
||||
import { cusEntToCusPrice } from "@shared/utils/cusEntUtils/convertCusEntUtils/cusEntToCusPrice";
|
||||
import { customerPriceToCustomerEntitlement } from "@shared/utils/cusPriceUtils/convertCustomerPrice/customerPriceToCustomerEntitlement";
|
||||
import { isOneOffPrice } from "@shared/utils/productUtils/priceUtils/classifyPriceUtils";
|
||||
import { StatusCodes } from "http-status-codes";
|
||||
import { generateId } from "@/utils/genUtils";
|
||||
|
||||
@@ -64,13 +63,11 @@ const assertAllowedIntervalUpdate = ({
|
||||
customerPrice?: FullCustomerPrice;
|
||||
overrides: UpdatePlanItemParamsV1;
|
||||
}) => {
|
||||
if (overrides.interval === undefined || overrides.interval === ResetInterval.OneOff)
|
||||
return;
|
||||
if (!customerPrice || !isOneOffPrice(customerPrice.price)) return;
|
||||
if (overrides.interval === undefined || !customerPrice) return;
|
||||
|
||||
throw new RecaseError({
|
||||
message:
|
||||
"update_items cannot change intervals for one-off paid items. Use remove_items and add_items instead.",
|
||||
"update_items cannot change intervals for paid items. Use remove_items and add_items instead.",
|
||||
code: ErrCode.InvalidProductItem,
|
||||
statusCode: StatusCodes.BAD_REQUEST,
|
||||
});
|
||||
|
||||
@@ -1,55 +1,21 @@
|
||||
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";
|
||||
import type { FullCustomerEntitlement } from "@autumn/shared";
|
||||
|
||||
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(":");
|
||||
) => identity.internalFeatureId;
|
||||
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -30,10 +30,8 @@ const addToGroup = <T>(groups: Map<string, T[]>, key: string, value: T) => {
|
||||
};
|
||||
|
||||
const groupCustomerEntitlementsByCarryIdentity = ({
|
||||
customerProduct,
|
||||
customerEntitlements,
|
||||
}: {
|
||||
customerProduct: FullCusProduct;
|
||||
customerEntitlements: FullCustomerEntitlement[];
|
||||
}) => {
|
||||
const customerEntitlementsByKey = new Map<
|
||||
@@ -45,7 +43,6 @@ const groupCustomerEntitlementsByCarryIdentity = ({
|
||||
const key = carryIdentityToKey(
|
||||
customerEntitlementToCarryIdentity({
|
||||
customerEntitlement,
|
||||
customerProduct,
|
||||
}),
|
||||
);
|
||||
addToGroup(customerEntitlementsByKey, key, customerEntitlement);
|
||||
@@ -84,11 +81,9 @@ const getIdentityCustomerProductCarryGroups = ({
|
||||
fromCustomerEntitlements: FullCustomerEntitlement[];
|
||||
}): CustomerProductCarryGroup[] => {
|
||||
const toEntitlementsByKey = groupCustomerEntitlementsByCarryIdentity({
|
||||
customerProduct: toCustomerProduct,
|
||||
customerEntitlements: toCustomerProduct.customer_entitlements,
|
||||
});
|
||||
const fromEntitlementsByKey = groupCustomerEntitlementsByCarryIdentity({
|
||||
customerProduct: fromCustomerProduct,
|
||||
customerEntitlements: fromCustomerEntitlements,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
type AutumnBillingPlan,
|
||||
cusProductToProduct,
|
||||
type InsertCustomerEntitlement,
|
||||
type PatchContext,
|
||||
type TrialContext,
|
||||
type UpdateSubscriptionBillingContext,
|
||||
@@ -68,8 +69,14 @@ export const initPatchCustomerProduct = ({
|
||||
}): {
|
||||
finalCustomerProduct: PatchContext["finalCustomerProduct"];
|
||||
customerProductUpdates: CustomerProductUpdates;
|
||||
oneOffPrepaidCarryOverCustomerEntitlements: InsertCustomerEntitlement[];
|
||||
} => {
|
||||
const { customerPrices, customerEntitlements } =
|
||||
const {
|
||||
customerPrices,
|
||||
customerEntitlements,
|
||||
oneOffPrepaidCarryOverEntitlements,
|
||||
oneOffPrepaidCarryOverCustomerEntitlements,
|
||||
} =
|
||||
initPatchedCustomerEntitlementsAndPrices({
|
||||
ctx,
|
||||
billingContext,
|
||||
@@ -95,6 +102,7 @@ export const initPatchCustomerProduct = ({
|
||||
});
|
||||
patchContext.insertCustomerPrices = customerPrices;
|
||||
patchContext.insertCustomerEntitlements = customerEntitlements;
|
||||
patchContext.customEntitlements.push(...oneOffPrepaidCarryOverEntitlements);
|
||||
patchContext.fullProduct = cusProductToProduct({
|
||||
cusProduct: patchContext.finalCustomerProduct,
|
||||
});
|
||||
@@ -116,5 +124,6 @@ export const initPatchCustomerProduct = ({
|
||||
...trialUpdates,
|
||||
...customUpdates,
|
||||
},
|
||||
oneOffPrepaidCarryOverCustomerEntitlements,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type {
|
||||
Entitlement,
|
||||
FullCustomerEntitlement,
|
||||
FullCustomerPrice,
|
||||
InsertCustomerEntitlement,
|
||||
PatchContext,
|
||||
UpdateSubscriptionBillingContext,
|
||||
} from "@autumn/shared";
|
||||
@@ -33,6 +35,8 @@ export const initPatchedCustomerEntitlementsAndPrices = ({
|
||||
}): {
|
||||
customerPrices: FullCustomerPrice[];
|
||||
customerEntitlements: FullCustomerEntitlement[];
|
||||
oneOffPrepaidCarryOverEntitlements: Entitlement[];
|
||||
oneOffPrepaidCarryOverCustomerEntitlements: InsertCustomerEntitlement[];
|
||||
} => {
|
||||
const {
|
||||
fullCustomer,
|
||||
@@ -115,6 +119,9 @@ export const initPatchedCustomerEntitlementsAndPrices = ({
|
||||
return { fromCustomerEntitlement, toCustomerEntitlement };
|
||||
}),
|
||||
});
|
||||
const oneOffPrepaidCarryOverEntitlements: Entitlement[] = [];
|
||||
const oneOffPrepaidCarryOverCustomerEntitlements: InsertCustomerEntitlement[] =
|
||||
[];
|
||||
|
||||
for (const carryGroup of carryGroups) {
|
||||
applyExistingStatesToCustomerProduct({
|
||||
@@ -132,15 +139,23 @@ export const initPatchedCustomerEntitlementsAndPrices = ({
|
||||
},
|
||||
});
|
||||
|
||||
applyOneOffPrepaidCarryOvers({
|
||||
const oneOffPrepaidCarryOvers = applyOneOffPrepaidCarryOvers({
|
||||
oldCustomerProduct: carryGroup.fromCustomerProduct,
|
||||
newCustomerProduct: carryGroup.toCustomerProduct,
|
||||
fullCustomer,
|
||||
});
|
||||
oneOffPrepaidCarryOverEntitlements.push(
|
||||
...oneOffPrepaidCarryOvers.entitlements,
|
||||
);
|
||||
oneOffPrepaidCarryOverCustomerEntitlements.push(
|
||||
...oneOffPrepaidCarryOvers.customerEntitlements,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
customerPrices: customerProductWithNewItemsOnly.customer_prices,
|
||||
customerEntitlements: customerProductWithNewItemsOnly.customer_entitlements,
|
||||
oneOffPrepaidCarryOverEntitlements,
|
||||
oneOffPrepaidCarryOverCustomerEntitlements,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* Contract: delete/add patch migrations carry same-feature usage, one-off prepaid balance, and reset anchors.
|
||||
* These scenarios intentionally avoid update_items; item changes are remove_items + add_items.
|
||||
*/
|
||||
|
||||
import { expect, test } from "bun:test";
|
||||
import type { ApiCustomerV3, ApiCustomerV5 } from "@autumn/shared";
|
||||
import {
|
||||
BillingMethod,
|
||||
ResetInterval,
|
||||
} from "@autumn/shared";
|
||||
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
|
||||
import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect";
|
||||
import { getBalanceBucket } from "@tests/integration/utils/getBalanceBucket";
|
||||
import { TestFeature } from "@tests/setup/v2Features";
|
||||
import { items } from "@tests/utils/fixtures/items";
|
||||
import { itemsV2 } from "@tests/utils/fixtures/itemsV2";
|
||||
import { products } from "@tests/utils/fixtures/products";
|
||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
|
||||
import chalk from "chalk";
|
||||
import { runUpdatePlanMigration } from "../../utils/runUpdatePlanMigration";
|
||||
|
||||
const TEN_MINUTES_MS = 10 * 60 * 1000;
|
||||
|
||||
const expectCloseToMs = ({
|
||||
actual,
|
||||
expected,
|
||||
}: {
|
||||
actual?: number | null;
|
||||
expected: number;
|
||||
}) => {
|
||||
expect(actual).not.toBeNull();
|
||||
expect(Math.abs((actual ?? 0) - expected)).toBeLessThanOrEqual(
|
||||
TEN_MINUTES_MS,
|
||||
);
|
||||
};
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("migrations complex delete/add: lifetime item to monthly carries usage onto subscription reset")}`, async () => {
|
||||
const customerId = "migration-complex-lifetime-to-monthly";
|
||||
const pro = products.pro({
|
||||
id: "migration-complex-lifetime-to-monthly-plan",
|
||||
items: [items.lifetimeMessages({ includedUsage: 100 })],
|
||||
});
|
||||
|
||||
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.advanceTestClock({ days: 10 }),
|
||||
s.track({ featureId: TestFeature.Messages, value: 40, timeout: 2000 }),
|
||||
],
|
||||
});
|
||||
const before = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
|
||||
const currentPeriodEnd = before.subscriptions.find(
|
||||
(subscription) => subscription.plan_id === pro.id,
|
||||
)?.current_period_end;
|
||||
expect(currentPeriodEnd).not.toBeNull();
|
||||
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: {
|
||||
remove_items: [{ feature_id: TestFeature.Messages }],
|
||||
add_items: [itemsV2.monthlyMessages({ included: 150 })],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
runOnServer: false,
|
||||
noBillingChanges: true,
|
||||
});
|
||||
|
||||
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
|
||||
expectBalanceCorrect({
|
||||
customer,
|
||||
featureId: TestFeature.Messages,
|
||||
remaining: 110,
|
||||
usage: 40,
|
||||
nextResetAt: currentPeriodEnd!,
|
||||
planId: pro.id,
|
||||
breakdown: {
|
||||
[ResetInterval.Month]: {
|
||||
included_grant: 150,
|
||||
remaining: 110,
|
||||
usage: 40,
|
||||
},
|
||||
},
|
||||
});
|
||||
const monthlyBucket = getBalanceBucket({
|
||||
subject: customer,
|
||||
featureId: TestFeature.Messages,
|
||||
resetInterval: ResetInterval.Month,
|
||||
});
|
||||
expectCloseToMs({
|
||||
actual: monthlyBucket.reset?.resets_at,
|
||||
expected: currentPeriodEnd!,
|
||||
});
|
||||
await expectCustomerInvoiceCorrect({
|
||||
customer: await autumnV1.customers.get<ApiCustomerV3>(customerId),
|
||||
count: invoiceCountBefore,
|
||||
});
|
||||
});
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("migrations complex delete/add: monthly plus one-off prepaid to monthly carries usage and lifetime balance")}`, async () => {
|
||||
const customerId = "migration-complex-monthly-oneoff-to-monthly";
|
||||
const pro = products.pro({
|
||||
id: "migration-complex-monthly-oneoff-to-monthly-plan",
|
||||
items: [
|
||||
items.monthlyMessages({ includedUsage: 100 }),
|
||||
items.oneOffMessages({ includedUsage: 0, billingUnits: 100, price: 10 }),
|
||||
],
|
||||
});
|
||||
|
||||
const { autumnV1, autumnV2_2, ctx } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro] }),
|
||||
],
|
||||
actions: [
|
||||
s.attach({
|
||||
productId: pro.id,
|
||||
options: [{ feature_id: TestFeature.Messages, quantity: 200 }],
|
||||
}),
|
||||
s.track({ featureId: TestFeature.Messages, value: 150, timeout: 2000 }),
|
||||
],
|
||||
});
|
||||
const before = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
|
||||
const currentPeriodEnd = before.subscriptions.find(
|
||||
(subscription) => subscription.plan_id === pro.id,
|
||||
)?.current_period_end;
|
||||
expect(currentPeriodEnd).not.toBeNull();
|
||||
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: {
|
||||
remove_items: [{ feature_id: TestFeature.Messages }],
|
||||
add_items: [itemsV2.monthlyMessages({ included: 300 })],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
runOnServer: false,
|
||||
noBillingChanges: true,
|
||||
});
|
||||
|
||||
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
|
||||
expectBalanceCorrect({
|
||||
customer,
|
||||
featureId: TestFeature.Messages,
|
||||
remaining: 350,
|
||||
usage: 100,
|
||||
nextResetAt: currentPeriodEnd!,
|
||||
planId: pro.id,
|
||||
breakdown: {
|
||||
[ResetInterval.Month]: {
|
||||
included_grant: 300,
|
||||
remaining: 200,
|
||||
usage: 100,
|
||||
},
|
||||
[ResetInterval.OneOff]: {
|
||||
included_grant: 150,
|
||||
prepaid_grant: 0,
|
||||
remaining: 150,
|
||||
usage: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
const monthlyBucket = getBalanceBucket({
|
||||
subject: customer,
|
||||
featureId: TestFeature.Messages,
|
||||
resetInterval: ResetInterval.Month,
|
||||
});
|
||||
expectCloseToMs({
|
||||
actual: monthlyBucket.reset?.resets_at,
|
||||
expected: currentPeriodEnd!,
|
||||
});
|
||||
await expectCustomerInvoiceCorrect({
|
||||
customer: await autumnV1.customers.get<ApiCustomerV3>(customerId),
|
||||
count: invoiceCountBefore,
|
||||
});
|
||||
});
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("migrations complex delete/add: monthly included increase plus one-off price change preserves both buckets")}`, async () => {
|
||||
const customerId = "migration-complex-monthly-oneoff-price-change";
|
||||
const pro = products.pro({
|
||||
id: "migration-complex-monthly-oneoff-price-change-plan",
|
||||
items: [
|
||||
items.monthlyMessages({ includedUsage: 100 }),
|
||||
items.oneOffMessages({ includedUsage: 0, billingUnits: 100, price: 10 }),
|
||||
],
|
||||
});
|
||||
|
||||
const { autumnV1, autumnV2_2, ctx } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro] }),
|
||||
],
|
||||
actions: [
|
||||
s.attach({
|
||||
productId: pro.id,
|
||||
options: [{ feature_id: TestFeature.Messages, quantity: 200 }],
|
||||
}),
|
||||
s.track({ featureId: TestFeature.Messages, value: 150, timeout: 2000 }),
|
||||
],
|
||||
});
|
||||
const before = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
|
||||
const currentPeriodEnd = before.subscriptions.find(
|
||||
(subscription) => subscription.plan_id === pro.id,
|
||||
)?.current_period_end;
|
||||
expect(currentPeriodEnd).not.toBeNull();
|
||||
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: {
|
||||
remove_items: [{ feature_id: TestFeature.Messages }],
|
||||
add_items: [
|
||||
itemsV2.monthlyMessages({ included: 300 }),
|
||||
itemsV2.oneOffPrepaidMessages({
|
||||
amount: 15,
|
||||
billingUnits: 100,
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
runOnServer: false,
|
||||
noBillingChanges: true,
|
||||
});
|
||||
|
||||
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
|
||||
expectBalanceCorrect({
|
||||
customer,
|
||||
featureId: TestFeature.Messages,
|
||||
remaining: 350,
|
||||
usage: 100,
|
||||
nextResetAt: currentPeriodEnd!,
|
||||
planId: pro.id,
|
||||
breakdown: {
|
||||
[ResetInterval.Month]: {
|
||||
included_grant: 300,
|
||||
remaining: 200,
|
||||
usage: 100,
|
||||
},
|
||||
[BillingMethod.Prepaid]: {
|
||||
included_grant: 150,
|
||||
prepaid_grant: 0,
|
||||
remaining: 150,
|
||||
usage: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
const monthlyBucket = getBalanceBucket({
|
||||
subject: customer,
|
||||
featureId: TestFeature.Messages,
|
||||
resetInterval: ResetInterval.Month,
|
||||
});
|
||||
const prepaidBucket = getBalanceBucket({
|
||||
subject: customer,
|
||||
featureId: TestFeature.Messages,
|
||||
billingMethod: BillingMethod.Prepaid,
|
||||
});
|
||||
expectCloseToMs({
|
||||
actual: monthlyBucket.reset?.resets_at,
|
||||
expected: currentPeriodEnd!,
|
||||
});
|
||||
expect(prepaidBucket.reset?.interval).toBe(ResetInterval.OneOff);
|
||||
expect(prepaidBucket.price?.amount).toBe(15);
|
||||
await expectCustomerInvoiceCorrect({
|
||||
customer: await autumnV1.customers.get<ApiCustomerV3>(customerId),
|
||||
count: invoiceCountBefore,
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from "@autumn/shared";
|
||||
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";
|
||||
@@ -83,6 +84,77 @@ test.concurrent(`${chalk.yellowBright("migrations update_items interval: subscri
|
||||
});
|
||||
});
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("migrations update_items interval: monthly paid item interval changes are rejected")}`, async () => {
|
||||
const customerId = "migration-update-items-monthly-paid-rejected";
|
||||
const base = products.base({
|
||||
id: "migration-update-items-monthly-paid-rejected-plan",
|
||||
items: [
|
||||
items.prepaid({
|
||||
featureId: TestFeature.Credits,
|
||||
includedUsage: 100,
|
||||
billingUnits: 100,
|
||||
price: 10,
|
||||
}),
|
||||
items.consumableMessages({ includedUsage: 50, price: 0.1 }),
|
||||
],
|
||||
});
|
||||
|
||||
const { autumnV2_2, ctx } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [base] }),
|
||||
],
|
||||
actions: [s.billing.attach({ productId: base.id })],
|
||||
});
|
||||
|
||||
const cases = [
|
||||
{
|
||||
name: "prepaid",
|
||||
filter: {
|
||||
feature_id: TestFeature.Credits,
|
||||
billing_method: BillingMethod.Prepaid,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "usage-based",
|
||||
filter: {
|
||||
feature_id: TestFeature.Messages,
|
||||
billing_method: BillingMethod.UsageBased,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
for (const testCase of cases) {
|
||||
await expect(
|
||||
runUpdatePlanMigration({
|
||||
ctx,
|
||||
migrationClient: autumnV2_2,
|
||||
migrationId: `${customerId}-${testCase.name}-mig`,
|
||||
customerId,
|
||||
filter: { customer: { plan: { plan_id: base.id } } },
|
||||
operations: {
|
||||
customer: [
|
||||
{
|
||||
type: "update_plan",
|
||||
plan_filter: { plan_id: base.id },
|
||||
customize: {
|
||||
update_items: [
|
||||
{
|
||||
filter: testCase.filter,
|
||||
interval: ResetInterval.OneOff,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
runOnServer: false,
|
||||
}),
|
||||
).rejects.toThrow(/paid items/i);
|
||||
}
|
||||
});
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("migrations update_items interval: one-off prepaid interval changes are rejected")}`, async () => {
|
||||
const customerId = "migration-update-items-one-off-prepaid-rejected";
|
||||
const oneOffPrepaid = constructPrepaidItem({
|
||||
@@ -134,5 +206,5 @@ test.concurrent(`${chalk.yellowBright("migrations update_items interval: one-off
|
||||
},
|
||||
runOnServer: false,
|
||||
}),
|
||||
).rejects.toThrow(/one-off paid/i);
|
||||
).rejects.toThrow(/paid items/i);
|
||||
});
|
||||
|
||||
@@ -19,12 +19,13 @@ export const UpdatePlanItemParamsV1Schema = z
|
||||
description:
|
||||
"Override the matched item's reset interval. Use 'one_off' for non-resetting balances.",
|
||||
}),
|
||||
})
|
||||
.meta({
|
||||
title: "UpdatePlanItem",
|
||||
description:
|
||||
"Patch an existing plan item in place. Supports included and interval changes.",
|
||||
});
|
||||
})
|
||||
.meta({
|
||||
title: "UpdatePlanItem",
|
||||
description:
|
||||
"Deprecated. Use remove_items and add_items to replace plan items.",
|
||||
deprecated: true,
|
||||
});
|
||||
|
||||
export type UpdatePlanItemParamsV1 = z.infer<typeof UpdatePlanItemParamsV1Schema>;
|
||||
|
||||
@@ -34,21 +35,22 @@ export const CustomizePlanV1Schema = z
|
||||
description:
|
||||
"Override the base price of the plan. Pass null to remove the base price.",
|
||||
}),
|
||||
items: z.array(CreatePlanItemParamsV1Schema).optional().meta({
|
||||
description:
|
||||
"Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items / update_items.",
|
||||
}),
|
||||
items: z.array(CreatePlanItemParamsV1Schema).optional().meta({
|
||||
description:
|
||||
"Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items / deprecated update_items.",
|
||||
}),
|
||||
add_items: z.array(CreatePlanItemParamsV1Schema).optional().meta({
|
||||
description: "Items to add to the plan.",
|
||||
}),
|
||||
remove_items: z.array(PlanItemFilterSchema).optional().meta({
|
||||
description: "Filters selecting items to remove from the plan.",
|
||||
}),
|
||||
update_items: z.array(UpdatePlanItemParamsV1Schema).optional().meta({
|
||||
description:
|
||||
"Patch existing matched plan items. Runs before add_items, after remove_items.",
|
||||
internal: true,
|
||||
}),
|
||||
update_items: z.array(UpdatePlanItemParamsV1Schema).optional().meta({
|
||||
description:
|
||||
"Deprecated. Use remove_items and add_items to replace matched plan items.",
|
||||
internal: true,
|
||||
deprecated: true,
|
||||
}),
|
||||
free_trial: FreeTrialParamsV1Schema.nullable().optional().meta({
|
||||
description:
|
||||
"Override the plan's default free trial. Pass an object to set a custom trial, or null to remove the trial entirely.",
|
||||
@@ -62,10 +64,10 @@ export const CustomizePlanV1Schema = z
|
||||
data.add_items !== undefined ||
|
||||
data.remove_items !== undefined ||
|
||||
data.update_items !== undefined,
|
||||
{
|
||||
message:
|
||||
"When using customize, at least one of price, items, add_items, remove_items, update_items, or free_trial must be provided",
|
||||
},
|
||||
{
|
||||
message:
|
||||
"When using customize, at least one of price, items, add_items, remove_items, deprecated update_items, or free_trial must be provided",
|
||||
},
|
||||
)
|
||||
.refine(
|
||||
(data) =>
|
||||
@@ -75,10 +77,10 @@ export const CustomizePlanV1Schema = z
|
||||
data.remove_items !== undefined ||
|
||||
data.update_items !== undefined)
|
||||
),
|
||||
{
|
||||
message:
|
||||
"customize.items (PUT-style) cannot be combined with add_items / remove_items / update_items (PATCH-style); pick one approach",
|
||||
},
|
||||
{
|
||||
message:
|
||||
"customize.items (PUT-style) cannot be combined with add_items / remove_items / deprecated update_items (PATCH-style); pick one approach",
|
||||
},
|
||||
)
|
||||
.meta({
|
||||
title: "CustomizePlan",
|
||||
|
||||
@@ -7,10 +7,14 @@ import { PlanFilterSchema } from "../../../filters/planFilter.js";
|
||||
|
||||
export const MigrationUpdatePlanCustomizeSchema = z
|
||||
.object({
|
||||
price: BasePriceParamsSchema.nullable().optional(),
|
||||
add_items: z.array(CreatePlanItemParamsV1Schema).optional(),
|
||||
remove_items: z.array(PlanItemFilterSchema).optional(),
|
||||
update_items: z.array(UpdatePlanItemParamsV1Schema).optional(),
|
||||
price: BasePriceParamsSchema.nullable().optional(),
|
||||
add_items: z.array(CreatePlanItemParamsV1Schema).optional(),
|
||||
remove_items: z.array(PlanItemFilterSchema).optional(),
|
||||
update_items: z.array(UpdatePlanItemParamsV1Schema).optional().meta({
|
||||
description:
|
||||
"Deprecated. Use remove_items and add_items to replace matched plan items.",
|
||||
deprecated: true,
|
||||
}),
|
||||
})
|
||||
.refine(
|
||||
(data) =>
|
||||
@@ -18,11 +22,11 @@ export const MigrationUpdatePlanCustomizeSchema = z
|
||||
data.add_items !== undefined ||
|
||||
data.remove_items !== undefined ||
|
||||
data.update_items !== undefined,
|
||||
{
|
||||
message:
|
||||
"update_plan.customize requires at least one of price, add_items, remove_items, or update_items",
|
||||
},
|
||||
);
|
||||
{
|
||||
message:
|
||||
"update_plan.customize requires at least one of price, add_items, remove_items, or deprecated update_items",
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Ordered customer operation: update every customer product matched by
|
||||
|
||||
Reference in New Issue
Block a user