added update items to update plan op, starting with included
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
import type {
|
||||
CustomizePlanV1,
|
||||
Entitlement,
|
||||
Feature,
|
||||
FullCusEntWithFullCusProduct,
|
||||
FullCusProduct,
|
||||
FullCustomerEntitlement,
|
||||
FullCustomerPrice,
|
||||
Price,
|
||||
UpdatePlanItemParamsV1,
|
||||
} from "@autumn/shared";
|
||||
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 { generateId } from "@/utils/genUtils";
|
||||
|
||||
type CustomerProductItemPair = {
|
||||
customerPrice?: FullCustomerPrice;
|
||||
customerEntitlement?: FullCustomerEntitlement;
|
||||
};
|
||||
|
||||
const getCustomerProductItemPairs = ({
|
||||
targetCustomerProduct,
|
||||
}: {
|
||||
targetCustomerProduct: FullCusProduct;
|
||||
}): CustomerProductItemPair[] => {
|
||||
const pairs: CustomerProductItemPair[] =
|
||||
targetCustomerProduct.customer_prices.map((customerPrice) => ({
|
||||
customerPrice,
|
||||
customerEntitlement: customerPriceToCustomerEntitlement({
|
||||
customerPrice,
|
||||
customerEntitlements: targetCustomerProduct.customer_entitlements,
|
||||
}),
|
||||
}));
|
||||
|
||||
for (const customerEntitlement of targetCustomerProduct.customer_entitlements) {
|
||||
const customerPrice = cusEntToCusPrice({
|
||||
cusEnt: {
|
||||
...customerEntitlement,
|
||||
customer_product: targetCustomerProduct,
|
||||
} satisfies FullCusEntWithFullCusProduct,
|
||||
});
|
||||
|
||||
if (!customerPrice) {
|
||||
pairs.push({ customerEntitlement });
|
||||
}
|
||||
}
|
||||
|
||||
return pairs;
|
||||
};
|
||||
|
||||
const applyOverridesToEntitlement = ({
|
||||
source,
|
||||
overrides,
|
||||
}: {
|
||||
source: Entitlement;
|
||||
overrides: UpdatePlanItemParamsV1;
|
||||
}): Entitlement => ({
|
||||
...source,
|
||||
id: generateId("ent"),
|
||||
is_custom: true,
|
||||
created_at: Date.now(),
|
||||
allowance:
|
||||
overrides.included !== undefined ? overrides.included : source.allowance,
|
||||
});
|
||||
|
||||
const applyOverridesToPrice = ({
|
||||
source,
|
||||
newEntitlementId,
|
||||
}: {
|
||||
source: Price;
|
||||
newEntitlementId: string;
|
||||
}): Price => ({
|
||||
...source,
|
||||
id: generateId("pr"),
|
||||
is_custom: true,
|
||||
created_at: Date.now(),
|
||||
entitlement_id: newEntitlementId,
|
||||
});
|
||||
|
||||
/**
|
||||
* Patch existing items in place. For each `update_items[i]`, find matching
|
||||
* customer-entitlement / customer-price pairs on the target customer product,
|
||||
* clone the underlying entitlement (and price, if any) with the overrides
|
||||
* applied, and emit them as delete + add buckets. Existing usage and rollovers
|
||||
* carry forward via the shared patch carry plumbing.
|
||||
*/
|
||||
export const handleCustomizeUpdateItems = ({
|
||||
customize,
|
||||
targetCustomerProduct,
|
||||
features: _features,
|
||||
}: {
|
||||
customize: CustomizePlanV1;
|
||||
targetCustomerProduct: FullCusProduct;
|
||||
features: Feature[];
|
||||
}): {
|
||||
customerPrices: FullCustomerPrice[];
|
||||
customerEntitlements: FullCustomerEntitlement[];
|
||||
prices: Price[];
|
||||
entitlements: Entitlement[];
|
||||
} => {
|
||||
const updateItems = customize.update_items ?? [];
|
||||
if (updateItems.length === 0) {
|
||||
return {
|
||||
customerPrices: [],
|
||||
customerEntitlements: [],
|
||||
prices: [],
|
||||
entitlements: [],
|
||||
};
|
||||
}
|
||||
|
||||
const deleteCustomerPriceIds = new Set<string>();
|
||||
const deleteCustomerEntitlementIds = new Set<string>();
|
||||
const deleteCustomerPrices: FullCustomerPrice[] = [];
|
||||
const deleteCustomerEntitlements: FullCustomerEntitlement[] = [];
|
||||
const newPrices: Price[] = [];
|
||||
const newEntitlements: Entitlement[] = [];
|
||||
|
||||
const pairs = getCustomerProductItemPairs({ targetCustomerProduct });
|
||||
|
||||
for (const update of updateItems) {
|
||||
const matchedPairs = pairs.filter((pair) =>
|
||||
planItemFilterMatchesCustomerPair({
|
||||
filter: update.filter,
|
||||
customerPrice: pair.customerPrice,
|
||||
customerEntitlement: pair.customerEntitlement,
|
||||
}),
|
||||
);
|
||||
|
||||
for (const pair of matchedPairs) {
|
||||
if (!pair.customerEntitlement) continue;
|
||||
if (deleteCustomerEntitlementIds.has(pair.customerEntitlement.id))
|
||||
continue;
|
||||
|
||||
const newEntitlement = applyOverridesToEntitlement({
|
||||
source: pair.customerEntitlement.entitlement,
|
||||
overrides: update,
|
||||
});
|
||||
newEntitlements.push(newEntitlement);
|
||||
deleteCustomerEntitlements.push(pair.customerEntitlement);
|
||||
deleteCustomerEntitlementIds.add(pair.customerEntitlement.id);
|
||||
|
||||
if (pair.customerPrice && !deleteCustomerPriceIds.has(pair.customerPrice.id)) {
|
||||
const newPrice = applyOverridesToPrice({
|
||||
source: pair.customerPrice.price,
|
||||
newEntitlementId: newEntitlement.id,
|
||||
});
|
||||
newPrices.push(newPrice);
|
||||
deleteCustomerPrices.push(pair.customerPrice);
|
||||
deleteCustomerPriceIds.add(pair.customerPrice.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also remove any sibling customer_prices whose price is linked to an
|
||||
// entitlement being deleted — keeps `targetCustomerProduct` consistent.
|
||||
for (const customerPrice of targetCustomerProduct.customer_prices) {
|
||||
if (deleteCustomerPriceIds.has(customerPrice.id)) continue;
|
||||
const entitlementId = customerPrice.price.entitlement_id;
|
||||
if (!entitlementId) continue;
|
||||
const matchesDeletedEnt = deleteCustomerEntitlements.some(
|
||||
(deleted) => deleted.entitlement.id === entitlementId,
|
||||
);
|
||||
if (matchesDeletedEnt) {
|
||||
deleteCustomerPrices.push(customerPrice);
|
||||
deleteCustomerPriceIds.add(customerPrice.id);
|
||||
}
|
||||
}
|
||||
|
||||
targetCustomerProduct.customer_entitlements =
|
||||
targetCustomerProduct.customer_entitlements.filter(
|
||||
(customerEntitlement) =>
|
||||
!deleteCustomerEntitlementIds.has(customerEntitlement.id),
|
||||
);
|
||||
targetCustomerProduct.customer_prices =
|
||||
targetCustomerProduct.customer_prices.filter(
|
||||
(customerPrice) => !deleteCustomerPriceIds.has(customerPrice.id),
|
||||
);
|
||||
|
||||
return {
|
||||
customerPrices: deleteCustomerPrices,
|
||||
customerEntitlements: deleteCustomerEntitlements,
|
||||
prices: newPrices,
|
||||
entitlements: newEntitlements,
|
||||
};
|
||||
};
|
||||
@@ -14,6 +14,7 @@ import { handleCustomizeAddItems } from "./handleCustomizeAddItems";
|
||||
import { handleCustomizeDeleteItems } from "./handleCustomizeDeleteItems";
|
||||
import { handleCustomizeNoopItems } from "./handleCustomizeNoopItems";
|
||||
import { handleCustomizePrice } from "./handleCustomizePrice";
|
||||
import { handleCustomizeUpdateItems } from "./handleCustomizeUpdateItems";
|
||||
import type { ReusePricesAndEntitlements } from "./types";
|
||||
|
||||
const applyProductDefinitionToCustomerProduct = ({
|
||||
@@ -123,10 +124,33 @@ export const setupPatchContext = ({
|
||||
targetCustomerProduct: finalCustomerProduct,
|
||||
});
|
||||
|
||||
const {
|
||||
customerPrices: updateDeleteCustomerPrices,
|
||||
customerEntitlements: updateDeleteCustomerEntitlements,
|
||||
prices: updateNewPrices,
|
||||
entitlements: updateNewEntitlements,
|
||||
} = handleCustomizeUpdateItems({
|
||||
customize: params.customize ?? {},
|
||||
targetCustomerProduct: finalCustomerProduct,
|
||||
features: ctx.features,
|
||||
});
|
||||
|
||||
const patchFullProduct = cusProductToProduct({
|
||||
cusProduct: finalCustomerProduct,
|
||||
});
|
||||
|
||||
// Surface update_items' new entitlements / prices on the patched product
|
||||
// snapshot so downstream consumers (initPatchedCustomerEntitlementsAndPrices,
|
||||
// add_items noop check) see the updated shape.
|
||||
for (const newEnt of updateNewEntitlements) {
|
||||
const feature = ctx.features.find(
|
||||
(candidate) => candidate.internal_id === newEnt.internal_feature_id,
|
||||
);
|
||||
if (!feature) continue;
|
||||
patchFullProduct.entitlements.push({ ...newEnt, feature });
|
||||
}
|
||||
patchFullProduct.prices.push(...updateNewPrices);
|
||||
|
||||
const {
|
||||
customerPrices: deletePriceCustomerPrices,
|
||||
prices: customPricePrices,
|
||||
@@ -161,11 +185,19 @@ export const setupPatchContext = ({
|
||||
insertCustomerEntitlements: [],
|
||||
deleteCustomerPrices: uniqueCustomerPrices([
|
||||
...deleteCustomerPrices,
|
||||
...updateDeleteCustomerPrices,
|
||||
...deletePriceCustomerPrices,
|
||||
]),
|
||||
deleteCustomerEntitlements,
|
||||
customPrices: [...customPricePrices, ...customItemPrices],
|
||||
customEntitlements,
|
||||
deleteCustomerEntitlements: [
|
||||
...deleteCustomerEntitlements,
|
||||
...updateDeleteCustomerEntitlements,
|
||||
],
|
||||
customPrices: [
|
||||
...customPricePrices,
|
||||
...updateNewPrices,
|
||||
...customItemPrices,
|
||||
],
|
||||
customEntitlements: [...updateNewEntitlements, ...customEntitlements],
|
||||
};
|
||||
|
||||
return patchContext;
|
||||
|
||||
@@ -64,7 +64,9 @@ export const setupUpdatePlanProductContext = async ({
|
||||
preparedOp.version === undefined &&
|
||||
customize.price === undefined &&
|
||||
customize.add_items?.length === 0 &&
|
||||
customize.remove_items === undefined
|
||||
customize.remove_items === undefined &&
|
||||
(customize.update_items === undefined ||
|
||||
customize.update_items.length === 0)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* TDD coverage for update_plan.customize.update_items — basic flows.
|
||||
*
|
||||
* Contract under test:
|
||||
* New types/fields:
|
||||
* - customize.update_items: Array<{ filter: PlanItemFilter; included?: number }>
|
||||
* New behaviors:
|
||||
* - Bumping `included` for a matched item updates the entitlement's
|
||||
* allowance and balance, while carrying existing usage forward.
|
||||
* - Lowering `included` below current usage drops the balance accordingly
|
||||
* (no overage created beyond the carried usage).
|
||||
* - `next_reset_at` is preserved across update_items (no cycle shift).
|
||||
* Side effects:
|
||||
* - The customer product is marked is_custom = true after the migration.
|
||||
* - No Stripe invoice is generated.
|
||||
*/
|
||||
|
||||
import { expect, test } from "bun:test";
|
||||
import type { ApiCustomerV3, ApiCustomerV5 } 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 { CusService } from "@/internal/customers/CusService";
|
||||
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService";
|
||||
import { runUpdatePlanMigration } from "../../utils/runUpdatePlanMigration";
|
||||
|
||||
const expectActiveProductIsCustom = async ({
|
||||
ctx,
|
||||
customerId,
|
||||
productId,
|
||||
isCustom,
|
||||
}: {
|
||||
ctx: Awaited<ReturnType<typeof initScenario>>["ctx"];
|
||||
customerId: string;
|
||||
productId: string;
|
||||
isCustom: boolean;
|
||||
}) => {
|
||||
const customer = await CusService.get({
|
||||
db: ctx.db,
|
||||
idOrInternalId: customerId,
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
});
|
||||
if (!customer) throw new Error(`Customer ${customerId} not found`);
|
||||
const cusProducts = await CusProductService.list({
|
||||
db: ctx.db,
|
||||
internalCustomerId: customer.internal_id,
|
||||
});
|
||||
const cusProduct = cusProducts.find(
|
||||
(candidate) => candidate.product.id === productId,
|
||||
);
|
||||
expect(
|
||||
cusProduct,
|
||||
`active cusProduct ${productId} not found for customer ${customerId}`,
|
||||
).toBeDefined();
|
||||
expect(cusProduct?.is_custom).toBe(isCustom);
|
||||
};
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("migrations update_items: bump included carries usage forward")}`, async () => {
|
||||
const customerId = "migration-update-items-basic-bump";
|
||||
const base = products.base({
|
||||
id: "migration-update-items-basic-bump-plan",
|
||||
items: [items.monthlyMessages({ includedUsage: 100 })],
|
||||
});
|
||||
|
||||
const { autumnV1, autumnV2_2, ctx } = await initScenario({
|
||||
customerId,
|
||||
setup: [s.customer(), s.products({ list: [base] })],
|
||||
actions: [
|
||||
s.billing.attach({ productId: base.id }),
|
||||
s.track({ featureId: TestFeature.Messages, value: 30, timeout: 2000 }),
|
||||
],
|
||||
});
|
||||
|
||||
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.Messages }, included: 200 },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
|
||||
await expectCustomerProducts({ customer, active: [base.id] });
|
||||
expectBalanceCorrect({
|
||||
customer,
|
||||
featureId: TestFeature.Messages,
|
||||
remaining: 170,
|
||||
usage: 30,
|
||||
planId: base.id,
|
||||
});
|
||||
|
||||
await expectActiveProductIsCustom({
|
||||
ctx,
|
||||
customerId,
|
||||
productId: base.id,
|
||||
isCustom: true,
|
||||
});
|
||||
|
||||
await expectCustomerInvoiceCorrect({
|
||||
customer: await autumnV1.customers.get<ApiCustomerV3>(customerId),
|
||||
count: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("migrations update_items: lower included below current usage clamps balance to zero")}`, async () => {
|
||||
const customerId = "migration-update-items-basic-lower";
|
||||
const base = products.base({
|
||||
id: "migration-update-items-basic-lower-plan",
|
||||
items: [items.monthlyMessages({ includedUsage: 100 })],
|
||||
});
|
||||
|
||||
const { autumnV2_2, ctx } = await initScenario({
|
||||
customerId,
|
||||
setup: [s.customer(), s.products({ list: [base] })],
|
||||
actions: [
|
||||
s.billing.attach({ productId: base.id }),
|
||||
s.track({ featureId: TestFeature.Messages, value: 30, timeout: 2000 }),
|
||||
],
|
||||
});
|
||||
|
||||
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.Messages }, included: 50 },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
|
||||
expectBalanceCorrect({
|
||||
customer,
|
||||
featureId: TestFeature.Messages,
|
||||
remaining: 20,
|
||||
usage: 30,
|
||||
planId: base.id,
|
||||
});
|
||||
});
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("migrations update_items: preserves next_reset_at on the updated entitlement")}`, async () => {
|
||||
const customerId = "migration-update-items-reset-preserved";
|
||||
const base = products.base({
|
||||
id: "migration-update-items-reset-preserved-plan",
|
||||
items: [items.monthlyMessages({ includedUsage: 100 })],
|
||||
});
|
||||
|
||||
const { autumnV2_2, ctx } = await initScenario({
|
||||
customerId,
|
||||
setup: [s.customer(), s.products({ list: [base] })],
|
||||
actions: [s.billing.attach({ productId: base.id })],
|
||||
});
|
||||
|
||||
const before = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
|
||||
const beforeResetAt = before.balances[TestFeature.Messages]?.next_reset_at;
|
||||
expect(
|
||||
beforeResetAt,
|
||||
"pre-update next_reset_at should be set on monthly entitlement",
|
||||
).not.toBeNull();
|
||||
|
||||
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.Messages }, included: 300 },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
|
||||
expectBalanceCorrect({
|
||||
customer,
|
||||
featureId: TestFeature.Messages,
|
||||
remaining: 300,
|
||||
usage: 0,
|
||||
nextResetAt: beforeResetAt as number,
|
||||
planId: base.id,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* TDD coverage for update_items lifecycle behaviors:
|
||||
* - Cycle anchor preservation across a mid-cycle update (test-clock advance).
|
||||
* - Newly-tracked usage applies to the updated entitlement.
|
||||
* - Idempotency: running the same update_items twice is a no-op on the
|
||||
* second pass.
|
||||
*
|
||||
* Contract under test:
|
||||
* New behaviors:
|
||||
* - Mid-cycle update_items does NOT shift the billing/reset anchor; at
|
||||
* next reset the balance resets to the NEW included value (not the
|
||||
* old one).
|
||||
* - The new entitlement participates in normal track/deduct flows.
|
||||
* - Running an identical update_items migration twice yields the same
|
||||
* end state as running it once.
|
||||
*/
|
||||
|
||||
import { expect, test } from "bun:test";
|
||||
import { type ApiCustomerV5 } 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 { advanceTestClock } from "@tests/utils/stripeUtils";
|
||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
|
||||
import chalk from "chalk";
|
||||
import { runUpdatePlanMigration } from "../../utils/runUpdatePlanMigration";
|
||||
|
||||
test(`${chalk.yellowBright("migrations update_items: cycle anchor survives mid-cycle update; next reset uses new included")}`, async () => {
|
||||
const customerId = "migration-update-items-cycle-anchor";
|
||||
const pro = products.pro({
|
||||
id: "migration-update-items-cycle-anchor-plan",
|
||||
items: [items.monthlyMessages({ includedUsage: 100 })],
|
||||
});
|
||||
|
||||
const { autumnV2_2, ctx, testClockId } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success", testClock: true }),
|
||||
s.products({ list: [pro] }),
|
||||
],
|
||||
actions: [
|
||||
s.billing.attach({ productId: pro.id }),
|
||||
s.advanceTestClock({ days: 10 }),
|
||||
],
|
||||
});
|
||||
|
||||
const before = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
|
||||
const anchorBefore = before.balances[TestFeature.Messages]
|
||||
?.next_reset_at as number;
|
||||
expect(anchorBefore, "monthly entitlement must have a next_reset_at").not.toBeNull();
|
||||
|
||||
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.Messages }, included: 300 },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const afterUpdate = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
|
||||
expectBalanceCorrect({
|
||||
customer: afterUpdate,
|
||||
featureId: TestFeature.Messages,
|
||||
remaining: 300,
|
||||
usage: 0,
|
||||
nextResetAt: anchorBefore,
|
||||
planId: pro.id,
|
||||
});
|
||||
|
||||
// Advance past the original anchor — the entitlement should reset to the
|
||||
// NEW included value (300), not the old (100).
|
||||
await advanceTestClock({
|
||||
stripeCli: ctx.stripeCli,
|
||||
testClockId: testClockId!,
|
||||
numberOfDays: 30,
|
||||
waitForSeconds: 30,
|
||||
});
|
||||
|
||||
const afterReset = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
|
||||
expectBalanceCorrect({
|
||||
customer: afterReset,
|
||||
featureId: TestFeature.Messages,
|
||||
remaining: 300,
|
||||
usage: 0,
|
||||
planId: pro.id,
|
||||
});
|
||||
});
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("migrations update_items: tracking new usage on the updated entitlement deducts correctly")}`, async () => {
|
||||
const customerId = "migration-update-items-track-after-update";
|
||||
const base = products.base({
|
||||
id: "migration-update-items-track-after-update-plan",
|
||||
items: [items.monthlyMessages({ includedUsage: 100 })],
|
||||
});
|
||||
|
||||
const { autumnV1, autumnV2_2, ctx } = await initScenario({
|
||||
customerId,
|
||||
setup: [s.customer(), s.products({ list: [base] })],
|
||||
actions: [s.billing.attach({ productId: base.id })],
|
||||
});
|
||||
|
||||
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.Messages }, included: 250 },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// Track 75 against the (now patched) entitlement.
|
||||
await autumnV1.track(
|
||||
{
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Messages,
|
||||
value: 75,
|
||||
},
|
||||
{ timeout: 2000 },
|
||||
);
|
||||
|
||||
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
|
||||
expectBalanceCorrect({
|
||||
customer,
|
||||
featureId: TestFeature.Messages,
|
||||
remaining: 175,
|
||||
usage: 75,
|
||||
planId: base.id,
|
||||
});
|
||||
});
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("migrations update_items: running the same migration twice is idempotent")}`, async () => {
|
||||
const customerId = "migration-update-items-idempotent";
|
||||
const base = products.base({
|
||||
id: "migration-update-items-idempotent-plan",
|
||||
items: [items.monthlyMessages({ includedUsage: 100 })],
|
||||
});
|
||||
|
||||
const { autumnV2_2, ctx } = await initScenario({
|
||||
customerId,
|
||||
setup: [s.customer(), s.products({ list: [base] })],
|
||||
actions: [
|
||||
s.billing.attach({ productId: base.id }),
|
||||
s.track({ featureId: TestFeature.Messages, value: 40, timeout: 2000 }),
|
||||
],
|
||||
});
|
||||
|
||||
const runMigration = (migrationSuffix: string) =>
|
||||
runUpdatePlanMigration({
|
||||
ctx,
|
||||
migrationClient: autumnV2_2,
|
||||
migrationId: `${customerId}-mig-${migrationSuffix}`,
|
||||
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.Messages },
|
||||
included: 250,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await runMigration("first");
|
||||
|
||||
const afterFirst = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
|
||||
expectBalanceCorrect({
|
||||
customer: afterFirst,
|
||||
featureId: TestFeature.Messages,
|
||||
remaining: 210,
|
||||
usage: 40,
|
||||
planId: base.id,
|
||||
});
|
||||
|
||||
await runMigration("second");
|
||||
|
||||
const afterSecond = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
|
||||
expectBalanceCorrect({
|
||||
customer: afterSecond,
|
||||
featureId: TestFeature.Messages,
|
||||
remaining: 210,
|
||||
usage: 40,
|
||||
planId: base.id,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* TDD coverage for update_items mixed with add_items / remove_items in the
|
||||
* same customize.
|
||||
*
|
||||
* Contract under test:
|
||||
* New behaviors:
|
||||
* - update_items, remove_items, and add_items in one customize compose
|
||||
* coherently: removes drop items, updates patch existing items, adds
|
||||
* insert new items.
|
||||
* - update_items runs BEFORE add_items, so a feature that already has a
|
||||
* cusEnt is updated in place rather than skipped by add_items' noop
|
||||
* logic, and the freshly-added items in the same migration are not
|
||||
* eligible matches for update_items.
|
||||
* - Items that the user targets exclusively with remove_items are not
|
||||
* also picked up by update_items running against the same product.
|
||||
*/
|
||||
|
||||
import { test } from "bun:test";
|
||||
import type { ApiCustomerV3, ApiCustomerV5 } from "@autumn/shared";
|
||||
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
|
||||
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";
|
||||
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 { expect } from "bun:test";
|
||||
import { runUpdatePlanMigration } from "../../utils/runUpdatePlanMigration";
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("migrations update_items: update + add + remove compose correctly in one customize")}`, async () => {
|
||||
const customerId = "migration-update-items-mixed-compose";
|
||||
const base = products.base({
|
||||
id: "migration-update-items-mixed-compose-plan",
|
||||
items: [
|
||||
items.monthlyMessages({ includedUsage: 100 }),
|
||||
items.monthlyWords({ includedUsage: 80 }),
|
||||
],
|
||||
});
|
||||
|
||||
const { autumnV1, autumnV2_2, ctx } = await initScenario({
|
||||
customerId,
|
||||
setup: [s.customer(), s.products({ list: [base] })],
|
||||
actions: [
|
||||
s.billing.attach({ productId: base.id }),
|
||||
s.track({ featureId: TestFeature.Messages, value: 20, timeout: 2000 }),
|
||||
],
|
||||
});
|
||||
|
||||
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.Messages }, included: 250 },
|
||||
],
|
||||
remove_items: [{ feature_id: TestFeature.Words }],
|
||||
add_items: [itemsV2.dashboard()],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
|
||||
|
||||
// update_items: messages updated 100 -> 250 with 20 usage carried.
|
||||
expectBalanceCorrect({
|
||||
customer,
|
||||
featureId: TestFeature.Messages,
|
||||
remaining: 230,
|
||||
usage: 20,
|
||||
planId: base.id,
|
||||
});
|
||||
|
||||
// remove_items: words dropped.
|
||||
expect(
|
||||
customer.balances[TestFeature.Words],
|
||||
"remove_items must drop the words entitlement",
|
||||
).toBeUndefined();
|
||||
|
||||
// add_items: dashboard boolean now present.
|
||||
expectFlagCorrect({
|
||||
customer,
|
||||
featureId: TestFeature.Dashboard,
|
||||
planId: base.id,
|
||||
});
|
||||
|
||||
await expectCustomerInvoiceCorrect({
|
||||
customer: await autumnV1.customers.get<ApiCustomerV3>(customerId),
|
||||
count: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("migrations update_items: filter that overlaps remove_items only deletes once")}`, async () => {
|
||||
const customerId = "migration-update-items-mixed-no-double-delete";
|
||||
const base = products.base({
|
||||
id: "migration-update-items-mixed-no-double-delete-plan",
|
||||
items: [items.monthlyMessages({ includedUsage: 100 })],
|
||||
});
|
||||
|
||||
const { autumnV2_2, ctx } = await initScenario({
|
||||
customerId,
|
||||
setup: [s.customer(), s.products({ list: [base] })],
|
||||
actions: [s.billing.attach({ productId: base.id })],
|
||||
});
|
||||
|
||||
// User asks to BOTH remove and update the same item — the operation should
|
||||
// still succeed; resulting state mirrors "remove wins" since the item is
|
||||
// gone before update_items can target a fresh row.
|
||||
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.Messages }],
|
||||
update_items: [
|
||||
{ filter: { feature_id: TestFeature.Messages }, included: 999 },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
|
||||
expect(
|
||||
customer.balances[TestFeature.Messages],
|
||||
"remove + update for the same feature should leave the feature gone, not 999",
|
||||
).toBeUndefined();
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* 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 {
|
||||
type ApiCustomerV3,
|
||||
type ApiCustomerV5,
|
||||
BillingInterval,
|
||||
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 { runUpdatePlanMigration } from "../../utils/runUpdatePlanMigration";
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("migrations update_items: interval filter only touches monthly cusEnt; lifetime untouched")}`, async () => {
|
||||
const customerId = "migration-update-items-multi-monthly-only";
|
||||
const base = products.base({
|
||||
id: "migration-update-items-multi-monthly-only-plan",
|
||||
items: [
|
||||
items.monthlyMessages({ includedUsage: 100 }),
|
||||
items.lifetimeMessages({ includedUsage: 50 }),
|
||||
],
|
||||
});
|
||||
|
||||
const { autumnV1, autumnV2_2, ctx } = await initScenario({
|
||||
customerId,
|
||||
setup: [s.customer(), s.products({ list: [base] })],
|
||||
actions: [
|
||||
s.billing.attach({ productId: base.id }),
|
||||
s.track({ featureId: TestFeature.Messages, value: 30, timeout: 2000 }),
|
||||
],
|
||||
});
|
||||
|
||||
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.Messages,
|
||||
interval: BillingInterval.Month,
|
||||
},
|
||||
included: 300,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
|
||||
await expectCustomerProducts({ customer, active: [base.id] });
|
||||
|
||||
// Aggregated balance: monthly (300 - 30 usage) + lifetime untouched (50)
|
||||
expectBalanceCorrect({
|
||||
customer,
|
||||
featureId: TestFeature.Messages,
|
||||
remaining: 320,
|
||||
usage: 30,
|
||||
planId: base.id,
|
||||
breakdown: {
|
||||
[ResetInterval.Month]: { included_grant: 300, remaining: 270, usage: 30 },
|
||||
[ResetInterval.OneOff]: { included_grant: 50, remaining: 50, usage: 0 },
|
||||
},
|
||||
});
|
||||
|
||||
await expectCustomerInvoiceCorrect({
|
||||
customer: await autumnV1.customers.get<ApiCustomerV3>(customerId),
|
||||
count: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("migrations update_items: feature-id-only filter updates every cusEnt for that feature")}`, async () => {
|
||||
const customerId = "migration-update-items-multi-feature-wide";
|
||||
const base = products.base({
|
||||
id: "migration-update-items-multi-feature-wide-plan",
|
||||
items: [
|
||||
items.monthlyMessages({ includedUsage: 100 }),
|
||||
items.lifetimeMessages({ includedUsage: 50 }),
|
||||
],
|
||||
});
|
||||
|
||||
const { autumnV2_2, ctx } = await initScenario({
|
||||
customerId,
|
||||
setup: [s.customer(), s.products({ list: [base] })],
|
||||
actions: [s.billing.attach({ productId: base.id })],
|
||||
});
|
||||
|
||||
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.Messages }, included: 999 },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
|
||||
// Both monthly and lifetime entitlements should now be granted 999.
|
||||
expectBalanceCorrect({
|
||||
customer,
|
||||
featureId: TestFeature.Messages,
|
||||
remaining: 1998,
|
||||
usage: 0,
|
||||
planId: base.id,
|
||||
breakdown: {
|
||||
[ResetInterval.Month]: { included_grant: 999, remaining: 999 },
|
||||
[ResetInterval.OneOff]: { included_grant: 999, remaining: 999 },
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,352 @@
|
||||
/**
|
||||
* TDD coverage for update_items against paid / billed features and edge
|
||||
* cases.
|
||||
*
|
||||
* Contract under test:
|
||||
* New behaviors:
|
||||
* - PREPAID feature update_items: bumping `included` carries the
|
||||
* customer-selected quantity forward and leaves the Stripe
|
||||
* subscription unchanged. No new invoice.
|
||||
* - CONSUMABLE (usage-in-arrear) update_items: bumping `included`
|
||||
* preserves arrears usage. Stripe subscription untouched.
|
||||
* - update_items targeting a free feature on a customer with a PAID
|
||||
* Stripe subscription leaves the subscription's line items + anchor
|
||||
* intact (no proration invoice).
|
||||
* - Filter that matches nothing is a graceful no-op (the migration
|
||||
* succeeds, customer state unchanged, no charge artifacts).
|
||||
* - Unlimited entitlement + update_items.included: the override is
|
||||
* silently ignored (unlimited stays unlimited; we do not regress to
|
||||
* a numeric allowance).
|
||||
*/
|
||||
|
||||
import { expect, test } from "bun:test";
|
||||
import {
|
||||
type ApiCustomerV3,
|
||||
type ApiCustomerV5,
|
||||
BillingMethod,
|
||||
} from "@autumn/shared";
|
||||
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
|
||||
import { expectStripeSubscriptionUnchanged } from "@tests/integration/billing/utils/stripe/expectStripeSubscriptionUnchanged";
|
||||
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 { runUpdatePlanMigration } from "../../utils/runUpdatePlanMigration";
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("migrations update_items: prepaid feature — preserves selected quantity, Stripe sub unchanged, no invoice")}`, async () => {
|
||||
const customerId = "migration-update-items-prepaid";
|
||||
const pro = products.pro({
|
||||
id: "migration-update-items-prepaid-plan",
|
||||
items: [
|
||||
items.prepaidMessages({
|
||||
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.billing.attach({
|
||||
productId: pro.id,
|
||||
options: [{ feature_id: TestFeature.Messages, quantity: 200 }],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const customerBefore = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
const invoiceCountBefore = customerBefore.invoices?.length ?? 0;
|
||||
const stripeCustomerId = customerBefore.stripe_id as string;
|
||||
|
||||
const subsBefore = await ctx.stripeCli.subscriptions.list({
|
||||
customer: stripeCustomerId,
|
||||
status: "all",
|
||||
});
|
||||
const subBefore = subsBefore.data.find(
|
||||
(sub) => sub.status === "active" || sub.status === "trialing",
|
||||
);
|
||||
expect(subBefore, "expected a paid Stripe sub for the prepaid plan").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: {
|
||||
update_items: [
|
||||
{ filter: { feature_id: TestFeature.Messages }, included: 100 },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
|
||||
// Bumping `included` on a prepaid item absorbs that allowance into the
|
||||
// already-purchased prepaid quantity (see cusProductToConvertedFeatureOptions).
|
||||
// Total grant stays at the original 200; the new entitlement carries
|
||||
// `included_grant: 100` and the prepaid_grant drops to 100.
|
||||
expectBalanceCorrect({
|
||||
customer,
|
||||
featureId: TestFeature.Messages,
|
||||
remaining: 200,
|
||||
usage: 0,
|
||||
breakdown: {
|
||||
[BillingMethod.Prepaid]: {
|
||||
included_grant: 100,
|
||||
prepaid_grant: 100,
|
||||
remaining: 200,
|
||||
usage: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
});
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("migrations update_items: consumable (usage-in-arrear) feature — preserves arrears usage, Stripe sub unchanged")}`, async () => {
|
||||
const customerId = "migration-update-items-consumable";
|
||||
const pro = products.pro({
|
||||
id: "migration-update-items-consumable-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 }),
|
||||
s.track({ featureId: TestFeature.Messages, value: 150, timeout: 2000 }),
|
||||
],
|
||||
});
|
||||
|
||||
const customerBefore = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
const invoiceCountBefore = customerBefore.invoices?.length ?? 0;
|
||||
const stripeCustomerId = customerBefore.stripe_id as string;
|
||||
const subsBefore = await ctx.stripeCli.subscriptions.list({
|
||||
customer: stripeCustomerId,
|
||||
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: {
|
||||
update_items: [
|
||||
{ filter: { feature_id: TestFeature.Messages }, included: 300 },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
|
||||
// new included (300) - existing usage (150) → remaining 150
|
||||
expectBalanceCorrect({
|
||||
customer,
|
||||
featureId: TestFeature.Messages,
|
||||
remaining: 150,
|
||||
usage: 150,
|
||||
breakdown: {
|
||||
[BillingMethod.UsageBased]: {
|
||||
included_grant: 300,
|
||||
remaining: 150,
|
||||
usage: 150,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
});
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("migrations update_items: allocated (entity-scoped) feature — per-entity balances preserved")}`, async () => {
|
||||
const customerId = "migration-update-items-allocated";
|
||||
const base = products.base({
|
||||
id: "migration-update-items-allocated-plan",
|
||||
items: [items.freeAllocatedUsers({ includedUsage: 5 })],
|
||||
});
|
||||
|
||||
const { autumnV1, autumnV2_2, ctx, entities } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer(),
|
||||
s.products({ list: [base] }),
|
||||
s.entities({ count: 2, featureId: TestFeature.Users }),
|
||||
],
|
||||
actions: [s.billing.attach({ productId: base.id })],
|
||||
});
|
||||
|
||||
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.Users }, included: 10 },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// Allocated/seat features count existing entities as usage (see
|
||||
// mergeEntitiesWithExistingUsages). With 2 entities + new included=10, the
|
||||
// expected post-update balance per entity is 10 − 2 = 8.
|
||||
for (const entity of entities) {
|
||||
const customer = await autumnV1.entities.get(customerId, entity.id);
|
||||
expect(
|
||||
customer.features?.[TestFeature.Users]?.balance,
|
||||
`entity ${entity.id} should have balance 8 (10 included minus 2 entity-as-usage)`,
|
||||
).toBe(8);
|
||||
}
|
||||
});
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("migrations update_items: filter that matches nothing is a graceful no-op")}`, async () => {
|
||||
const customerId = "migration-update-items-no-match";
|
||||
const base = products.base({
|
||||
id: "migration-update-items-no-match-plan",
|
||||
items: [items.monthlyMessages({ includedUsage: 100 })],
|
||||
});
|
||||
|
||||
const { autumnV1, autumnV2_2, ctx } = await initScenario({
|
||||
customerId,
|
||||
setup: [s.customer(), s.products({ list: [base] })],
|
||||
actions: [
|
||||
s.billing.attach({ productId: base.id }),
|
||||
s.track({ featureId: TestFeature.Messages, value: 20, timeout: 2000 }),
|
||||
],
|
||||
});
|
||||
|
||||
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: "does-not-exist-on-this-plan" },
|
||||
included: 999,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
|
||||
// Untouched state: original 100 included, 20 used → 80 remaining.
|
||||
expectBalanceCorrect({
|
||||
customer,
|
||||
featureId: TestFeature.Messages,
|
||||
remaining: 80,
|
||||
usage: 20,
|
||||
planId: base.id,
|
||||
});
|
||||
|
||||
await expectCustomerInvoiceCorrect({
|
||||
customer: await autumnV1.customers.get<ApiCustomerV3>(customerId),
|
||||
count: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("migrations update_items: included on an unlimited entitlement is ignored (still unlimited)")}`, async () => {
|
||||
const customerId = "migration-update-items-unlimited";
|
||||
const base = products.base({
|
||||
id: "migration-update-items-unlimited-plan",
|
||||
items: [items.unlimitedMessages()],
|
||||
});
|
||||
|
||||
const { autumnV2_2, ctx } = await initScenario({
|
||||
customerId,
|
||||
setup: [s.customer(), s.products({ list: [base] })],
|
||||
actions: [s.billing.attach({ productId: base.id })],
|
||||
});
|
||||
|
||||
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.Messages }, included: 500 },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
|
||||
const balance = customer.balances[TestFeature.Messages];
|
||||
expect(
|
||||
balance?.unlimited,
|
||||
"unlimited entitlement must stay unlimited after update_items",
|
||||
).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* TDD coverage for update_items + rollover.
|
||||
*
|
||||
* Contract under test:
|
||||
* New behaviors:
|
||||
* - When an entitlement with an active rollover is updated via update_items,
|
||||
* the rollover is carried over onto the new entitlement.
|
||||
* - The updated entitlement keeps the same rollover configuration that
|
||||
* was already in place (no need to redeclare it when only bumping
|
||||
* `included`).
|
||||
*/
|
||||
|
||||
import { test } from "bun:test";
|
||||
import {
|
||||
type ApiCustomerV3,
|
||||
type ApiCustomerV5,
|
||||
RolloverExpiryDurationType,
|
||||
} 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 { runUpdatePlanMigration } from "../../utils/runUpdatePlanMigration";
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("migrations update_items: rollover from old cusEnt carries onto new cusEnt")}`, async () => {
|
||||
const customerId = "migration-update-items-rollover-carry";
|
||||
const base = products.base({
|
||||
id: "migration-update-items-rollover-plan",
|
||||
items: [
|
||||
items.monthlyMessagesWithRollover({
|
||||
includedUsage: 400,
|
||||
rolloverConfig: {
|
||||
max: 500,
|
||||
length: 1,
|
||||
duration: RolloverExpiryDurationType.Month,
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const { autumnV1, autumnV2_2, ctx } = await initScenario({
|
||||
customerId,
|
||||
setup: [s.customer(), s.products({ list: [base] })],
|
||||
actions: [
|
||||
s.billing.attach({ productId: base.id }),
|
||||
s.track({ featureId: TestFeature.Messages, value: 250, timeout: 2000 }),
|
||||
s.resetFeature({ featureId: TestFeature.Messages }),
|
||||
],
|
||||
});
|
||||
|
||||
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.Messages }, included: 500 },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
|
||||
// 500 new included + 150 unused that rolled over = 650 available.
|
||||
expectBalanceCorrect({
|
||||
customer,
|
||||
featureId: TestFeature.Messages,
|
||||
remaining: 650,
|
||||
usage: 0,
|
||||
rollovers: [{ balance: 150 }],
|
||||
});
|
||||
|
||||
await expectCustomerInvoiceCorrect({
|
||||
customer: await autumnV1.customers.get<ApiCustomerV3>(customerId),
|
||||
count: 0,
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,25 @@ import { CreatePlanItemParamsV1Schema } from "@api/products/items/crud/createPla
|
||||
import { PlanItemFilterSchema } from "@api/products/items/filter/planItemFilter";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const UpdatePlanItemParamsV1Schema = z
|
||||
.object({
|
||||
filter: PlanItemFilterSchema.meta({
|
||||
description:
|
||||
"Filter selecting which existing plan item(s) to update. Same shape as remove_items filters.",
|
||||
}),
|
||||
included: z.number().nonnegative().optional().meta({
|
||||
description:
|
||||
"Override the matched item's included usage / allowance. Existing usage carries forward.",
|
||||
}),
|
||||
})
|
||||
.meta({
|
||||
title: "UpdatePlanItem",
|
||||
description:
|
||||
"Patch an existing plan item in place. Phase 1 supports only `included`.",
|
||||
});
|
||||
|
||||
export type UpdatePlanItemParamsV1 = z.infer<typeof UpdatePlanItemParamsV1Schema>;
|
||||
|
||||
export const CustomizePlanV1Schema = z
|
||||
.object({
|
||||
price: BasePriceParamsSchema.nullable().optional().meta({
|
||||
@@ -12,7 +31,7 @@ export const CustomizePlanV1Schema = z
|
||||
}),
|
||||
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.",
|
||||
"Override the items in the plan (PUT-style — replaces all existing items). Mutually exclusive with add_items / remove_items / update_items.",
|
||||
}),
|
||||
add_items: z.array(CreatePlanItemParamsV1Schema).optional().meta({
|
||||
description: "Items to add to the plan.",
|
||||
@@ -20,6 +39,11 @@ export const CustomizePlanV1Schema = z
|
||||
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,
|
||||
}),
|
||||
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.",
|
||||
@@ -31,21 +55,24 @@ export const CustomizePlanV1Schema = z
|
||||
data.price !== undefined ||
|
||||
data.free_trial !== undefined ||
|
||||
data.add_items !== undefined ||
|
||||
data.remove_items !== undefined,
|
||||
data.remove_items !== undefined ||
|
||||
data.update_items !== undefined,
|
||||
{
|
||||
message:
|
||||
"When using customize, at least one of price, items, add_items, remove_items, or free_trial must be provided",
|
||||
"When using customize, at least one of price, items, add_items, remove_items, update_items, or free_trial must be provided",
|
||||
},
|
||||
)
|
||||
.refine(
|
||||
(data) =>
|
||||
!(
|
||||
data.items !== undefined &&
|
||||
(data.add_items !== undefined || data.remove_items !== undefined)
|
||||
(data.add_items !== undefined ||
|
||||
data.remove_items !== undefined ||
|
||||
data.update_items !== undefined)
|
||||
),
|
||||
{
|
||||
message:
|
||||
"customize.items (PUT-style) cannot be combined with add_items/remove_items (PATCH-style); pick one approach",
|
||||
"customize.items (PUT-style) cannot be combined with add_items / remove_items / update_items (PATCH-style); pick one approach",
|
||||
},
|
||||
)
|
||||
.meta({
|
||||
@@ -65,7 +92,8 @@ export const hasCustomItems = (
|
||||
customize.price !== undefined ||
|
||||
customize.items !== undefined ||
|
||||
customize.add_items !== undefined ||
|
||||
customize.remove_items !== undefined
|
||||
customize.remove_items !== undefined ||
|
||||
customize.update_items !== undefined
|
||||
);
|
||||
};
|
||||
|
||||
@@ -75,4 +103,5 @@ export const isCustomizePlanPatchStyle = (
|
||||
customize?.items === undefined &&
|
||||
(customize?.price !== undefined ||
|
||||
customize?.add_items !== undefined ||
|
||||
customize?.remove_items !== undefined);
|
||||
customize?.remove_items !== undefined ||
|
||||
customize?.update_items !== undefined);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { BasePriceParamsSchema } from "@api/products/components/basePrice/basePrice";
|
||||
import { PlanItemFilterSchema } from "@api/products/items/filter/planItemFilter";
|
||||
import { z } from "zod/v4";
|
||||
import { UpdatePlanItemParamsV1Schema } from "../../../../billing/common/customizePlan/customizePlanV1.js";
|
||||
import { CreatePlanItemParamsV1Schema } from "../../../../products/items/crud/createPlanItemParamsV1.js";
|
||||
import { PlanFilterSchema } from "../../../filters/planFilter.js";
|
||||
|
||||
@@ -9,15 +10,17 @@ export const MigrationUpdatePlanCustomizeSchema = z
|
||||
price: BasePriceParamsSchema.nullable().optional(),
|
||||
add_items: z.array(CreatePlanItemParamsV1Schema).optional(),
|
||||
remove_items: z.array(PlanItemFilterSchema).optional(),
|
||||
update_items: z.array(UpdatePlanItemParamsV1Schema).optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) =>
|
||||
data.price !== undefined ||
|
||||
data.add_items !== undefined ||
|
||||
data.remove_items !== undefined,
|
||||
data.remove_items !== undefined ||
|
||||
data.update_items !== undefined,
|
||||
{
|
||||
message:
|
||||
"update_plan.customize requires at least one of price, add_items, or remove_items",
|
||||
"update_plan.customize requires at least one of price, add_items, remove_items, or update_items",
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user