diff --git a/server/src/internal/billing/v2/actions/attach/compute/computeAttachNewCustomerProduct.ts b/server/src/internal/billing/v2/actions/attach/compute/computeAttachNewCustomerProduct.ts index 5b1854a1e..6b36e3be3 100644 --- a/server/src/internal/billing/v2/actions/attach/compute/computeAttachNewCustomerProduct.ts +++ b/server/src/internal/billing/v2/actions/attach/compute/computeAttachNewCustomerProduct.ts @@ -1,10 +1,11 @@ -import type { AttachBillingContext } from "@autumn/shared"; +import type { AttachBillingContext, AttachParamsV1 } from "@autumn/shared"; import { CusProductStatus, deduplicateArray, type FullCusProduct, } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; +import { applyCarryOverUsageFeatureIds } from "@/internal/billing/v2/utils/handleCarryOvers/carryOverUtils"; import { initFullCustomerProduct } from "@/internal/billing/v2/utils/initFullCustomerProduct/initFullCustomerProduct"; /** @@ -16,9 +17,11 @@ import { initFullCustomerProduct } from "@/internal/billing/v2/utils/initFullCus export const computeAttachNewCustomerProduct = ({ ctx, attachBillingContext, + params = {} as AttachParamsV1, }: { ctx: AutumnContext; attachBillingContext: AttachBillingContext; + params?: AttachParamsV1; }): FullCusProduct => { const { attachProduct, @@ -41,6 +44,7 @@ export const computeAttachNewCustomerProduct = ({ const currentCustomerEntitlements = currentCustomerProduct?.customer_entitlements ?? []; + // LEGACY: carry_from_previous flag on entitlements const featuresToCarryUsagesFor = deduplicateArray( currentCustomerEntitlements .filter((ce) => { @@ -49,6 +53,13 @@ export const computeAttachNewCustomerProduct = ({ .map((ce) => ce.entitlement.feature.id), ); + // carry_over_usages param override — replaces legacy list with consumable feature ids if necessary + const consumableFeatureIdsToCarry = applyCarryOverUsageFeatureIds({ + params, + currentCustomerEntitlements, + featuresToCarryUsagesFor, + }); + // Determine if this is a scheduled product (downgrade) const isScheduled = planTiming === "end_of_cycle"; @@ -56,7 +67,7 @@ export const computeAttachNewCustomerProduct = ({ !isScheduled && currentCustomerProduct ? { fromCustomerProduct: currentCustomerProduct, - consumableFeatureIdsToCarry: featuresToCarryUsagesFor, + consumableFeatureIdsToCarry, } : undefined; diff --git a/server/src/internal/billing/v2/actions/attach/compute/computeAttachPlan.ts b/server/src/internal/billing/v2/actions/attach/compute/computeAttachPlan.ts index 11cd3ee76..94e4ae8ef 100644 --- a/server/src/internal/billing/v2/actions/attach/compute/computeAttachPlan.ts +++ b/server/src/internal/billing/v2/actions/attach/compute/computeAttachPlan.ts @@ -40,6 +40,7 @@ export const computeAttachPlan = ({ const newCustomerProduct = computeAttachNewCustomerProduct({ ctx, attachBillingContext, + params, }); const updateCustomerProduct = computeAttachTransitionUpdates({ diff --git a/server/src/internal/billing/v2/actions/attach/errors/handleAttachV2Errors.ts b/server/src/internal/billing/v2/actions/attach/errors/handleAttachV2Errors.ts index a36e0bae8..20f101dc5 100644 --- a/server/src/internal/billing/v2/actions/attach/errors/handleAttachV2Errors.ts +++ b/server/src/internal/billing/v2/actions/attach/errors/handleAttachV2Errors.ts @@ -6,6 +6,7 @@ import type { import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { handleAttachInvoiceModeErrors } from "@/internal/billing/v2/actions/attach/errors/handleAttachInvoiceModeErrors"; import { handleCarryOverBalancesErrors } from "@/internal/billing/v2/actions/attach/errors/handleCarryOverBalancesErrors"; +import { handleCarryOverUsagesErrors } from "@/internal/billing/v2/actions/attach/errors/handleCarryOverUsagesErrors"; import { handleCurrentCustomerProductErrors } from "@/internal/billing/v2/actions/attach/errors/handleCurrentCustomerProductErrors"; import { handleNewBillingSubscriptionErrors } from "@/internal/billing/v2/actions/attach/errors/handleNewBillingSubscriptionErrors"; import { handleScheduledSwitchOneOffErrors } from "@/internal/billing/v2/actions/attach/errors/handleScheduledSwitchOneOffErrors"; @@ -57,7 +58,10 @@ export const handleAttachV2Errors = async ({ // 8. Carry over balances errors (non-consumable features, downgrade block) handleCarryOverBalancesErrors({ ctx, params, billingContext }); - // 9. Proration behavior errors (none restrictions) + // 9. Carry over usages errors (non-consumable features, downgrade block) + handleCarryOverUsagesErrors({ ctx, params, billingContext }); + + // 10. Proration behavior errors (none restrictions) handleProrationBehaviorErrors({ billingContext, currentCustomerProduct: billingContext.currentCustomerProduct, @@ -65,14 +69,14 @@ export const handleAttachV2Errors = async ({ params, }); - // 9. Subscription ID uniqueness + // 11. Subscription ID uniqueness await handleSubscriptionIdErrors({ db: ctx.db, internalCustomerId: billingContext.fullCustomer.internal_id, subscriptionIds: [billingContext.externalId], }); - // 9. Custom line items errors (only valid for subscription updates) + // 12. Custom line items errors (only valid for subscription updates) handleCustomLineItemsErrors({ params, billingContext, diff --git a/server/src/internal/billing/v2/actions/attach/errors/handleCarryOverUsagesErrors.ts b/server/src/internal/billing/v2/actions/attach/errors/handleCarryOverUsagesErrors.ts new file mode 100644 index 000000000..10ee89827 --- /dev/null +++ b/server/src/internal/billing/v2/actions/attach/errors/handleCarryOverUsagesErrors.ts @@ -0,0 +1,63 @@ +import type { AttachBillingContext, AttachParamsV1 } from "@autumn/shared"; +import { + ErrCode, + featureUtils, + isBooleanFeature, + RecaseError, +} from "@autumn/shared"; +import type { AutumnContext } from "@/honoUtils/HonoEnv"; + +/** + * Validates carry_over_usages params. + * + * - Only valid for immediate switches — errors on scheduled/downgrade. + * - Boolean and allocated (continuous_use) features cannot have usages carried over. + */ +export const handleCarryOverUsagesErrors = ({ + ctx, + params, + billingContext, +}: { + ctx: AutumnContext; + params: AttachParamsV1; + billingContext: AttachBillingContext; +}) => { + const carryOver = params.carry_over_usages; + if (!carryOver?.enabled) return; + + if (billingContext.planTiming !== "immediate") { + throw new RecaseError({ + message: + "carry_over_usages is only supported for immediate plan switches (upgrades). It cannot be used with scheduled downgrades.", + code: ErrCode.InvalidRequest, + statusCode: 400, + }); + } + + const featureIds = carryOver.feature_ids; + if (!featureIds?.length) return; + + for (const featureId of featureIds) { + const feature = featureUtils.find.byId({ + features: ctx.features, + featureId, + errorOnNotFound: true, + }); + + if (isBooleanFeature({ feature })) { + throw new RecaseError({ + message: `carry_over_usages is not supported for boolean features. Feature '${featureId}' is a boolean (static) feature and does not have a consumable usage.`, + code: ErrCode.InvalidRequest, + statusCode: 400, + }); + } + + if (featureUtils.isAllocated(feature)) { + throw new RecaseError({ + message: `carry_over_usages is not supported for non-consumable features. Feature '${featureId}' is a non-consumable (allocated) feature and does not have a consumable usage to carry over.`, + code: ErrCode.InvalidRequest, + statusCode: 400, + }); + } + } +}; diff --git a/server/src/internal/billing/v2/utils/handleCarryOvers/carryOverUtils.ts b/server/src/internal/billing/v2/utils/handleCarryOvers/carryOverUtils.ts new file mode 100644 index 000000000..9abddb962 --- /dev/null +++ b/server/src/internal/billing/v2/utils/handleCarryOvers/carryOverUtils.ts @@ -0,0 +1,46 @@ +import type { AttachParamsV1, FullCustomerEntitlement } from "@autumn/shared"; +import { + deduplicateArray, + featureUtils, + isBooleanCusEnt, + isUnlimitedCusEnt, +} from "@autumn/shared"; + +/** Mutates featuresToCarryUsagesFor with consumable feature IDs from carry_over_usages params and returns it. */ +export const applyCarryOverUsageFeatureIds = ({ + params, + currentCustomerEntitlements, + featuresToCarryUsagesFor, +}: { + params: AttachParamsV1; + currentCustomerEntitlements: FullCustomerEntitlement[]; + featuresToCarryUsagesFor: string[]; +}): string[] => { + const carryOverUsages = params.carry_over_usages; + if (!carryOverUsages?.enabled) return featuresToCarryUsagesFor; + + const allConsumableFeatureIds = deduplicateArray( + currentCustomerEntitlements + .filter( + (ce) => + !isBooleanCusEnt({ cusEnt: ce }) && + !isUnlimitedCusEnt(ce) && + !featureUtils.isAllocated(ce.entitlement.feature), + ) + .map((ce) => ce.entitlement.feature.id), + ); + + const overrideFeatureIds = carryOverUsages.feature_ids + ? allConsumableFeatureIds.filter((id) => + carryOverUsages.feature_ids!.includes(id), + ) + : allConsumableFeatureIds; + + featuresToCarryUsagesFor.splice( + 0, + featuresToCarryUsagesFor.length, + ...overrideFeatureIds, + ); + + return featuresToCarryUsagesFor; +}; diff --git a/server/tests/integration/billing/attach/params/carry-over-usages/carry-over-usage-basic.test.ts b/server/tests/integration/billing/attach/params/carry-over-usages/carry-over-usage-basic.test.ts new file mode 100644 index 000000000..0a9bf8cc4 --- /dev/null +++ b/server/tests/integration/billing/attach/params/carry-over-usages/carry-over-usage-basic.test.ts @@ -0,0 +1,172 @@ +/** + * Carry Over Usages - Basic Tests + * + * Tests for carry_over_usages: { enabled: true } on immediate plan upgrades. + * + * Key behaviors: + * - Existing usage is deducted from the new plan's allowance on upgrade + * - Zero usage is a silent no-op (new plan starts at full allowance) + * - New plan balance is clamped to zero — cannot go negative from carried usage + */ + +import { test } from "bun:test"; +import type { ApiCustomerV3 } from "@autumn/shared"; +import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect"; +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"; + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 1: Basic usage carry over (deduction) +// +// Pro: 50 messages, 40 used (balance=10) +// Upgrade to Premium (200) with carry_over_usages: { enabled: true } +// Expected: balance = 160 (200 - 40 carried usage), usage = 40 +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("carry-over-usage 1: existing usage is deducted from new plan allowance on upgrade")}`, async () => { + const proMessages = items.monthlyMessages({ includedUsage: 50 }); + const premiumMessages = items.monthlyMessages({ includedUsage: 200 }); + + const pro = products.pro({ id: "pro", items: [proMessages] }); + const premium = products.premium({ id: "premium", items: [premiumMessages] }); + + const { customerId, autumnV2_1, autumnV1 } = await initScenario({ + customerId: "carry-over-usage-basic1", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + ], + actions: [s.attach({ productId: pro.id, timeout: 4000 })], + }); + + // Track 40 units (balance: 50 → 10) + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 40, + }); + + // Wait for Redis → Postgres sync + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // Upgrade to Premium with carry_over_usages enabled + await autumnV2_1.billing.attach({ + customer_id: customerId, + plan_id: premium.id, + carry_over_usages: { enabled: true }, + }); + + await new Promise((resolve) => setTimeout(resolve, 2000)); + + const customer = await autumnV1.customers.get(customerId); + + // Balance = 200 (Premium allowance) - 40 (carried usage) = 160 + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + balance: 160, + usage: 40, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 2: Zero usage — nothing to carry +// +// Pro: 50 messages, 0 used (balance=50, nothing consumed) +// Upgrade to Premium (200) with carry_over_usages: { enabled: true } +// Expected: balance = 200 (no deduction — usage was zero) +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("carry-over-usage 2: zero usage is a no-op — new plan starts at full allowance")}`, async () => { + const proMessages = items.monthlyMessages({ includedUsage: 50 }); + const premiumMessages = items.monthlyMessages({ includedUsage: 200 }); + + const pro = products.pro({ id: "pro", items: [proMessages] }); + const premium = products.premium({ id: "premium", items: [premiumMessages] }); + + const { customerId, autumnV2_1, autumnV1 } = await initScenario({ + customerId: "carry-over-usage-zero", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + ], + actions: [s.attach({ productId: pro.id, timeout: 4000 })], + }); + + // No tracking — usage stays at 0 + + // Upgrade with carry_over_usages — zero usage is a silent no-op + await autumnV2_1.billing.attach({ + customer_id: customerId, + plan_id: premium.id, + carry_over_usages: { enabled: true }, + }); + + await new Promise((resolve) => setTimeout(resolve, 2000)); + + const customer = await autumnV1.customers.get(customerId); + + // Balance = 200 only (no deduction — nothing was used) + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + balance: 200, + usage: 0, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 3: Clamp to zero — usage exceeds new plan allowance +// +// Pro: 50 messages, 50 used (balance=0, all used) +// Upgrade to Premium (30 messages) with carry_over_usages: { enabled: true } +// Expected: balance = 0 (50 usage > 30 new allowance — clamped, not negative) +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("carry-over-usage 3: balance is clamped to zero when carried usage exceeds new plan allowance")}`, async () => { + const proMessages = items.monthlyMessages({ includedUsage: 50 }); + const premiumMessages = items.monthlyMessages({ includedUsage: 30 }); + + const pro = products.pro({ id: "pro", items: [proMessages] }); + const premium = products.premium({ id: "premium", items: [premiumMessages] }); + + const { customerId, autumnV2_1, autumnV1 } = await initScenario({ + customerId: "carry-over-usage-clamp", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + ], + actions: [s.attach({ productId: pro.id, timeout: 4000 })], + }); + + // Exhaust all 50 allowance (balance: 50 → 0) + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 50, + }); + + // Wait for Redis → Postgres sync + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // Upgrade with carry_over_usages — 50 usage > 30 new allowance, must clamp to 0 + await autumnV2_1.billing.attach({ + customer_id: customerId, + plan_id: premium.id, + carry_over_usages: { enabled: true }, + }); + + await new Promise((resolve) => setTimeout(resolve, 2000)); + + const customer = await autumnV1.customers.get(customerId); + + // Balance = 0 (clamped — cannot go negative) + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + balance: 0, + }); +}); diff --git a/server/tests/integration/billing/attach/params/carry-over-usages/carry-over-usage-errors.test.ts b/server/tests/integration/billing/attach/params/carry-over-usages/carry-over-usage-errors.test.ts new file mode 100644 index 000000000..0b377975e --- /dev/null +++ b/server/tests/integration/billing/attach/params/carry-over-usages/carry-over-usage-errors.test.ts @@ -0,0 +1,107 @@ +/** + * Carry Over Usages — Error Cases + * + * carry_over_usages validation: + * - Scheduled/downgrade switches are blocked → InvalidRequest + * - Allocated (continuous_use) features in feature_ids are blocked → InvalidRequest + */ + +import { test } from "bun:test"; +import { ErrCode } from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features"; +import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils"; +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"; + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 1: Downgrade with carry_over_usages is blocked +// +// Premium: 500 messages, 100 used (balance=400) +// Downgrade to Pro (100) with carry_over_usages: { enabled: true } +// Expected: InvalidRequest — carry_over_usages only supports immediate upgrades +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("carry-over-usage-errors 1: carry_over_usages on scheduled downgrade returns InvalidRequest")}`, async () => { + const premiumMessages = items.monthlyMessages({ includedUsage: 500 }); + const proMessages = items.monthlyMessages({ includedUsage: 100 }); + + const premium = products.premium({ id: "premium", items: [premiumMessages] }); + const pro = products.pro({ id: "pro", items: [proMessages] }); + + const { customerId, autumnV2_1 } = await initScenario({ + customerId: "carry-over-usage-err-downgrade", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [premium, pro] }), + ], + actions: [s.attach({ productId: premium.id, timeout: 4000 })], + }); + + // Track some usage so there's something to (not) carry + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 100, + }); + + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // Downgrade with carry_over_usages — should be rejected + await expectAutumnError({ + errCode: ErrCode.InvalidRequest, + func: async () => { + await autumnV2_1.billing.attach({ + customer_id: customerId, + plan_id: pro.id, + carry_over_usages: { enabled: true }, + }); + }, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 2: Allocated feature in feature_ids → error +// +// Pro with allocated seats (prorated billing) +// Attach Premium with carry_over_usages: { enabled: true, feature_ids: ["users"] } +// Expected: InvalidRequest — allocated features have no consumable usage to carry +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("carry-over-usage-errors 2: allocated feature in feature_ids returns InvalidRequest")}`, async () => { + const proItems = [ + items.monthlyMessages({ includedUsage: 100 }), + items.allocatedUsers({ includedUsage: 5 }), + ]; + const premiumItems = [ + items.monthlyMessages({ includedUsage: 500 }), + items.allocatedUsers({ includedUsage: 10 }), + ]; + + const pro = products.pro({ id: "pro", items: proItems }); + const premium = products.premium({ id: "premium", items: premiumItems }); + + const { customerId, autumnV2_1 } = await initScenario({ + customerId: "carry-over-usage-err-allocated", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + ], + actions: [s.attach({ productId: pro.id, timeout: 4000 })], + }); + + await expectAutumnError({ + errCode: ErrCode.InvalidRequest, + func: async () => { + await autumnV2_1.billing.attach({ + customer_id: customerId, + plan_id: premium.id, + carry_over_usages: { + enabled: true, + feature_ids: [TestFeature.Users], + }, + }); + }, + }); +}); diff --git a/server/tests/integration/billing/attach/params/carry-over-usages/carry-over-usage-feature-ids.test.ts b/server/tests/integration/billing/attach/params/carry-over-usages/carry-over-usage-feature-ids.test.ts new file mode 100644 index 000000000..68b63195d --- /dev/null +++ b/server/tests/integration/billing/attach/params/carry-over-usages/carry-over-usage-feature-ids.test.ts @@ -0,0 +1,94 @@ +/** + * Carry Over Usages — feature_ids Filter Tests + * + * When feature_ids is provided, only the listed features have their usage + * carried over. All other consumable features start at their full new allowance. + */ + +import { test } from "bun:test"; +import type { ApiCustomerV3 } from "@autumn/shared"; +import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect"; +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"; + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 1: feature_ids filter — only listed feature has usage carried over +// +// Pro: 50 messages (30 used), 100 words (60 used) +// Upgrade to Premium (200 messages, 300 words) +// with carry_over_usages: { enabled: true, feature_ids: ["messages"] } +// Expected: +// messages: balance = 170 (200 - 30 carried usage), usage = 30 +// words: balance = 300 (full new allowance — words not in feature_ids), usage = 0 +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("carry-over-usage-feature-ids 1: only feature_ids listed have usage carried — other features start at full new allowance")}`, async () => { + const proMessages = items.monthlyMessages({ includedUsage: 50 }); + const proWords = items.monthlyWords({ includedUsage: 100 }); + + const premiumMessages = items.monthlyMessages({ includedUsage: 200 }); + const premiumWords = items.monthlyWords({ includedUsage: 300 }); + + const pro = products.pro({ id: "pro", items: [proMessages, proWords] }); + const premium = products.premium({ + id: "premium", + items: [premiumMessages, premiumWords], + }); + + const { customerId, autumnV2_1, autumnV1 } = await initScenario({ + customerId: "carry-over-usage-feature-ids1", + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro, premium] }), + ], + actions: [s.attach({ productId: pro.id, timeout: 4000 })], + }); + + // Track 30 messages and 60 words + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: 30, + }); + await autumnV2_1.track({ + customer_id: customerId, + feature_id: TestFeature.Words, + value: 60, + }); + + // Wait for Redis → Postgres sync + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // Upgrade with carry_over_usages scoped to messages only + await autumnV2_1.billing.attach({ + customer_id: customerId, + plan_id: premium.id, + carry_over_usages: { + enabled: true, + feature_ids: [TestFeature.Messages], + }, + }); + + await new Promise((resolve) => setTimeout(resolve, 2000)); + + const customer = await autumnV1.customers.get(customerId); + + // messages: 200 - 30 carried usage = 170 + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + balance: 170, + usage: 30, + }); + + // words: full new allowance (not in feature_ids — usage not carried) + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Words, + balance: 300, + usage: 0, + }); +}); diff --git a/shared/api/billing/attachV2/attachParamsV1.ts b/shared/api/billing/attachV2/attachParamsV1.ts index e40a35b10..62cade8a8 100644 --- a/shared/api/billing/attachV2/attachParamsV1.ts +++ b/shared/api/billing/attachV2/attachParamsV1.ts @@ -48,6 +48,21 @@ export const AttachParamsV1Schema = BillingParamsBaseV1Schema.extend({ .meta({ description: "Whether to carry over balances from the previous plan.", }), + + carry_over_usages: z + .object({ + enabled: z.boolean().meta({ + description: "Whether to carry over usages from the previous plan.", + }), + feature_ids: z.array(z.string()).optional().meta({ + description: + "The IDs of the features to carry over usages for. If left undefined, all consumable features will be carried over.", + }), + }) + .optional() + .meta({ + description: "Whether to carry over usages from the previous plan.", + }), }); export type AttachParamsV1 = z.infer;