From e6077458a22efbf86f6fd9fac18dfa9ece3bae90 Mon Sep 17 00:00:00 2001 From: John Yeo Date: Tue, 17 Feb 2026 12:48:11 +0000 Subject: [PATCH] feat: new attach / update subscription params --- server/src/external/autumn/autumnCli.ts | 38 +- .../src/honoMiddlewares/versionedValidator.ts | 1 + .../billing/v2/actions/attach/attach.ts | 14 +- .../attach/compute/computeAttachPlan.ts | 4 +- .../attach/compute/finalizeAttachPlan.ts | 4 +- .../v2/actions/attach/createAutumnCheckout.ts | 6 +- .../handleAttachBillingBehaviorErrors.ts | 8 +- .../attach/errors/handleAttachV2Errors.ts | 4 +- .../attach/setup/setupAttachBillingContext.ts | 11 +- .../attach/setup/setupAttachProductContext.ts | 6 +- .../attach/setup/setupAttachTrialContext.ts | 4 +- .../billing/v2/actions/legacy/legacyAttach.ts | 14 +- .../billing/v2/actions/legacy/renew.ts | 4 +- .../v2/actions/legacy/updateQuantity.ts | 4 +- .../billing/v2/actions/migrate/migrate.ts | 4 +- .../computeUpdateSubscriptionIntent.ts | 6 +- .../compute/computeUpdateSubscriptionPlan.ts | 11 +- .../compute/customPlan/computeCustomPlan.ts | 7 +- .../compute/finalizeUpdateSubscriptionPlan.ts | 4 +- .../errors/handleBillingBehaviorErrors.ts | 4 +- .../errors/handleCustomPlanErrors.ts | 12 +- .../errors/handleFeatureQuantityErrors.ts | 6 +- .../errors/handleOneOffErrors.ts | 10 +- .../errors/handleUpdateSubscriptionErrors.ts | 4 +- .../setup/findTargetCustomerProduct.ts | 4 +- .../setup/setupDefaultProductContext.ts | 4 +- .../setupUpdateSubscriptionBillingContext.ts | 11 +- .../setupUpdateSubscriptionProductContext.ts | 6 +- .../setupUpdateSubscriptionTrialContext.ts | 4 +- .../updateSubscription/updateSubscription.ts | 4 +- .../paramsToFeatureOptions.ts | 4 +- .../billing/v2/handlers/handleAttachV2.ts | 14 +- .../v2/handlers/handlePreviewAttach.ts | 13 +- .../handlePreviewUpdateSubscription.ts | 13 +- .../v2/handlers/handleUpdateSubscription.ts | 9 +- .../billing/v2/setup/setupCancelMode.ts | 5 +- .../v2/setup/setupCustomFullProduct.ts | 22 +- .../v2/setup/setupFeatureQuantitiesContext.ts | 4 +- .../v2/setup/setupFullCustomerContext.ts | 4 +- .../v2/setup/setupInvoiceModeContext.ts | 6 +- .../v2/setup/setupTransitionConfigs.ts | 7 +- .../trialContext/handleFreeTrialParam.ts | 8 +- .../billingPlanToAutumnCheckout.ts | 4 +- .../handlers/handleConfirmCheckout.ts | 4 +- .../checkouts/handlers/handleGetCheckout.ts | 4 +- .../handlers/handlePreviewCheckout.ts | 6 +- .../customers/cancel/handleCancelV2.ts | 4 +- .../free-trials/initFreeTrialFromParamsV1.ts | 25 ++ .../attach/v2-params/v2-customize.test.ts | 396 ++++++++++++++++++ .../attach/v2-params/v2-free-trial.test.ts | 153 +++++++ .../v2-params/v2-customize.test.ts | 265 ++++++++++++ .../v2-params/v2-free-trial.test.ts | 158 +++++++ .../utils/expectCustomerFeatureCorrect.ts | 3 +- .../apply-existing-rollovers1.test.ts | 3 +- .../apply-existing-rollovers3.test.ts | 3 +- .../apply-existing-rollovers4.test.ts | 4 +- .../apply-existing-rollovers5.test.ts | 4 +- .../apply-existing-rollovers6.test.ts | 3 +- ...t-to-existing-usages-paid-features.test.ts | 30 +- .../cus-product-to-existing-usages.test.ts | 6 +- .../setup-feature-quantities-context.spec.ts | 270 ++++++++++-- .../stripe/stripeSubscriptionTestHelpers.ts | 64 ++- ...ription-items-update-multi-product.spec.ts | 70 ++-- .../build-subscription-items-update.spec.ts | 28 +- ...compute-update-subscription-intent.spec.ts | 32 +- server/tests/utils/fixtures/db/contexts.ts | 4 +- .../utils/fixtures/db/customerEntitlements.ts | 4 + .../tests/utils/fixtures/db/entitlements.ts | 11 +- server/tests/utils/fixtures/db/prices.ts | 1 + server/tests/utils/fixtures/db/products.ts | 11 +- server/tests/utils/fixtures/itemsV2.ts | 133 ++++++ .../check/changes/V1.2_CheckQueryChange.ts | 3 + .../requestChanges/V1.2_TrackParamsChange.ts | 3 + shared/api/billing/attachV2/attachParamsV0.ts | 8 +- shared/api/billing/attachV2/attachParamsV1.ts | 48 +++ .../requestChanges/V1.2_AttachParamsChange.ts | 50 +++ .../billingParamsBaseV0.ts} | 8 +- .../billingParamsBase/billingParamsBaseV1.ts | 22 + shared/api/billing/common/billingResponse.ts | 1 - .../common/customizePlan/customizePlanV0.ts | 6 + .../common/customizePlan/customizePlanV1.ts | 14 + .../mappers/customizePlanV1ToV0.ts | 62 +++ shared/api/billing/common/redirectMode.ts | 4 + shared/api/billing/index.ts | 10 +- .../V1.2_UpdateSubscriptionParamsChange.ts | 50 +++ .../updateSubscriptionV0Params.ts | 4 +- .../updateSubscriptionV1Params.ts | 99 +++++ .../mappers/freeTrialParamsV0ToV1.ts | 20 + .../V1.2_CustomerQueryChange.ts | 3 + .../requestChanges/V1.2_EntityQueryChange.ts | 3 + .../changes/V1.2_FeatureParamsChange.ts | 3 + .../components/basePrice/basePrice.ts | 26 ++ .../basePrice/basePriceToProductItem.ts | 53 +++ .../api/products/crud/createPlanParamsV0.ts | 10 +- .../items/crud/createPlanItemParamsV1.ts | 10 +- .../mappers/planItemParamsV1ToPlanItemV0.ts | 2 +- .../items/mappers/planItemV0ToProductItem.ts | 7 + .../products/items/mappers/planItemV1ToV0.ts | 26 +- .../items/previousVersions/apiPlanItemV0.ts | 7 + .../mappers/planV0ToBasePriceProductItem.ts | 50 --- .../products/mappers/planV0ToProductItems.ts | 9 +- .../products/mappers/planV1ToProductItems.ts | 40 ++ .../products/previousVersions/apiPlanV0.ts | 12 +- .../versionChangeUtils/VersionChange.ts | 4 + .../versionChangeUtils/applyVersionChanges.ts | 6 + .../versionChangeRegistry.ts | 4 + shared/models/checkouts/checkoutTable.ts | 8 +- shared/types/sharedContext.ts | 1 + .../productItemToBasePriceParams.ts | 21 + .../productItemToPlanItemParamsV1.ts | 67 +++ .../productItemsToCustomizePlanV1.ts | 31 ++ versionAttach.md | 124 ++++++ 112 files changed, 2555 insertions(+), 419 deletions(-) create mode 100644 server/src/internal/products/free-trials/initFreeTrialFromParamsV1.ts create mode 100644 server/tests/integration/billing/attach/v2-params/v2-customize.test.ts create mode 100644 server/tests/integration/billing/attach/v2-params/v2-free-trial.test.ts create mode 100644 server/tests/integration/billing/update-subscription/v2-params/v2-customize.test.ts create mode 100644 server/tests/integration/billing/update-subscription/v2-params/v2-free-trial.test.ts create mode 100644 server/tests/utils/fixtures/itemsV2.ts create mode 100644 shared/api/billing/attachV2/attachParamsV1.ts create mode 100644 shared/api/billing/attachV2/requestChanges/V1.2_AttachParamsChange.ts rename shared/api/billing/common/{billingParamsBase.ts => billingParamsBase/billingParamsBaseV0.ts} (72%) create mode 100644 shared/api/billing/common/billingParamsBase/billingParamsBaseV1.ts create mode 100644 shared/api/billing/common/customizePlan/customizePlanV0.ts create mode 100644 shared/api/billing/common/customizePlan/customizePlanV1.ts create mode 100644 shared/api/billing/common/customizePlan/mappers/customizePlanV1ToV0.ts create mode 100644 shared/api/billing/common/redirectMode.ts create mode 100644 shared/api/billing/updateSubscription/requestChanges/V1.2_UpdateSubscriptionParamsChange.ts create mode 100644 shared/api/billing/updateSubscription/updateSubscriptionV1Params.ts create mode 100644 shared/api/common/freeTrial/mappers/freeTrialParamsV0ToV1.ts create mode 100644 shared/api/products/components/basePrice/basePrice.ts create mode 100644 shared/api/products/components/basePrice/basePriceToProductItem.ts delete mode 100644 shared/api/products/mappers/planV0ToBasePriceProductItem.ts create mode 100644 shared/api/products/mappers/planV1ToProductItems.ts create mode 100644 shared/utils/productV2Utils/productItemUtils/convertProductItem/productItemToBasePriceParams.ts create mode 100644 shared/utils/productV2Utils/productItemUtils/convertProductItem/productItemToPlanItemParamsV1.ts create mode 100644 shared/utils/productV2Utils/productItemUtils/convertProductItem/productItemsToCustomizePlanV1.ts create mode 100644 versionAttach.md diff --git a/server/src/external/autumn/autumnCli.ts b/server/src/external/autumn/autumnCli.ts index fdf6e9477..521f7e653 100644 --- a/server/src/external/autumn/autumnCli.ts +++ b/server/src/external/autumn/autumnCli.ts @@ -11,8 +11,6 @@ import { type AttachBodyV0, type AttachParamsV0Input, type BalancesUpdateParams, - type BillingPreviewResponse, - type BillingResponse, type CheckQuery, type CreateBalanceParamsV0, type CreateCustomerInternalOptions, @@ -263,13 +261,13 @@ export class AutumnInt { return data; } - async attach( - params: AttachBodyV0, + async attach( + params: TInput, { skipWebhooks, idempotencyKey, }: { skipWebhooks?: boolean; idempotencyKey?: string } = {}, - ) { + ): Promise { const headers: Record = {}; if (skipWebhooks !== undefined) { headers["x-skip-webhooks"] = skipWebhooks ? "true" : "false"; @@ -815,13 +813,13 @@ export class AutumnInt { }; subscriptions = { - update: async ( - params: UpdateSubscriptionV0Params, + update: async ( + params: TInput, { timeout, skipWebhooks, }: { timeout?: number; skipWebhooks?: boolean } = {}, - ): Promise => { + ): Promise => { const headers: Record = {}; if (skipWebhooks !== undefined) { headers["x-skip-webhooks"] = skipWebhooks ? "true" : "false"; @@ -838,15 +836,21 @@ export class AutumnInt { return data; }, - previewUpdate: async (params: UpdateSubscriptionV0Params) => { + previewUpdate: async ( + params: TInput, + ): Promise => { const data = await this.post(`/subscriptions/preview_update`, params); return data; }, }; billing = { - attach: async ( - params: Omit & { items?: ProductItem[] }, + attach: async < + TInput = Omit & { + items?: ProductItem[]; + }, + >( + params: TInput, { skipWebhooks, idempotencyKey, @@ -856,7 +860,7 @@ export class AutumnInt { idempotencyKey?: string; timeout?: number; } = {}, - ) => { + ): Promise => { const headers: Record = {}; if (skipWebhooks !== undefined) { headers["x-skip-webhooks"] = skipWebhooks ? "true" : "false"; @@ -877,9 +881,13 @@ export class AutumnInt { return data; }, - previewAttach: async ( - params: Omit & { items?: ProductItem[] }, - ): Promise => { + previewAttach: async < + TInput = Omit & { + items?: ProductItem[]; + }, + >( + params: TInput, + ): Promise => { const data = await this.post(`/billing/preview_attach`, { ...params, redirect_mode: "if_required", diff --git a/server/src/honoMiddlewares/versionedValidator.ts b/server/src/honoMiddlewares/versionedValidator.ts index 6fe897bda..78a56b2dd 100644 --- a/server/src/honoMiddlewares/versionedValidator.ts +++ b/server/src/honoMiddlewares/versionedValidator.ts @@ -122,6 +122,7 @@ export const versionedValidator = ({ fromVersion: userVersion, toVersion: new ApiVersionClass(LATEST_VERSION), resource, + ctx, }); // Replace validated data with transformed version diff --git a/server/src/internal/billing/v2/actions/attach/attach.ts b/server/src/internal/billing/v2/actions/attach/attach.ts index 1991eec82..c9be5fe9e 100644 --- a/server/src/internal/billing/v2/actions/attach/attach.ts +++ b/server/src/internal/billing/v2/actions/attach/attach.ts @@ -1,9 +1,9 @@ -import { - type AttachBillingContext, - type AttachParamsV0, - type BillingContextOverride, - type BillingPlan, - type BillingResult, +import type { + AttachBillingContext, + AttachParamsV1, + BillingContextOverride, + BillingPlan, + BillingResult, } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { computeAttachPlan } from "@/internal/billing/v2/actions/attach/compute/computeAttachPlan"; @@ -31,7 +31,7 @@ export async function attach({ contextOverride, }: { ctx: AutumnContext; - params: AttachParamsV0; + params: AttachParamsV1; preview?: boolean; skipAutumnCheckout?: boolean; contextOverride?: BillingContextOverride; 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 117c7f52e..6a57a86db 100644 --- a/server/src/internal/billing/v2/actions/attach/compute/computeAttachPlan.ts +++ b/server/src/internal/billing/v2/actions/attach/compute/computeAttachPlan.ts @@ -1,6 +1,6 @@ import type { AttachBillingContext, - AttachParamsV0, + AttachParamsV1, AutumnBillingPlan, } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; @@ -25,7 +25,7 @@ export const computeAttachPlan = ({ }: { ctx: AutumnContext; attachBillingContext: AttachBillingContext; - params: AttachParamsV0; + params: AttachParamsV1; }): AutumnBillingPlan => { const { currentCustomerProduct, diff --git a/server/src/internal/billing/v2/actions/attach/compute/finalizeAttachPlan.ts b/server/src/internal/billing/v2/actions/attach/compute/finalizeAttachPlan.ts index 73ac3fecb..501f6275b 100644 --- a/server/src/internal/billing/v2/actions/attach/compute/finalizeAttachPlan.ts +++ b/server/src/internal/billing/v2/actions/attach/compute/finalizeAttachPlan.ts @@ -1,6 +1,6 @@ import type { AttachBillingContext, - AttachParamsV0, + AttachParamsV1, AutumnBillingPlan, } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; @@ -19,7 +19,7 @@ export const finalizeAttachPlan = ({ ctx: AutumnContext; plan: AutumnBillingPlan; attachBillingContext: AttachBillingContext; - params: AttachParamsV0; + params: AttachParamsV1; }): AutumnBillingPlan => { plan.lineItems = finalizeLineItems({ ctx, diff --git a/server/src/internal/billing/v2/actions/attach/createAutumnCheckout.ts b/server/src/internal/billing/v2/actions/attach/createAutumnCheckout.ts index 1b1710e4f..3c220e6a2 100644 --- a/server/src/internal/billing/v2/actions/attach/createAutumnCheckout.ts +++ b/server/src/internal/billing/v2/actions/attach/createAutumnCheckout.ts @@ -1,6 +1,6 @@ import type { AttachBillingContext, - AttachParamsV0, + AttachParamsV1, BillingPlan, } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; @@ -20,11 +20,11 @@ export async function createAutumnCheckout({ billingPlan, }: { ctx: AutumnContext; - params: AttachParamsV0; + params: AttachParamsV1; billingContext: AttachBillingContext; billingPlan: BillingPlan; }): Promise { - const { checkout, checkoutUrl } = await billingPlanToAutumnCheckout({ + const { checkout } = await billingPlanToAutumnCheckout({ ctx, params, billingContext, diff --git a/server/src/internal/billing/v2/actions/attach/errors/handleAttachBillingBehaviorErrors.ts b/server/src/internal/billing/v2/actions/attach/errors/handleAttachBillingBehaviorErrors.ts index e56fa5bcc..e7b19bfa0 100644 --- a/server/src/internal/billing/v2/actions/attach/errors/handleAttachBillingBehaviorErrors.ts +++ b/server/src/internal/billing/v2/actions/attach/errors/handleAttachBillingBehaviorErrors.ts @@ -1,11 +1,11 @@ import { + type AttachBillingContext, + type AttachParamsV1, + type AutumnBillingPlan, cusProductToPrices, ErrCode, isFreeProduct, RecaseError, - type AttachBillingContext, - type AttachParamsV0, - type AutumnBillingPlan, } from "@autumn/shared"; import { getTrialStateTransition } from "@/internal/billing/v2/utils/billingContext/getTrialStateTransition"; @@ -22,7 +22,7 @@ export const handleAttachBillingBehaviorErrors = ({ }: { billingContext: AttachBillingContext; autumnBillingPlan: AutumnBillingPlan; - params: AttachParamsV0; + params: AttachParamsV1; }) => { // Only validate when billing_behavior is 'next_cycle_only' (defer charges) if (params.billing_behavior !== "next_cycle_only") return; 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 2777b4f40..40dcb253a 100644 --- a/server/src/internal/billing/v2/actions/attach/errors/handleAttachV2Errors.ts +++ b/server/src/internal/billing/v2/actions/attach/errors/handleAttachV2Errors.ts @@ -1,6 +1,6 @@ import type { AttachBillingContext, - AttachParamsV0, + AttachParamsV1, AutumnBillingPlan, } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; @@ -24,7 +24,7 @@ export const handleAttachV2Errors = ({ ctx: AutumnContext; billingContext: AttachBillingContext; autumnBillingPlan: AutumnBillingPlan; - params: AttachParamsV0; + params: AttachParamsV1; }) => { // 1. External PSP errors (RevenueCat) handleExternalPSPErrors({ diff --git a/server/src/internal/billing/v2/actions/attach/setup/setupAttachBillingContext.ts b/server/src/internal/billing/v2/actions/attach/setup/setupAttachBillingContext.ts index 597eceacd..1a97d5d45 100644 --- a/server/src/internal/billing/v2/actions/attach/setup/setupAttachBillingContext.ts +++ b/server/src/internal/billing/v2/actions/attach/setup/setupAttachBillingContext.ts @@ -1,12 +1,9 @@ import type { AttachBillingContext, + AttachParamsV1, BillingContextOverride, } from "@autumn/shared"; -import { - type AttachParamsV0, - BillingVersion, - notNullish, -} from "@autumn/shared"; +import { BillingVersion, notNullish } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { setupStripeBillingContext } from "@/internal/billing/v2/providers/stripe/setup/setupStripeBillingContext"; import { setupBillingCycleAnchor } from "@/internal/billing/v2/setup/setupBillingCycleAnchor"; @@ -30,7 +27,7 @@ export const setupAttachBillingContext = async ({ contextOverride = {}, }: { ctx: AutumnContext; - params: AttachParamsV0; + params: AttachParamsV1; contextOverride?: BillingContextOverride; }): Promise => { const { fullCustomer: fullCustomerOverride } = contextOverride; @@ -84,7 +81,7 @@ export const setupAttachBillingContext = async ({ }); const invoiceMode = setupInvoiceModeContext({ params }); - const isCustom = notNullish(params.items); + const isCustom = notNullish(params.customize); // Timestamp context const currentEpochMs = testClockFrozenTime ?? Date.now(); diff --git a/server/src/internal/billing/v2/actions/attach/setup/setupAttachProductContext.ts b/server/src/internal/billing/v2/actions/attach/setup/setupAttachProductContext.ts index 7079e0838..465cc35ca 100644 --- a/server/src/internal/billing/v2/actions/attach/setup/setupAttachProductContext.ts +++ b/server/src/internal/billing/v2/actions/attach/setup/setupAttachProductContext.ts @@ -1,4 +1,4 @@ -import type { AttachParamsV0, BillingContextOverride } from "@autumn/shared"; +import type { AttachParamsV1, BillingContextOverride } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { ProductService } from "@/internal/products/ProductService"; import { setupCustomFullProduct } from "../../../setup/setupCustomFullProduct"; @@ -12,7 +12,7 @@ export const setupAttachProductContext = async ({ contextOverride = {}, }: { ctx: AutumnContext; - params: AttachParamsV0; + params: AttachParamsV1; contextOverride?: BillingContextOverride; }) => { const { productContext } = contextOverride; @@ -37,7 +37,7 @@ export const setupAttachProductContext = async ({ } = await setupCustomFullProduct({ ctx, currentFullProduct: fullProduct, - customItems: params.items, + customizePlan: params.customize, }); return { diff --git a/server/src/internal/billing/v2/actions/attach/setup/setupAttachTrialContext.ts b/server/src/internal/billing/v2/actions/attach/setup/setupAttachTrialContext.ts index a89c36cb2..cb107ba6b 100644 --- a/server/src/internal/billing/v2/actions/attach/setup/setupAttachTrialContext.ts +++ b/server/src/internal/billing/v2/actions/attach/setup/setupAttachTrialContext.ts @@ -1,5 +1,5 @@ import { - type AttachParamsV0, + type AttachParamsV1, type FullCusProduct, type FullCustomer, type FullProduct, @@ -30,7 +30,7 @@ export const setupAttachTrialContext = async ({ currentContext, }: { ctx: AutumnContext; - params: AttachParamsV0; + params: AttachParamsV1; currentContext: { fullCustomer: FullCustomer; attachProduct: FullProduct; diff --git a/server/src/internal/billing/v2/actions/legacy/legacyAttach.ts b/server/src/internal/billing/v2/actions/legacy/legacyAttach.ts index bb39fa9a4..47f61418c 100644 --- a/server/src/internal/billing/v2/actions/legacy/legacyAttach.ts +++ b/server/src/internal/billing/v2/actions/legacy/legacyAttach.ts @@ -1,5 +1,9 @@ -import type { BillingContextOverride, PlanTiming } from "@autumn/shared"; -import { type AttachParamsV0, BillingVersion } from "@autumn/shared"; +import type { + AttachParamsV1, + BillingContextOverride, + PlanTiming, +} from "@autumn/shared"; +import { BillingVersion } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { billingActions } from "@/internal/billing/v2/actions"; import { attachParamsToStripeBillingContext } from "@/internal/billing/v2/actions/legacy/utils/attachParamsToStripeBillingContext"; @@ -44,14 +48,10 @@ export const legacyAttach = async ({ const fullCustomer = attachParams.customer; - const params: AttachParamsV0 = { + const params: AttachParamsV1 = { customer_id: fullCustomer.id || fullCustomer.internal_id, entity_id: fullCustomer.entity?.id, product_id: fullProduct.id, - // items: body.items, - // version: body.version, - // invoice: body.invoice, - // free_trial: body.free_trial === false ? null : undefined, invoice: attachParams.invoiceOnly, enable_product_immediately: true, diff --git a/server/src/internal/billing/v2/actions/legacy/renew.ts b/server/src/internal/billing/v2/actions/legacy/renew.ts index cb1cc1545..6fe309d4d 100644 --- a/server/src/internal/billing/v2/actions/legacy/renew.ts +++ b/server/src/internal/billing/v2/actions/legacy/renew.ts @@ -1,6 +1,6 @@ import type { UpdateSubscriptionBillingContextOverride, - UpdateSubscriptionV0Params, + UpdateSubscriptionV1Params, } from "@autumn/shared"; import { type AttachBodyV0, @@ -63,7 +63,7 @@ export const renew = async ({ const fullCustomer = attachParams.customer; - const params: UpdateSubscriptionV0Params = { + const params: UpdateSubscriptionV1Params = { customer_id: fullCustomer.id || fullCustomer.internal_id, entity_id: fullCustomer.entity?.id, product_id: fullProduct.id, diff --git a/server/src/internal/billing/v2/actions/legacy/updateQuantity.ts b/server/src/internal/billing/v2/actions/legacy/updateQuantity.ts index 947c12729..abd4b38dc 100644 --- a/server/src/internal/billing/v2/actions/legacy/updateQuantity.ts +++ b/server/src/internal/billing/v2/actions/legacy/updateQuantity.ts @@ -1,6 +1,6 @@ import type { UpdateSubscriptionBillingContextOverride, - UpdateSubscriptionV0Params, + UpdateSubscriptionV1Params, } from "@autumn/shared"; import { type AttachBodyV0, @@ -64,7 +64,7 @@ export const updateQuantity = async ({ const fullCustomer = attachParams.customer; - const params: UpdateSubscriptionV0Params = { + const params: UpdateSubscriptionV1Params = { customer_id: fullCustomer.id || fullCustomer.internal_id, entity_id: fullCustomer.entity?.id, product_id: fullProduct.id, diff --git a/server/src/internal/billing/v2/actions/migrate/migrate.ts b/server/src/internal/billing/v2/actions/migrate/migrate.ts index 51a73a030..9a7b0519d 100644 --- a/server/src/internal/billing/v2/actions/migrate/migrate.ts +++ b/server/src/internal/billing/v2/actions/migrate/migrate.ts @@ -6,7 +6,7 @@ import { type FullCustomer, type FullProduct, featureUtils, - type UpdateSubscriptionV0Params, + type UpdateSubscriptionV1Params, } from "@autumn/shared"; import type { FeatureOptionsParamsV0 } from "@shared/api/billing/common/featureOptions/featureOptionsParamsV0"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; @@ -45,7 +45,7 @@ export async function migrate({ reset_after_trial_end: true, })); - const updateSubscriptionParams: UpdateSubscriptionV0Params = { + const updateSubscriptionParams: UpdateSubscriptionV1Params = { customer_id: fullCustomer.id || fullCustomer.internal_id, customer_product_id: currentCustomerProduct.id, entity_id: entity?.id, diff --git a/server/src/internal/billing/v2/actions/updateSubscription/compute/computeUpdateSubscriptionIntent.ts b/server/src/internal/billing/v2/actions/updateSubscription/compute/computeUpdateSubscriptionIntent.ts index 9f906a2bd..bbb47966c 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/compute/computeUpdateSubscriptionIntent.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/compute/computeUpdateSubscriptionIntent.ts @@ -1,4 +1,4 @@ -import type { UpdateSubscriptionV0Params } from "@shared/index"; +import type { UpdateSubscriptionV1Params } from "@shared/index"; export enum UpdateSubscriptionIntent { UpdateQuantity = "update_quantity", @@ -10,9 +10,9 @@ export enum UpdateSubscriptionIntent { * Compute the intent for a subscription update */ export const computeUpdateSubscriptionIntent = ( - params: UpdateSubscriptionV0Params, + params: UpdateSubscriptionV1Params, ): UpdateSubscriptionIntent => { - const itemsChanged = params.items !== undefined; + const itemsChanged = params.customize !== undefined; const versionChanged = params.version !== undefined; const freeTrialChanged = params.free_trial !== undefined; diff --git a/server/src/internal/billing/v2/actions/updateSubscription/compute/computeUpdateSubscriptionPlan.ts b/server/src/internal/billing/v2/actions/updateSubscription/compute/computeUpdateSubscriptionPlan.ts index 6cc07525d..e3a05e166 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/compute/computeUpdateSubscriptionPlan.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/compute/computeUpdateSubscriptionPlan.ts @@ -1,7 +1,9 @@ -import type { UpdateSubscriptionV0Params } from "@autumn/shared"; +import type { + AutumnBillingPlan, + UpdateSubscriptionBillingContext, + UpdateSubscriptionV1Params, +} from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; -import type { UpdateSubscriptionBillingContext } from "@autumn/shared"; -import type { AutumnBillingPlan } from "@autumn/shared"; import { computeCancelPlan } from "@/internal/billing/v2/actions/updateSubscription/compute/cancel/computeCancelPlan"; @@ -23,7 +25,7 @@ export const computeUpdateSubscriptionPlan = async ({ }: { ctx: AutumnContext; billingContext: UpdateSubscriptionBillingContext; - params: UpdateSubscriptionV0Params; + params: UpdateSubscriptionV1Params; }): Promise => { const intent = computeUpdateSubscriptionIntent(params); @@ -39,7 +41,6 @@ export const computeUpdateSubscriptionPlan = async ({ plan = await computeCustomPlan({ ctx, updateSubscriptionContext: billingContext, - params, }); break; case UpdateSubscriptionIntent.None: diff --git a/server/src/internal/billing/v2/actions/updateSubscription/compute/customPlan/computeCustomPlan.ts b/server/src/internal/billing/v2/actions/updateSubscription/compute/customPlan/computeCustomPlan.ts index 94faed94f..d232cf2c4 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/compute/customPlan/computeCustomPlan.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/compute/customPlan/computeCustomPlan.ts @@ -2,10 +2,7 @@ import type { AutumnBillingPlan, UpdateSubscriptionBillingContext, } from "@autumn/shared"; -import { - CusProductStatus, - type UpdateSubscriptionV0Params, -} from "@autumn/shared"; +import { CusProductStatus } from "@autumn/shared"; import type { AutumnContext } from "@server/honoUtils/HonoEnv"; import { computeDeleteCustomerProduct } from "@/internal/billing/v2/actions/updateSubscription/compute/computeDeleteCustomerProduct"; import { computeCustomPlanNewCustomerProduct } from "@/internal/billing/v2/actions/updateSubscription/compute/customPlan/computeCustomPlanNewCustomerProduct"; @@ -14,11 +11,9 @@ import { buildAutumnLineItems } from "@/internal/billing/v2/compute/computeAutum export const computeCustomPlan = async ({ ctx, updateSubscriptionContext, - params, }: { ctx: AutumnContext; updateSubscriptionContext: UpdateSubscriptionBillingContext; - params: UpdateSubscriptionV0Params; }) => { const { customerProduct, diff --git a/server/src/internal/billing/v2/actions/updateSubscription/compute/finalizeUpdateSubscriptionPlan.ts b/server/src/internal/billing/v2/actions/updateSubscription/compute/finalizeUpdateSubscriptionPlan.ts index 81ed08eb6..c96848701 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/compute/finalizeUpdateSubscriptionPlan.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/compute/finalizeUpdateSubscriptionPlan.ts @@ -2,7 +2,7 @@ import { type AutumnBillingPlan, isCustomerProductOneOff, type UpdateSubscriptionBillingContext, - type UpdateSubscriptionV0Params, + type UpdateSubscriptionV1Params, } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { finalizeLineItems } from "@/internal/billing/v2/compute/finalize/finalizeLineItems"; @@ -20,7 +20,7 @@ export const finalizeUpdateSubscriptionPlan = ({ ctx: AutumnContext; plan: AutumnBillingPlan; billingContext: UpdateSubscriptionBillingContext; - params: UpdateSubscriptionV0Params; + params: UpdateSubscriptionV1Params; }): AutumnBillingPlan => { // Finalize line items (shared logic) plan.lineItems = finalizeLineItems({ diff --git a/server/src/internal/billing/v2/actions/updateSubscription/errors/handleBillingBehaviorErrors.ts b/server/src/internal/billing/v2/actions/updateSubscription/errors/handleBillingBehaviorErrors.ts index 4055072fd..212cb6e4a 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/errors/handleBillingBehaviorErrors.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/errors/handleBillingBehaviorErrors.ts @@ -7,7 +7,7 @@ import { ErrCode, isFreeProduct, RecaseError, - type UpdateSubscriptionV0Params, + type UpdateSubscriptionV1Params, } from "@autumn/shared"; import { getTrialStateTransition } from "@/internal/billing/v2/utils/billingContext/getTrialStateTransition"; import { @@ -22,7 +22,7 @@ export const handleBillingBehaviorErrors = ({ }: { billingContext: UpdateSubscriptionBillingContext; billingPlan: BillingPlan; - params: UpdateSubscriptionV0Params; + params: UpdateSubscriptionV1Params; }) => { const { autumn: autumnBillingPlan } = billingPlan; diff --git a/server/src/internal/billing/v2/actions/updateSubscription/errors/handleCustomPlanErrors.ts b/server/src/internal/billing/v2/actions/updateSubscription/errors/handleCustomPlanErrors.ts index dde79f35f..7e162e49c 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/errors/handleCustomPlanErrors.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/errors/handleCustomPlanErrors.ts @@ -1,12 +1,14 @@ +import type { + AutumnBillingPlan, + UpdateSubscriptionBillingContext, +} from "@autumn/shared"; import { cusProductToProduct, productsAreSame, RecaseError, - type UpdateSubscriptionV0Params, + type UpdateSubscriptionV1Params, } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; -import type { UpdateSubscriptionBillingContext } from "@autumn/shared"; -import type { AutumnBillingPlan } from "@autumn/shared"; export const handleCustomPlanErrors = ({ ctx, @@ -17,9 +19,9 @@ export const handleCustomPlanErrors = ({ ctx: AutumnContext; billingContext: UpdateSubscriptionBillingContext; autumnBillingPlan: AutumnBillingPlan; - params: UpdateSubscriptionV0Params; + params: UpdateSubscriptionV1Params; }) => { - if (!params.items) return; + if (!params.customize) return; const newCustomerProduct = autumnBillingPlan.insertCustomerProducts?.[0]; const currentCustomerProduct = billingContext.customerProduct; diff --git a/server/src/internal/billing/v2/actions/updateSubscription/errors/handleFeatureQuantityErrors.ts b/server/src/internal/billing/v2/actions/updateSubscription/errors/handleFeatureQuantityErrors.ts index ac1184e0c..bd4eded44 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/errors/handleFeatureQuantityErrors.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/errors/handleFeatureQuantityErrors.ts @@ -1,6 +1,7 @@ import type { AutumnBillingPlan, UpdateSubscriptionBillingContext, + UpdateSubscriptionV1Params, } from "@autumn/shared"; import { cusProductToPrices, @@ -9,7 +10,6 @@ import { nullish, priceToFeature, RecaseError, - type UpdateSubscriptionV0Params, type UsagePriceConfig, } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; @@ -22,7 +22,7 @@ const checkInputFeatureQuantitiesAreValid = ({ billingContext, }: { ctx: AutumnContext; - params: UpdateSubscriptionV0Params; + params: UpdateSubscriptionV1Params; autumnBillingPlan: AutumnBillingPlan; billingContext: UpdateSubscriptionBillingContext; }) => { @@ -66,7 +66,7 @@ export const handleFeatureQuantityErrors = ({ ctx: AutumnContext; billingContext: UpdateSubscriptionBillingContext; autumnBillingPlan: AutumnBillingPlan; - params: UpdateSubscriptionV0Params; + params: UpdateSubscriptionV1Params; }) => { // 1. Check if param feature IDs are valid checkInputFeatureQuantitiesAreValid({ diff --git a/server/src/internal/billing/v2/actions/updateSubscription/errors/handleOneOffErrors.ts b/server/src/internal/billing/v2/actions/updateSubscription/errors/handleOneOffErrors.ts index 7002c65e0..a7342d17f 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/errors/handleOneOffErrors.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/errors/handleOneOffErrors.ts @@ -1,3 +1,7 @@ +import type { + AutumnBillingPlan, + UpdateSubscriptionBillingContext, +} from "@autumn/shared"; import { cusProductToProduct, isCustomerProductOneOff, @@ -5,12 +9,10 @@ import { isOneOffPrice, productsAreSame, RecaseError, - type UpdateSubscriptionV0Params, + type UpdateSubscriptionV1Params, } from "@autumn/shared"; import { cusProductToPrices } from "@shared/utils/cusProductUtils/convertCusProduct"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; -import type { UpdateSubscriptionBillingContext } from "@autumn/shared"; -import type { AutumnBillingPlan } from "@autumn/shared"; import { getTrialStateTransition } from "@/internal/billing/v2/utils/billingContext/getTrialStateTransition"; export const handleOneOffErrors = ({ @@ -22,7 +24,7 @@ export const handleOneOffErrors = ({ ctx: AutumnContext; billingContext: UpdateSubscriptionBillingContext; autumnBillingPlan: AutumnBillingPlan; - params: UpdateSubscriptionV0Params; + params: UpdateSubscriptionV1Params; }) => { const { customerProduct } = billingContext; diff --git a/server/src/internal/billing/v2/actions/updateSubscription/errors/handleUpdateSubscriptionErrors.ts b/server/src/internal/billing/v2/actions/updateSubscription/errors/handleUpdateSubscriptionErrors.ts index 3190886dc..70da895df 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/errors/handleUpdateSubscriptionErrors.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/errors/handleUpdateSubscriptionErrors.ts @@ -1,7 +1,7 @@ import type { BillingPlan, UpdateSubscriptionBillingContext, - UpdateSubscriptionV0Params, + UpdateSubscriptionV1Params, } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { handleCancelEndOfCycleErrors } from "@/internal/billing/v2/actions/updateSubscription/errors/handleCancelEndOfCycleErrors"; @@ -28,7 +28,7 @@ export const handleUpdateSubscriptionErrors = async ({ ctx: AutumnContext; billingContext: UpdateSubscriptionBillingContext; billingPlan: BillingPlan; - params: UpdateSubscriptionV0Params; + params: UpdateSubscriptionV1Params; }) => { const { customerProduct } = billingContext; diff --git a/server/src/internal/billing/v2/actions/updateSubscription/setup/findTargetCustomerProduct.ts b/server/src/internal/billing/v2/actions/updateSubscription/setup/findTargetCustomerProduct.ts index cd61900b7..de804404d 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/setup/findTargetCustomerProduct.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/setup/findTargetCustomerProduct.ts @@ -1,14 +1,14 @@ import { type FullCustomer, isCusProductOnEntity, - type UpdateSubscriptionV0Params, + type UpdateSubscriptionV1Params, } from "@autumn/shared"; export const findTargetCustomerProduct = ({ params, fullCustomer, }: { - params: UpdateSubscriptionV0Params; + params: UpdateSubscriptionV1Params; fullCustomer: FullCustomer; }) => { const cusProducts = fullCustomer.customer_products; diff --git a/server/src/internal/billing/v2/actions/updateSubscription/setup/setupDefaultProductContext.ts b/server/src/internal/billing/v2/actions/updateSubscription/setup/setupDefaultProductContext.ts index 043acd6ea..75567e62c 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/setup/setupDefaultProductContext.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/setup/setupDefaultProductContext.ts @@ -3,7 +3,7 @@ import { type FullCusProduct, type FullProduct, nullish, - type UpdateSubscriptionV0Params, + type UpdateSubscriptionV1Params, } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { getFreeDefaultProductByGroup } from "@/internal/customers/cusProducts/cusProductUtils"; @@ -18,7 +18,7 @@ export const setupDefaultProductContext = async ({ customerProduct, }: { ctx: AutumnContext; - params: UpdateSubscriptionV0Params; + params: UpdateSubscriptionV1Params; customerProduct: FullCusProduct; }): Promise => { // Only fetch if cancel is requested (not null/undefined) diff --git a/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionBillingContext.ts b/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionBillingContext.ts index 4cd9fc86a..05bc8cfca 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionBillingContext.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionBillingContext.ts @@ -1,12 +1,9 @@ import type { UpdateSubscriptionBillingContext, UpdateSubscriptionBillingContextOverride, + UpdateSubscriptionV1Params, } from "@autumn/shared"; -import { - BillingVersion, - notNullish, - type UpdateSubscriptionV0Params, -} from "@autumn/shared"; +import { BillingVersion, notNullish } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { setupDefaultProductContext } from "@/internal/billing/v2/actions/updateSubscription/setup/setupDefaultProductContext"; import { setupUpdateSubscriptionProductContext } from "@/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionProductContext"; @@ -31,7 +28,7 @@ export const setupUpdateSubscriptionBillingContext = async ({ contextOverride = {}, }: { ctx: AutumnContext; - params: UpdateSubscriptionV0Params; + params: UpdateSubscriptionV1Params; contextOverride?: UpdateSubscriptionBillingContextOverride; }): Promise => { const fullCustomer = await setupFullCustomerContext({ @@ -101,7 +98,7 @@ export const setupUpdateSubscriptionBillingContext = async ({ }); const invoiceMode = setupInvoiceModeContext({ params }); - const isCustom = notNullish(params.items); + const isCustom = notNullish(params.customize); const defaultProduct = await setupDefaultProductContext({ ctx, diff --git a/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionProductContext.ts b/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionProductContext.ts index 285ca5abf..8209abf96 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionProductContext.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionProductContext.ts @@ -4,7 +4,7 @@ import { notNullish, RecaseError, type UpdateSubscriptionBillingContextOverride, - type UpdateSubscriptionV0Params, + type UpdateSubscriptionV1Params, } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { ProductService } from "@/internal/products/ProductService"; @@ -19,7 +19,7 @@ export const setupUpdateSubscriptionProductContext = async ({ }: { ctx: AutumnContext; fullCustomer: FullCustomer; - params: UpdateSubscriptionV0Params; + params: UpdateSubscriptionV1Params; contextOverride?: UpdateSubscriptionBillingContextOverride; }) => { const { productContext } = contextOverride; @@ -59,7 +59,7 @@ export const setupUpdateSubscriptionProductContext = async ({ } = await setupCustomFullProduct({ ctx, currentFullProduct: fullProduct, - customItems: params.items, + customizePlan: params.customize, }); return { diff --git a/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionTrialContext.ts b/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionTrialContext.ts index 1a80e13d3..5c7069250 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionTrialContext.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionTrialContext.ts @@ -1,5 +1,5 @@ import type { - BillingParamsBase, + BillingParamsBaseV1, FullCusProduct, FullProduct, TrialContext, @@ -33,7 +33,7 @@ export const setupUpdateSubscriptionTrialContext = ({ customerProduct?: FullCusProduct; currentEpochMs: number; fullProduct: FullProduct; - params: BillingParamsBase; + params: BillingParamsBaseV1; }): TrialContext | undefined => { // Handle explicit free_trial param (null or value) if (params.free_trial !== undefined) { diff --git a/server/src/internal/billing/v2/actions/updateSubscription/updateSubscription.ts b/server/src/internal/billing/v2/actions/updateSubscription/updateSubscription.ts index c8906f482..52aeb181b 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/updateSubscription.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/updateSubscription.ts @@ -3,7 +3,7 @@ import type { BillingResult, UpdateSubscriptionBillingContext, UpdateSubscriptionBillingContextOverride, - UpdateSubscriptionV0Params, + UpdateSubscriptionV1Params, } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { computeUpdateSubscriptionPlan } from "@/internal/billing/v2/actions/updateSubscription/compute/computeUpdateSubscriptionPlan"; @@ -23,7 +23,7 @@ export async function updateSubscription({ contextOverride, }: { ctx: AutumnContext; - params: UpdateSubscriptionV0Params; + params: UpdateSubscriptionV1Params; preview?: boolean; contextOverride?: UpdateSubscriptionBillingContextOverride; }): Promise<{ diff --git a/server/src/internal/billing/v2/compute/computeAutumnUtils/paramsToFeatureOptions.ts b/server/src/internal/billing/v2/compute/computeAutumnUtils/paramsToFeatureOptions.ts index b75d4969a..cab6e3bee 100644 --- a/server/src/internal/billing/v2/compute/computeAutumnUtils/paramsToFeatureOptions.ts +++ b/server/src/internal/billing/v2/compute/computeAutumnUtils/paramsToFeatureOptions.ts @@ -1,5 +1,5 @@ import type { - BillingParamsBase, + BillingParamsBaseV1, EntitlementWithFeature, FeatureOptions, Price, @@ -12,7 +12,7 @@ export const paramsToFeatureOptions = ({ price, entitlement, }: { - params: BillingParamsBase; + params: BillingParamsBaseV1; price: Price; entitlement: EntitlementWithFeature; }): FeatureOptions | undefined => { diff --git a/server/src/internal/billing/v2/handlers/handleAttachV2.ts b/server/src/internal/billing/v2/handlers/handleAttachV2.ts index 67056c349..afb72d0a5 100644 --- a/server/src/internal/billing/v2/handlers/handleAttachV2.ts +++ b/server/src/internal/billing/v2/handlers/handleAttachV2.ts @@ -1,10 +1,20 @@ -import { AttachParamsV0Schema, InternalError } from "@autumn/shared"; +import { + AffectedResource, + ApiVersion, + AttachParamsV0Schema, + AttachParamsV1Schema, + InternalError, +} from "@autumn/shared"; import { billingActions } from "@/internal/billing/v2/actions"; import { createRoute } from "../../../../honoMiddlewares/routeHandler"; import { billingResultToResponse } from "../utils/billingResult/billingResultToResponse"; export const handleAttachV2 = createRoute({ - body: AttachParamsV0Schema, + versionedBody: { + latest: AttachParamsV1Schema, + [ApiVersion.V1_Beta]: AttachParamsV0Schema, + }, + resource: AffectedResource.Attach, lock: process.env.NODE_ENV !== "development" ? { diff --git a/server/src/internal/billing/v2/handlers/handlePreviewAttach.ts b/server/src/internal/billing/v2/handlers/handlePreviewAttach.ts index 6695202c8..522ef44fd 100644 --- a/server/src/internal/billing/v2/handlers/handlePreviewAttach.ts +++ b/server/src/internal/billing/v2/handlers/handlePreviewAttach.ts @@ -1,11 +1,20 @@ -import { AttachParamsV0Schema } from "@autumn/shared"; +import { + AffectedResource, + ApiVersion, + AttachParamsV0Schema, + AttachParamsV1Schema, +} from "@autumn/shared"; import { billingActions } from "@/internal/billing/v2/actions"; import { billingPlanToChanges } from "@/internal/billing/v2/utils/billingPlanToChanges.js"; import { billingPlanToPreviewResponse } from "@/internal/billing/v2/utils/billingPlanToPreviewResponse"; import { createRoute } from "../../../../honoMiddlewares/routeHandler"; export const handlePreviewAttach = createRoute({ - body: AttachParamsV0Schema, + versionedBody: { + latest: AttachParamsV1Schema, + [ApiVersion.V1_Beta]: AttachParamsV0Schema, + }, + resource: AffectedResource.Attach, lock: process.env.NODE_ENV !== "development" ? { diff --git a/server/src/internal/billing/v2/handlers/handlePreviewUpdateSubscription.ts b/server/src/internal/billing/v2/handlers/handlePreviewUpdateSubscription.ts index 94a9effc5..972ba0792 100644 --- a/server/src/internal/billing/v2/handlers/handlePreviewUpdateSubscription.ts +++ b/server/src/internal/billing/v2/handlers/handlePreviewUpdateSubscription.ts @@ -1,10 +1,19 @@ -import { UpdateSubscriptionV0ParamsSchema } from "@autumn/shared"; +import { + AffectedResource, + ApiVersion, + UpdateSubscriptionV0ParamsSchema, + UpdateSubscriptionV1ParamsSchema, +} from "@autumn/shared"; import { billingActions } from "@/internal/billing/v2/actions"; import { billingPlanToPreviewResponse } from "@/internal/billing/v2/utils/billingPlanToPreviewResponse"; import { createRoute } from "../../../../honoMiddlewares/routeHandler"; export const handlePreviewUpdateSubscription = createRoute({ - body: UpdateSubscriptionV0ParamsSchema, + versionedBody: { + latest: UpdateSubscriptionV1ParamsSchema, + [ApiVersion.V1_Beta]: UpdateSubscriptionV0ParamsSchema, + }, + resource: AffectedResource.ApiSubscriptionUpdate, handler: async (c) => { const ctx = c.get("ctx"); const body = c.req.valid("json"); diff --git a/server/src/internal/billing/v2/handlers/handleUpdateSubscription.ts b/server/src/internal/billing/v2/handlers/handleUpdateSubscription.ts index 43954cc25..91ef0defa 100644 --- a/server/src/internal/billing/v2/handlers/handleUpdateSubscription.ts +++ b/server/src/internal/billing/v2/handlers/handleUpdateSubscription.ts @@ -1,13 +1,20 @@ import { + AffectedResource, + ApiVersion, InternalError, UpdateSubscriptionV0ParamsSchema, + UpdateSubscriptionV1ParamsSchema, } from "@autumn/shared"; import { billingActions } from "@/internal/billing/v2/actions"; import { createRoute } from "../../../../honoMiddlewares/routeHandler"; import { billingResultToResponse } from "../utils/billingResult/billingResultToResponse"; export const handleUpdateSubscription = createRoute({ - body: UpdateSubscriptionV0ParamsSchema, + versionedBody: { + latest: UpdateSubscriptionV1ParamsSchema, + [ApiVersion.V1_Beta]: UpdateSubscriptionV0ParamsSchema, + }, + resource: AffectedResource.ApiSubscriptionUpdate, lock: process.env.NODE_ENV !== "development" ? { diff --git a/server/src/internal/billing/v2/setup/setupCancelMode.ts b/server/src/internal/billing/v2/setup/setupCancelMode.ts index aca20bb52..ba3a7db0b 100644 --- a/server/src/internal/billing/v2/setup/setupCancelMode.ts +++ b/server/src/internal/billing/v2/setup/setupCancelMode.ts @@ -1,5 +1,4 @@ -import type { UpdateSubscriptionV0Params } from "@shared/api/billing/updateSubscription/updateSubscriptionV0Params"; -import type { CancelAction } from "@autumn/shared"; +import type { CancelAction, UpdateSubscriptionV1Params } from "@autumn/shared"; /** * Setup cancel action from params @@ -14,7 +13,7 @@ import type { CancelAction } from "@autumn/shared"; export const setupCancelAction = ({ params, }: { - params: UpdateSubscriptionV0Params; + params: UpdateSubscriptionV1Params; }): CancelAction | undefined => { return params.cancel_action; }; diff --git a/server/src/internal/billing/v2/setup/setupCustomFullProduct.ts b/server/src/internal/billing/v2/setup/setupCustomFullProduct.ts index d67279799..a4d05190a 100644 --- a/server/src/internal/billing/v2/setup/setupCustomFullProduct.ts +++ b/server/src/internal/billing/v2/setup/setupCustomFullProduct.ts @@ -1,18 +1,24 @@ -import type { FullProduct, ProductItem } from "@autumn/shared"; +import { + type CustomizePlanV1, + customizePlanV1ToV0, + type FullProduct, +} from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { getEntsWithFeature } from "@/internal/products/entitlements/entitlementUtils"; import { handleNewProductItems } from "@/internal/products/product-items/productItemUtils/handleNewProductItems"; export const setupCustomFullProduct = async ({ ctx, - customItems, + // customItems, currentFullProduct, + customizePlan, }: { ctx: AutumnContext; - customItems?: ProductItem[]; + // customItems?: ProductItem[]; currentFullProduct: FullProduct; + customizePlan?: CustomizePlanV1; }) => { - if (!customItems) { + if (!customizePlan) { return { fullProduct: currentFullProduct, customPrices: [], @@ -20,6 +26,8 @@ export const setupCustomFullProduct = async ({ }; } + // Customize plan -> custom items + const { db, logger, features } = ctx; const { prices: currentPrices, entitlements: currentEntitlements } = @@ -30,7 +38,11 @@ export const setupCustomFullProduct = async ({ db, curPrices: currentPrices, curEnts: currentEntitlements, - newItems: customItems, + newItems: customizePlanV1ToV0({ + ctx, + customizePlanV1: customizePlan, + fullProduct: currentFullProduct, + }), features, product: currentFullProduct, logger, diff --git a/server/src/internal/billing/v2/setup/setupFeatureQuantitiesContext.ts b/server/src/internal/billing/v2/setup/setupFeatureQuantitiesContext.ts index 54a93e3ea..88a77d82b 100644 --- a/server/src/internal/billing/v2/setup/setupFeatureQuantitiesContext.ts +++ b/server/src/internal/billing/v2/setup/setupFeatureQuantitiesContext.ts @@ -1,6 +1,6 @@ import { type BillingContextOverride, - type BillingParamsBase, + type BillingParamsBaseV1, cusProductToConvertedFeatureOptions, type FeatureOptions, type FullCusProduct, @@ -25,7 +25,7 @@ export const setupFeatureQuantitiesContext = ({ contextOverride = {}, }: { ctx: AutumnContext; - featureQuantitiesParams: BillingParamsBase; + featureQuantitiesParams: BillingParamsBaseV1; fullProduct: FullProduct; currentCustomerProduct?: FullCusProduct; initializeUndefinedQuantities?: boolean; diff --git a/server/src/internal/billing/v2/setup/setupFullCustomerContext.ts b/server/src/internal/billing/v2/setup/setupFullCustomerContext.ts index eb74ffed6..0b09435c2 100644 --- a/server/src/internal/billing/v2/setup/setupFullCustomerContext.ts +++ b/server/src/internal/billing/v2/setup/setupFullCustomerContext.ts @@ -1,4 +1,4 @@ -import type { BillingParamsBase } from "@autumn/shared"; +import type { BillingParamsBaseV1 } from "@autumn/shared"; import type { AutumnContext } from "@server/honoUtils/HonoEnv"; import { CusService } from "@server/internal/customers/CusService"; @@ -7,7 +7,7 @@ export const setupFullCustomerContext = async ({ params, }: { ctx: AutumnContext; - params: BillingParamsBase; + params: BillingParamsBaseV1; }) => { const { db, org, env } = ctx; const { customer_id: customerId } = params; diff --git a/server/src/internal/billing/v2/setup/setupInvoiceModeContext.ts b/server/src/internal/billing/v2/setup/setupInvoiceModeContext.ts index 699f657fc..c55b0f84f 100644 --- a/server/src/internal/billing/v2/setup/setupInvoiceModeContext.ts +++ b/server/src/internal/billing/v2/setup/setupInvoiceModeContext.ts @@ -1,12 +1,12 @@ import type { - AttachParamsV0, - UpdateSubscriptionV0Params, + AttachParamsV1, + UpdateSubscriptionV1Params, } from "@autumn/shared"; export const setupInvoiceModeContext = ({ params, }: { - params: UpdateSubscriptionV0Params | AttachParamsV0; + params: UpdateSubscriptionV1Params | AttachParamsV1; }) => { if (params?.invoice !== true) { return undefined; diff --git a/server/src/internal/billing/v2/setup/setupTransitionConfigs.ts b/server/src/internal/billing/v2/setup/setupTransitionConfigs.ts index 238064cce..20e311b96 100644 --- a/server/src/internal/billing/v2/setup/setupTransitionConfigs.ts +++ b/server/src/internal/billing/v2/setup/setupTransitionConfigs.ts @@ -1,11 +1,14 @@ -import type { BillingContextOverride, BillingParamsBase } from "@autumn/shared"; +import type { + BillingContextOverride, + BillingParamsBaseV1, +} from "@autumn/shared"; import type { TransitionConfig } from "@models/billingModels/context/transitionConfig"; export const setupTransitionConfigs = ({ params, contextOverride = {}, }: { - params: BillingParamsBase; + params: BillingParamsBaseV1; contextOverride?: BillingContextOverride; }): TransitionConfig[] => { if (contextOverride.transitionConfigs) { diff --git a/server/src/internal/billing/v2/setup/trialContext/handleFreeTrialParam.ts b/server/src/internal/billing/v2/setup/trialContext/handleFreeTrialParam.ts index deb533766..42d27cb48 100644 --- a/server/src/internal/billing/v2/setup/trialContext/handleFreeTrialParam.ts +++ b/server/src/internal/billing/v2/setup/trialContext/handleFreeTrialParam.ts @@ -4,10 +4,10 @@ import { isCustomerProductTrialing, isProductPaidAndRecurring, } from "@autumn/shared"; -import type { FreeTrialParamsV0 } from "@shared/api/common/freeTrial/freeTrialParamsV0"; +import type { FreeTrialParamsV1 } from "@shared/api/common/freeTrial/freeTrialParamsV1"; import type Stripe from "stripe"; import { isStripeSubscriptionTrialing } from "@/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils"; -import { initFreeTrial } from "@/internal/products/free-trials/initFreeTrial"; +import { initFreeTrialFromParamsV1 } from "@/internal/products/free-trials/initFreeTrialFromParamsV1"; /** * Handles explicit free_trial parameter passed to attach/update subscription. @@ -22,7 +22,7 @@ export const handleFreeTrialParam = ({ fullProduct, currentEpochMs, }: { - freeTrialParams: FreeTrialParamsV0 | null; + freeTrialParams: FreeTrialParamsV1 | null; stripeSubscription?: Stripe.Subscription; customerProduct?: FullCusProduct; fullProduct: FullProduct; @@ -48,7 +48,7 @@ export const handleFreeTrialParam = ({ } // free_trial: { length, duration } → Fresh trial - const dbFreeTrial = initFreeTrial({ + const dbFreeTrial = initFreeTrialFromParamsV1({ freeTrialParams, internalProductId: fullProduct.internal_id, isCustom: true, diff --git a/server/src/internal/billing/v2/utils/billingPlan/billingPlanToAutumnCheckout.ts b/server/src/internal/billing/v2/utils/billingPlan/billingPlanToAutumnCheckout.ts index 9d5189462..f72270039 100644 --- a/server/src/internal/billing/v2/utils/billingPlan/billingPlanToAutumnCheckout.ts +++ b/server/src/internal/billing/v2/utils/billingPlan/billingPlanToAutumnCheckout.ts @@ -1,6 +1,6 @@ import { type AttachBillingContext, - type AttachParamsV0, + type AttachParamsV1, type BillingPlan, type Checkout, CheckoutAction, @@ -29,7 +29,7 @@ export async function billingPlanToAutumnCheckout({ billingContext, }: { ctx: AutumnContext; - params: AttachParamsV0; + params: AttachParamsV1; billingContext: AttachBillingContext; billingPlan: BillingPlan; }): Promise<{ checkout: Checkout; checkoutUrl: string }> { diff --git a/server/src/internal/checkouts/handlers/handleConfirmCheckout.ts b/server/src/internal/checkouts/handlers/handleConfirmCheckout.ts index c119dc8a4..3c2a4f1f2 100644 --- a/server/src/internal/checkouts/handlers/handleConfirmCheckout.ts +++ b/server/src/internal/checkouts/handlers/handleConfirmCheckout.ts @@ -1,5 +1,5 @@ import { - type AttachParamsV0, + type AttachParamsV1, type Checkout, CheckoutAction, CheckoutStatus, @@ -43,7 +43,7 @@ export const handleConfirmCheckout = createRoute({ }); } - const params = checkout.params as AttachParamsV0; + const params = checkout.params as AttachParamsV1; try { // Execute attach (not preview mode) diff --git a/server/src/internal/checkouts/handlers/handleGetCheckout.ts b/server/src/internal/checkouts/handlers/handleGetCheckout.ts index 0000febdf..c76240efd 100644 --- a/server/src/internal/checkouts/handlers/handleGetCheckout.ts +++ b/server/src/internal/checkouts/handlers/handleGetCheckout.ts @@ -1,5 +1,5 @@ import { - type AttachParamsV0, + type AttachParamsV1, type Checkout, CheckoutAction, ErrCode, @@ -31,7 +31,7 @@ export const handleGetCheckout = createRoute({ }); } - const params = checkout.params as AttachParamsV0; + const params = checkout.params as AttachParamsV1; // Re-run attach in preview mode to get current billing plan const { billingContext, billingPlan } = await billingActions.attach({ diff --git a/server/src/internal/checkouts/handlers/handlePreviewCheckout.ts b/server/src/internal/checkouts/handlers/handlePreviewCheckout.ts index 5ea63adec..ee20c2a34 100644 --- a/server/src/internal/checkouts/handlers/handlePreviewCheckout.ts +++ b/server/src/internal/checkouts/handlers/handlePreviewCheckout.ts @@ -1,5 +1,5 @@ import { - type AttachParamsV0, + type AttachParamsV1, type Checkout, CheckoutAction, ErrCode, @@ -44,10 +44,10 @@ export const handlePreviewCheckout = createRoute({ }); } - const originalParams = checkout.params as AttachParamsV0; + const originalParams = checkout.params as AttachParamsV1; // Merge provided options with original params - const params: AttachParamsV0 = { + const params: AttachParamsV1 = { ...originalParams, options: body.options, }; diff --git a/server/src/internal/customers/cancel/handleCancelV2.ts b/server/src/internal/customers/cancel/handleCancelV2.ts index f5afb876a..2bb4c5519 100644 --- a/server/src/internal/customers/cancel/handleCancelV2.ts +++ b/server/src/internal/customers/cancel/handleCancelV2.ts @@ -1,4 +1,4 @@ -import type { UpdateSubscriptionV0Params } from "@autumn/shared"; +import type { UpdateSubscriptionV1Params } from "@autumn/shared"; import { createRoute } from "@/honoMiddlewares/routeHandler"; import { computeUpdateSubscriptionPlan } from "@/internal/billing/v2/actions/updateSubscription/compute/computeUpdateSubscriptionPlan"; import { handleUpdateSubscriptionErrors } from "@/internal/billing/v2/actions/updateSubscription/errors/handleUpdateSubscriptionErrors"; @@ -23,7 +23,7 @@ export const handleCancelV2 = createRoute({ prorate: bodyProrate = true, } = await c.req.json(); - const updateSubscriptionBody: UpdateSubscriptionV0Params = { + const updateSubscriptionBody: UpdateSubscriptionV1Params = { customer_id, product_id, entity_id, diff --git a/server/src/internal/products/free-trials/initFreeTrialFromParamsV1.ts b/server/src/internal/products/free-trials/initFreeTrialFromParamsV1.ts new file mode 100644 index 000000000..7e9e9fb3e --- /dev/null +++ b/server/src/internal/products/free-trials/initFreeTrialFromParamsV1.ts @@ -0,0 +1,25 @@ +import type { FreeTrial } from "@autumn/shared"; +import type { FreeTrialParamsV1 } from "@shared/api/common/freeTrial/freeTrialParamsV1"; +import { generateId } from "@/utils/genUtils"; + +export const initFreeTrialFromParamsV1 = ({ + freeTrialParams, + internalProductId, + isCustom = false, +}: { + freeTrialParams: FreeTrialParamsV1; + internalProductId: string; + isCustom?: boolean; +}): FreeTrial => { + return { + id: generateId("ft"), + created_at: Date.now(), + internal_product_id: internalProductId, + is_custom: isCustom, + + duration: freeTrialParams.duration_type, + length: freeTrialParams.duration_length, + card_required: freeTrialParams.card_required, + unique_fingerprint: true, + }; +}; diff --git a/server/tests/integration/billing/attach/v2-params/v2-customize.test.ts b/server/tests/integration/billing/attach/v2-params/v2-customize.test.ts new file mode 100644 index 000000000..5775e1a4f --- /dev/null +++ b/server/tests/integration/billing/attach/v2-params/v2-customize.test.ts @@ -0,0 +1,396 @@ +import { expect, test } from "bun:test"; +import type { ApiCustomerV3, AttachParamsV1Input } from "@autumn/shared"; +import { + expectCustomerFeatureCorrect, + expectCustomerFeatureExists, +} from "@tests/integration/billing/utils/expectCustomerFeatureCorrect"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { expectProductActive } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +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 { isPriceItem } from "@/internal/products/product-items/productItemUtils/getItemType.js"; + +test.concurrent(`${chalk.yellowBright("v2-customize attach: both customize.price + customize.items")}`, async () => { + const customerId = "v2-attach-customize-both"; + + const base = products.base({ + id: "base", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const { autumnV1, autumnV2 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [base] }), + ], + actions: [], + }); + + const params: AttachParamsV1Input = { + customer_id: customerId, + product_id: base.id, + redirect_mode: "if_required", + customize: { + price: itemsV2.monthlyPrice({ amount: 30 }), + items: [itemsV2.monthlyWords({ included: 250 }), itemsV2.dashboard()], + }, + }; + + const preview = + await autumnV2.billing.previewAttach(params); + expect(preview.total).toBe(30); + + await autumnV2.billing.attach(params); + + const customer = await autumnV1.customers.get(customerId); + await expectProductActive({ customer, productId: base.id }); + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Words, + includedUsage: 250, + balance: 250, + usage: 0, + }); + await expectCustomerFeatureExists({ + customer, + featureId: TestFeature.Dashboard, + }); + await expectCustomerInvoiceCorrect({ + customer, + count: 1, + latestTotal: 30, + }); +}); + +test.concurrent(`${chalk.yellowBright("v2-customize attach: only customize.price")}`, async () => { + const customerId = "v2-attach-customize-only-price"; + + const base = products.base({ + id: "base", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const { autumnV1, autumnV2 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [base] }), + ], + actions: [], + }); + + const params: AttachParamsV1Input = { + customer_id: customerId, + product_id: base.id, + redirect_mode: "if_required", + customize: { + price: itemsV2.monthlyPrice({ amount: 25 }), + }, + }; + + const preview = + await autumnV2.billing.previewAttach(params); + expect(preview.total).toBe(25); + + await autumnV2.billing.attach(params); + + const customer = await autumnV1.customers.get(customerId); + await expectProductActive({ customer, productId: base.id }); + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: 100, + balance: 100, + usage: 0, + }); + await expectCustomerInvoiceCorrect({ + customer, + count: 1, + latestTotal: 25, + }); +}); + +test.concurrent(`${chalk.yellowBright("v2-customize attach: only customize.items")}`, async () => { + const customerId = "v2-attach-customize-only-items"; + + const pro = products.base({ + id: "pro", + items: [ + items.monthlyMessages({ includedUsage: 100 }), + items.monthlyPrice({ price: 20 }), + ], + }); + + const { autumnV1, autumnV2 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [], + }); + + const params: AttachParamsV1Input = { + customer_id: customerId, + product_id: pro.id, + redirect_mode: "if_required", + customize: { + // price: null, + items: [itemsV2.monthlyMessages({ included: 220 })], + }, + }; + + const preview = + await autumnV2.billing.previewAttach(params); + expect(preview.total).toBe(20); + + await autumnV2.billing.attach(params); + + const customer = await autumnV1.customers.get(customerId); + await expectProductActive({ customer, productId: pro.id }); + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: 220, + balance: 220, + usage: 0, + }); + await expectCustomerInvoiceCorrect({ + customer, + count: 1, + }); + + // Verify that the original product price is still attached (price: null means keep original) + + const customerProduct = customer.products.find((p) => p.id === pro.id); + const priceItem = customerProduct?.items?.find(isPriceItem); + expect(priceItem).toBeDefined(); + expect(priceItem?.price).toBe(20); +}); + +test.concurrent(`${chalk.yellowBright("v2-customize attach: price null makes product free")}`, async () => { + const customerId = "v2-attach-customize-price-null-free"; + + const pro = products.base({ + id: "pro", + items: [ + items.monthlyMessages({ includedUsage: 100 }), + items.monthlyWords({ includedUsage: 50 }), + items.monthlyPrice({ price: 20 }), + ], + }); + + const { autumnV1, autumnV2 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [], + }); + + const params: AttachParamsV1Input = { + customer_id: customerId, + product_id: pro.id, + redirect_mode: "if_required", + customize: { + price: null, + items: [itemsV2.monthlyMessages({ included: 200 })], + }, + }; + + const preview = + await autumnV2.billing.previewAttach(params); + expect(preview.total).toBe(0); + + await autumnV2.billing.attach(params); + + const customer = await autumnV1.customers.get(customerId); + await expectProductActive({ customer, productId: pro.id }); + + // Verify the customized feature is correct + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: 200, + balance: 200, + usage: 0, + }); + + // Verify no invoice was created (product is free) + await expectCustomerInvoiceCorrect({ + customer, + count: 0, + }); + + // Verify no price item exists on the customer product + const customerProduct = customer.products.find((p) => p.id === pro.id); + const priceItem = customerProduct?.items?.find(isPriceItem); + expect(priceItem).toBeUndefined(); +}); + +test.concurrent(`${chalk.yellowBright("v2-customize attach: plan item v1 prepaid mapping")}`, async () => { + const customerId = "v2-attach-customize-prepaid-map"; + + const base = products.base({ + id: "base", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const { autumnV1, autumnV2 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [base] }), + ], + actions: [], + }); + + const params: AttachParamsV1Input = { + customer_id: customerId, + product_id: base.id, + redirect_mode: "if_required", + options: [{ feature_id: TestFeature.Messages, quantity: 100 }], + customize: { + price: null, + items: [itemsV2.prepaidMessages({ amount: 10, billingUnits: 100 })], + }, + }; + + const preview = + await autumnV2.billing.previewAttach(params); + expect(preview.total).toBe(10); + + await autumnV2.billing.attach(params); + + const customer = await autumnV1.customers.get(customerId); + await expectCustomerInvoiceCorrect({ + customer, + count: 1, + latestTotal: 10, + }); +}); + +test.concurrent(`${chalk.yellowBright("v2-customize attach: plan item v1 multi-feature mapping")}`, async () => { + const customerId = "v2-attach-customize-multi-feature-map"; + + const base = products.base({ + id: "base", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const { autumnV1, autumnV2 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [base] }), + ], + actions: [], + }); + + const params: AttachParamsV1Input = { + customer_id: customerId, + product_id: base.id, + redirect_mode: "if_required", + customize: { + price: itemsV2.monthlyPrice({ amount: 40 }), + items: [ + itemsV2.monthlyMessages({ included: 300 }), + itemsV2.monthlyWords({ included: 150 }), + ], + }, + }; + + await autumnV2.billing.attach(params); + + const customer = await autumnV1.customers.get(customerId); + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: 300, + balance: 300, + usage: 0, + }); + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Words, + includedUsage: 150, + balance: 150, + usage: 0, + }); +}); + +test.concurrent(`${chalk.yellowBright("v2-customize attach: paid feature mix (consumable + prepaid + allocated)")}`, async () => { + const customerId = "v2-attach-customize-paid-feature-mix"; + + const base = products.base({ + id: "base", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const { autumnV1, autumnV2 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [base] }), + ], + actions: [], + }); + + const params: AttachParamsV1Input = { + customer_id: customerId, + product_id: base.id, + redirect_mode: "if_required", + options: [{ feature_id: TestFeature.Words, quantity: 500 }], + customize: { + price: itemsV2.monthlyPrice({ amount: 40 }), + items: [ + itemsV2.consumableMessages({ amount: 1 }), + itemsV2.prepaidWords({ amount: 15, billingUnits: 100, included: 200 }), + itemsV2.allocatedUsers({ amount: 10, included: 3 }), + ], + }, + }; + + const preview = + await autumnV2.billing.previewAttach(params); + + // Base ($40) + prepaid words ($15 for 100 units) + allocated users at included quantity + expect(preview.total).toBe(85); + + await autumnV2.billing.attach(params); + + const customer = await autumnV1.customers.get(customerId); + + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Words, + includedUsage: 500, + balance: 500, + usage: 0, + }); + + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Users, + includedUsage: 3, + balance: 3, + usage: 0, + }); + + await expectCustomerFeatureExists({ + customer, + featureId: TestFeature.Messages, + }); + + await expectCustomerInvoiceCorrect({ + customer, + count: 1, + latestTotal: preview.total, + }); +}); diff --git a/server/tests/integration/billing/attach/v2-params/v2-free-trial.test.ts b/server/tests/integration/billing/attach/v2-params/v2-free-trial.test.ts new file mode 100644 index 000000000..adf978125 --- /dev/null +++ b/server/tests/integration/billing/attach/v2-params/v2-free-trial.test.ts @@ -0,0 +1,153 @@ +import { expect, test } from "bun:test"; +import type { ApiCustomerV3, AttachParamsV1Input } from "@autumn/shared"; +import { FreeTrialDuration, ms } from "@autumn/shared"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { + expectProductNotTrialing, + expectProductTrialing, +} from "@tests/integration/billing/utils/expectCustomerProductTrialing"; +import { expectPreviewNextCycleCorrect } from "@tests/integration/billing/utils/expectPreviewNextCycleCorrect"; +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 { addMonths } from "date-fns"; + +test.concurrent(`${chalk.yellowBright("v2-free-trial attach: set trial with v1 free_trial params")}`, async () => { + const customerId = "v2-attach-free-trial-set"; + + const pro = products.base({ + id: "pro", + items: [ + items.monthlyMessages({ includedUsage: 100 }), + items.monthlyPrice({ price: 20 }), + ], + }); + + const { autumnV1, autumnV2, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [], + }); + + const params: AttachParamsV1Input = { + customer_id: customerId, + product_id: pro.id, + redirect_mode: "if_required", + free_trial: { + duration_length: 7, + duration_type: FreeTrialDuration.Day, + }, + }; + + const preview = + await autumnV2.billing.previewAttach(params); + expect(preview.total).toBe(0); + expectPreviewNextCycleCorrect({ + preview, + startsAt: advancedTo + ms.days(7), + total: 20, + }); + + await autumnV2.billing.attach(params); + + const customer = await autumnV1.customers.get(customerId); + await expectProductTrialing({ + customer, + productId: pro.id, + trialEndsAt: advancedTo + ms.days(7), + }); + await expectCustomerInvoiceCorrect({ + customer, + count: 1, + latestTotal: 0, + }); +}); + +test.concurrent(`${chalk.yellowBright("v2-free-trial attach: remove product trial with free_trial null")}`, async () => { + const customerId = "v2-attach-free-trial-null"; + + const proTrial = products.proWithTrial({ + id: "pro-trial", + items: [items.monthlyMessages({ includedUsage: 100 })], + trialDays: 14, + }); + + const { autumnV1, autumnV2, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [proTrial] }), + ], + actions: [], + }); + + const params: AttachParamsV1Input = { + customer_id: customerId, + product_id: proTrial.id, + redirect_mode: "if_required", + free_trial: null, + }; + + const preview = + await autumnV2.billing.previewAttach(params); + expect(preview.total).toBe(20); + + await autumnV2.billing.attach(params); + + const customer = await autumnV1.customers.get(customerId); + await expectProductNotTrialing({ + customer, + productId: proTrial.id, + nowMs: advancedTo, + }); + await expectCustomerInvoiceCorrect({ + customer, + count: 1, + latestTotal: 20, + }); +}); + +test.concurrent(`${chalk.yellowBright("v2-free-trial attach: month-based v1 free_trial params")}`, async () => { + const customerId = "v2-attach-free-trial-month"; + + const pro = products.base({ + id: "pro", + items: [ + items.monthlyMessages({ includedUsage: 100 }), + items.monthlyPrice({ price: 20 }), + ], + }); + + const { autumnV1, autumnV2, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [], + }); + + const params: AttachParamsV1Input = { + customer_id: customerId, + product_id: pro.id, + redirect_mode: "if_required", + free_trial: { + duration_length: 1, + duration_type: FreeTrialDuration.Month, + }, + }; + + await autumnV2.billing.attach(params); + + const customer = await autumnV1.customers.get(customerId); + await expectProductTrialing({ + customer, + productId: pro.id, + trialEndsAt: addMonths(advancedTo, 1).getTime(), + toleranceMs: ms.hours(2), + }); +}); diff --git a/server/tests/integration/billing/update-subscription/v2-params/v2-customize.test.ts b/server/tests/integration/billing/update-subscription/v2-params/v2-customize.test.ts new file mode 100644 index 000000000..61a6eccfd --- /dev/null +++ b/server/tests/integration/billing/update-subscription/v2-params/v2-customize.test.ts @@ -0,0 +1,265 @@ +import { expect, test } from "bun:test"; +import type { + ApiCustomerV3, + UpdateSubscriptionV1ParamsInput, +} from "@autumn/shared"; +import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +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"; + +test.concurrent(`${chalk.yellowBright("v2-customize update: both customize.price + customize.items")}`, async () => { + const customerId = "v2-update-customize-both"; + + const pro = products.base({ + id: "pro", + items: [ + items.monthlyMessages({ includedUsage: 100 }), + items.monthlyPrice({ price: 20 }), + ], + }); + + const { autumnV1, autumnV2 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + + const params: UpdateSubscriptionV1ParamsInput = { + customer_id: customerId, + product_id: pro.id, + customize: { + price: itemsV2.monthlyPrice({ amount: 30 }), + items: [itemsV2.monthlyWords({ included: 200 })], + }, + }; + + const preview = + await autumnV2.subscriptions.previewUpdate( + params, + ); + expect(preview.total).toBe(10); + + await autumnV2.subscriptions.update(params); + + const customer = await autumnV1.customers.get(customerId); + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Words, + includedUsage: 200, + balance: 200, + usage: 0, + }); + await expectCustomerInvoiceCorrect({ + customer, + count: 2, + latestTotal: 10, + }); +}); + +test.concurrent(`${chalk.yellowBright("v2-customize update: only customize.price")}`, async () => { + const customerId = "v2-update-customize-only-price"; + + const pro = products.base({ + id: "pro", + items: [ + items.monthlyMessages({ includedUsage: 100 }), + items.monthlyPrice({ price: 20 }), + ], + }); + + const { autumnV1, autumnV2 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + + const params: UpdateSubscriptionV1ParamsInput = { + customer_id: customerId, + product_id: pro.id, + customize: { + price: itemsV2.monthlyPrice({ amount: 24 }), + }, + }; + + const preview = + await autumnV2.subscriptions.previewUpdate( + params, + ); + expect(preview.total).toBe(4); + + await autumnV2.subscriptions.update(params); + + const customer = await autumnV1.customers.get(customerId); + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: 100, + balance: 100, + usage: 0, + }); + await expectCustomerInvoiceCorrect({ + customer, + count: 2, + latestTotal: 4, + }); +}); + +test.concurrent(`${chalk.yellowBright("v2-customize update: only customize.items")}`, async () => { + const customerId = "v2-update-customize-only-items"; + + const pro = products.base({ + id: "pro", + items: [ + items.monthlyMessages({ includedUsage: 100 }), + items.monthlyPrice({ price: 20 }), + ], + }); + + const { autumnV1, autumnV2 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + + const params: UpdateSubscriptionV1ParamsInput = { + customer_id: customerId, + product_id: pro.id, + customize: { + price: null, + items: [itemsV2.monthlyMessages({ included: 180 })], + }, + }; + + const preview = + await autumnV2.subscriptions.previewUpdate( + params, + ); + expect(preview.total).toBe(-20); + + await autumnV2.subscriptions.update(params); + + const customer = await autumnV1.customers.get(customerId); + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: 180, + balance: 180, + usage: 0, + }); + await expectCustomerInvoiceCorrect({ + customer, + count: 2, + latestTotal: -20, + }); +}); + +test.concurrent(`${chalk.yellowBright("v2-customize update: plan item v1 prepaid mapping")}`, async () => { + const customerId = "v2-update-customize-prepaid-map"; + + const pro = products.base({ + id: "pro", + items: [ + items.monthlyMessages({ includedUsage: 100 }), + items.monthlyPrice({ price: 20 }), + ], + }); + + const { autumnV1, autumnV2 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + + const params: UpdateSubscriptionV1ParamsInput = { + customer_id: customerId, + product_id: pro.id, + options: [{ feature_id: TestFeature.Messages, quantity: 100 }], + customize: { + price: null, + items: [itemsV2.prepaidMessages({ amount: 10, billingUnits: 100 })], + }, + }; + + const preview = + await autumnV2.subscriptions.previewUpdate( + params, + ); + expect(preview.total).toBe(-10); + + await autumnV2.subscriptions.update(params); + + const customer = await autumnV1.customers.get(customerId); + await expectCustomerInvoiceCorrect({ + customer, + count: 2, + latestTotal: -10, + }); +}); + +test.concurrent(`${chalk.yellowBright("v2-customize update: plan item v1 multi-feature mapping")}`, async () => { + const customerId = "v2-update-customize-multi-feature-map"; + + const pro = products.base({ + id: "pro", + items: [ + items.monthlyMessages({ includedUsage: 100 }), + items.monthlyPrice({ price: 20 }), + ], + }); + + const { autumnV1, autumnV2 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + + const params: UpdateSubscriptionV1ParamsInput = { + customer_id: customerId, + product_id: pro.id, + customize: { + price: itemsV2.monthlyPrice({ amount: 40 }), + items: [ + itemsV2.monthlyMessages({ included: 300 }), + itemsV2.monthlyWords({ included: 150 }), + ], + }, + }; + + await autumnV2.subscriptions.update(params); + + const customer = await autumnV1.customers.get(customerId); + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: 300, + balance: 300, + usage: 0, + }); + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Words, + includedUsage: 150, + balance: 150, + usage: 0, + }); +}); diff --git a/server/tests/integration/billing/update-subscription/v2-params/v2-free-trial.test.ts b/server/tests/integration/billing/update-subscription/v2-params/v2-free-trial.test.ts new file mode 100644 index 000000000..01f11fde8 --- /dev/null +++ b/server/tests/integration/billing/update-subscription/v2-params/v2-free-trial.test.ts @@ -0,0 +1,158 @@ +import { expect, test } from "bun:test"; +import type { + ApiCustomerV3, + UpdateSubscriptionV1ParamsInput, +} from "@autumn/shared"; +import { FreeTrialDuration, ms } from "@autumn/shared"; +import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect"; +import { + expectProductNotTrialing, + expectProductTrialing, +} from "@tests/integration/billing/utils/expectCustomerProductTrialing"; +import { expectPreviewNextCycleCorrect } from "@tests/integration/billing/utils/expectPreviewNextCycleCorrect"; +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 { addMonths } from "date-fns"; + +test.concurrent(`${chalk.yellowBright("v2-free-trial update: set trial with v1 free_trial params")}`, async () => { + const customerId = "v2-update-free-trial-set"; + + const pro = products.base({ + id: "pro", + items: [ + items.monthlyMessages({ includedUsage: 100 }), + items.monthlyPrice({ price: 20 }), + ], + }); + + const { autumnV1, autumnV2, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", testClock: true }), + s.products({ list: [pro] }), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + + const params: UpdateSubscriptionV1ParamsInput = { + customer_id: customerId, + product_id: pro.id, + free_trial: { + duration_length: 7, + duration_type: FreeTrialDuration.Day, + }, + }; + + const preview = + await autumnV2.subscriptions.previewUpdate( + params, + ); + expect(preview.total).toBe(-20); + expectPreviewNextCycleCorrect({ + preview, + startsAt: advancedTo + ms.days(7), + total: 20, + }); + + await autumnV2.subscriptions.update(params); + + const customer = await autumnV1.customers.get(customerId); + await expectProductTrialing({ + customer, + productId: pro.id, + trialEndsAt: advancedTo + ms.days(7), + }); + await expectCustomerInvoiceCorrect({ + customer, + count: 2, + latestTotal: -20, + }); +}); + +test.concurrent(`${chalk.yellowBright("v2-free-trial update: remove trial with free_trial null")}`, async () => { + const customerId = "v2-update-free-trial-remove"; + + const proTrial = products.proWithTrial({ + id: "pro-trial", + items: [items.monthlyMessages({ includedUsage: 100 })], + trialDays: 14, + }); + + const { autumnV1, autumnV2, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", testClock: true }), + s.products({ list: [proTrial] }), + ], + actions: [ + s.billing.attach({ productId: proTrial.id }), + s.advanceTestClock({ days: 3 }), + ], + }); + + const params: UpdateSubscriptionV1ParamsInput = { + customer_id: customerId, + product_id: proTrial.id, + free_trial: null, + }; + + const preview = + await autumnV2.subscriptions.previewUpdate( + params, + ); + expect(preview.total).toBe(20); + + await autumnV2.subscriptions.update(params); + + const customer = await autumnV1.customers.get(customerId); + await expectProductNotTrialing({ + customer, + productId: proTrial.id, + nowMs: advancedTo, + }); + await expectCustomerInvoiceCorrect({ + customer, + count: 2, + latestTotal: 20, + }); +}); + +test.concurrent(`${chalk.yellowBright("v2-free-trial update: replace active trial with month trial")}`, async () => { + const customerId = "v2-update-free-trial-replace"; + + const proTrial = products.proWithTrial({ + id: "pro-trial", + items: [items.monthlyMessages({ includedUsage: 100 })], + trialDays: 7, + }); + + const { autumnV1, autumnV2, advancedTo } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", testClock: true }), + s.products({ list: [proTrial] }), + ], + actions: [s.billing.attach({ productId: proTrial.id })], + }); + + const params: UpdateSubscriptionV1ParamsInput = { + customer_id: customerId, + product_id: proTrial.id, + free_trial: { + duration_length: 1, + duration_type: FreeTrialDuration.Month, + }, + }; + + await autumnV2.subscriptions.update(params); + + const customer = await autumnV1.customers.get(customerId); + await expectProductTrialing({ + customer, + productId: proTrial.id, + trialEndsAt: addMonths(advancedTo, 1).getTime(), + toleranceMs: ms.hours(2), + }); +}); diff --git a/server/tests/integration/billing/utils/expectCustomerFeatureCorrect.ts b/server/tests/integration/billing/utils/expectCustomerFeatureCorrect.ts index f0c14252a..032c8a11b 100644 --- a/server/tests/integration/billing/utils/expectCustomerFeatureCorrect.ts +++ b/server/tests/integration/billing/utils/expectCustomerFeatureCorrect.ts @@ -5,7 +5,6 @@ import { ApiVersion, formatMs, } from "@autumn/shared"; -import type { Customer } from "autumn-js"; import { AutumnInt } from "@/external/autumn/autumnCli"; const defaultAutumn = new AutumnInt({ version: ApiVersion.V1_2 }); @@ -16,7 +15,7 @@ export const expectCustomerFeatureExists = async ({ featureId, }: { customerId?: string; - customer?: Customer | ApiEntityV0; + customer?: ApiCustomerV3 | ApiEntityV0; featureId: string; }) => { const customer = providedCustomer diff --git a/server/tests/unit/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers1.test.ts b/server/tests/unit/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers1.test.ts index dacc2cd4a..05373f703 100644 --- a/server/tests/unit/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers1.test.ts +++ b/server/tests/unit/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers1.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import type { ExistingRollover } from "@autumn/shared"; +import { type ExistingRollover, RolloverExpiryDurationType } from "@autumn/shared"; import { customerEntitlements } from "@tests/utils/fixtures/db/customerEntitlements"; import { customerProducts } from "@tests/utils/fixtures/db/customerProducts"; import chalk from "chalk"; @@ -16,6 +16,7 @@ describe(chalk.yellowBright("applyExistingRollovers"), () => { featureName: "Words", allowance: 5000, balance: 5000, + rollover: { max: null, duration: RolloverExpiryDurationType.Month, length: 1 }, }); const newCusProduct = customerProducts.create({ diff --git a/server/tests/unit/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers3.test.ts b/server/tests/unit/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers3.test.ts index 0090d7061..db1ba0f72 100644 --- a/server/tests/unit/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers3.test.ts +++ b/server/tests/unit/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers3.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import type { ExistingRollover } from "@autumn/shared"; +import { type ExistingRollover, RolloverExpiryDurationType } from "@autumn/shared"; import { customerEntitlements } from "@tests/utils/fixtures/db/customerEntitlements"; import { customerProducts } from "@tests/utils/fixtures/db/customerProducts"; import chalk from "chalk"; @@ -19,6 +19,7 @@ describe( featureName: "Words", allowance: 5000, balance: 5000, + rollover: { max: null, duration: RolloverExpiryDurationType.Month, length: 1 }, }); const newCusProduct = customerProducts.create({ diff --git a/server/tests/unit/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers4.test.ts b/server/tests/unit/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers4.test.ts index e8f56d8b7..0f76d79ca 100644 --- a/server/tests/unit/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers4.test.ts +++ b/server/tests/unit/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers4.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import type { ExistingRollover } from "@autumn/shared"; +import { type ExistingRollover, RolloverExpiryDurationType } from "@autumn/shared"; import { customerEntitlements } from "@tests/utils/fixtures/db/customerEntitlements"; import { customerProducts } from "@tests/utils/fixtures/db/customerProducts"; import chalk from "chalk"; @@ -15,6 +15,7 @@ describe( featureName: "Feature A", allowance: 100, balance: 100, + rollover: { max: null, duration: RolloverExpiryDurationType.Month, length: 1 }, }); const cusEntB = customerEntitlements.create({ @@ -23,6 +24,7 @@ describe( featureName: "Feature B", allowance: 200, balance: 200, + rollover: { max: null, duration: RolloverExpiryDurationType.Month, length: 1 }, }); const newCusProduct = customerProducts.create({ diff --git a/server/tests/unit/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers5.test.ts b/server/tests/unit/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers5.test.ts index dae08de96..64a494a19 100644 --- a/server/tests/unit/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers5.test.ts +++ b/server/tests/unit/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers5.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import type { ExistingRollover } from "@autumn/shared"; +import { type ExistingRollover, RolloverExpiryDurationType } from "@autumn/shared"; import { customerEntitlements } from "@tests/utils/fixtures/db/customerEntitlements"; import { customerProducts } from "@tests/utils/fixtures/db/customerProducts"; import chalk from "chalk"; @@ -18,6 +18,7 @@ describe( featureName: "Words", allowance: 100, balance: 100, + rollover: { max: null, duration: RolloverExpiryDurationType.Month, length: 1 }, }); const cusEntSecond = customerEntitlements.create({ @@ -27,6 +28,7 @@ describe( featureName: "Words", allowance: 200, balance: 200, + rollover: { max: null, duration: RolloverExpiryDurationType.Month, length: 1 }, }); const newCusProduct = customerProducts.create({ diff --git a/server/tests/unit/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers6.test.ts b/server/tests/unit/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers6.test.ts index e8748f81c..6d970379d 100644 --- a/server/tests/unit/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers6.test.ts +++ b/server/tests/unit/billing/existing-rollovers/apply-existing-rollovers/apply-existing-rollovers6.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import type { ExistingRollover } from "@autumn/shared"; +import { type ExistingRollover, RolloverExpiryDurationType } from "@autumn/shared"; import { customerEntitlements } from "@tests/utils/fixtures/db/customerEntitlements"; import { customerProducts } from "@tests/utils/fixtures/db/customerProducts"; import chalk from "chalk"; @@ -19,6 +19,7 @@ describe( featureName: "Seats", allowance: 10, balance: 10, + rollover: { max: null, duration: RolloverExpiryDurationType.Month, length: 1 }, }); const newCusProduct = customerProducts.create({ diff --git a/server/tests/unit/billing/existing-usages/cus-product-to-existing-usages/cus-product-to-existing-usages-paid-features.test.ts b/server/tests/unit/billing/existing-usages/cus-product-to-existing-usages/cus-product-to-existing-usages-paid-features.test.ts index 4d92218e1..20ea0e9cd 100644 --- a/server/tests/unit/billing/existing-usages/cus-product-to-existing-usages/cus-product-to-existing-usages-paid-features.test.ts +++ b/server/tests/unit/billing/existing-usages/cus-product-to-existing-usages/cus-product-to-existing-usages-paid-features.test.ts @@ -45,7 +45,7 @@ describe( customerPrices: [customerPrice], }); - const existingUsages = cusProductToExistingUsages({ cusProduct }); + const existingUsages = cusProductToExistingUsages({ cusProduct, carryAllConsumableFeatures: true }); // Usage should be 150: 100 from included + 50 from overage // Formula: usage = includedUsage - balance = 100 - (-50) = 150 @@ -83,7 +83,7 @@ describe( customerPrices: [customerPrice], }); - const existingUsages = cusProductToExistingUsages({ cusProduct }); + const existingUsages = cusProductToExistingUsages({ cusProduct, carryAllConsumableFeatures: true }); // Usage should be 70: allowance 100 - balance 30 = 70 expect(existingUsages[internalFeatureId]).toBeDefined(); @@ -121,7 +121,7 @@ describe( customerPrices: [customerPrice], }); - const existingUsages = cusProductToExistingUsages({ cusProduct }); + const existingUsages = cusProductToExistingUsages({ cusProduct, carryAllConsumableFeatures: true }); // Usage should be 200: all from overage (0 - (-200) = 200) expect(existingUsages[internalFeatureId]).toBeDefined(); @@ -158,7 +158,7 @@ describe( customerPrices: [customerPrice], }); - const existingUsages = cusProductToExistingUsages({ cusProduct }); + const existingUsages = cusProductToExistingUsages({ cusProduct, carryAllConsumableFeatures: true }); // Usage should be 100: exactly at the limit expect(existingUsages[internalFeatureId]).toBeDefined(); @@ -201,7 +201,7 @@ describe( customerPrices: [customerPrice], }); - const existingUsages = cusProductToExistingUsages({ cusProduct }); + const existingUsages = cusProductToExistingUsages({ cusProduct, carryAllConsumableFeatures: true }); // Usage should be 3: 5 included - 2 remaining = 3 used expect(existingUsages[internalFeatureId]).toBeDefined(); @@ -238,7 +238,7 @@ describe( customerPrices: [customerPrice], }); - const existingUsages = cusProductToExistingUsages({ cusProduct }); + const existingUsages = cusProductToExistingUsages({ cusProduct, carryAllConsumableFeatures: true }); // Usage should be 5: 3 - (-2) = 5 total seats used expect(existingUsages[internalFeatureId]).toBeDefined(); @@ -275,7 +275,7 @@ describe( customerPrices: [customerPrice], }); - const existingUsages = cusProductToExistingUsages({ cusProduct }); + const existingUsages = cusProductToExistingUsages({ cusProduct, carryAllConsumableFeatures: true }); // Usage should be 10: 0 - (-10) = 10 expect(existingUsages[internalFeatureId]).toBeDefined(); @@ -320,7 +320,7 @@ describe( customerPrices: [customerPrice], }); - const existingUsages = cusProductToExistingUsages({ cusProduct }); + const existingUsages = cusProductToExistingUsages({ cusProduct, carryAllConsumableFeatures: true }); // For prepaid, usage = purchasedQuantity - balance // purchasedQuantity from 10 billing units = 1000 @@ -362,7 +362,7 @@ describe( customerPrices: [customerPrice], }); - const existingUsages = cusProductToExistingUsages({ cusProduct }); + const existingUsages = cusProductToExistingUsages({ cusProduct, carryAllConsumableFeatures: true }); expect(existingUsages[internalFeatureId]).toBeDefined(); }); @@ -399,7 +399,7 @@ describe( customerPrices: [customerPrice], }); - const existingUsages = cusProductToExistingUsages({ cusProduct }); + const existingUsages = cusProductToExistingUsages({ cusProduct, carryAllConsumableFeatures: true }); expect(existingUsages[internalFeatureId]).toBeDefined(); // Overage should be captured @@ -471,7 +471,7 @@ describe( ], }); - const existingUsages = cusProductToExistingUsages({ cusProduct }); + const existingUsages = cusProductToExistingUsages({ cusProduct, carryAllConsumableFeatures: true }); // Messages: 100 - (-20) = 120 used expect(existingUsages[messagesFeatureId]).toBeDefined(); @@ -550,7 +550,7 @@ describe( ], }); - const existingUsages = cusProductToExistingUsages({ cusProduct }); + const existingUsages = cusProductToExistingUsages({ cusProduct, carryAllConsumableFeatures: true }); // Messages: 50 - (-150) = 200 used expect(existingUsages[messagesFeatureId]).toBeDefined(); @@ -601,7 +601,7 @@ describe( customerPrices: [customerPrice], }); - const existingUsages = cusProductToExistingUsages({ cusProduct }); + const existingUsages = cusProductToExistingUsages({ cusProduct, carryAllConsumableFeatures: true }); // Usage should be 0 expect(existingUsages[internalFeatureId]).toBeDefined(); @@ -638,7 +638,7 @@ describe( customerPrices: [customerPrice], }); - const existingUsages = cusProductToExistingUsages({ cusProduct }); + const existingUsages = cusProductToExistingUsages({ cusProduct, carryAllConsumableFeatures: true }); // Usage should be 10100: 100 - (-10000) = 10100 expect(existingUsages[internalFeatureId]).toBeDefined(); @@ -675,7 +675,7 @@ describe( customerPrices: [customerPrice], }); - const existingUsages = cusProductToExistingUsages({ cusProduct }); + const existingUsages = cusProductToExistingUsages({ cusProduct, carryAllConsumableFeatures: true }); // Usage should be 150.5 expect(existingUsages[internalFeatureId]).toBeDefined(); diff --git a/server/tests/unit/billing/existing-usages/cus-product-to-existing-usages/cus-product-to-existing-usages.test.ts b/server/tests/unit/billing/existing-usages/cus-product-to-existing-usages/cus-product-to-existing-usages.test.ts index 4791f7f10..dcf05cd6e 100644 --- a/server/tests/unit/billing/existing-usages/cus-product-to-existing-usages/cus-product-to-existing-usages.test.ts +++ b/server/tests/unit/billing/existing-usages/cus-product-to-existing-usages/cus-product-to-existing-usages.test.ts @@ -37,7 +37,7 @@ describe(chalk.yellowBright("cusProductToExistingUsages"), () => { }); // Act - const existingUsages = cusProductToExistingUsages({ cusProduct }); + const existingUsages = cusProductToExistingUsages({ cusProduct, carryAllConsumableFeatures: true }); // Assert: Total usage should be 40 (20 + 20) expect(existingUsages[internalFeatureId]).toBeDefined(); @@ -78,7 +78,7 @@ describe(chalk.yellowBright("cusProductToExistingUsages"), () => { }); // Act - const existingUsages = cusProductToExistingUsages({ cusProduct }); + const existingUsages = cusProductToExistingUsages({ cusProduct, carryAllConsumableFeatures: true }); // Assert expect(existingUsages[internalFeatureId]).toBeDefined(); @@ -130,7 +130,7 @@ describe(chalk.yellowBright("cusProductToExistingUsages"), () => { }); // Act - const existingUsages = cusProductToExistingUsages({ cusProduct }); + const existingUsages = cusProductToExistingUsages({ cusProduct, carryAllConsumableFeatures: true }); // Assert: Usage should be 20 (allowance 100 - balance 80) // NOT 50 (allowance 100 + rollover 30 - balance 80) diff --git a/server/tests/unit/billing/setup-feature-quantities/setup-feature-quantities-context.spec.ts b/server/tests/unit/billing/setup-feature-quantities/setup-feature-quantities-context.spec.ts index d1526f73f..8b8fe109c 100644 --- a/server/tests/unit/billing/setup-feature-quantities/setup-feature-quantities-context.spec.ts +++ b/server/tests/unit/billing/setup-feature-quantities/setup-feature-quantities-context.spec.ts @@ -1,7 +1,8 @@ import { describe, expect, test } from "bun:test"; -import type { UpdateSubscriptionV0Params } from "@autumn/shared"; +import type { UpdateSubscriptionV1Params } from "@autumn/shared"; import { contexts } from "@tests/utils/fixtures/db/contexts"; import { customerProducts } from "@tests/utils/fixtures/db/customerProducts"; +import { entitlements } from "@tests/utils/fixtures/db/entitlements"; import { features } from "@tests/utils/fixtures/db/features"; import { prices } from "@tests/utils/fixtures/db/prices"; import { products } from "@tests/utils/fixtures/db/products"; @@ -18,12 +19,23 @@ describe(chalk.yellowBright("setupFeatureQuantitiesContext"), () => { name: "Credits", }); + const entitlement = entitlements.create({ + id: "ent_credits", + featureId: "credits", + featureName: "Credits", + allowance: 0, + }); + const price = prices.createPrepaid({ id: "price_credits", featureId: "credits", + entitlementId: "ent_credits", }); - const fullProduct = products.createFull({ prices: [price] }); + const fullProduct = products.createFull({ + prices: [price], + entitlements: [entitlement], + }); const cusProduct = customerProducts.create({ options: [ { @@ -35,7 +47,7 @@ describe(chalk.yellowBright("setupFeatureQuantitiesContext"), () => { customerPrices: [prices.createCustomer({ price })], }); - const params: UpdateSubscriptionV0Params = { + const params: UpdateSubscriptionV1Params = { customer_id: "cus_test", product_id: "prod_test", // No options provided @@ -61,15 +73,26 @@ describe(chalk.yellowBright("setupFeatureQuantitiesContext"), () => { name: "Credits", }); + const entitlement = entitlements.create({ + id: "ent_credits", + featureId: "credits", + featureName: "Credits", + allowance: 0, + }); + const price = prices.createPrepaid({ id: "price_credits", featureId: "credits", + entitlementId: "ent_credits", }); - const fullProduct = products.createFull({ prices: [price] }); + const fullProduct = products.createFull({ + prices: [price], + entitlements: [entitlement], + }); const cusProduct = customerProducts.create({ options: [] }); - const params: UpdateSubscriptionV0Params = { + const params: UpdateSubscriptionV1Params = { customer_id: "cus_test", product_id: "prod_test", options: [{ feature_id: "credits", quantity: 50 }], @@ -95,12 +118,23 @@ describe(chalk.yellowBright("setupFeatureQuantitiesContext"), () => { name: "Credits", }); + const entitlement = entitlements.create({ + id: "ent_credits", + featureId: "credits", + featureName: "Credits", + allowance: 0, + }); + const price = prices.createPrepaid({ id: "price_credits", featureId: "credits", + entitlementId: "ent_credits", }); - const fullProduct = products.createFull({ prices: [price] }); + const fullProduct = products.createFull({ + prices: [price], + entitlements: [entitlement], + }); const cusProduct = customerProducts.create({ options: [ { @@ -111,7 +145,7 @@ describe(chalk.yellowBright("setupFeatureQuantitiesContext"), () => { ], }); - const params: UpdateSubscriptionV0Params = { + const params: UpdateSubscriptionV1Params = { customer_id: "cus_test", product_id: "prod_test", options: [{ feature_id: "credits", quantity: 200 }], @@ -147,21 +181,44 @@ describe(chalk.yellowBright("setupFeatureQuantitiesContext"), () => { name: "Storage", }); + const creditsEnt = entitlements.create({ + id: "ent_credits", + featureId: "credits", + featureName: "Credits", + allowance: 0, + }); + const seatsEnt = entitlements.create({ + id: "ent_seats", + featureId: "seats", + featureName: "Seats", + allowance: 0, + }); + const storageEnt = entitlements.create({ + id: "ent_storage", + featureId: "storage", + featureName: "Storage", + allowance: 0, + }); + const creditsPrice = prices.createPrepaid({ id: "price_credits", featureId: "credits", + entitlementId: "ent_credits", }); const seatsPrice = prices.createPrepaid({ id: "price_seats", featureId: "seats", + entitlementId: "ent_seats", }); const storagePrice = prices.createPrepaid({ id: "price_storage", featureId: "storage", + entitlementId: "ent_storage", }); const fullProduct = products.createFull({ prices: [creditsPrice, seatsPrice, storagePrice], + entitlements: [creditsEnt, seatsEnt, storageEnt], }); const cusProduct = customerProducts.create({ @@ -189,7 +246,7 @@ describe(chalk.yellowBright("setupFeatureQuantitiesContext"), () => { ], }); - const params: UpdateSubscriptionV0Params = { + const params: UpdateSubscriptionV1Params = { customer_id: "cus_test", product_id: "prod_test", options: [{ feature_id: "seats", quantity: 10 }], // Only updating seats @@ -225,16 +282,27 @@ describe(chalk.yellowBright("setupFeatureQuantitiesContext"), () => { name: "Credits", }); + const entitlement = entitlements.create({ + id: "ent_credits", + featureId: "credits", + featureName: "Credits", + allowance: 0, + }); + const price = prices.createPrepaid({ id: "price_credits", featureId: "credits", + entitlementId: "ent_credits", billingUnits: 100, // Billing in units of 100 }); - const fullProduct = products.createFull({ prices: [price] }); + const fullProduct = products.createFull({ + prices: [price], + entitlements: [entitlement], + }); const cusProduct = customerProducts.create({ options: [] }); - const params: UpdateSubscriptionV0Params = { + const params: UpdateSubscriptionV1Params = { customer_id: "cus_test", product_id: "prod_test", options: [{ feature_id: "credits", quantity: 150 }], // Should round up to 200 @@ -261,16 +329,27 @@ describe(chalk.yellowBright("setupFeatureQuantitiesContext"), () => { name: "Credits", }); + const entitlement = entitlements.create({ + id: "ent_credits", + featureId: "credits", + featureName: "Credits", + allowance: 0, + }); + const price = prices.createPrepaid({ id: "price_credits", featureId: "credits", + entitlementId: "ent_credits", billingUnits: 50, }); - const fullProduct = products.createFull({ prices: [price] }); + const fullProduct = products.createFull({ + prices: [price], + entitlements: [entitlement], + }); const cusProduct = customerProducts.create({ options: [] }); - const params: UpdateSubscriptionV0Params = { + const params: UpdateSubscriptionV1Params = { customer_id: "cus_test", product_id: "prod_test", options: [{ feature_id: "credits", quantity: 200 }], // Exact multiple @@ -296,16 +375,27 @@ describe(chalk.yellowBright("setupFeatureQuantitiesContext"), () => { name: "Credits", }); + const entitlement = entitlements.create({ + id: "ent_credits", + featureId: "credits", + featureName: "Credits", + allowance: 0, + }); + const price = prices.createPrepaid({ id: "price_credits", featureId: "credits", + entitlementId: "ent_credits", billingUnits: 1000, }); - const fullProduct = products.createFull({ prices: [price] }); + const fullProduct = products.createFull({ + prices: [price], + entitlements: [entitlement], + }); const cusProduct = customerProducts.create({ options: [] }); - const params: UpdateSubscriptionV0Params = { + const params: UpdateSubscriptionV1Params = { customer_id: "cus_test", product_id: "prod_test", options: [{ feature_id: "credits", quantity: 1 }], // Should round to 1000 @@ -334,10 +424,18 @@ describe(chalk.yellowBright("setupFeatureQuantitiesContext"), () => { name: "Credits", }); + const entitlement = entitlements.create({ + id: "ent_credits", + featureId: "credits", + featureName: "Credits", + allowance: 0, + }); + // Old price: billing units of 100 const oldPrice = prices.createPrepaid({ id: "price_credits_old", featureId: "credits", + entitlementId: "ent_credits", billingUnits: 100, }); @@ -345,10 +443,14 @@ describe(chalk.yellowBright("setupFeatureQuantitiesContext"), () => { const newPrice = prices.createPrepaid({ id: "price_credits_new", featureId: "credits", + entitlementId: "ent_credits", billingUnits: 250, }); - const fullProduct = products.createFull({ prices: [newPrice] }); + const fullProduct = products.createFull({ + prices: [newPrice], + entitlements: [entitlement], + }); const cusProduct = customerProducts.create({ options: [ @@ -361,7 +463,7 @@ describe(chalk.yellowBright("setupFeatureQuantitiesContext"), () => { customerPrices: [prices.createCustomer({ price: oldPrice })], }); - const params: UpdateSubscriptionV0Params = { + const params: UpdateSubscriptionV1Params = { customer_id: "cus_test", product_id: "prod_test", // No options - should inherit from current @@ -393,19 +495,31 @@ describe(chalk.yellowBright("setupFeatureQuantitiesContext"), () => { name: "Credits", }); + const entitlement = entitlements.create({ + id: "ent_credits", + featureId: "credits", + featureName: "Credits", + allowance: 0, + }); + const oldPrice = prices.createPrepaid({ id: "price_credits_old", featureId: "credits", + entitlementId: "ent_credits", billingUnits: 100, }); const newPrice = prices.createPrepaid({ id: "price_credits_new", featureId: "credits", + entitlementId: "ent_credits", billingUnits: 250, }); - const fullProduct = products.createFull({ prices: [newPrice] }); + const fullProduct = products.createFull({ + prices: [newPrice], + entitlements: [entitlement], + }); const cusProduct = customerProducts.create({ options: [ @@ -418,7 +532,7 @@ describe(chalk.yellowBright("setupFeatureQuantitiesContext"), () => { customerPrices: [prices.createCustomer({ price: oldPrice })], }); - const params: UpdateSubscriptionV0Params = { + const params: UpdateSubscriptionV1Params = { customer_id: "cus_test", product_id: "prod_test", }; @@ -447,13 +561,24 @@ describe(chalk.yellowBright("setupFeatureQuantitiesContext"), () => { name: "Credits", }); + const entitlement = entitlements.create({ + id: "ent_credits", + featureId: "credits", + featureName: "Credits", + allowance: 0, + }); + const newPrice = prices.createPrepaid({ id: "price_credits_new", featureId: "credits", + entitlementId: "ent_credits", billingUnits: 250, }); - const fullProduct = products.createFull({ prices: [newPrice] }); + const fullProduct = products.createFull({ + prices: [newPrice], + entitlements: [entitlement], + }); const cusProduct = customerProducts.create({ options: [ @@ -466,7 +591,7 @@ describe(chalk.yellowBright("setupFeatureQuantitiesContext"), () => { // No customerPrices - can't interpret stored quantity }); - const params: UpdateSubscriptionV0Params = { + const params: UpdateSubscriptionV1Params = { customer_id: "cus_test", product_id: "prod_test", }; @@ -493,19 +618,28 @@ describe(chalk.yellowBright("setupFeatureQuantitiesContext"), () => { name: "Credits", }); + const entitlement = entitlements.create({ + id: "ent_credits", + featureId: "credits", + featureName: "Credits", + allowance: 0, + }); + const fixedPrice = prices.createFixed({ id: "price_fixed" }); const prepaidPrice = prices.createPrepaid({ id: "price_credits", featureId: "credits", + entitlementId: "ent_credits", }); const fullProduct = products.createFull({ prices: [fixedPrice, prepaidPrice], + entitlements: [entitlement], }); const cusProduct = customerProducts.create({ options: [] }); - const params: UpdateSubscriptionV0Params = { + const params: UpdateSubscriptionV1Params = { customer_id: "cus_test", product_id: "prod_test", options: [{ feature_id: "credits", quantity: 50 }], @@ -528,10 +662,10 @@ describe(chalk.yellowBright("setupFeatureQuantitiesContext"), () => { describe("edge cases", () => { test("empty prices array returns empty array", () => { - const fullProduct = products.createFull({ prices: [] }); + const fullProduct = products.createFull({ prices: [], entitlements: [] }); const cusProduct = customerProducts.create({ options: [] }); - const params: UpdateSubscriptionV0Params = { + const params: UpdateSubscriptionV1Params = { customer_id: "cus_test", product_id: "prod_test", }; @@ -548,21 +682,25 @@ describe(chalk.yellowBright("setupFeatureQuantitiesContext"), () => { expect(result).toHaveLength(0); }); - test("throws error when feature not found for price", () => { + test("throws error when entitlement not found for price", () => { const price = prices.createPrepaid({ id: "price_credits", featureId: "credits", + entitlementId: "ent_credits", }); - const fullProduct = products.createFull({ prices: [price] }); + // No entitlements provided - entitlement won't be found + const fullProduct = products.createFull({ + prices: [price], + entitlements: [], + }); const cusProduct = customerProducts.create({ options: [] }); - const params: UpdateSubscriptionV0Params = { + const params: UpdateSubscriptionV1Params = { customer_id: "cus_test", product_id: "prod_test", }; - // Empty features array - feature won't be found const ctx = contexts.create({ features: [] }); expect(() => @@ -572,7 +710,7 @@ describe(chalk.yellowBright("setupFeatureQuantitiesContext"), () => { fullProduct, currentCustomerProduct: cusProduct, }), - ).toThrow("Feature not found for price"); + ).toThrow("Entitlement not found for price"); }); test("neither current nor new has quantity → feature not included", () => { @@ -581,15 +719,26 @@ describe(chalk.yellowBright("setupFeatureQuantitiesContext"), () => { name: "Credits", }); + const entitlement = entitlements.create({ + id: "ent_credits", + featureId: "credits", + featureName: "Credits", + allowance: 0, + }); + const price = prices.createPrepaid({ id: "price_credits", featureId: "credits", + entitlementId: "ent_credits", }); - const fullProduct = products.createFull({ prices: [price] }); + const fullProduct = products.createFull({ + prices: [price], + entitlements: [entitlement], + }); const cusProduct = customerProducts.create({ options: [] }); // No current options - const params: UpdateSubscriptionV0Params = { + const params: UpdateSubscriptionV1Params = { customer_id: "cus_test", product_id: "prod_test", // No options in params either @@ -614,12 +763,23 @@ describe(chalk.yellowBright("setupFeatureQuantitiesContext"), () => { name: "Credits", }); + const entitlement = entitlements.create({ + id: "ent_credits", + featureId: "credits", + featureName: "Credits", + allowance: 0, + }); + const price = prices.createPrepaid({ id: "price_credits", featureId: "credits", + entitlementId: "ent_credits", }); - const fullProduct = products.createFull({ prices: [price] }); + const fullProduct = products.createFull({ + prices: [price], + entitlements: [entitlement], + }); const cusProduct = customerProducts.create({ options: [ { @@ -631,7 +791,7 @@ describe(chalk.yellowBright("setupFeatureQuantitiesContext"), () => { customerPrices: [prices.createCustomer({ price })], }); - const params: UpdateSubscriptionV0Params = { + const params: UpdateSubscriptionV1Params = { customer_id: "cus_test", product_id: "prod_test", options: [], // Explicitly empty @@ -657,12 +817,23 @@ describe(chalk.yellowBright("setupFeatureQuantitiesContext"), () => { name: "Credits", }); + const entitlement = entitlements.create({ + id: "ent_credits", + featureId: "credits", + featureName: "Credits", + allowance: 0, + }); + const price = prices.createPrepaid({ id: "price_credits", featureId: "credits", + entitlementId: "ent_credits", }); - const fullProduct = products.createFull({ prices: [price] }); + const fullProduct = products.createFull({ + prices: [price], + entitlements: [entitlement], + }); const cusProduct = customerProducts.create({ options: [ { @@ -674,7 +845,7 @@ describe(chalk.yellowBright("setupFeatureQuantitiesContext"), () => { customerPrices: [prices.createCustomer({ price })], }); - const params: UpdateSubscriptionV0Params = { + const params: UpdateSubscriptionV1Params = { customer_id: "cus_test", product_id: "prod_test", options: [{ feature_id: "credits", quantity: 0 }], @@ -700,13 +871,25 @@ describe(chalk.yellowBright("setupFeatureQuantitiesContext"), () => { name: "Credits", }); + const entitlement = entitlements.create({ + id: "ent_credits", + featureId: "credits", + internalFeatureId: "internal_credits_v2", + featureName: "Credits", + allowance: 0, + }); + const price = prices.createPrepaid({ id: "price_credits", featureId: "credits", internalFeatureId: "internal_credits_v2", + entitlementId: "ent_credits", }); - const fullProduct = products.createFull({ prices: [price] }); + const fullProduct = products.createFull({ + prices: [price], + entitlements: [entitlement], + }); const cusProduct = customerProducts.create({ options: [ { @@ -718,7 +901,7 @@ describe(chalk.yellowBright("setupFeatureQuantitiesContext"), () => { customerPrices: [prices.createCustomer({ price })], }); - const params: UpdateSubscriptionV0Params = { + const params: UpdateSubscriptionV1Params = { customer_id: "cus_test", product_id: "prod_test", }; @@ -742,12 +925,23 @@ describe(chalk.yellowBright("setupFeatureQuantitiesContext"), () => { name: "Seats", }); + const entitlement = entitlements.create({ + id: "ent_seats", + featureId: "seats", + featureName: "Seats", + allowance: 0, + }); + const price = prices.createPrepaid({ id: "price_seats", featureId: "seats", + entitlementId: "ent_seats", }); - const fullProduct = products.createFull({ prices: [price] }); + const fullProduct = products.createFull({ + prices: [price], + entitlements: [entitlement], + }); const cusProduct = customerProducts.create({ options: [ { @@ -759,7 +953,7 @@ describe(chalk.yellowBright("setupFeatureQuantitiesContext"), () => { customerPrices: [prices.createCustomer({ price })], }); - const params: UpdateSubscriptionV0Params = { + const params: UpdateSubscriptionV1Params = { customer_id: "cus_test", product_id: "prod_test", // No options provided - should carry over from current diff --git a/server/tests/unit/billing/stripe/stripeSubscriptionTestHelpers.ts b/server/tests/unit/billing/stripe/stripeSubscriptionTestHelpers.ts index 2da89eb24..c43735ceb 100644 --- a/server/tests/unit/billing/stripe/stripeSubscriptionTestHelpers.ts +++ b/server/tests/unit/billing/stripe/stripeSubscriptionTestHelpers.ts @@ -180,15 +180,15 @@ export const createCustomerPricesForProduct = ({ /** * Gets all stripe price IDs from a product helper result. - * Entity-level products use stripeEmptyPriceId for consumable prices. + * Note: As of current implementation, withEntity is hardcoded to false in customerProductToStripeItemSpecs, + * so consumable prices always use the regular (non-empty) price ID. */ export const getStripePriceIds = ( product: ReturnType, - { isEntityLevel = false }: { isEntityLevel?: boolean } = {}, + { isEntityLevel: _isEntityLevel = false }: { isEntityLevel?: boolean } = {}, ): string[] => { - const consumablePriceId = isEntityLevel - ? `stripe_${product.product.id}_consumable_empty` - : `stripe_${product.product.id}_consumable`; + // withEntity is hardcoded to false, so always use regular consumable price + const consumablePriceId = `stripe_${product.product.id}_consumable`; return [ `stripe_${product.product.id}_fixed`, @@ -232,21 +232,16 @@ export const expectPhaseItems = ( export const getExpectedPhaseItems = ( product: ReturnType, { - isEntityLevel = false, + isEntityLevel: _isEntityLevel = false, fixedQuantityMultiplier = 1, }: { isEntityLevel?: boolean; fixedQuantityMultiplier?: number } = {}, ): ExpectedPhaseItem[] => { const productId = product.product.id; const { expectedQuantities } = product; - const consumablePriceId = isEntityLevel - ? `stripe_${productId}_consumable_empty` - : `stripe_${productId}_consumable`; - - // Entity consumable uses empty price with quantity 0 - const consumableQuantity = isEntityLevel - ? expectedQuantities.consumableEntity - : expectedQuantities.consumable; + // withEntity is hardcoded to false, so always use regular consumable price + const consumablePriceId = `stripe_${productId}_consumable`; + const consumableQuantity = expectedQuantities.consumable; return [ { @@ -298,24 +293,19 @@ export type ExpectedSubscriptionItemUpdate = { * Gets expected subscription item updates for creating a new product. * Uses the expectedQuantities from the product helper. * - * @param isEntityLevel - If true, uses stripeEmptyPriceId for consumable (quantity 0) + * Note: As of current implementation, withEntity is hardcoded to false in customerProductToStripeItemSpecs, + * so consumable prices always use the regular (non-empty) price ID. */ export const getExpectedNewProductItems = ( product: ReturnType, - { isEntityLevel = false }: { isEntityLevel?: boolean } = {}, + { isEntityLevel: _isEntityLevel = false }: { isEntityLevel?: boolean } = {}, ): ExpectedSubscriptionItemUpdate[] => { const productId = product.product.id; const { expectedQuantities } = product; - const consumablePriceId = isEntityLevel - ? `stripe_${productId}_consumable_empty` - : `stripe_${productId}_consumable`; - - // Entity consumable uses empty price with quantity 0 - // Customer consumable is metered (no quantity) - const consumableQuantity = isEntityLevel - ? expectedQuantities.consumableEntity - : expectedQuantities.consumable; + // withEntity is hardcoded to false, so always use regular consumable price (metered, no quantity) + const consumablePriceId = `stripe_${productId}_consumable`; + const consumableQuantity = expectedQuantities.consumable; const items: ExpectedSubscriptionItemUpdate[] = [ { @@ -370,17 +360,17 @@ export const expectSubscriptionItemsUpdate = ( * Creates stripe subscription items from product's expected prices. * Useful for setting up existing subscription state. * - * Note: For customer-level (non-entity) products, metered consumable prices - * are excluded because they don't have a meaningful quantity to compare. - * Entity-level products use the empty price with quantity 0. + * Note: As of current implementation, withEntity is hardcoded to false in customerProductToStripeItemSpecs, + * so consumable prices always use the regular (non-empty) price ID. Metered consumable prices + * are excluded by default because they don't have a meaningful quantity to compare. * - * @param includeMetered - If true, includes metered consumable even for customer-level. - * Use this when testing scenarios that need the full item set. + * @param includeMetered - If true, includes metered consumable. Use this when testing scenarios + * that need the full item set. */ export const createStripeItemsFromProduct = ( product: ReturnType, { - isEntityLevel = false, + isEntityLevel: _isEntityLevel = false, itemIdPrefix = "si", includeMetered = false, }: { @@ -410,15 +400,9 @@ export const createStripeItemsFromProduct = ( }, ]; - // For entity-level, always include empty price with quantity 0 - // For customer-level, only include metered if explicitly requested - if (isEntityLevel) { - items.push({ - id: `${itemIdPrefix}_${productId}_consumable`, - priceId: `stripe_${productId}_consumable_empty`, - quantity: expectedQuantities.consumableEntity ?? 0, - }); - } else if (includeMetered) { + // Only include metered if explicitly requested (withEntity is hardcoded to false, + // so always use regular consumable price) + if (includeMetered) { items.push({ id: `${itemIdPrefix}_${productId}_consumable`, priceId: `stripe_${productId}_consumable`, diff --git a/server/tests/unit/billing/stripe/subscriptions/build-subscription-items-update-multi-product.spec.ts b/server/tests/unit/billing/stripe/subscriptions/build-subscription-items-update-multi-product.spec.ts index 00e63ec6c..335bb9882 100644 --- a/server/tests/unit/billing/stripe/subscriptions/build-subscription-items-update-multi-product.spec.ts +++ b/server/tests/unit/billing/stripe/subscriptions/build-subscription-items-update-multi-product.spec.ts @@ -105,12 +105,13 @@ describe( ); expect(prepaidItem?.quantity).toBe(200); - // Consumable (entities use empty price): 0 + 0 = 0 + // Consumable (metered, withEntity hardcoded to false): no quantity const consumableItem = result.find((item) => item.price?.includes("consumable"), ); - expect(consumableItem?.price).toBe("stripe_pro_consumable_empty"); - expect(consumableItem?.quantity).toBe(0); + expect(consumableItem?.price).toBe("stripe_pro_consumable"); + // Metered prices should NOT have quantity + expect("quantity" in (consumableItem ?? {})).toBe(false); // Allocated: 5 + 5 = 10 const allocatedItem = result.find((item) => @@ -293,8 +294,8 @@ describe( finalCustomerProducts: [entity1ProProduct, entity2ProProduct], }); - // Should update quantities (double them) - expect(result).toHaveLength(3); // fixed, prepaid, allocated (consumable stays 0) + // Should update quantities (double them) + add metered consumable + expect(result).toHaveLength(4); // fixed, prepaid, allocated, consumable (metered) const fixedItem = result.find((item) => item.id?.includes("fixed")); expect(fixedItem?.quantity).toBe(2); @@ -306,6 +307,12 @@ describe( item.id?.includes("allocated"), ); expect(allocatedItem?.quantity).toBe(10); + + // Metered consumable needs to be added + const consumableItem = result.find((item) => + item.price?.includes("consumable"), + ); + expect(consumableItem).toBeDefined(); }); test("Remove one entity from two-entity subscription", () => { @@ -334,7 +341,7 @@ describe( subscriptionIds: ["sub_123"], }); - // Current subscription has 2 entities worth of quantities + // Current subscription has 2 entities worth of quantities (no consumable since withEntity is false) const stripeSubscription = stripeSubscriptions.create({ id: "sub_123", items: [ @@ -348,11 +355,6 @@ describe( priceId: "stripe_pro_prepaid", quantity: 200, }, - { - id: "si_pro_consumable", - priceId: "stripe_pro_consumable_empty", - quantity: 0, - }, { id: "si_pro_allocated", priceId: "stripe_pro_allocated", @@ -373,8 +375,8 @@ describe( finalCustomerProducts: [entity1ProProduct], }); - // Should update quantities (halve them) - expect(result).toHaveLength(3); // fixed, prepaid, allocated + // Should update quantities (halve them) + add metered consumable + expect(result).toHaveLength(4); // fixed, prepaid, allocated, consumable const fixedItem = result.find((item) => item.id?.includes("fixed")); expect(fixedItem?.quantity).toBe(1); @@ -386,6 +388,12 @@ describe( item.id?.includes("allocated"), ); expect(allocatedItem?.quantity).toBe(5); + + // Metered consumable needs to be added + const consumableItem = result.find((item) => + item.price?.includes("consumable"), + ); + expect(consumableItem).toBeDefined(); }); }); @@ -524,13 +532,12 @@ describe( finalCustomerProducts: [customerProProduct, entityProProduct], }); - // Should have 5 items: + // Should have 4 items (withEntity hardcoded to false, so both use metered): // - fixed (1+1=2) // - prepaid (100+50=150) // - allocated (5+3=8) - // - consumable metered (customer) - // - consumable empty (entity) - expect(result).toHaveLength(5); + // - consumable metered (both customer and entity use regular price) + expect(result).toHaveLength(4); // Fixed: 1 + 1 = 2 const fixedItem = result.find((item) => item.price?.includes("fixed")); @@ -548,16 +555,11 @@ describe( ); expect(allocatedItem?.quantity).toBe(8); - // Both consumable prices should be present + // Only one consumable price (metered, withEntity is false) const consumableMetered = result.find( (item) => item.price === "stripe_pro_consumable", ); - const consumableEmpty = result.find( - (item) => item.price === "stripe_pro_consumable_empty", - ); expect(consumableMetered).toBeDefined(); - expect(consumableEmpty).toBeDefined(); - expect(consumableEmpty?.quantity).toBe(0); // Metered should not have quantity expect("quantity" in (consumableMetered ?? {})).toBe(false); }); @@ -588,7 +590,7 @@ describe( subscriptionIds: ["sub_123"], }); - // Current subscription has both customer and entity + // Current subscription has customer and entity (both use regular metered price - withEntity is false) const stripeSubscription = stripeSubscriptions.create({ id: "sub_123", items: [ @@ -598,16 +600,6 @@ describe( priceId: "stripe_pro_prepaid", quantity: 200, }, - { - id: "si_pro_consumable", - priceId: "stripe_pro_consumable", - quantity: 0, - }, - { - id: "si_pro_consumable_empty", - priceId: "stripe_pro_consumable_empty", - quantity: 0, - }, { id: "si_pro_allocated", priceId: "stripe_pro_allocated", @@ -628,7 +620,7 @@ describe( finalCustomerProducts: [entityProProduct], }); - // Should update quantities and remove metered consumable + // Should update quantities + add metered consumable (wasn't in subscription) // Fixed: 2 -> 1 const fixedItem = result.find((item) => item.id === "si_pro_fixed"); expect(fixedItem?.quantity).toBe(1); @@ -643,11 +635,11 @@ describe( ); expect(allocatedItem?.quantity).toBe(5); - // Metered consumable should be deleted - const deletedMetered = result.find( - (item) => item.id === "si_pro_consumable" && item.deleted, + // Metered consumable should be added (wasn't in subscription) + const consumableItem = result.find( + (item) => item.price === "stripe_pro_consumable", ); - expect(deletedMetered).toBeDefined(); + expect(consumableItem).toBeDefined(); }); }); diff --git a/server/tests/unit/billing/stripe/subscriptions/build-subscription-items-update.spec.ts b/server/tests/unit/billing/stripe/subscriptions/build-subscription-items-update.spec.ts index 9dacf4b38..4a2c9232e 100644 --- a/server/tests/unit/billing/stripe/subscriptions/build-subscription-items-update.spec.ts +++ b/server/tests/unit/billing/stripe/subscriptions/build-subscription-items-update.spec.ts @@ -483,7 +483,7 @@ describe( expect(result[0].price).toBe("stripe_premium_consumable"); }); - test("Entity product, same quantities returns empty array", () => { + test("Entity product, same quantities returns only metered addition", () => { const premium = createProductWithAllPriceTypes({ productId: "premium", productName: "Premium", @@ -508,7 +508,8 @@ describe( entityId: "entity_1", }); - // Entity-level includes the empty price with quantity 0 + // Entity-level subscription has 3 items (no metered consumable) + // Note: withEntity is hardcoded to false, so entity products use regular metered prices const stripeSubscription = stripeSubscriptions.create({ id: "sub_123", items: createStripeItemsFromProduct(premium, { isEntityLevel: true }), @@ -526,8 +527,9 @@ describe( finalCustomerProducts: [entityCustomerProduct], }); - // No changes needed - all quantities match - expect(result).toHaveLength(0); + // Only the metered consumable needs to be added (wasn't in existing subscription) + expect(result).toHaveLength(1); + expect(result[0].price).toBe("stripe_premium_consumable"); }); }); @@ -568,14 +570,14 @@ describe( ]); }); - test("Remove entity product marks all items including consumable as deleted", () => { + test("Remove entity product marks all items as deleted", () => { const premium = createProductWithAllPriceTypes({ productId: "premium", productName: "Premium", customerProductId: "cus_prod_premium_entity", }); - // Entity-level subscription has 4 items (including empty consumable) + // Entity-level subscription has 3 items (no metered consumable - withEntity is hardcoded to false) const stripeSubscription = stripeSubscriptions.create({ id: "sub_123", items: createStripeItemsFromProduct(premium, { isEntityLevel: true }), @@ -593,12 +595,11 @@ describe( finalCustomerProducts: [], }); - // Should delete all 4 items - expect(result).toHaveLength(4); + // Should delete all 3 items (fixed, prepaid, allocated) + expect(result).toHaveLength(3); expectSubscriptionItemsUpdate(result, [ { id: "si_premium_fixed", deleted: true }, { id: "si_premium_prepaid", deleted: true }, - { id: "si_premium_consumable", deleted: true }, { id: "si_premium_allocated", deleted: true }, ]); }); @@ -753,7 +754,7 @@ describe( expect(result).toHaveLength(0); }); - test("Entity-level consumable uses stripe_empty_price_id with quantity 0", () => { + test("Entity-level consumable uses regular metered price (withEntity hardcoded to false)", () => { const premium = createProductWithAllPriceTypes({ productId: "premium", productName: "Premium", @@ -787,13 +788,14 @@ describe( finalCustomerProducts: [entityCustomerProduct], }); - // Find the consumable item + // Find the consumable item - now uses regular metered price (withEntity is hardcoded to false) const consumableItem = result.find((item) => item.price?.includes("consumable"), ); expect(consumableItem).toBeDefined(); - expect(consumableItem?.price).toBe("stripe_premium_consumable_empty"); - expect(consumableItem?.quantity).toBe(0); + expect(consumableItem?.price).toBe("stripe_premium_consumable"); + // Metered prices should NOT have quantity + expect("quantity" in (consumableItem ?? {})).toBe(false); }); test("Customer-level consumable (metered) has no quantity", () => { diff --git a/server/tests/unit/billing/update-subscription/compute-update-subscription-intent.spec.ts b/server/tests/unit/billing/update-subscription/compute-update-subscription-intent.spec.ts index 63b4e352b..2f5becf58 100644 --- a/server/tests/unit/billing/update-subscription/compute-update-subscription-intent.spec.ts +++ b/server/tests/unit/billing/update-subscription/compute-update-subscription-intent.spec.ts @@ -8,14 +8,14 @@ */ import { describe, expect, test } from "bun:test"; -import type { UpdateSubscriptionV0Params } from "@autumn/shared"; +import type { UpdateSubscriptionV1Params } from "@autumn/shared"; import chalk from "chalk"; import { computeUpdateSubscriptionIntent, UpdateSubscriptionIntent, } from "@/internal/billing/v2/actions/updateSubscription/compute/computeUpdateSubscriptionIntent"; -const baseParams: UpdateSubscriptionV0Params = { +const baseParams: UpdateSubscriptionV1Params = { customer_id: "cus_test", product_id: "prod_test", }; @@ -23,7 +23,7 @@ const baseParams: UpdateSubscriptionV0Params = { describe(chalk.yellowBright("computeUpdateSubscriptionIntent"), () => { describe(chalk.cyan("Version parameter priority"), () => { test("returns UpdatePlan when version is specified", () => { - const params: UpdateSubscriptionV0Params = { + const params: UpdateSubscriptionV1Params = { ...baseParams, version: 2, }; @@ -34,7 +34,7 @@ describe(chalk.yellowBright("computeUpdateSubscriptionIntent"), () => { }); test("returns UpdatePlan when version is specified even with options", () => { - const params: UpdateSubscriptionV0Params = { + const params: UpdateSubscriptionV1Params = { ...baseParams, version: 3, options: [{ feature_id: "seats", quantity: 10 }], @@ -46,7 +46,7 @@ describe(chalk.yellowBright("computeUpdateSubscriptionIntent"), () => { }); test("returns UpdatePlan when version is 0", () => { - const params: UpdateSubscriptionV0Params = { + const params: UpdateSubscriptionV1Params = { ...baseParams, version: 0, }; @@ -58,8 +58,8 @@ describe(chalk.yellowBright("computeUpdateSubscriptionIntent"), () => { }); describe(chalk.cyan("UpdateQuantity intent"), () => { - test("returns UpdateQuantity when options provided without items", () => { - const params: UpdateSubscriptionV0Params = { + test("returns UpdateQuantity when options provided without customize", () => { + const params: UpdateSubscriptionV1Params = { ...baseParams, options: [{ feature_id: "seats", quantity: 5 }], }; @@ -70,7 +70,7 @@ describe(chalk.yellowBright("computeUpdateSubscriptionIntent"), () => { }); test("returns UpdateQuantity with multiple options", () => { - const params: UpdateSubscriptionV0Params = { + const params: UpdateSubscriptionV1Params = { ...baseParams, options: [ { feature_id: "seats", quantity: 5 }, @@ -85,10 +85,12 @@ describe(chalk.yellowBright("computeUpdateSubscriptionIntent"), () => { }); describe(chalk.cyan("UpdatePlan intent (default)"), () => { - test("returns UpdatePlan when items provided", () => { - const params: UpdateSubscriptionV0Params = { + test("returns UpdatePlan when customize provided", () => { + const params: UpdateSubscriptionV1Params = { ...baseParams, - items: [{ feature_id: "seats", included_usage: 10 }], + customize: { + items: [{ feature_id: "seats", included: 10 }], + }, }; const result = computeUpdateSubscriptionIntent(params); @@ -96,11 +98,13 @@ describe(chalk.yellowBright("computeUpdateSubscriptionIntent"), () => { expect(result).toBe(UpdateSubscriptionIntent.UpdatePlan); }); - test("returns UpdatePlan when both options and items provided", () => { - const params: UpdateSubscriptionV0Params = { + test("returns UpdatePlan when both options and customize provided", () => { + const params: UpdateSubscriptionV1Params = { ...baseParams, options: [{ feature_id: "seats", quantity: 5 }], - items: [{ feature_id: "seats", included_usage: 10 }], + customize: { + items: [{ feature_id: "seats", included: 10 }], + }, }; const result = computeUpdateSubscriptionIntent(params); diff --git a/server/tests/utils/fixtures/db/contexts.ts b/server/tests/utils/fixtures/db/contexts.ts index f4084b33d..5c3ad6fea 100644 --- a/server/tests/utils/fixtures/db/contexts.ts +++ b/server/tests/utils/fixtures/db/contexts.ts @@ -54,6 +54,7 @@ const createBilling = ({ currentEpochMs = Date.now(), billingCycleAnchorMs = "now", resetCycleAnchorMs = "now", + billingVersion = BillingVersion.V1, }: { customerProducts?: FullCusProduct[]; fullProducts?: FullProduct[]; @@ -62,6 +63,7 @@ const createBilling = ({ currentEpochMs?: number; billingCycleAnchorMs?: number | "now"; resetCycleAnchorMs?: number | "now"; + billingVersion?: BillingVersion; }): BillingContext => ({ fullCustomer: customers.create({ customerProducts }), stripeCustomer: stripeCustomers.create(), @@ -75,7 +77,7 @@ const createBilling = ({ customPrices: [], customEnts: [], isCustom: false, - billingVersion: BillingVersion.V2, + billingVersion, }); // ═══════════════════════════════════════════════════════════════════ diff --git a/server/tests/utils/fixtures/db/customerEntitlements.ts b/server/tests/utils/fixtures/db/customerEntitlements.ts index 6a0368cb3..c51fae55d 100644 --- a/server/tests/utils/fixtures/db/customerEntitlements.ts +++ b/server/tests/utils/fixtures/db/customerEntitlements.ts @@ -3,6 +3,7 @@ import { type EntityBalance, FeatureType, type FullCustomerEntitlement, + type RolloverConfig, } from "@autumn/shared"; import { entitlements } from "./entitlements"; @@ -26,6 +27,7 @@ const create = ({ nextResetAt = null, entities = null, entityFeatureId = null, + rollover = null, }: { id?: string; entitlementId?: string; @@ -43,6 +45,7 @@ const create = ({ nextResetAt?: number | null; entities?: Record | null; entityFeatureId?: string | null; + rollover?: RolloverConfig | null; }): FullCustomerEntitlement => { const entId = entitlementId ?? `ent_${featureId}`; return { @@ -72,6 +75,7 @@ const create = ({ interval, intervalCount, entityFeatureId, + rollover, }), replaceables: [], rollovers: [], diff --git a/server/tests/utils/fixtures/db/entitlements.ts b/server/tests/utils/fixtures/db/entitlements.ts index 6d995b816..f8c05e8e2 100644 --- a/server/tests/utils/fixtures/db/entitlements.ts +++ b/server/tests/utils/fixtures/db/entitlements.ts @@ -1,4 +1,9 @@ -import { AllowanceType, type EntInterval, FeatureType } from "@autumn/shared"; +import { + AllowanceType, + type EntInterval, + FeatureType, + type RolloverConfig, +} from "@autumn/shared"; import { features } from "./features"; /** @@ -15,6 +20,7 @@ const create = ({ interval = null, intervalCount = 1, entityFeatureId = null, + rollover = null, }: { id?: string; featureId: string; @@ -26,6 +32,7 @@ const create = ({ interval?: EntInterval | null; intervalCount?: number; entityFeatureId?: string | null; + rollover?: RolloverConfig | null; }) => ({ id: id ?? `ent_${featureId}_${crypto.randomUUID().slice(0, 8)}`, created_at: Date.now(), @@ -40,7 +47,7 @@ const create = ({ entity_feature_id: entityFeatureId, feature_id: featureId, usage_limit: null, - rollover: null, + rollover, feature: features.create({ id: featureId, internalId: internalFeatureId, diff --git a/server/tests/utils/fixtures/db/prices.ts b/server/tests/utils/fixtures/db/prices.ts index 5eef62088..073ed49a0 100644 --- a/server/tests/utils/fixtures/db/prices.ts +++ b/server/tests/utils/fixtures/db/prices.ts @@ -43,6 +43,7 @@ const createPrepaid = ({ usage_tiers: [{ to: Infinite, amount: 10 }], interval: BillingInterval.Month, stripe_price_id: stripePriceId ?? `stripe_price_${id}`, + stripe_prepaid_price_v2_id: stripePriceId ?? `stripe_price_${id}`, }, }) as Price; diff --git a/server/tests/utils/fixtures/db/products.ts b/server/tests/utils/fixtures/db/products.ts index b819e2941..02a40334b 100644 --- a/server/tests/utils/fixtures/db/products.ts +++ b/server/tests/utils/fixtures/db/products.ts @@ -1,4 +1,9 @@ -import { AppEnv, type FullProduct, type Price } from "@autumn/shared"; +import { + AppEnv, + type EntitlementWithFeature, + type FullProduct, + type Price, +} from "@autumn/shared"; /** * Create a product fixture @@ -33,12 +38,14 @@ const createFull = ({ id = "prod_test", name = "Test Product", prices = [], + entitlements = [], stripeProductId, isAddOn = false, }: { id?: string; name?: string; prices?: Price[]; + entitlements?: EntitlementWithFeature[]; stripeProductId?: string; isAddOn?: boolean; }): FullProduct => @@ -58,7 +65,7 @@ const createFull = ({ base_variant_id: null, archived: false, prices, - entitlements: [], + entitlements, free_trial: null, }) as FullProduct; diff --git a/server/tests/utils/fixtures/itemsV2.ts b/server/tests/utils/fixtures/itemsV2.ts new file mode 100644 index 000000000..3dc792c3e --- /dev/null +++ b/server/tests/utils/fixtures/itemsV2.ts @@ -0,0 +1,133 @@ +import { + BillingInterval, + BillingMethod, + OnDecrease, + OnIncrease, + ResetInterval, +} from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features"; + +const monthlyPrice = ({ + amount = 20, + intervalCount, +}: { + amount?: number; + intervalCount?: number; +} = {}) => ({ + amount, + interval: BillingInterval.Month, + interval_count: intervalCount, +}); + +const annualPrice = ({ + amount = 200, + intervalCount, +}: { + amount?: number; + intervalCount?: number; +} = {}) => ({ + amount, + interval: BillingInterval.Year, + interval_count: intervalCount, +}); + +const monthlyMessages = ({ included = 100 }: { included?: number } = {}) => ({ + feature_id: TestFeature.Messages, + included, + reset: { + interval: ResetInterval.Month, + }, +}); + +const monthlyWords = ({ included = 100 }: { included?: number } = {}) => ({ + feature_id: TestFeature.Words, + included, + reset: { + interval: ResetInterval.Month, + }, +}); + +const dashboard = () => ({ + feature_id: TestFeature.Dashboard, +}); + +const prepaidMessages = ({ + amount = 10, + billingUnits = 100, + included = 0, +}: { + amount?: number; + billingUnits?: number; + included?: number; +} = {}) => ({ + feature_id: TestFeature.Messages, + included, + price: { + amount, + interval: BillingInterval.Month, + billing_method: BillingMethod.Prepaid, + billing_units: billingUnits, + }, +}); + +const prepaidWords = ({ + amount = 10, + billingUnits = 100, + included = 0, +}: { + amount?: number; + billingUnits?: number; + included?: number; +} = {}) => ({ + feature_id: TestFeature.Words, + included, + price: { + amount, + interval: BillingInterval.Month, + billing_method: BillingMethod.Prepaid, + billing_units: billingUnits, + }, +}); + +const consumableMessages = ({ amount = 1 }: { amount?: number } = {}) => ({ + feature_id: TestFeature.Messages, + price: { + amount, + interval: BillingInterval.Month, + billing_method: BillingMethod.UsageBased, + billing_units: 1, + }, +}); + +const allocatedUsers = ({ + amount = 10, + included = 0, +}: { + amount?: number; + included?: number; +} = {}) => ({ + feature_id: TestFeature.Users, + included, + price: { + amount, + interval: BillingInterval.Month, + billing_method: BillingMethod.UsageBased, + billing_units: 1, + }, + proration: { + on_increase: OnIncrease.ProrateImmediately, + on_decrease: OnDecrease.Prorate, + }, +}); + +export const itemsV2 = { + monthlyPrice, + annualPrice, + monthlyMessages, + monthlyWords, + dashboard, + prepaidMessages, + prepaidWords, + consumableMessages, + allocatedUsers, +} as const; diff --git a/shared/api/balances/check/changes/V1.2_CheckQueryChange.ts b/shared/api/balances/check/changes/V1.2_CheckQueryChange.ts index 50b409753..5247cdc80 100644 --- a/shared/api/balances/check/changes/V1.2_CheckQueryChange.ts +++ b/shared/api/balances/check/changes/V1.2_CheckQueryChange.ts @@ -4,6 +4,7 @@ import { defineVersionChange, } from "@api/versionUtils/versionChangeUtils/VersionChange.js"; import type { z } from "zod/v4"; +import type { SharedContext } from "../../../../types/sharedContext.js"; import { CheckQuerySchema } from "../checkParams.js"; import { CheckExpand } from "../enums/CheckExpand.js"; @@ -42,8 +43,10 @@ export const V1_2_CheckQueryChange = defineVersionChange({ // Request: V1.2 → V2.0 (add expand option) transformRequest: ({ + ctx: _ctx, input, }: { + ctx: SharedContext; input: z.infer; }) => { const existingExpand = input.expand || []; diff --git a/shared/api/balances/track/requestChanges/V1.2_TrackParamsChange.ts b/shared/api/balances/track/requestChanges/V1.2_TrackParamsChange.ts index 262be59f5..1f040561c 100644 --- a/shared/api/balances/track/requestChanges/V1.2_TrackParamsChange.ts +++ b/shared/api/balances/track/requestChanges/V1.2_TrackParamsChange.ts @@ -4,6 +4,7 @@ import { defineVersionChange, } from "@api/versionUtils/versionChangeUtils/VersionChange.js"; import type { z } from "zod/v4"; +import type { SharedContext } from "../../../../types/sharedContext.js"; import { TrackParamsSchema } from "../trackParams.js"; /** @@ -31,8 +32,10 @@ export const V1_2_TrackParamsChange = defineVersionChange({ // Request: V1.2 → V2.0 (extract properties.value to value if not already set) transformRequest: ({ + ctx: _ctx, input, }: { + ctx: SharedContext; input: z.infer; }): z.infer => { // Keep original value if provided, otherwise extract from properties.value diff --git a/shared/api/billing/attachV2/attachParamsV0.ts b/shared/api/billing/attachV2/attachParamsV0.ts index b15cbc32b..68caef40f 100644 --- a/shared/api/billing/attachV2/attachParamsV0.ts +++ b/shared/api/billing/attachV2/attachParamsV0.ts @@ -1,13 +1,11 @@ +import { RedirectModeSchema } from "@api/billing/common/redirectMode.js"; import { z } from "zod/v4"; import { PlanTimingSchema } from "../../../models/billingModels/context/attachBillingContext.js"; import { ProductItemSchema } from "../../../models/productV2Models/productItemModels/productItemModels.js"; import { BillingBehaviorSchema } from "../common/billingBehavior.js"; -import { BillingParamsBaseSchema } from "../common/billingParamsBase.js"; +import { BillingParamsBaseV0Schema } from "../common/billingParamsBase/billingParamsBaseV0.js"; -export const RedirectModeSchema = z.enum(["always", "if_required", "never"]); -export type RedirectMode = z.infer; - -export const ExtAttachParamsV0Schema = BillingParamsBaseSchema.extend({ +export const ExtAttachParamsV0Schema = BillingParamsBaseV0Schema.extend({ // Product identification product_id: z.string(), diff --git a/shared/api/billing/attachV2/attachParamsV1.ts b/shared/api/billing/attachV2/attachParamsV1.ts new file mode 100644 index 000000000..0e4a49e76 --- /dev/null +++ b/shared/api/billing/attachV2/attachParamsV1.ts @@ -0,0 +1,48 @@ +import { BillingParamsBaseV1Schema } from "@api/billing/common/billingParamsBase/billingParamsBaseV1.js"; +import { z } from "zod/v4"; +import { PlanTimingSchema } from "../../../models/billingModels/context/attachBillingContext.js"; +import { BillingBehaviorSchema } from "../common/billingBehavior.js"; +import { RedirectModeSchema } from "../common/redirectMode.js"; + +export const AttachParamsV1Schema = BillingParamsBaseV1Schema.extend({ + // Product identification + product_id: z.string(), + + // Invoice mode + invoice: z.boolean().optional(), + enable_product_immediately: z.boolean().optional(), + finalize_invoice: z.boolean().optional(), + // invoice_mode: z + // .object({ + // enabled: z.boolean(), + // enable_product_immediately: z.boolean(), + // finalize_invoice: z.boolean(), + // }) + // .optional(), + + // Checkout behavior + redirect_mode: RedirectModeSchema.default("always"), + success_url: z.string().optional(), + new_billing_subscription: z.boolean().optional(), + plan_schedule: PlanTimingSchema.optional(), + billing_behavior: BillingBehaviorSchema.optional(), + adjustable_quantity: z.boolean().optional(), +}); + +// export const AttachParamsV1Schema = ExtAttachParamsV1Schema.extend({ +// // Custom product configuration +// items: z.array(ProductItemSchema).optional(), +// }).refine( +// (data) => { +// if (data.items && data.items.length === 0) { +// return false; +// } +// return true; +// }, +// { +// message: "Must provide at least one item when using custom plan", +// }, +// ); + +export type AttachParamsV1 = z.infer; +export type AttachParamsV1Input = z.input; diff --git a/shared/api/billing/attachV2/requestChanges/V1.2_AttachParamsChange.ts b/shared/api/billing/attachV2/requestChanges/V1.2_AttachParamsChange.ts new file mode 100644 index 000000000..d33b79836 --- /dev/null +++ b/shared/api/billing/attachV2/requestChanges/V1.2_AttachParamsChange.ts @@ -0,0 +1,50 @@ +import { freeTrialParamsV0ToV1 } from "@api/common/freeTrial/mappers/freeTrialParamsV0ToV1.js"; +import { ApiVersion } from "@api/versionUtils/ApiVersion.js"; +import { + AffectedResource, + defineVersionChange, +} from "@api/versionUtils/versionChangeUtils/VersionChange.js"; +import { productItemsToCustomizePlanV1 } from "@utils/productV2Utils/productItemUtils/convertProductItem/productItemsToCustomizePlanV1.js"; +import type { z } from "zod/v4"; +import type { SharedContext } from "../../../../types/sharedContext.js"; +import { AttachParamsV0Schema } from "../attachParamsV0.js"; +import { AttachParamsV1Schema } from "../attachParamsV1.js"; + +export const V1_2_AttachParamsChange = defineVersionChange({ + name: "V1.2 Attach Params Change", + newVersion: ApiVersion.V2_0, + oldVersion: ApiVersion.V1_Beta, + description: [ + "Maps free_trial {length,duration} to {duration_length,duration_type}", + "Maps top-level items to customize.items", + ], + affectedResources: [AffectedResource.Attach], + newSchema: AttachParamsV1Schema, + oldSchema: AttachParamsV0Schema, + affectsRequest: true, + affectsResponse: false, + transformRequest: ({ + ctx, + input, + }: { + ctx: SharedContext; + input: z.infer; + }): z.infer => { + const customizeV1 = input.items + ? productItemsToCustomizePlanV1({ + ctx, + items: input.items, + }) + : undefined; + + const freeTrialV1 = freeTrialParamsV0ToV1({ + freeTrialParamsV0: input.free_trial, + }); + + return { + ...input, + free_trial: freeTrialV1, + customize: customizeV1, + }; + }, +}); diff --git a/shared/api/billing/common/billingParamsBase.ts b/shared/api/billing/common/billingParamsBase/billingParamsBaseV0.ts similarity index 72% rename from shared/api/billing/common/billingParamsBase.ts rename to shared/api/billing/common/billingParamsBase/billingParamsBaseV0.ts index 184c65c7c..4ecc2fbcb 100644 --- a/shared/api/billing/common/billingParamsBase.ts +++ b/shared/api/billing/common/billingParamsBase/billingParamsBaseV0.ts @@ -2,10 +2,10 @@ import { FeatureOptionsParamsV0Schema } from "@api/billing/common/featureOptions import { FreeTrialParamsV0Schema } from "@api/common/freeTrial/freeTrialParamsV0.js"; import { ProductItemSchema } from "@models/productV2Models/productItemModels/productItemModels.js"; import { z } from "zod/v4"; -import { CustomerDataSchema } from "../../common/customerData.js"; -import { EntityDataSchema } from "../../common/entityData.js"; +import { CustomerDataSchema } from "../../../common/customerData.js"; +import { EntityDataSchema } from "../../../common/entityData.js"; -export const BillingParamsBaseSchema = z.object({ +export const BillingParamsBaseV0Schema = z.object({ customer_id: z.string(), entity_id: z.string().nullish(), customer_data: CustomerDataSchema.optional(), @@ -18,4 +18,4 @@ export const BillingParamsBaseSchema = z.object({ items: z.array(ProductItemSchema).optional(), }); -export type BillingParamsBase = z.infer; +export type BillingParamsBaseV0 = z.infer; diff --git a/shared/api/billing/common/billingParamsBase/billingParamsBaseV1.ts b/shared/api/billing/common/billingParamsBase/billingParamsBaseV1.ts new file mode 100644 index 000000000..4f32dfa65 --- /dev/null +++ b/shared/api/billing/common/billingParamsBase/billingParamsBaseV1.ts @@ -0,0 +1,22 @@ +import { FeatureOptionsParamsV0Schema } from "@api/billing/common/featureOptions/featureOptionsParamsV0.js"; +import { FreeTrialParamsV1Schema } from "@api/common/freeTrial/freeTrialParamsV1.js"; +import { z } from "zod/v4"; +import { CustomerDataSchema } from "../../../common/customerData.js"; +import { EntityDataSchema } from "../../../common/entityData.js"; +import { CustomizePlanV1Schema } from "../customizePlan/customizePlanV1.js"; + +export const BillingParamsBaseV1Schema = z.object({ + customer_id: z.string(), + entity_id: z.string().nullish(), + customer_data: CustomerDataSchema.optional(), + entity_data: EntityDataSchema.optional(), + + // Used for both update and attach + options: z.array(FeatureOptionsParamsV0Schema).nullish(), + version: z.number().optional(), + + free_trial: FreeTrialParamsV1Schema.nullable().optional(), + customize: CustomizePlanV1Schema.optional(), +}); + +export type BillingParamsBaseV1 = z.infer; diff --git a/shared/api/billing/common/billingResponse.ts b/shared/api/billing/common/billingResponse.ts index 1e8575c04..92ff11586 100644 --- a/shared/api/billing/common/billingResponse.ts +++ b/shared/api/billing/common/billingResponse.ts @@ -28,7 +28,6 @@ export const BillingResponseSchema = z.object({ .optional(), payment_url: z.string().nullable(), - required_action: BillingResponseRequiredActionSchema.optional(), }); diff --git a/shared/api/billing/common/customizePlan/customizePlanV0.ts b/shared/api/billing/common/customizePlan/customizePlanV0.ts new file mode 100644 index 000000000..7fcdedad6 --- /dev/null +++ b/shared/api/billing/common/customizePlan/customizePlanV0.ts @@ -0,0 +1,6 @@ +import { ProductItemSchema } from "@models/productV2Models/productItemModels/productItemModels"; +import { z } from "zod/v4"; + +export const CustomizePlanV0Schema = z.array(ProductItemSchema); + +export type CustomizePlanV0 = z.infer; diff --git a/shared/api/billing/common/customizePlan/customizePlanV1.ts b/shared/api/billing/common/customizePlan/customizePlanV1.ts new file mode 100644 index 000000000..46ca28040 --- /dev/null +++ b/shared/api/billing/common/customizePlan/customizePlanV1.ts @@ -0,0 +1,14 @@ +import { BasePriceParamsSchema } from "@api/products/components/basePrice/basePrice"; +import { CreatePlanItemParamsV1Schema } from "@api/products/items/crud/createPlanItemParamsV1"; +import { z } from "zod/v4"; + +export const CustomizePlanV1Schema = z + .object({ + price: BasePriceParamsSchema.nullable().optional(), // null to remove base price + items: z.array(CreatePlanItemParamsV1Schema).optional(), + }) + .refine((data) => data.items !== undefined || data.price !== undefined, { + message: "When using customize, either items or price must be provided", + }); + +export type CustomizePlanV1 = z.infer; diff --git a/shared/api/billing/common/customizePlan/mappers/customizePlanV1ToV0.ts b/shared/api/billing/common/customizePlan/mappers/customizePlanV1ToV0.ts new file mode 100644 index 000000000..a1c884a62 --- /dev/null +++ b/shared/api/billing/common/customizePlan/mappers/customizePlanV1ToV0.ts @@ -0,0 +1,62 @@ +import { basePriceToProductItem } from "@api/products/components/basePrice/basePriceToProductItem"; +import { planV1ToProductItems } from "@api/products/mappers/planV1ToProductItems"; +import type { FullProduct } from "@models/productModels/productModels"; +import { isPriceItem, mapToProductItems } from "@utils/index"; +import type { SharedContext } from "../../../../../types/sharedContext"; +import type { CustomizePlanV0 } from "../customizePlanV0"; +import type { CustomizePlanV1 } from "../customizePlanV1"; + +export const customizePlanV1ToV0 = ({ + ctx, + customizePlanV1, + fullProduct, +}: { + ctx: SharedContext; + customizePlanV1: CustomizePlanV1; + fullProduct: FullProduct; +}): CustomizePlanV0 => { + const currentProductItems = mapToProductItems({ + prices: fullProduct.prices, + entitlements: fullProduct.entitlements, + features: ctx.features, + }); + + if ( + customizePlanV1.price !== undefined && + customizePlanV1.items !== undefined + ) { + // 1. If price AND items provided, return full items array + return planV1ToProductItems({ + ctx, + plan: { price: customizePlanV1.price, items: customizePlanV1.items }, + }); + } else if ( + customizePlanV1.price !== undefined && + customizePlanV1.items === undefined + ) { + // 2. If price provided, but no items, then customize base price and carry over feature items + const featureItems = currentProductItems.filter( + (item) => !isPriceItem(item), + ); + + const basePriceItem = customizePlanV1.price + ? basePriceToProductItem({ + ctx, + basePrice: customizePlanV1.price, + }) + : undefined; + + return basePriceItem ? [basePriceItem, ...featureItems] : featureItems; + } else { + // 3. If no price provided, then carry over base price + const basePriceItem = currentProductItems.filter((item) => + isPriceItem(item), + ); + const featureItems = planV1ToProductItems({ + ctx, + plan: { price: null, items: customizePlanV1.items ?? [] }, + }); + + return [...basePriceItem, ...featureItems]; + } +}; diff --git a/shared/api/billing/common/redirectMode.ts b/shared/api/billing/common/redirectMode.ts new file mode 100644 index 000000000..68ebd57c6 --- /dev/null +++ b/shared/api/billing/common/redirectMode.ts @@ -0,0 +1,4 @@ +import { z } from "zod/v4"; + +export const RedirectModeSchema = z.enum(["always", "if_required", "never"]); +export type RedirectMode = z.infer; diff --git a/shared/api/billing/index.ts b/shared/api/billing/index.ts index 711d76b27..2fb44a4fd 100644 --- a/shared/api/billing/index.ts +++ b/shared/api/billing/index.ts @@ -4,16 +4,24 @@ export * from "./attach/prevVersions/attachBodyV0.js"; export * from "./attach/prevVersions/attachResponseV1.js"; // Attach V2 export * from "./attachV2/attachParamsV0.js"; +export * from "./attachV2/attachParamsV1.js"; + // Checkout export * from "./checkout/prevVersions/checkoutParamsV0.js"; export * from "./checkout/prevVersions/checkoutResponseV0.js"; // Common -export * from "./common/billingParamsBase.js"; +export * from "./common/billingParamsBase/billingParamsBaseV0.js"; +export * from "./common/billingParamsBase/billingParamsBaseV1.js"; export * from "./common/billingPreviewResponse.js"; export * from "./common/billingResponse.js"; export * from "./common/cancelAction.js"; +export * from "./common/customizePlan/customizePlanV0.js"; +export * from "./common/customizePlan/customizePlanV1.js"; +export * from "./common/customizePlan/mappers/customizePlanV1ToV0.js"; +export * from "./common/redirectMode.js"; export * from "./common/refundBehavior.js"; export * from "./updateSubscription/previewUpdateSubscriptionResponse.js"; // Update Subscription export * from "./updateSubscription/updateSubscriptionV0Params.js"; +export * from "./updateSubscription/updateSubscriptionV1Params.js"; diff --git a/shared/api/billing/updateSubscription/requestChanges/V1.2_UpdateSubscriptionParamsChange.ts b/shared/api/billing/updateSubscription/requestChanges/V1.2_UpdateSubscriptionParamsChange.ts new file mode 100644 index 000000000..37c4dde4c --- /dev/null +++ b/shared/api/billing/updateSubscription/requestChanges/V1.2_UpdateSubscriptionParamsChange.ts @@ -0,0 +1,50 @@ +import { freeTrialParamsV0ToV1 } from "@api/common/freeTrial/mappers/freeTrialParamsV0ToV1.js"; +import { ApiVersion } from "@api/versionUtils/ApiVersion.js"; +import { + AffectedResource, + defineVersionChange, +} from "@api/versionUtils/versionChangeUtils/VersionChange.js"; +import { productItemsToCustomizePlanV1 } from "@utils/productV2Utils/productItemUtils/convertProductItem/productItemsToCustomizePlanV1.js"; +import type { z } from "zod/v4"; +import type { SharedContext } from "../../../../types/sharedContext.js"; +import { UpdateSubscriptionV0ParamsSchema } from "../updateSubscriptionV0Params.js"; +import { UpdateSubscriptionV1ParamsSchema } from "../updateSubscriptionV1Params.js"; + +export const V1_2_UpdateSubscriptionParamsChange = defineVersionChange({ + name: "V1.2 Update Subscription Params Change", + newVersion: ApiVersion.V2_0, + oldVersion: ApiVersion.V1_Beta, + description: [ + "Maps free_trial {length,duration} to {duration_length,duration_type}", + "Maps top-level items to customize.items", + ], + affectedResources: [AffectedResource.ApiSubscriptionUpdate], + newSchema: UpdateSubscriptionV1ParamsSchema, + oldSchema: UpdateSubscriptionV0ParamsSchema, + affectsRequest: true, + affectsResponse: false, + transformRequest: ({ + ctx, + input, + }: { + ctx: SharedContext; + input: z.infer; + }): z.infer => { + const customizeV1 = input.items + ? productItemsToCustomizePlanV1({ + ctx, + items: input.items, + }) + : undefined; + + const freeTrialV1 = freeTrialParamsV0ToV1({ + freeTrialParamsV0: input.free_trial, + }); + + return { + ...input, + free_trial: freeTrialV1, + customize: customizeV1, + }; + }, +}); diff --git a/shared/api/billing/updateSubscription/updateSubscriptionV0Params.ts b/shared/api/billing/updateSubscription/updateSubscriptionV0Params.ts index 91a22cf2e..c0db0b202 100644 --- a/shared/api/billing/updateSubscription/updateSubscriptionV0Params.ts +++ b/shared/api/billing/updateSubscription/updateSubscriptionV0Params.ts @@ -2,11 +2,11 @@ import { RefundBehaviorSchema } from "@api/billing/common/refundBehavior"; import { nullish } from "@utils/utils"; import { z } from "zod/v4"; import { BillingBehaviorSchema } from "../common/billingBehavior"; -import { BillingParamsBaseSchema } from "../common/billingParamsBase"; +import { BillingParamsBaseV0Schema } from "../common/billingParamsBase/billingParamsBaseV0"; import { CancelActionSchema } from "../common/cancelAction"; export const ExtUpdateSubscriptionV0ParamsSchema = - BillingParamsBaseSchema.extend({ + BillingParamsBaseV0Schema.extend({ // Product identification (optional for update subscription - can target by customer_product_id) product_id: z.string().nullish(), diff --git a/shared/api/billing/updateSubscription/updateSubscriptionV1Params.ts b/shared/api/billing/updateSubscription/updateSubscriptionV1Params.ts new file mode 100644 index 000000000..e19d4a097 --- /dev/null +++ b/shared/api/billing/updateSubscription/updateSubscriptionV1Params.ts @@ -0,0 +1,99 @@ +import { nullish } from "@utils/utils"; +import { z } from "zod/v4"; +import { BillingBehaviorSchema } from "../common/billingBehavior"; +import { BillingParamsBaseV1Schema } from "../common/billingParamsBase/billingParamsBaseV1"; +import { CancelActionSchema } from "../common/cancelAction"; + +export const UpdateSubscriptionV1ParamsSchema = + BillingParamsBaseV1Schema.extend({ + product_id: z.string().nullish(), + + invoice: z.boolean().optional(), + enable_product_immediately: z.boolean().optional(), + finalize_invoice: z.boolean().optional(), + + cancel_action: CancelActionSchema.optional(), + billing_behavior: BillingBehaviorSchema.optional(), + + customer_product_id: z.string().optional().meta({ + internal: true, + }), + }) + + .check((ctx) => { + if (ctx.value.options && ctx.value.options.length > 0) { + const invalidFeatures = ctx.value.options + .filter((opt) => nullish(opt.quantity) || opt.quantity < 0) + .map((opt) => opt.feature_id); + + if (invalidFeatures.length > 0) { + ctx.issues.push({ + code: "custom", + message: `Options quantity must be >= 0 for features: ${invalidFeatures.join(", ")}`, + input: ctx.value, + }); + } + } + }) + .refine( + (data) => { + if (data.cancel_action !== "cancel_immediately") return true; + + const forbiddenFields = [ + "options", + "version", + "free_trial", + "customize", + ] as const; + return !forbiddenFields.some((field) => data[field] !== undefined); + }, + { + message: + "Cannot pass options, items, version, or free_trial when cancel_action is 'cancel_immediately'. Immediate cancellation only processes a prorated refund.", + }, + ) + .refine( + (data) => { + if (data.cancel_action !== "cancel_end_of_cycle") return true; + + // Cannot pass free_trial when cancel_action is 'cancel_end_of_cycle' + return data.free_trial === undefined; + }, + { + message: + "Cannot pass free_trial when cancel_action is 'cancel_end_of_cycle'.", + }, + ); + +export type UpdateSubscriptionV1Params = z.infer< + typeof UpdateSubscriptionV1ParamsSchema +>; + +export type UpdateSubscriptionV1ParamsInput = z.input< + typeof UpdateSubscriptionV1ParamsSchema +>; + +// Schedules (epoch milliseconds) +// plan_custom_start_date: z.number().optional(), +// billing_cycle_anchor: z.number().optional(), + +// keep_existing_plan: true, //disable_plan_switch +// prorate_billing: true, +// invoice_only: true, + +// carry_over_balance: true, +// reset_usage: true, + +// billing_custom_start_date: "2025-11-04", +// billing_custom_end_date: "2025-12-04", +// billing_cycle_anchor: "2025-11-04", +// billing_due_date: "2025-11-04", + +// new_billing_subscription: true, //fka combine_subscriptions, separate_billing_subscriptions +// require_payment_method: true, //fka force_checkout +// reset_balances: true + +// plan_schedule: "immediate", // or "next_cycle", "custom_date" +// plan_custom_start_date: "2025-11-04", +// plan_custom_end_date: "2025-12-04", +// billing_schedule: "immediate", // or "next_cycle", "custom_date" diff --git a/shared/api/common/freeTrial/mappers/freeTrialParamsV0ToV1.ts b/shared/api/common/freeTrial/mappers/freeTrialParamsV0ToV1.ts new file mode 100644 index 000000000..96ee3c9e5 --- /dev/null +++ b/shared/api/common/freeTrial/mappers/freeTrialParamsV0ToV1.ts @@ -0,0 +1,20 @@ +import type { FreeTrialParamsV0 } from "@api/common/freeTrial/freeTrialParamsV0"; +import type { FreeTrialParamsV1 } from "@api/common/freeTrial/freeTrialParamsV1"; + +export const freeTrialParamsV0ToV1 = ({ + freeTrialParamsV0, +}: { + freeTrialParamsV0: FreeTrialParamsV0 | null | undefined; +}): FreeTrialParamsV1 | null | undefined => { + // If it's undefined, means no action + if (freeTrialParamsV0 === undefined) return undefined; + + // If it's null, means remove the trial + if (freeTrialParamsV0 === null) return null; + + return { + duration_length: freeTrialParamsV0.length, + duration_type: freeTrialParamsV0.duration, + card_required: freeTrialParamsV0.card_required, + }; +}; diff --git a/shared/api/customers/requestChanges/V1.2_CustomerQueryChange.ts b/shared/api/customers/requestChanges/V1.2_CustomerQueryChange.ts index f580f7e5e..c0d36cf1a 100644 --- a/shared/api/customers/requestChanges/V1.2_CustomerQueryChange.ts +++ b/shared/api/customers/requestChanges/V1.2_CustomerQueryChange.ts @@ -5,6 +5,7 @@ import { } from "@api/versionUtils/versionChangeUtils/VersionChange.js"; import { CusExpand } from "@models/cusModels/cusExpand.js"; import type { z } from "zod/v4"; +import type { SharedContext } from "../../../types/sharedContext.js"; import { GetCustomerQuerySchema } from "../customerOpModels.js"; /** @@ -43,8 +44,10 @@ export const V1_2_CustomerQueryChange = defineVersionChange({ // Request: V1.2 → V2.0 (add expand options) transformRequest: ({ + ctx: _ctx, input, }: { + ctx: SharedContext; input: z.infer; }) => { const existingExpand = input.expand || []; diff --git a/shared/api/entities/requestChanges/V1.2_EntityQueryChange.ts b/shared/api/entities/requestChanges/V1.2_EntityQueryChange.ts index 0c7c1d97e..f6ff5ab4c 100644 --- a/shared/api/entities/requestChanges/V1.2_EntityQueryChange.ts +++ b/shared/api/entities/requestChanges/V1.2_EntityQueryChange.ts @@ -5,6 +5,7 @@ import { } from "@api/versionUtils/versionChangeUtils/VersionChange.js"; import { CusExpand } from "@models/cusModels/cusExpand.js"; import type { z } from "zod/v4"; +import type { SharedContext } from "../../../types/sharedContext.js"; import { type GetEntityQuery, GetEntityQuerySchema, @@ -46,8 +47,10 @@ export const V1_2_EntityQueryChange = defineVersionChange({ // Request: V1.2 → V2.0 (add expand options) transformRequest: ({ + ctx: _ctx, input, }: { + ctx: SharedContext; input: z.infer; }) => { const existingExpand = input.expand || []; diff --git a/shared/api/features/changes/V1.2_FeatureParamsChange.ts b/shared/api/features/changes/V1.2_FeatureParamsChange.ts index b5965434e..06dba6caf 100644 --- a/shared/api/features/changes/V1.2_FeatureParamsChange.ts +++ b/shared/api/features/changes/V1.2_FeatureParamsChange.ts @@ -4,6 +4,7 @@ import { defineVersionChange, } from "@api/versionUtils/versionChangeUtils/VersionChange.js"; import type { z } from "zod/v4"; +import type { SharedContext } from "../../../types/sharedContext.js"; import { featureV0ToV1Type } from "../../../utils/featureUtils/convertFeatureUtils.js"; import { CreateFeatureV1ParamsSchema } from "../featureV1OpModels.js"; import { CreateFeatureV0ParamsSchema } from "../prevVersions/featureV0OpModels.js"; @@ -46,8 +47,10 @@ export const V1_2_CreateFeatureChange = defineVersionChange({ // Request: V0 → V1 (old format to new) transformRequest: ({ + ctx: _ctx, input, }: { + ctx: SharedContext; input: z.infer; }): z.infer => { const { type, consumable } = featureV0ToV1Type({ type: input.type }); diff --git a/shared/api/products/components/basePrice/basePrice.ts b/shared/api/products/components/basePrice/basePrice.ts new file mode 100644 index 000000000..1078593fa --- /dev/null +++ b/shared/api/products/components/basePrice/basePrice.ts @@ -0,0 +1,26 @@ +import { BillingInterval } from "@models/productModels/intervals/billingInterval"; +import { z } from "zod/v4"; +import { DisplaySchema } from "../display"; + +export const BasePriceSchema = z.object({ + amount: z.number(), + interval: z.enum(BillingInterval), + interval_count: z.number().optional(), + display: DisplaySchema.optional(), +}); + +export const BasePriceParamsSchema = BasePriceSchema.omit({ + display: true, +}).extend({ + interval_count: z.number().optional(), + + entitlement_id: z.string().optional().meta({ + internal: true, + }), + price_id: z.string().optional().meta({ + internal: true, + }), +}); + +export type BasePrice = z.infer; +export type BasePriceParams = z.infer; diff --git a/shared/api/products/components/basePrice/basePriceToProductItem.ts b/shared/api/products/components/basePrice/basePriceToProductItem.ts new file mode 100644 index 000000000..1e20ee29e --- /dev/null +++ b/shared/api/products/components/basePrice/basePriceToProductItem.ts @@ -0,0 +1,53 @@ +import type { + BasePrice, + BasePriceParams, +} from "@api/products/components/basePrice/basePrice"; +import { BillingInterval } from "@models/productModels/intervals/billingInterval"; +import { + type ProductItem, + ProductItemType, +} from "@models/productV2Models/productItemModels/productItemModels"; +import { getProductItemDisplay } from "@utils/productDisplayUtils"; +import { billingToItemInterval } from "@utils/productV2Utils/productItemUtils/itemIntervalUtils"; +import type { SharedContext } from "../../../../types/sharedContext"; + +export const basePriceToProductItem = ({ + ctx, + basePrice, +}: { + ctx: SharedContext; + basePrice: BasePrice | BasePriceParams; +}): ProductItem => { + const basePriceDisplay = + "display" in basePrice ? basePrice.display : undefined; + + const entitlementId = + "entitlement_id" in basePrice + ? (basePrice.entitlement_id ?? undefined) + : undefined; + const priceId = + "price_id" in basePrice ? (basePrice.price_id ?? undefined) : undefined; + + const item = { + type: ProductItemType.Price, + feature_id: null, + feature: null, + interval: billingToItemInterval({ + billingInterval: basePrice.interval ?? BillingInterval.Month, + }), + interval_count: basePrice.interval_count ?? 1, + price: basePrice.amount ?? 0, + + entitlement_id: entitlementId, + price_id: priceId, + } satisfies ProductItem; + + const display = basePriceDisplay + ? getProductItemDisplay({ item, features: ctx.features }) + : undefined; + + return { + ...item, + display, + }; +}; diff --git a/shared/api/products/crud/createPlanParamsV0.ts b/shared/api/products/crud/createPlanParamsV0.ts index 3c97c0185..67403b06b 100644 --- a/shared/api/products/crud/createPlanParamsV0.ts +++ b/shared/api/products/crud/createPlanParamsV0.ts @@ -1,5 +1,5 @@ import { FreeTrialParamsV1Schema } from "@api/common/freeTrial/freeTrialParamsV1.js"; -import { BillingInterval } from "@models/productModels/intervals/billingInterval.js"; +import { BasePriceParamsSchema } from "@api/products/components/basePrice/basePrice.js"; import { idRegex } from "@utils/utils.js"; import { z } from "zod/v4"; import { CreatePlanItemParamsV1Schema } from "../items/crud/createPlanItemParamsV1.js"; @@ -14,13 +14,7 @@ export const CreatePlanParamsV1Schema = z.object({ add_on: z.boolean().default(false), auto_enable: z.boolean().default(false), - price: z - .object({ - amount: z.number(), - interval: z.enum(BillingInterval), - interval_count: z.number().optional(), - }) - .optional(), + price: BasePriceParamsSchema.optional(), items: z.array(CreatePlanItemParamsV1Schema).optional(), free_trial: FreeTrialParamsV1Schema.optional(), diff --git a/shared/api/products/items/crud/createPlanItemParamsV1.ts b/shared/api/products/items/crud/createPlanItemParamsV1.ts index f3609a391..88143fe1a 100644 --- a/shared/api/products/items/crud/createPlanItemParamsV1.ts +++ b/shared/api/products/items/crud/createPlanItemParamsV1.ts @@ -45,11 +45,19 @@ export const CreatePlanItemParamsV1Schema = z rollover: z .object({ - max: z.number(), + max: z.number().optional(), expiry_duration_type: z.enum(RolloverExpiryDurationType), expiry_duration_length: z.number().optional(), }) .optional(), + + // Internal + entitlement_id: z.string().optional().meta({ + internal: true, + }), + price_id: z.string().optional().meta({ + internal: true, + }), }) .check((ctx) => { const resetInterval = ctx.value.reset?.interval; diff --git a/shared/api/products/items/mappers/planItemParamsV1ToPlanItemV0.ts b/shared/api/products/items/mappers/planItemParamsV1ToPlanItemV0.ts index fa3838cb9..a1effd429 100644 --- a/shared/api/products/items/mappers/planItemParamsV1ToPlanItemV0.ts +++ b/shared/api/products/items/mappers/planItemParamsV1ToPlanItemV0.ts @@ -51,7 +51,7 @@ export function planItemParamsV1ToPlanItemV0({ rollover: item.rollover ? { - max: item.rollover.max, + max: item.rollover.max ?? null, expiry_duration_type: item.rollover.expiry_duration_type, expiry_duration_length: item.rollover.expiry_duration_length, } diff --git a/shared/api/products/items/mappers/planItemV0ToProductItem.ts b/shared/api/products/items/mappers/planItemV0ToProductItem.ts index 2845be53c..12c9ff153 100644 --- a/shared/api/products/items/mappers/planItemV0ToProductItem.ts +++ b/shared/api/products/items/mappers/planItemV0ToProductItem.ts @@ -119,6 +119,10 @@ export const planItemV0ToProductItem = ({ ? ProductItemType.FeaturePrice : ProductItemType.Feature; + const entitlementId = + "entitlement_id" in planItem ? planItem.entitlement_id : undefined; + const priceId = "price_id" in planItem ? planItem.price_id : undefined; + return ProductItemSchema.parse({ type, @@ -155,5 +159,8 @@ export const planItemV0ToProductItem = ({ config, display: "display" in planItem ? planItem.display : undefined, + + entitlement_id: entitlementId, + price_id: priceId, } satisfies ProductItem); }; diff --git a/shared/api/products/items/mappers/planItemV1ToV0.ts b/shared/api/products/items/mappers/planItemV1ToV0.ts index da862688f..bbfe0bc27 100644 --- a/shared/api/products/items/mappers/planItemV1ToV0.ts +++ b/shared/api/products/items/mappers/planItemV1ToV0.ts @@ -1,12 +1,19 @@ import { billingMethodToUsageModel } from "@api/products/components/mappers/billingMethodTousageModel.js"; import type { ApiPlanItemV0 } from "@api/products/items/previousVersions/apiPlanItemV0.js"; import type { ApiPlanItemV1 } from "../apiPlanItemV1.js"; +import type { CreatePlanItemParamsV1 } from "../crud/createPlanItemParamsV1.js"; /** Transform ApiPlanItemV1 to ApiPlanItemV0 */ -export function planItemV1ToV0(item: ApiPlanItemV1): ApiPlanItemV0 { - const { included, price, ...restItem } = item; +export function planItemV1ToV0( + item: ApiPlanItemV1 | CreatePlanItemParamsV1, +): ApiPlanItemV0 { + const { included = 0, price, ...restItem } = item; + + const billingUnits = price?.billing_units ?? 1; + return { ...restItem, + unlimited: item.unlimited ?? false, granted_balance: included, reset: item.reset ? { @@ -21,10 +28,21 @@ export function planItemV1ToV0(item: ApiPlanItemV1): ApiPlanItemV0 { tiers: price.tiers, interval: price.interval, interval_count: price.interval_count, - billing_units: price.billing_units, + billing_units: billingUnits, usage_model: billingMethodToUsageModel(price.billing_method), - max_purchase: price.max_purchase, + max_purchase: price.max_purchase ?? null, } : null, + + rollover: item.rollover + ? { + max: item.rollover.max ?? null, + expiry_duration_type: item.rollover.expiry_duration_type, + expiry_duration_length: item.rollover.expiry_duration_length, + } + : undefined, + + entitlement_id: "entitlement_id" in item ? item.entitlement_id : undefined, + price_id: "price_id" in item ? item.price_id : undefined, }; } diff --git a/shared/api/products/items/previousVersions/apiPlanItemV0.ts b/shared/api/products/items/previousVersions/apiPlanItemV0.ts index d1d6fc589..53c36c94f 100644 --- a/shared/api/products/items/previousVersions/apiPlanItemV0.ts +++ b/shared/api/products/items/previousVersions/apiPlanItemV0.ts @@ -57,6 +57,13 @@ export const ApiPlanItemV0Schema = z on_decrease: z.enum(OnDecrease).optional(), }) .optional(), + + entitlement_id: z.string().optional().meta({ + internal: true, + }), + price_id: z.string().optional().meta({ + internal: true, + }), }) .check((ctx) => { const resetInterval = ctx.value.reset?.interval; diff --git a/shared/api/products/mappers/planV0ToBasePriceProductItem.ts b/shared/api/products/mappers/planV0ToBasePriceProductItem.ts deleted file mode 100644 index 19c088fa3..000000000 --- a/shared/api/products/mappers/planV0ToBasePriceProductItem.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { ApiPlanItemV0 } from "@api/models"; -import type { ApiPlan } from "@api/products/previousVersions/apiPlanV0"; -import { BillingInterval } from "@models/productModels/intervals/billingInterval"; -import { - type ProductItem, - ProductItemType, -} from "@models/productV2Models/productItemModels/productItemModels"; -import { getProductItemDisplay } from "@utils/productDisplayUtils"; -import { billingToItemInterval } from "@utils/productV2Utils/productItemUtils/itemIntervalUtils"; -import type { SharedContext } from "../../../types"; - -export const planV0ToBasePriceProductItem = ({ - ctx, - plan, -}: { - ctx: SharedContext; - plan: { - features: ApiPlanItemV0[]; - price: ApiPlan["price"]; - }; -}): ProductItem | undefined => { - if (!plan.price) return; - - const basePrice = plan.price; - - const basePriceDisplay = - "display" in basePrice ? basePrice.display : undefined; - - if (basePrice) { - const item = { - type: ProductItemType.Price, - feature_id: null, - feature: null, - interval: billingToItemInterval({ - billingInterval: basePrice.interval ?? BillingInterval.Month, - }), - interval_count: basePrice.interval_count ?? 1, - price: basePrice.amount ?? 0, - } satisfies ProductItem; - - const display = - basePriceDisplay ?? - getProductItemDisplay({ item, features: ctx.features }); - - return { - ...item, - display, - }; - } -}; diff --git a/shared/api/products/mappers/planV0ToProductItems.ts b/shared/api/products/mappers/planV0ToProductItems.ts index 3b3f978a0..c21e0820a 100644 --- a/shared/api/products/mappers/planV0ToProductItems.ts +++ b/shared/api/products/mappers/planV0ToProductItems.ts @@ -1,5 +1,6 @@ import { type ApiPlanItemV0, apiPlanItem } from "@api/models"; -import { planV0ToBasePriceProductItem } from "@api/products/mappers/planV0ToBasePriceProductItem"; +import type { BasePriceParams } from "@api/products/components/basePrice/basePrice"; +import { basePriceToProductItem } from "@api/products/components/basePrice/basePriceToProductItem"; import type { ApiPlan } from "@api/products/previousVersions/apiPlanV0"; import type { ProductItem } from "@models/productV2Models/productItemModels/productItemModels"; import type { SharedContext } from "../../../types"; @@ -12,7 +13,7 @@ export const planV0ToProductItems = ({ ctx: SharedContext; plan: { features: ApiPlanItemV0[]; - price: ApiPlan["price"]; + price: ApiPlan["price"] | BasePriceParams; }; }): ProductItem[] => { // Convert features to items @@ -21,7 +22,9 @@ export const planV0ToProductItems = ({ apiPlanItem.map.v0ToProductItem({ ctx, planItem }), ) ?? []; - const basePriceItem = planV0ToBasePriceProductItem({ ctx, plan }); + const basePriceItem = plan.price + ? basePriceToProductItem({ ctx, basePrice: plan.price }) + : undefined; if (basePriceItem) { featureItems.splice(0, 0, basePriceItem); diff --git a/shared/api/products/mappers/planV1ToProductItems.ts b/shared/api/products/mappers/planV1ToProductItems.ts new file mode 100644 index 000000000..24ab3018b --- /dev/null +++ b/shared/api/products/mappers/planV1ToProductItems.ts @@ -0,0 +1,40 @@ +import { + type ApiPlanItemV1, + planItemV0ToProductItem, + planItemV1ToV0, +} from "@api/models"; +import type { BasePriceParams } from "@api/products/components/basePrice/basePrice"; +import { basePriceToProductItem } from "@api/products/components/basePrice/basePriceToProductItem"; +import type { ApiPlan } from "@api/products/previousVersions/apiPlanV0"; +import type { ProductItem } from "@models/productV2Models/productItemModels/productItemModels"; +import type { SharedContext } from "../../../types"; +import type { CreatePlanItemParamsV1 } from "../items/crud/createPlanItemParamsV1"; + +// Required. +export const planV1ToProductItems = ({ + ctx, + plan, +}: { + ctx: SharedContext; + plan: { + items: ApiPlanItemV1[] | CreatePlanItemParamsV1[]; + price: ApiPlan["price"] | BasePriceParams; + }; +}): ProductItem[] => { + // Convert features to items + const featureItems = + plan.items?.map((planItem) => { + const planV0 = planItemV1ToV0(planItem); + return planItemV0ToProductItem({ ctx, planItem: planV0 }); + }) ?? []; + + const basePriceItem = plan.price + ? basePriceToProductItem({ ctx, basePrice: plan.price }) + : undefined; + + if (basePriceItem) { + featureItems.splice(0, 0, basePriceItem); + } + + return featureItems; +}; diff --git a/shared/api/products/previousVersions/apiPlanV0.ts b/shared/api/products/previousVersions/apiPlanV0.ts index b51321a35..de363689b 100644 --- a/shared/api/products/previousVersions/apiPlanV0.ts +++ b/shared/api/products/previousVersions/apiPlanV0.ts @@ -1,9 +1,8 @@ +import { BasePriceSchema } from "@api/products/components/basePrice/basePrice.js"; import { AttachScenario } from "@models/checkModels/checkPreviewModels.js"; import { AppEnv } from "@models/genModels/genEnums.js"; -import { BillingInterval } from "@models/productModels/intervals/billingInterval.js"; import { z } from "zod/v4"; import { ApiFreeTrialV2Schema } from "../components/apiFreeTrialV2.js"; -import { DisplaySchema } from "../components/display.js"; import { ApiPlanItemV0Schema } from "../items/previousVersions/apiPlanItemV0.js"; export { @@ -22,14 +21,7 @@ export const ApiPlanV0Schema = z.object({ default: z.boolean(), // Change - price: z - .object({ - amount: z.number(), - interval: z.enum(BillingInterval), - interval_count: z.number().optional(), - display: DisplaySchema.optional(), - }) - .nullable(), + price: BasePriceSchema.nullable(), features: z.array(ApiPlanItemV0Schema), free_trial: ApiFreeTrialV2Schema.nullable().optional(), diff --git a/shared/api/versionUtils/versionChangeUtils/VersionChange.ts b/shared/api/versionUtils/versionChangeUtils/VersionChange.ts index 65ef6bda3..2db1a76b3 100644 --- a/shared/api/versionUtils/versionChangeUtils/VersionChange.ts +++ b/shared/api/versionUtils/versionChangeUtils/VersionChange.ts @@ -129,11 +129,13 @@ export abstract class VersionChange< transformRequest({ input, legacyData: _legacyData, + ctx: _ctx, }: { input: z.infer; legacyData?: TLegacyDataSchema extends ZodType ? z.infer : never; + ctx?: SharedContext; }): z.infer { // Default: no-op (override if change affects requests) return input as unknown as z.infer; @@ -241,6 +243,7 @@ export interface VersionChangeConfig< legacyData?: TLegacyDataSchema extends ZodType ? z.infer : never; + ctx: SharedContext; }) => z.infer; /** @@ -305,6 +308,7 @@ export function defineVersionChange< legacyData?: TLegacyDataSchema extends ZodType ? z.infer : never; + ctx: SharedContext; }): z.infer { if (config.transformRequest) { const result = config.transformRequest(params); diff --git a/shared/api/versionUtils/versionChangeUtils/applyVersionChanges.ts b/shared/api/versionUtils/versionChangeUtils/applyVersionChanges.ts index 5e5f4faed..1bab84109 100644 --- a/shared/api/versionUtils/versionChangeUtils/applyVersionChanges.ts +++ b/shared/api/versionUtils/versionChangeUtils/applyVersionChanges.ts @@ -147,12 +147,14 @@ export function applyRequestVersionChanges({ fromVersion, toVersion, resource, + ctx, }: { input: T; legacyData?: TLegacyData; fromVersion: ApiVersionClass; // User's version (old) toVersion: ApiVersionClass; // Latest version (new) resource: AffectedResource; + ctx: SharedContext; }): T { // If versions are equal, no transformation needed if (fromVersion.eq(toVersion)) { @@ -204,6 +206,7 @@ export function applyRequestVersionChanges({ input: transformedData, // biome-ignore lint/suspicious/noExplicitAny: Runtime type flexibility needed for version changes legacyData: legacyData as any, + ctx, }) as T; } } @@ -277,12 +280,14 @@ export function applyRequestVersionChangesToArray({ fromVersion, toVersion, resource, + ctx, }: { inputArray: T[]; legacyData?: TLegacyData; fromVersion: ApiVersionClass; toVersion: ApiVersionClass; resource: AffectedResource; + ctx: SharedContext; }): T[] { return inputArray.map((item) => applyRequestVersionChanges({ @@ -291,6 +296,7 @@ export function applyRequestVersionChangesToArray({ fromVersion, toVersion, resource, + ctx, }), ); } diff --git a/shared/api/versionUtils/versionChangeUtils/versionChangeRegistry.ts b/shared/api/versionUtils/versionChangeUtils/versionChangeRegistry.ts index 7392459a0..1af759cde 100644 --- a/shared/api/versionUtils/versionChangeUtils/versionChangeRegistry.ts +++ b/shared/api/versionUtils/versionChangeUtils/versionChangeRegistry.ts @@ -35,6 +35,8 @@ import { V2_0_TrackChange } from "../../balances/track/changes/V2.0_TrackChange. import { V1_2_TrackParamsChange } from "../../balances/track/requestChanges/V1.2_TrackParamsChange.js"; // Import attach changes import { V0_2_AttachChange } from "../../billing/attach/changes/V0.2_AttachChange.js"; +import { V1_2_AttachParamsChange } from "../../billing/attachV2/requestChanges/V1.2_AttachParamsChange.js"; +import { V1_2_UpdateSubscriptionParamsChange } from "../../billing/updateSubscription/requestChanges/V1.2_UpdateSubscriptionParamsChange.js"; import { ApiVersion } from "../ApiVersion.js"; import type { VersionChangeConstructor } from "./VersionChange.js"; import { VersionChangeRegistryClass } from "./VersionChangeRegistryClass.js"; @@ -59,6 +61,8 @@ export const V2_CHANGES: VersionChangeConstructor[] = [ V1_2_CheckQueryChange, // Transforms Check Query TO V2.0 format (adds expand options) V1_2_TrackChange, // Transforms Track TO V1.2 format from V0.2 format V1_2_TrackParamsChange, // Transforms Track params TO V2.0 (maps properties.value → value) + V1_2_AttachParamsChange, // Transforms attach params TO V2.0 (free_trial/items -> free_trial/customize) + V1_2_UpdateSubscriptionParamsChange, // Transforms update params TO V2.0 (free_trial/items -> free_trial/customize) V1_2_FeatureChange, // Transforms Feature TO V1_Beta format (V0) from V2 format (V1) V1_2_CreateFeatureChange, // Transforms Create Feature params TO V1_Beta diff --git a/shared/models/checkouts/checkoutTable.ts b/shared/models/checkouts/checkoutTable.ts index 9254710ba..1a51d4b29 100644 --- a/shared/models/checkouts/checkoutTable.ts +++ b/shared/models/checkouts/checkoutTable.ts @@ -1,8 +1,8 @@ +import type { AttachParamsV1 } from "@api/billing/attachV2/attachParamsV1.js"; +import type { UpdateSubscriptionV1Params } from "@api/billing/updateSubscription/updateSubscriptionV1Params.js"; import type { InferInsertModel, InferSelectModel } from "drizzle-orm"; import { integer, jsonb, numeric, pgTable, text } from "drizzle-orm/pg-core"; -import type { AttachParamsV0 } from "../../api/billing/attachV2/attachParamsV0.js"; -import type { UpdateSubscriptionV0Params } from "../../api/billing/updateSubscription/updateSubscriptionV0Params.js"; -import { sqlNow } from "../../db/utils.js"; +import { sqlNow } from "../../db/utils"; export enum CheckoutStatus { Pending = "pending", @@ -15,7 +15,7 @@ export enum CheckoutAction { UpdateSubscription = "update_subscription", } -export type CheckoutParams = AttachParamsV0 | UpdateSubscriptionV0Params; +export type CheckoutParams = AttachParamsV1 | UpdateSubscriptionV1Params; export const checkouts = pgTable("checkouts", { id: text().primaryKey().notNull(), diff --git a/shared/types/sharedContext.ts b/shared/types/sharedContext.ts index 97da4ab88..f9aba3599 100644 --- a/shared/types/sharedContext.ts +++ b/shared/types/sharedContext.ts @@ -11,6 +11,7 @@ export type SharedContext = { // Objects logger: AutumnLogger; + expand: string[]; // db: DrizzleCli; // clickhouseClient?: ClickHouseClient; diff --git a/shared/utils/productV2Utils/productItemUtils/convertProductItem/productItemToBasePriceParams.ts b/shared/utils/productV2Utils/productItemUtils/convertProductItem/productItemToBasePriceParams.ts new file mode 100644 index 000000000..350692123 --- /dev/null +++ b/shared/utils/productV2Utils/productItemUtils/convertProductItem/productItemToBasePriceParams.ts @@ -0,0 +1,21 @@ +import type { BasePriceParams } from "@api/products/components/basePrice/basePrice"; +import type { ProductItem } from "@models/productV2Models/productItemModels/productItemModels"; +import { isPriceItem } from "../getItemType"; +import { itemToBillingInterval } from "../itemIntervalUtils"; + +export const productItemToBasePriceParams = ({ + item, +}: { + item: ProductItem; +}): BasePriceParams | undefined => { + if (!isPriceItem(item)) return undefined; + + return { + amount: item.price ?? 0, + interval: itemToBillingInterval({ item }), + interval_count: item.interval_count ?? undefined, + + entitlement_id: item.entitlement_id ?? undefined, + price_id: item.price_id ?? undefined, + }; +}; diff --git a/shared/utils/productV2Utils/productItemUtils/convertProductItem/productItemToPlanItemParamsV1.ts b/shared/utils/productV2Utils/productItemUtils/convertProductItem/productItemToPlanItemParamsV1.ts new file mode 100644 index 000000000..4bac7c614 --- /dev/null +++ b/shared/utils/productV2Utils/productItemUtils/convertProductItem/productItemToPlanItemParamsV1.ts @@ -0,0 +1,67 @@ +import { InternalError } from "@api/errors/base/InternalError"; +import type { CreatePlanItemParamsV1 } from "@api/products/items/crud/createPlanItemParamsV1"; +import { + OnDecrease, + OnIncrease, +} from "@models/productV2Models/productItemModels/productItemEnums"; +import type { ProductItem } from "@models/productV2Models/productItemModels/productItemModels"; +import type { SharedContext } from "../../../../types/sharedContext"; +import { productItemsToPlanItemsV1 } from "./productItemToPlanItemV1"; + +export const productItemToPlanItemParamsV1 = ({ + ctx, + item, +}: { + ctx: SharedContext; + item: ProductItem; +}): CreatePlanItemParamsV1 => { + const planItemV1 = productItemsToPlanItemsV1({ + items: [item], + features: ctx.features, + expand: ctx.expand, + })?.[0]; + + if (!planItemV1) { + throw new InternalError({ + message: "Failed to convert product item to plan item params v1", + }); + } + + return { + feature_id: planItemV1.feature_id, + included: planItemV1.included, + unlimited: planItemV1.unlimited, + reset: planItemV1.reset ?? undefined, + price: planItemV1.price + ? { + amount: planItemV1.price.amount, + tiers: planItemV1.price.tiers, + interval: planItemV1.price.interval, + interval_count: planItemV1.price.interval_count, + billing_units: planItemV1.price.billing_units, + billing_method: planItemV1.price.billing_method, + max_purchase: planItemV1.price.max_purchase ?? undefined, + } + : undefined, + proration: planItemV1.proration + ? { + on_increase: + planItemV1.proration.on_increase ?? OnIncrease.ProrateImmediately, + on_decrease: planItemV1.proration.on_decrease ?? OnDecrease.Prorate, + } + : undefined, + + rollover: planItemV1.rollover + ? { + max: planItemV1.rollover.max ?? undefined, + expiry_duration_type: planItemV1.rollover.expiry_duration_type, + expiry_duration_length: + planItemV1.rollover.expiry_duration_length ?? undefined, + } + : undefined, + + // Add internal fields + entitlement_id: item.entitlement_id ?? undefined, + price_id: item.price_id ?? undefined, + }; +}; diff --git a/shared/utils/productV2Utils/productItemUtils/convertProductItem/productItemsToCustomizePlanV1.ts b/shared/utils/productV2Utils/productItemUtils/convertProductItem/productItemsToCustomizePlanV1.ts new file mode 100644 index 000000000..121947794 --- /dev/null +++ b/shared/utils/productV2Utils/productItemUtils/convertProductItem/productItemsToCustomizePlanV1.ts @@ -0,0 +1,31 @@ +import type { CustomizePlanV1 } from "@api/billing/common/customizePlan/customizePlanV1"; +import type { ProductItem } from "@models/productV2Models/productItemModels/productItemModels"; +import type { SharedContext } from "../../../../types/sharedContext"; +import { isPriceItem } from "../getItemType"; +import { productItemToBasePriceParams } from "./productItemToBasePriceParams"; +import { productItemToPlanItemParamsV1 } from "./productItemToPlanItemParamsV1"; + +export const productItemsToCustomizePlanV1 = ({ + ctx, + items, +}: { + ctx: SharedContext; + items: ProductItem[]; +}): CustomizePlanV1 => { + const priceItem = items.find((item) => isPriceItem(item)); + + const basePriceParams = priceItem + ? productItemToBasePriceParams({ item: priceItem }) + : null; // needs to be null to remove the base price + + const featureItems = items.filter((item) => !isPriceItem(item)); + + const featureItemsParams = featureItems.map((item) => + productItemToPlanItemParamsV1({ ctx, item }), + ); + + return { + price: basePriceParams, + items: featureItemsParams, + }; +}; diff --git a/versionAttach.md b/versionAttach.md new file mode 100644 index 000000000..4f549c0c1 --- /dev/null +++ b/versionAttach.md @@ -0,0 +1,124 @@ +## Updated Test-First Plan (with itemsV2, typed inputs, and V1_Beta gate) + + ### Summary + + Sequence will be: + + 1. Add test scaffolding first (including new itemsV2 fixture file). + 2. Add new v2Params integration tests with typed request inputs. + 3. Implement request-versioning and internal param migration to pass tests. + 4. Fix remaining low-hanging type errors and run bun ts. + + ### Confirmed Constraints Applied + + 1. New fixture module will be a new file: + + - server/tests/utils/fixtures/itemsV2.ts + + 2. New test files must be exactly: + + - v2-customize.test.ts + - v2-free-trial.test.ts + + 3. Typed request-call pattern in tests: + + - autumnV2.billing.attach<..., AttachParamsV1Input>(...) style + - autumnV2.subscriptions.update<..., UpdateSubscriptionV1ParamsInput>(...) style + (using the same generic-template pattern as CRUD plan tests) + + 4. versionedBody previous version gate: + + - use ApiVersion.V1_Beta (not ApiVersion.V2_0) + + ——— + + ### Phase 1: Add Fixtures and Test Files First + + #### 1. New fixtures file + + Create server/tests/utils/fixtures/itemsV2.ts with V1-item builders suitable for customize.items, mirroring existing fixture ergonomics: + + - itemsV2.monthlyPrice(...) + - itemsV2.monthlyMessages(...) + - itemsV2.monthlyWords(...) + - itemsV2.dashboard(...) + - any minimal helpers needed for your specified edge cases + + These will return shapes compatible with V1 plan-item input (CreatePlanItemParamsV1 shape), not legacy product-item shape. + + #### 2. Attach tests + + Create: + + - server/tests/integration/billing/attach/v2Params/v2-customize.test.ts + - server/tests/integration/billing/attach/v2Params/v2-free-trial.test.ts + + v2-customize.test.ts coverage: + + - customize with both price + items + - customize with only price + - customize with only items + - 1-2 edge cases validating planItemsV1 -> internal product-item transform behavior through outcomes (preview total, customer feature state, invoice/subscription consistency) + + v2-free-trial.test.ts coverage: + + - 1-3 tests validating transformation correctness from legacy trial shape to V1 behavior (set/remove/preserve as needed) + + #### 3. Update-subscription tests + + Create: + + - server/tests/integration/billing/update-subscription/v2Params/v2-customize.test.ts + - server/tests/integration/billing/update-subscription/v2Params/v2-free-trial.test.ts + + Same scenario matrix as attach for customize and free-trial transform correctness. + + ——— + + ### Phase 2: Implement Versioning and Param Migration + + #### 1. Shared request schemas + + - Finalize attach V1 params to rely on customize (no top-level items in V1 schema). + - Add/update update-subscription V1 params schema with customize + V1 free_trial. + + #### 2. Request change files (v0 -> v1 body mapping) + + Add request-change classes for attach/update that map: + - free_trial.length/duration -> free_trial.duration_length/duration_type + Register these in version change registry. + + Update handlers (attach/update + previews) to use: + + - latest: V1 schema + - [ApiVersion.V1_Beta]: V0 schema + with correct resource for request-change application. + + #### 4. Internal billing v2 updates + + Refactor attach/update internal action graph to read: + No broader behavior changes beyond these two params. + + ——— + + ### Phase 3: Type-Error Cleanup and Verification + + 1. Run bun ts in server/. + 2. Fix low-hanging caller type mismatches surfaced by migration (including preview/checkout-adjacent paths if impacted by these param types). + 3. Run new v2Params test files individually. + ——— + + ### API / Interface Changes + + - New request-change classes for attach/update body transforms + - Handlers switched to version-aware body validation with V1 latest and V1_Beta legacy gate + + ——— + + ### Acceptance Criteria + + 1. All four v2Params test files exist with required names and scenario coverage. + 2. Tests use itemsV2 fixtures and typed generic input-call pattern. + 3. versionedBody uses ApiVersion.V1_Beta as legacy schema key. + 4. Attach/update endpoints correctly transform legacy free_trial and items into V1 format. + 5. bun ts passes in server/. \ No newline at end of file