From c3ae562b5b02a36151627923855a7a610e776db0 Mon Sep 17 00:00:00 2001 From: johnyeo Date: Thu, 28 May 2026 11:39:58 +0100 Subject: [PATCH] fix: migration bugs --- server/src/external/autumn/autumnCli.ts | 3 + .../carryExisting/carryIdentity.ts | 55 +++ .../customerProductCarryGroups.ts | 86 ++++ .../carryExisting/index.ts | 3 + .../projectCustomerProductForCarry.ts | 46 ++ .../getPatchCarryCustomerProduct.ts | 29 -- .../initPatchedCustomerProduct/index.ts | 1 - ...nitPatchedCustomerEntitlementsAndPrices.ts | 45 +- .../v2/handlers/handlePatchMigration.ts | 1 + .../migrations/v2/repos/updateMigration.ts | 7 +- .../evaluateMigrateCustomerStripe.ts | 37 +- .../migrations-v2/migrations-v2.test.ts | 34 ++ .../update-items-carry-groups.test.ts | 400 +++++++++++++++++ .../update-items/update-items-credits.test.ts | 416 ++++++++++++++++++ .../update-items-multi-cusent.test.ts | 18 +- .../update-plan-op-paid-features.test.ts | 64 ++- .../utils/runUpdatePlanMigration.ts | 4 + .../priceUtils/convertPriceUtils.ts | 21 + 18 files changed, 1179 insertions(+), 91 deletions(-) create mode 100644 server/src/internal/billing/v2/utils/initFullCustomerProduct/carryExisting/carryIdentity.ts create mode 100644 server/src/internal/billing/v2/utils/initFullCustomerProduct/carryExisting/customerProductCarryGroups.ts create mode 100644 server/src/internal/billing/v2/utils/initFullCustomerProduct/carryExisting/index.ts create mode 100644 server/src/internal/billing/v2/utils/initFullCustomerProduct/carryExisting/projectCustomerProductForCarry.ts delete mode 100644 server/src/internal/billing/v2/utils/initFullCustomerProduct/initPatchedCustomerProduct/getPatchCarryCustomerProduct.ts create mode 100644 server/tests/integration/billing/migrations-v2/update-plan-operation/update-items/update-items-carry-groups.test.ts create mode 100644 server/tests/integration/billing/migrations-v2/update-plan-operation/update-items/update-items-credits.test.ts diff --git a/server/src/external/autumn/autumnCli.ts b/server/src/external/autumn/autumnCli.ts index 5b7611ee3..bbb87177d 100644 --- a/server/src/external/autumn/autumnCli.ts +++ b/server/src/external/autumn/autumnCli.ts @@ -985,6 +985,7 @@ export class AutumnInt { id: string; filter?: MigrationFilter | null; operations?: Operations | null; + no_billing_changes?: boolean; }): Promise => { 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 => { 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 => { try { await this.post(`/migrations.delete`, { id: params.id }); diff --git a/server/src/internal/billing/v2/utils/initFullCustomerProduct/carryExisting/carryIdentity.ts b/server/src/internal/billing/v2/utils/initFullCustomerProduct/carryExisting/carryIdentity.ts new file mode 100644 index 000000000..4bfd33462 --- /dev/null +++ b/server/src/internal/billing/v2/utils/initFullCustomerProduct/carryExisting/carryIdentity.ts @@ -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, + }; +}; diff --git a/server/src/internal/billing/v2/utils/initFullCustomerProduct/carryExisting/customerProductCarryGroups.ts b/server/src/internal/billing/v2/utils/initFullCustomerProduct/carryExisting/customerProductCarryGroups.ts new file mode 100644 index 000000000..51b7efb06 --- /dev/null +++ b/server/src/internal/billing/v2/utils/initFullCustomerProduct/carryExisting/customerProductCarryGroups.ts @@ -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 = (groups: Map, 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, + }), + }; + }, + ); +}; diff --git a/server/src/internal/billing/v2/utils/initFullCustomerProduct/carryExisting/index.ts b/server/src/internal/billing/v2/utils/initFullCustomerProduct/carryExisting/index.ts new file mode 100644 index 000000000..64379fbb7 --- /dev/null +++ b/server/src/internal/billing/v2/utils/initFullCustomerProduct/carryExisting/index.ts @@ -0,0 +1,3 @@ +export * from "./carryIdentity"; +export * from "./customerProductCarryGroups"; +export * from "./projectCustomerProductForCarry"; diff --git a/server/src/internal/billing/v2/utils/initFullCustomerProduct/carryExisting/projectCustomerProductForCarry.ts b/server/src/internal/billing/v2/utils/initFullCustomerProduct/carryExisting/projectCustomerProductForCarry.ts new file mode 100644 index 000000000..f86f42dfa --- /dev/null +++ b/server/src/internal/billing/v2/utils/initFullCustomerProduct/carryExisting/projectCustomerProductForCarry.ts @@ -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(); + + 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, +}); diff --git a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initPatchedCustomerProduct/getPatchCarryCustomerProduct.ts b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initPatchedCustomerProduct/getPatchCarryCustomerProduct.ts deleted file mode 100644 index 3fb2a3c2e..000000000 --- a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initPatchedCustomerProduct/getPatchCarryCustomerProduct.ts +++ /dev/null @@ -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, - }; -}; diff --git a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initPatchedCustomerProduct/index.ts b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initPatchedCustomerProduct/index.ts index f7f907c78..335d458e5 100644 --- a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initPatchedCustomerProduct/index.ts +++ b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initPatchedCustomerProduct/index.ts @@ -1,4 +1,3 @@ export * from "./applyCustomerProductItemsPatch"; -export * from "./getPatchCarryCustomerProduct"; export * from "./initPatchCustomerProduct"; export * from "./initPatchedCustomerEntitlementsAndPrices"; diff --git a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initPatchedCustomerProduct/initPatchedCustomerEntitlementsAndPrices.ts b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initPatchedCustomerProduct/initPatchedCustomerEntitlementsAndPrices.ts index 1bc08ce19..1f6d1a705 100644 --- a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initPatchedCustomerProduct/initPatchedCustomerEntitlementsAndPrices.ts +++ b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initPatchedCustomerProduct/initPatchedCustomerEntitlementsAndPrices.ts @@ -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, diff --git a/server/src/internal/migrations/v2/handlers/handlePatchMigration.ts b/server/src/internal/migrations/v2/handlers/handlePatchMigration.ts index 66e9817d3..ce5dbe71e 100644 --- a/server/src/internal/migrations/v2/handlers/handlePatchMigration.ts +++ b/server/src/internal/migrations/v2/handlers/handlePatchMigration.ts @@ -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(), }), }); diff --git a/server/src/internal/migrations/v2/repos/updateMigration.ts b/server/src/internal/migrations/v2/repos/updateMigration.ts index 88c3f38e2..fcbc301a4 100644 --- a/server/src/internal/migrations/v2/repos/updateMigration.ts +++ b/server/src/internal/migrations/v2/repos/updateMigration.ts @@ -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 => { diff --git a/server/src/internal/migrations/v2/run/migrateCustomer/evaluateMigrateCustomerStripe.ts b/server/src/internal/migrations/v2/run/migrateCustomer/evaluateMigrateCustomerStripe.ts index a1c790134..7d4d942bd 100644 --- a/server/src/internal/migrations/v2/run/migrateCustomer/evaluateMigrateCustomerStripe.ts +++ b/server/src/internal/migrations/v2/run/migrateCustomer/evaluateMigrateCustomerStripe.ts @@ -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 => { + 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, diff --git a/server/tests/integration/billing/migrations-v2/migrations-v2.test.ts b/server/tests/integration/billing/migrations-v2/migrations-v2.test.ts index e69de29bb..078a5c9d6 100644 --- a/server/tests/integration/billing/migrations-v2/migrations-v2.test.ts +++ b/server/tests/integration/billing/migrations-v2/migrations-v2.test.ts @@ -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); + }, +); diff --git a/server/tests/integration/billing/migrations-v2/update-plan-operation/update-items/update-items-carry-groups.test.ts b/server/tests/integration/billing/migrations-v2/update-plan-operation/update-items/update-items-carry-groups.test.ts new file mode 100644 index 000000000..b4344bcdb --- /dev/null +++ b/server/tests/integration/billing/migrations-v2/update-plan-operation/update-items/update-items-carry-groups.test.ts @@ -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(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(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(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(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(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(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(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(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(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(customerId), + count: invoiceCountBefore, + }); +}); diff --git a/server/tests/integration/billing/migrations-v2/update-plan-operation/update-items/update-items-credits.test.ts b/server/tests/integration/billing/migrations-v2/update-plan-operation/update-items/update-items-credits.test.ts new file mode 100644 index 000000000..68ab6b752 --- /dev/null +++ b/server/tests/integration/billing/migrations-v2/update-plan-operation/update-items/update-items-credits.test.ts @@ -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(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(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(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(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(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(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(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(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(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( + customerId, + entities[0].id, + ); + const secondEntity = await autumnV2_2.entities.get( + 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(customerId), + count: invoiceCountBefore, + }); +}); diff --git a/server/tests/integration/billing/migrations-v2/update-plan-operation/update-items/update-items-multi-cusent.test.ts b/server/tests/integration/billing/migrations-v2/update-plan-operation/update-items/update-items-multi-cusent.test.ts index 94cc9b197..50cc28ae7 100644 --- a/server/tests/integration/billing/migrations-v2/update-plan-operation/update-items/update-items-multi-cusent.test.ts +++ b/server/tests/integration/billing/migrations-v2/update-plan-operation/update-items/update-items-multi-cusent.test.ts @@ -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, diff --git a/server/tests/integration/billing/migrations-v2/update-plan-operation/update-plan-op-paid-features.test.ts b/server/tests/integration/billing/migrations-v2/update-plan-operation/update-plan-op-paid-features.test.ts index 89ab13a08..785cb29c1 100644 --- a/server/tests/integration/billing/migrations-v2/update-plan-operation/update-plan-op-paid-features.test.ts +++ b/server/tests/integration/billing/migrations-v2/update-plan-operation/update-plan-op-paid-features.test.ts @@ -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(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(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(customerId), + count: invoiceCountBefore, + }); +}); diff --git a/server/tests/integration/billing/migrations-v2/utils/runUpdatePlanMigration.ts b/server/tests/integration/billing/migrations-v2/utils/runUpdatePlanMigration.ts index 1ede7395c..80312d49b 100644 --- a/server/tests/integration/billing/migrations-v2/utils/runUpdatePlanMigration.ts +++ b/server/tests/integration/billing/migrations-v2/utils/runUpdatePlanMigration.ts @@ -12,6 +12,7 @@ type MigrationClient = { id: string; filter?: MigrationFilter | null; operations?: Operations | null; + no_billing_changes?: boolean; }) => Promise; 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; timeoutMs?: number; @@ -80,6 +83,7 @@ export const runUpdatePlanMigration = async ({ id: migrationId, filter, operations, + no_billing_changes: noBillingChanges, }); if (runOnServer) { diff --git a/shared/utils/productUtils/priceUtils/convertPriceUtils.ts b/shared/utils/productUtils/priceUtils/convertPriceUtils.ts index 7297ca444..a1bfc7ec7 100644 --- a/shared/utils/productUtils/priceUtils/convertPriceUtils.ts +++ b/shared/utils/productUtils/priceUtils/convertPriceUtils.ts @@ -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; +};