From bd89579b744d1ba1726dd606566fa011be2d44f6 Mon Sep 17 00:00:00 2001 From: John Yeo Date: Wed, 11 Feb 2026 07:48:19 -0800 Subject: [PATCH] new plan params --- CLAUDE.md | 2 +- server/src/external/autumn/autumnCli.ts | 26 +- .../prepareNewBalanceForInsertion.ts | 12 +- .../createBalance/validateCreateBalance.ts | 7 +- .../balances/handlers/handleCreateBalance.ts | 4 +- .../trialContext/handleFreeTrialParam.ts | 2 +- .../products/free-trials/initFreeTrial.ts | 2 +- .../products/handlers/handleCreatePlan.ts | 6 +- .../products/handlers/handleDeleteProduct.ts | 2 +- .../handlers/handlePlanHasCustomersV2.ts | 4 +- .../handleUpdateProduct/handleUpdatePlan.ts | 8 +- .../internal/products/pricecn/pricecnUtils.ts | 38 +- .../crud/plans/create-plan-advanced.test.ts | 398 +++++++++++++++ .../crud/plans/create-plan-basic.test.ts | 457 +++++++++++++++-- .../crud/plans/create-plan-boolean.test.ts | 31 -- .../crud/plans/create-plan-complex.test.ts | 311 ------------ .../plans/create-plan-cross-version.test.ts | 181 ------- .../crud/plans/create-plan-flags.test.ts | 32 -- .../crud/plans/create-plan-metered.test.ts | 49 -- .../crud/plans/create-plan-pricing.test.ts | 61 --- .../crud/plans/create-plan-tiered.test.ts | 80 --- .../plans/create-plan-usage-pricing.test.ts | 51 -- .../crud/plans/create-plan-validation.test.ts | 102 ---- .../crud/plans/update-plan.test.ts | 475 +++++++++--------- .../openapi1.2/balancesOpenApi1.2.0.ts | 4 +- shared/api/_openapi2.0_/balancesOpenApi.ts | 4 +- .../balances/create/createBalanceParams.ts | 24 +- shared/api/balances/create/index.ts | 1 + .../createBalanceParamsV0ToPlanItemV0.ts | 34 ++ shared/api/balances/index.ts | 1 + .../api/billing/common/billingParamsBase.ts | 2 +- .../common/freeTrial/freeTrialParamsV0.ts | 0 .../api/common/freeTrial/freeTrialParamsV1.ts | 10 + .../cusPlans/changes/V1.2_CusPlanChange.ts | 5 +- shared/api/models.ts | 2 +- shared/api/products/apiFreeTrial.ts | 2 +- .../api/products/components/apiFreeTrialV2.ts | 2 +- .../mappers/billingMethodTousageModel.ts | 11 + .../api/products/crud/createPlanParamsV0.ts | 30 ++ shared/api/products/crud/index.ts | 3 + shared/api/products/crud/listPlanParams.ts | 16 + .../crud/mappers/planParamsV1ToProductV2.ts | 67 +++ shared/api/products/crud/planOpModels.ts | 62 --- .../api/products/crud/updatePlanParamsV0.ts | 17 + shared/api/products/index.ts | 6 +- ...mV0Params.ts => createPlanItemParamsV1.ts} | 23 +- shared/api/products/items/index.ts | 2 +- .../mappers/planItemParamsV1ToPlanItemV0.ts | 62 +++ .../items/mappers/planItemV0ToProductItem.ts | 17 +- .../products/items/mappers/planItemV1ToV0.ts | 12 +- .../items/utils/classifyPlanItemV0.ts | 14 +- shared/api/products/mappers/index.ts | 1 - .../mappers/planV0ToBasePriceProductItem.ts | 10 +- .../products/mappers/planV0ToProductItems.ts | 22 +- .../api/products/mappers/planV0ToProductV2.ts | 16 +- shared/api/products/productOpModels.ts | 3 + shared/index.ts | 17 +- .../models/productV2Models/productV2Models.ts | 5 +- .../utils/featureUtils/convertFeatureUtils.ts | 11 + 59 files changed, 1448 insertions(+), 1411 deletions(-) create mode 100644 server/tests/integration/crud/plans/create-plan-advanced.test.ts delete mode 100644 server/tests/integration/crud/plans/create-plan-boolean.test.ts delete mode 100644 server/tests/integration/crud/plans/create-plan-complex.test.ts delete mode 100644 server/tests/integration/crud/plans/create-plan-cross-version.test.ts delete mode 100644 server/tests/integration/crud/plans/create-plan-flags.test.ts delete mode 100644 server/tests/integration/crud/plans/create-plan-metered.test.ts delete mode 100644 server/tests/integration/crud/plans/create-plan-pricing.test.ts delete mode 100644 server/tests/integration/crud/plans/create-plan-tiered.test.ts delete mode 100644 server/tests/integration/crud/plans/create-plan-usage-pricing.test.ts delete mode 100644 server/tests/integration/crud/plans/create-plan-validation.test.ts create mode 100644 shared/api/balances/create/index.ts create mode 100644 shared/api/balances/create/mappers/createBalanceParamsV0ToPlanItemV0.ts create mode 100644 shared/api/balances/index.ts rename shared/api/{billing => }/common/freeTrial/freeTrialParamsV0.ts (100%) create mode 100644 shared/api/common/freeTrial/freeTrialParamsV1.ts create mode 100644 shared/api/products/components/mappers/billingMethodTousageModel.ts create mode 100644 shared/api/products/crud/createPlanParamsV0.ts create mode 100644 shared/api/products/crud/index.ts create mode 100644 shared/api/products/crud/listPlanParams.ts create mode 100644 shared/api/products/crud/mappers/planParamsV1ToProductV2.ts delete mode 100644 shared/api/products/crud/planOpModels.ts create mode 100644 shared/api/products/crud/updatePlanParamsV0.ts rename shared/api/products/items/crud/{createPlanItemV0Params.ts => createPlanItemParamsV1.ts} (83%) create mode 100644 shared/api/products/items/mappers/planItemParamsV1ToPlanItemV0.ts diff --git a/CLAUDE.md b/CLAUDE.md index 7650ff18b..b69735ae8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,7 +16,7 @@ in the test logs. Use your common sense # Linting and Codebase rules - You can access the biome linter by running `bunx biome check `. Always specify a folder path, as the codebase is quite large and you will get out of scope errors that you are not burdened to correct. If you would like to let biome automatically fix as much as it can, use `bunx biome check --write ` -- Note, biome does not perform typechecking. In which case you need to, you may run `tsgo --noEmit --skipLibCheck ` +- Note, biome does not perform typechecking. For type checking, run `bun ts` from the `server/` directory (which runs `bunx tsgo --build --noEmit`). This checks the entire server project with proper path resolution. - The `server/src/_luaScriptsV2/` folder contains Lua scripts for Redis atomic operations. Redis uses **Lua 5.1** - there is NO `goto` statement (added in Lua 5.2), so use if/else blocks instead. diff --git a/server/src/external/autumn/autumnCli.ts b/server/src/external/autumn/autumnCli.ts index 4bb736a24..fdf6e9477 100644 --- a/server/src/external/autumn/autumnCli.ts +++ b/server/src/external/autumn/autumnCli.ts @@ -14,7 +14,7 @@ import { type BillingPreviewResponse, type BillingResponse, type CheckQuery, - type CreateBalanceParams, + type CreateBalanceParamsV0, type CreateCustomerInternalOptions, type CreateCustomerParams, type CreateEntityParams, @@ -562,22 +562,22 @@ export class AutumnInt { }; products = { - update: async (productId: string, product: any) => { - // if (product.items && typeof product.items === "object") { - // product.items = Object.values(product.items); - // } + update: async ( + productId: string, + product: TInput, + ): Promise => { const data = await this.patch(`/products/${productId}`, product); - return data; + return data as TResponse; }, - get: async ( + get: async ( productId: string, { v1Schema = false }: { v1Schema?: boolean } = {}, - ) => { + ): Promise => { const data = await this.get( `/products/${productId}?${v1Schema ? "schemaVersion=1" : ""}`, ); - return data; + return data as T; }, list: async (): Promise<{ list: T }> => { @@ -585,9 +585,11 @@ export class AutumnInt { return data as { list: T }; }, - create: async (product: any) => { + create: async ( + product: TInput, + ): Promise => { const data = await this.post(`/products`, product); - return data; + return data as TResponse; }, delete: async (productId: string) => { @@ -796,7 +798,7 @@ export class AutumnInt { }; balances = { - create: async (params: CreateBalanceParams) => { + create: async (params: CreateBalanceParamsV0) => { const data = await this.post(`/balances/create`, params); return data; }, diff --git a/server/src/internal/balances/createBalance/prepareNewBalanceForInsertion.ts b/server/src/internal/balances/createBalance/prepareNewBalanceForInsertion.ts index c4ba0287b..be58b2142 100644 --- a/server/src/internal/balances/createBalance/prepareNewBalanceForInsertion.ts +++ b/server/src/internal/balances/createBalance/prepareNewBalanceForInsertion.ts @@ -1,6 +1,7 @@ import { apiPlanItem, - type CreateBalanceParams, + type CreateBalanceParamsV0, + createBalanceParamsV0ToPlanItemV0, enrichEntitlementWithFeature, type Feature, type FullCustomer, @@ -19,11 +20,16 @@ export const prepareNewBalanceForInsertion = async ({ ctx: AutumnContext; feature: Feature; fullCustomer: FullCustomer; - params: CreateBalanceParams; + params: CreateBalanceParamsV0; }) => { + const planItem = createBalanceParamsV0ToPlanItemV0({ + ctx, + params, + }); + const inputAsItem = apiPlanItem.map.v0ToProductItem({ ctx, - planItem: params, + planItem, }); const { ent: newEntitlement } = toFeature({ diff --git a/server/src/internal/balances/createBalance/validateCreateBalance.ts b/server/src/internal/balances/createBalance/validateCreateBalance.ts index b8301d473..ba7aa7d21 100644 --- a/server/src/internal/balances/createBalance/validateCreateBalance.ts +++ b/server/src/internal/balances/createBalance/validateCreateBalance.ts @@ -1,15 +1,12 @@ import { - type CreateBalanceParams, - ErrCode, + type CreateBalanceParamsV0, type Feature, FeatureType, type FullCustomer, RecaseError, ValidateCreateBalanceParamsSchema, } from "@autumn/shared"; -import { StatusCodes } from "http-status-codes"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; -import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService"; import { getApiCustomerBase } from "@/internal/customers/cusUtils/apiCusUtils/getApiCustomerBase"; export const validateCreateBalanceParams = async ({ @@ -19,7 +16,7 @@ export const validateCreateBalanceParams = async ({ fullCustomer, }: { ctx: AutumnContext; - params: CreateBalanceParams; + params: CreateBalanceParamsV0; feature: Feature; fullCustomer: FullCustomer; }) => { diff --git a/server/src/internal/balances/handlers/handleCreateBalance.ts b/server/src/internal/balances/handlers/handleCreateBalance.ts index ade5f6ec2..7089467d6 100644 --- a/server/src/internal/balances/handlers/handleCreateBalance.ts +++ b/server/src/internal/balances/handlers/handleCreateBalance.ts @@ -1,4 +1,4 @@ -import { CreateBalanceParamsSchema } from "@autumn/shared"; +import { CreateBalanceParamsV0Schema } from "@autumn/shared"; import { FeatureNotFoundError } from "@shared/index"; import type { DrizzleCli } from "@/db/initDrizzle"; import { createRoute } from "@/honoMiddlewares/routeHandler"; @@ -9,7 +9,7 @@ import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntit import { EntitlementService } from "@/internal/products/entitlements/EntitlementService"; export const handleCreateBalance = createRoute({ - body: CreateBalanceParamsSchema, + body: CreateBalanceParamsV0Schema, handler: async (c) => { const ctx = c.get("ctx"); const { org, env } = ctx; diff --git a/server/src/internal/billing/v2/setup/trialContext/handleFreeTrialParam.ts b/server/src/internal/billing/v2/setup/trialContext/handleFreeTrialParam.ts index 269fa2c3e..320012c51 100644 --- a/server/src/internal/billing/v2/setup/trialContext/handleFreeTrialParam.ts +++ b/server/src/internal/billing/v2/setup/trialContext/handleFreeTrialParam.ts @@ -4,7 +4,7 @@ import { isCustomerProductTrialing, isProductPaidAndRecurring, } from "@autumn/shared"; -import type { FreeTrialParamsV0 } from "@shared/api/billing/common/freeTrial/freeTrialParamsV0"; +import type { FreeTrialParamsV0 } from "@shared/api/common/freeTrial/freeTrialParamsV0"; import type Stripe from "stripe"; import { isStripeSubscriptionTrialing } from "@/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils"; import { initFreeTrial } from "@/internal/products/free-trials/initFreeTrial"; diff --git a/server/src/internal/products/free-trials/initFreeTrial.ts b/server/src/internal/products/free-trials/initFreeTrial.ts index bc6541c03..0a61cf054 100644 --- a/server/src/internal/products/free-trials/initFreeTrial.ts +++ b/server/src/internal/products/free-trials/initFreeTrial.ts @@ -3,7 +3,7 @@ import { CreateFreeTrialSchema, type FreeTrial, } from "@autumn/shared"; -import type { FreeTrialParamsV0 } from "@shared/api/billing/common/freeTrial/freeTrialParamsV0"; +import type { FreeTrialParamsV0 } from "@shared/api/common/freeTrial/freeTrialParamsV0"; import { generateId } from "@/utils/genUtils"; export const initFreeTrial = ({ diff --git a/server/src/internal/products/handlers/handleCreatePlan.ts b/server/src/internal/products/handlers/handleCreatePlan.ts index 726b52f6c..096d0ce10 100644 --- a/server/src/internal/products/handlers/handleCreatePlan.ts +++ b/server/src/internal/products/handlers/handleCreatePlan.ts @@ -4,7 +4,7 @@ import { ApiVersion, apiPlan, applyResponseVersionChanges, - CreatePlanParamsSchema, + CreatePlanParamsV1Schema, type CreateProductV2Params, CreateProductV2ParamsSchema, type Entitlement, @@ -36,7 +36,7 @@ import { validateDefaultFlag } from "./productActions/validateDefaultFlag.js"; export const handleCreatePlan = createRoute({ // body: CreateProductV2ParamsSchema, versionedBody: { - latest: CreatePlanParamsSchema, + latest: CreatePlanParamsV1Schema, [ApiVersion.V1_Beta]: CreateProductV2ParamsSchema, }, resource: AffectedResource.Product, @@ -46,7 +46,7 @@ export const handleCreatePlan = createRoute({ const v1_2Body = ( ctx.apiVersion.gte(ApiVersion.V2_0) - ? apiPlan.map.v0ToProductV2({ ctx, plan: body }) + ? apiPlan.map.paramsV1ToProductV2({ ctx, params: body }) : body ) as CreateProductV2Params; diff --git a/server/src/internal/products/handlers/handleDeleteProduct.ts b/server/src/internal/products/handlers/handleDeleteProduct.ts index 86f0c949d..1b56b1677 100644 --- a/server/src/internal/products/handlers/handleDeleteProduct.ts +++ b/server/src/internal/products/handlers/handleDeleteProduct.ts @@ -13,7 +13,7 @@ const DeleteProductParamsSchema = z.object({ }); const DeleteProductQuerySchema = z.object({ - all_versions: z.boolean(), + all_versions: z.boolean().default(false), }); export const handleDeleteProduct = createRoute({ diff --git a/server/src/internal/products/handlers/handlePlanHasCustomersV2.ts b/server/src/internal/products/handlers/handlePlanHasCustomersV2.ts index 5858998c0..0eb145a4f 100644 --- a/server/src/internal/products/handlers/handlePlanHasCustomersV2.ts +++ b/server/src/internal/products/handlers/handlePlanHasCustomersV2.ts @@ -37,9 +37,9 @@ export const handlePlanHasCustomersV2 = createRoute({ // V2.0+ (CLI): body is CreatePlanParams, convert to ProductV2 // < V2.0 (Dashboard): body is already ProductV2 const productV2 = apiVersion.gte(ApiVersion.V2_0) - ? (apiPlan.map.v0ToProductV2({ + ? (apiPlan.map.paramsV1ToProductV2({ ctx, - plan: body, + params: body, }) as ProductV2) : (body as ProductV2); diff --git a/server/src/internal/products/handlers/handleUpdateProduct/handleUpdatePlan.ts b/server/src/internal/products/handlers/handleUpdateProduct/handleUpdatePlan.ts index a0b416f76..ceb939e46 100644 --- a/server/src/internal/products/handlers/handleUpdateProduct/handleUpdatePlan.ts +++ b/server/src/internal/products/handlers/handleUpdateProduct/handleUpdatePlan.ts @@ -12,7 +12,7 @@ import { type ProductV2, productsAreSame, RecaseError, - UpdatePlanParamsSchema, + UpdatePlanParamsV1Schema, UpdatePlanQuerySchema, UpdateProductQuerySchema, UpdateProductSchema, @@ -38,7 +38,7 @@ import { handleUpdateProductDetails } from "./updateProductDetails.js"; export const handleUpdatePlan = createRoute({ versionedBody: { - latest: UpdatePlanParamsSchema, + latest: UpdatePlanParamsV1Schema, [ApiVersion.V1_Beta]: UpdateProductV2ParamsSchema, }, versionedQuery: { @@ -59,9 +59,9 @@ export const handleUpdatePlan = createRoute({ // V1.2 clients already send ProductV2, no conversion needed const v1_2Body = ctx.apiVersion.gte(new ApiVersionClass(ApiVersion.V2_0)) - ? (apiPlan.map.v0ToProductV2({ + ? (apiPlan.map.paramsV1ToProductV2({ ctx, - plan: body, + params: body, }) as UpdateProductV2Params) : (body as UpdateProductV2Params); diff --git a/server/src/internal/products/pricecn/pricecnUtils.ts b/server/src/internal/products/pricecn/pricecnUtils.ts index f81e84b51..0fa49aa8c 100644 --- a/server/src/internal/products/pricecn/pricecnUtils.ts +++ b/server/src/internal/products/pricecn/pricecnUtils.ts @@ -56,9 +56,9 @@ const sortProductItems = (items: ProductItem[], features: Feature[]) => { } // 3. Put feature price items in alphabetical order - const feature = features.find((f) => f.id == a.feature_id); + const feature = features.find((f) => f.id === a.feature_id); const aFeatureName = feature?.name; - const bFeatureName = features.find((f) => f.id == b.feature_id)?.name; + const bFeatureName = features.find((f) => f.id === b.feature_id)?.name; if (!aFeatureName || !bFeatureName) { return 0; @@ -96,7 +96,7 @@ const getPriceText = ({ const tiers = item.tiers; if (tiers) { - if (tiers.length == 1) { + if (tiers.length === 1) { return formatAmount({ org, amount: tiers[0].amount }); } @@ -139,7 +139,7 @@ export const getPricecnPrice = ({ secondaryText: priceItem.interval ? `per ${priceItem.interval}` : " ", }; } else { - const feature = features.find((f) => f.id == priceItem.feature_id); + const feature = features.find((f) => f.id === priceItem.feature_id); const texts = featurePricetoPricecnItem({ feature, item: priceItem, @@ -169,7 +169,7 @@ const featureToPricecnItem = ({ }); } // 1. If feature - if (item.feature_type == ProductItemFeatureType.Static) { + if (item.feature_type === ProductItemFeatureType.Static) { return { primaryText: feature.name, }; @@ -181,9 +181,9 @@ const featureToPricecnItem = ({ }); const includedUsageTxt = - item.included_usage == Infinite + item.included_usage === Infinite ? "Unlimited " - : nullish(item.included_usage) || item.included_usage == 0 + : nullish(item.included_usage) || item.included_usage === 0 ? "" : `${numberWithCommas(item.included_usage!)} `; @@ -232,7 +232,7 @@ const featurePricetoPricecnItem = ({ const priceStr = getPriceText({ item, org }); const billingFeatureName = getFeatureName({ feature, - plural: typeof item.billing_units == "number" && item.billing_units > 1, + plural: typeof item.billing_units === "number" && item.billing_units > 1, }); let priceStr2 = ""; @@ -274,13 +274,13 @@ const getAttachScenario = ({ } // 1. If current product is the same as the product, return active - if (curMainProduct?.product.id == fullProduct.id) { + if (curMainProduct?.product.id === fullProduct.id) { if (curMainProduct.canceled_at != null) { return AttachScenario.Renew; } else return AttachScenario.Active; } - if (curScheduledProduct?.product.id == fullProduct.id) { + if (curScheduledProduct?.product.id === fullProduct.id) { return AttachScenario.Scheduled; } @@ -339,7 +339,7 @@ export const toPricecnProduct = async ({ }; } - const feature = features.find((f) => f.id == i.feature_id); + const feature = features.find((f) => f.id === i.feature_id); if (isFeaturePriceItem(i)) { data = featurePricetoPricecnItem({ feature, @@ -362,8 +362,8 @@ export const toPricecnProduct = async ({ }; }); - const isCurrent = curMainProduct?.product.id == product.id; - const isScheduled = curScheduledProduct?.product.id == product.id; + const isCurrent = curMainProduct?.product.id === product.id; + const isScheduled = curScheduledProduct?.product.id === product.id; let buttonText = "Get Started"; @@ -385,7 +385,7 @@ export const toPricecnProduct = async ({ let baseVariant = null; if (fullProduct.base_variant_id) { baseVariant = otherProducts.find( - (p) => p.id == fullProduct.base_variant_id, + (p) => p.id === fullProduct.base_variant_id, ); } @@ -397,25 +397,25 @@ export const toPricecnProduct = async ({ let intervalGroup = null; if ( baseVariant || - otherProducts.some((p) => p.base_variant_id == product.id) + otherProducts.some((p) => p.base_variant_id === product.id) ) { const intervalSet = getLargestInterval({ prices: fullProduct.prices }); intervalGroup = intervalSet?.interval; } let trialAvailable = false; - if (product.free_trial && fullCus) { + if (fullProduct.free_trial && fullCus) { let trial = await getFreeTrialAfterFingerprint({ db, - freeTrial: product.free_trial, + freeTrial: fullProduct.free_trial, fingerprint: fullCus.fingerprint, internalCustomerId: fullCus.internal_id, multipleAllowed: org.config.multiple_trials, productId: product.id, }); - if (scenario == AttachScenario.Downgrade) trial = null; - trialAvailable = notNullish(trial) ? true : false; + if (scenario === AttachScenario.Downgrade) trial = null; + trialAvailable = !!notNullish(trial); } return { diff --git a/server/tests/integration/crud/plans/create-plan-advanced.test.ts b/server/tests/integration/crud/plans/create-plan-advanced.test.ts new file mode 100644 index 000000000..1b0c273a9 --- /dev/null +++ b/server/tests/integration/crud/plans/create-plan-advanced.test.ts @@ -0,0 +1,398 @@ +import { expect, test } from "bun:test"; +import { + type ApiPlan, + type ApiProduct, + ApiVersion, + BillingInterval, + BillingMethod, + type CreatePlanParamsInput, + FreeTrialDuration, + Infinite, + ProductItemInterval, + ResetInterval, + TierInfinite, + UsageModel, +} from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features"; +import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils"; +import chalk from "chalk"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; + +const autumnV2 = new AutumnInt({ version: ApiVersion.V2_0 }); +const autumnV1_2 = new AutumnInt({ version: ApiVersion.V1_2 }); + +// ═══════════════════════════════════════════════════════════════════════════════ +// METERED & USAGE FEATURES +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("create: metered feature with monthly reset")}`, async () => { + const productId = "metered_monthly"; + try { + await autumnV2.products.delete(productId); + } catch (_error) {} + + const created = await autumnV2.products.create< + ApiPlan, + CreatePlanParamsInput + >({ + id: productId, + name: "Metered Monthly", + items: [ + { + feature_id: TestFeature.Messages, + included: 1000, + reset: { interval: ResetInterval.Month }, + }, + ], + }); + + expect(created.features).toHaveLength(1); + expect(created.features[0]).toMatchObject({ + feature_id: TestFeature.Messages, + granted_balance: 1000, + }); + + const v1_2 = await autumnV1_2.products.get(productId); + expect(v1_2.items[0]).toMatchObject({ + included_usage: 1000, + interval: ProductItemInterval.Month, + }); +}); + +test.concurrent(`${chalk.yellowBright("create: usage pricing (pay-per-use)")}`, async () => { + const productId = "usage_price"; + try { + await autumnV2.products.delete(productId); + } catch (_error) {} + + const created = await autumnV2.products.create< + ApiPlan, + CreatePlanParamsInput + >({ + id: productId, + name: "Usage Price", + items: [ + { + feature_id: TestFeature.Messages, + price: { + amount: 10, + interval: BillingInterval.Month, + billing_method: BillingMethod.UsageBased, + billing_units: 1, + }, + }, + ], + }); + + expect(created.features[0]).toMatchObject({ + price: { amount: 10, usage_model: UsageModel.PayPerUse }, + }); + + const v1_2 = await autumnV1_2.products.get(productId); + expect(v1_2.items[0]).toMatchObject({ + price: 10, + usage_model: UsageModel.PayPerUse, + billing_units: 1, + interval: ProductItemInterval.Month, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TIERED PRICING +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("create: feature with tiered pricing")}`, async () => { + const productId = "tiered_pricing"; + try { + await autumnV2.products.delete(productId); + } catch (_error) {} + + const created = await autumnV2.products.create< + ApiPlan, + CreatePlanParamsInput + >({ + id: productId, + name: "Tiered Pricing Plan", + items: [ + { + feature_id: TestFeature.Messages, + price: { + tiers: [ + { to: 100, amount: 0.1 }, + { to: 500, amount: 0.08 }, + { to: TierInfinite, amount: 0.05 }, + ], + interval: BillingInterval.Month, + billing_method: BillingMethod.UsageBased, + billing_units: 1, + }, + }, + ], + }); + + const feature = created.features[0]; + expect(feature.price!.tiers).toHaveLength(3); + expect(feature.price!.tiers).toEqual([ + { to: 100, amount: 0.1 }, + { to: 500, amount: 0.08 }, + { to: TierInfinite, amount: 0.05 }, + ]); + expect(feature.price!.usage_model).toBe(UsageModel.PayPerUse); + + const v1_2 = await autumnV1_2.products.get(productId); + expect(v1_2.items[0].tiers).toHaveLength(3); + expect(v1_2.items[0].tiers).toEqual([ + { to: 100, amount: 0.1 }, + { to: 500, amount: 0.08 }, + { to: TierInfinite, amount: 0.05 }, + ]); + expect(v1_2.items[0]).toMatchObject({ + usage_model: UsageModel.PayPerUse, + interval: ProductItemInterval.Month, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// CROSS-VERSION CONSISTENCY +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("cross-version: V2 CREATE → V1.2 GET field transformations")}`, async () => { + const productId = "cross_v2"; + try { + await autumnV2.products.delete(productId); + } catch (_error) {} + + await autumnV2.products.create({ + id: productId, + name: "Cross Version V2", + add_on: true, + auto_enable: false, + price: { amount: 1000, interval: BillingInterval.Month }, + items: [ + { + feature_id: TestFeature.Messages, + included: 100, + reset: { interval: ResetInterval.Month }, + }, + ], + }); + + const v1_2 = await autumnV1_2.products.get(productId); + + expect(v1_2).toMatchObject({ + is_add_on: true, + is_default: false, + }); + expect(v1_2.items).toHaveLength(2); // base price + feature + + const basePrice = v1_2.items.find((i) => !i.feature_id); + expect(basePrice).toMatchObject({ price: 1000 }); + + const feature = v1_2.items.find((i) => i.feature_id === TestFeature.Messages); + expect(feature).toMatchObject({ included_usage: 100 }); +}); + +test.concurrent(`${chalk.yellowBright("cross-version: round-trip V2 → V1.2 → V2 data consistency")}`, async () => { + const productId = "roundtrip"; + try { + await autumnV2.products.delete(productId); + } catch (_error) {} + + await autumnV2.products.create({ + id: productId, + name: "Round Trip", + price: { amount: 5000, interval: BillingInterval.Month }, + items: [ + { + feature_id: TestFeature.Messages, + included: 500, + reset: { interval: ResetInterval.Month }, + }, + ], + }); + + const backToV2 = await autumnV2.products.get(productId); + expect(backToV2).toMatchObject({ + name: "Round Trip", + price: { amount: 5000 }, + }); + expect(backToV2.features).toHaveLength(1); + expect(backToV2.features[0]).toMatchObject({ granted_balance: 500 }); +}); + +test.concurrent(`${chalk.yellowBright("cross-version: free trial transformation (duration_type → duration)")}`, async () => { + const productId = "trial_transform"; + try { + await autumnV2.products.delete(productId); + } catch (_error) {} + + await autumnV2.products.create({ + id: productId, + name: "Trial Transform", + price: { amount: 2900, interval: BillingInterval.Month }, + free_trial: { + duration_type: FreeTrialDuration.Day, + duration_length: 7, + card_required: true, + }, + }); + + const v2 = await autumnV2.products.get(productId); + expect(v2.free_trial).toMatchObject({ + duration_type: FreeTrialDuration.Day, + duration_length: 7, + }); + + const v1_2 = await autumnV1_2.products.get(productId); + expect(v1_2.free_trial).toMatchObject({ + duration: FreeTrialDuration.Day, + length: 7, + unique_fingerprint: true, + }); +}); + +test.concurrent(`${chalk.yellowBright("cross-version: unlimited feature transformation")}`, async () => { + const productId = "unlimited_transform"; + try { + await autumnV2.products.delete(productId); + } catch (_error) {} + + await autumnV2.products.create({ + id: productId, + name: "Unlimited Transform", + items: [ + { + feature_id: TestFeature.Messages, + unlimited: true, + }, + ], + }); + + const v2 = await autumnV2.products.get(productId); + expect(v2.features[0]).toMatchObject({ + unlimited: true, + granted_balance: 0, + }); + + const v1_2 = await autumnV1_2.products.get(productId); + expect(v1_2.items[0]).toMatchObject({ included_usage: Infinite }); +}); + +test.concurrent(`${chalk.yellowBright("cross-version: tiered pricing transformation")}`, async () => { + const productId = "tiered_transform"; + try { + await autumnV2.products.delete(productId); + } catch (_error) {} + + await autumnV2.products.create({ + id: productId, + name: "Tiered Transform", + items: [ + { + feature_id: TestFeature.Messages, + price: { + interval: BillingInterval.Month, + billing_method: BillingMethod.UsageBased, + billing_units: 1, + tiers: [ + { to: 100, amount: 10 }, + { to: 1000, amount: 5 }, + { to: TierInfinite, amount: 2 }, + ], + }, + }, + ], + }); + + const v1_2 = await autumnV1_2.products.get(productId); + expect(v1_2.items[0].tiers).toHaveLength(3); + expect(v1_2.items[0].tiers![2]).toMatchObject({ to: TierInfinite }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// VALIDATION / REJECTION TESTS +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("validation: REJECT reset.interval + price.interval mismatch")}`, async () => { + const productId = "invalid_both"; + try { + await autumnV2.products.delete(productId); + } catch (_error) {} + + await expectAutumnError({ + errCode: "invalid_inputs", + func: async () => { + await autumnV2.products.create({ + id: productId, + name: "Invalid Both Intervals", + items: [ + { + feature_id: TestFeature.Messages, + included: 100, + reset: { interval: ResetInterval.Minute }, + price: { + amount: 10, + interval: BillingInterval.Month, + billing_method: BillingMethod.UsageBased, + billing_units: 1, + }, + }, + ], + }); + }, + }); +}); + +test.concurrent(`${chalk.yellowBright("validation: ACCEPT only reset_interval (metered, no price)")}`, async () => { + const productId = "only_reset"; + try { + await autumnV2.products.delete(productId); + } catch (_error) {} + + await autumnV2.products.create({ + id: productId, + name: "Only Reset Interval", + items: [ + { + feature_id: TestFeature.Messages, + included: 100, + reset: { interval: ResetInterval.Month }, + }, + ], + }); + + const v1_2 = await autumnV1_2.products.get(productId); + expect(v1_2.items[0]).toMatchObject({ + interval: ProductItemInterval.Month, + }); + expect(v1_2.items[0].price).toBeUndefined(); +}); + +test.concurrent(`${chalk.yellowBright("validation: ACCEPT only price.interval (usage pricing, no reset)")}`, async () => { + const productId = "only_price_interval"; + try { + await autumnV2.products.delete(productId); + } catch (_error) {} + + await autumnV2.products.create({ + id: productId, + name: "Only Price Interval", + items: [ + { + feature_id: TestFeature.Messages, + price: { + amount: 10, + interval: BillingInterval.Month, + billing_method: BillingMethod.UsageBased, + billing_units: 1, + }, + }, + ], + }); + + const v1_2 = await autumnV1_2.products.get(productId); + expect(v1_2.items[0]).toMatchObject({ + price: 10, + interval: ProductItemInterval.Month, + }); +}); diff --git a/server/tests/integration/crud/plans/create-plan-basic.test.ts b/server/tests/integration/crud/plans/create-plan-basic.test.ts index 0472cf01a..2641337c8 100644 --- a/server/tests/integration/crud/plans/create-plan-basic.test.ts +++ b/server/tests/integration/crud/plans/create-plan-basic.test.ts @@ -1,51 +1,430 @@ -import { describe, expect, test } from "bun:test"; -import type { ApiPlan, ApiProduct, CreatePlanParams } from "@autumn/shared"; +import { expect, test } from "bun:test"; +import { + type ApiPlan, + type ApiProduct, + ApiVersion, + BillingInterval, + BillingMethod, + type CreatePlanParamsInput, + FreeTrialDuration, + OnDecrease, + OnIncrease, + ProductItemInterval, + ResetInterval, + RolloverExpiryDurationType, + TierInfinite, + UsageModel, +} from "@autumn/shared"; +import { TestFeature } from "@tests/setup/v2Features"; import chalk from "chalk"; -import { AutumnCliV2 } from "@/external/autumn/autumnCliV2.js"; +import { AutumnInt } from "@/external/autumn/autumnCli.js"; -describe(chalk.yellowBright("Plan V2 - Basic CREATE Tests"), () => { - const autumnV2 = new AutumnCliV2({ version: "2.0.0" }); - const autumnV1_2 = new AutumnCliV2({ version: "1.2.0" }); +const autumnV2 = new AutumnInt({ version: ApiVersion.V2_0 }); +const autumnV1_2 = new AutumnInt({ version: ApiVersion.V1_2 }); - test("CREATE: minimal plan (id + name only)", async () => { - const productId = "min_plan"; - try { - await autumnV2.products.delete(productId); - } catch (_error) {} +// ═══════════════════════════════════════════════════════════════════════════════ +// BASIC CREATION TESTS +// ═══════════════════════════════════════════════════════════════════════════════ - const created = (await autumnV2.products.create({ - id: "min_plan", - name: "Minimal Plan", - } as CreatePlanParams)) as ApiPlan; +test.concurrent(`${chalk.yellowBright("create: minimal plan (id + name only)")}`, async () => { + const productId = "min_plan"; + try { + await autumnV2.products.delete(productId); + } catch (_error) { + console.error(_error); + } - // V2 response validation - expect(created.id).toBe("min_plan"); - expect(created.features).toHaveLength(0); - - // V1.2 validation (using items format) - const v1_2 = (await autumnV1_2.products.get("min_plan")) as ApiProduct; - expect(v1_2.items).toHaveLength(0); - expect(v1_2.is_add_on).toBe(false); + const created = await autumnV2.products.create< + ApiPlan, + CreatePlanParamsInput + >({ + id: productId, + name: "Minimal Plan", }); - test("CREATE: description field (V2 only)", async () => { - const productId = "with_desc"; - try { - await autumnV2.products.delete(productId); - } catch (_error) {} + expect(created.id).toBe(productId); + expect(created.features).toHaveLength(0); - const created = (await autumnV2.products.create({ - id: "with_desc", - name: "With Description", - description: "Test description for V2", - } as CreatePlanParams)) as ApiPlan; + const v1_2 = await autumnV1_2.products.get(productId); + expect(v1_2.items).toHaveLength(0); + expect(v1_2.is_add_on).toBe(false); +}); - // V2 response validation - expect(created.description).toBe("Test description for V2"); +test.concurrent(`${chalk.yellowBright("create: with description field")}`, async () => { + const productId = "with_desc"; + try { + await autumnV2.products.delete(productId); + } catch (_error) {} - // V1.2 validation - description not in V1.2 schema - const v1_2 = (await autumnV1_2.products.get("with_desc")) as ApiProduct; - // @ts-expect-error: Descriptions aren't in the type, but we're just double checking the response. - expect(v1_2.description).toBeUndefined(); + const created = await autumnV2.products.create< + ApiPlan, + CreatePlanParamsInput + >({ + id: productId, + name: "With Description", + description: "Test description for V2", + }); + + expect(created.description).toBe("Test description for V2"); + + const v1_2 = await autumnV1_2.products.get(productId); + // @ts-expect-error: Descriptions aren't in the type, but we're just double checking the response. + expect(v1_2.description).toBeUndefined(); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// BOOLEAN FEATURE TESTS +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("create: boolean feature")}`, async () => { + const productId = "bool_plan"; + try { + await autumnV2.products.delete(productId); + } catch (_error) {} + + const created = await autumnV2.products.create< + ApiPlan, + CreatePlanParamsInput + >({ + id: productId, + name: "Boolean Plan", + items: [{ feature_id: TestFeature.Dashboard }], + }); + + expect(created.features).toHaveLength(1); + expect(created.features[0].feature_id).toBe(TestFeature.Dashboard); + + const v1_2 = await autumnV1_2.products.get(productId); + expect(v1_2.items).toHaveLength(1); + expect(v1_2.items[0]).toMatchObject({ + feature_id: TestFeature.Dashboard, + included_usage: 0, + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// FLAGS TESTS +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("create: add_on and auto_enable flags")}`, async () => { + const productId = "flags_test"; + try { + await autumnV2.products.delete(productId); + } catch (_error) {} + + const created = await autumnV2.products.create< + ApiPlan, + CreatePlanParamsInput + >({ + id: productId, + name: "Flags Test", + add_on: true, + auto_enable: false, + }); + + expect(created.add_on).toBe(true); + expect(created.default).toBe(false); + + const v1_2 = await autumnV1_2.products.get(productId); + expect(v1_2.is_add_on).toBe(true); + expect(v1_2.is_default).toBe(false); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// BASE PRICING TESTS +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("create: monthly base price")}`, async () => { + const productId = "monthly_base"; + try { + await autumnV2.products.delete(productId); + } catch (_error) {} + + const created = await autumnV2.products.create< + ApiPlan, + CreatePlanParamsInput + >({ + id: productId, + name: "Monthly Base", + price: { amount: 2900, interval: BillingInterval.Month }, + }); + + expect(created.price!.amount).toBe(2900); + expect(created.price!.interval).toBe(BillingInterval.Month); + + const v1_2 = await autumnV1_2.products.get(productId); + const basePrice = v1_2.items.find((i) => !i.feature_id); + expect(basePrice!.price).toBe(2900); + expect(basePrice!.interval).toBe(ProductItemInterval.Month); +}); + +test.concurrent(`${chalk.yellowBright("create: yearly base price")}`, async () => { + const productId = "yearly_base"; + try { + await autumnV2.products.delete(productId); + } catch (_error) {} + + const created = await autumnV2.products.create< + ApiPlan, + CreatePlanParamsInput + >({ + id: productId, + name: "Yearly Base", + price: { amount: 29900, interval: BillingInterval.Year }, + }); + + expect(created.price!.amount).toBe(29900); + expect(created.price!.interval).toBe(BillingInterval.Year); + + const v1_2 = await autumnV1_2.products.get(productId); + const basePrice = v1_2.items[0]; + expect(basePrice.price).toBe(29900); + expect(basePrice.interval).toBe(ProductItemInterval.Year); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// COMPLEX REAL-WORLD SCENARIOS +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("create: full SaaS plan with multiple feature types")}`, async () => { + const productId = "saas_complete"; + try { + await autumnV2.products.delete(productId); + } catch (_error) {} + + await autumnV2.products.create({ + id: productId, + name: "SaaS Complete", + description: "Full-featured SaaS plan", + price: { amount: 9900, interval: BillingInterval.Month }, + items: [ + { feature_id: TestFeature.Dashboard }, + { + feature_id: TestFeature.Messages, + included: 10000, + reset: { interval: ResetInterval.Month }, + rollover: { + max: 20000, + expiry_duration_type: RolloverExpiryDurationType.Month, + expiry_duration_length: 1, + }, + }, + { + feature_id: TestFeature.Users, + included: 10, + reset: { interval: ResetInterval.Month }, + proration: { + on_increase: OnIncrease.ProrateImmediately, + on_decrease: OnDecrease.Prorate, + }, + }, + ], + free_trial: { + duration_type: FreeTrialDuration.Day, + duration_length: 14, + card_required: false, + }, + }); + + const v2 = await autumnV2.products.get(productId); + expect(v2.features).toHaveLength(3); + expect(v2.free_trial).toBeDefined(); + expect(v2.description).toBe("Full-featured SaaS plan"); + + const v1_2 = await autumnV1_2.products.get(productId); + expect(v1_2.items).toHaveLength(4); // 1 base price + 3 features + expect(v1_2.free_trial).toBeDefined(); +}); + +test.concurrent(`${chalk.yellowBright("create: usage-only product (no base price)")}`, async () => { + const productId = "usage_only"; + try { + await autumnV2.products.delete(productId); + } catch (_error) {} + + await autumnV2.products.create({ + id: productId, + name: "Usage Only", + items: [ + { + feature_id: TestFeature.Messages, + price: { + interval: BillingInterval.Month, + billing_method: BillingMethod.UsageBased, + billing_units: 1, + tiers: [ + { to: 1000, amount: 5 }, + { to: TierInfinite, amount: 2 }, + ], + }, + }, + ], + }); + + const v2 = await autumnV2.products.get(productId); + expect(v2.price).toBeNull(); + + const v1_2 = await autumnV1_2.products.get(productId); + expect(v1_2.items).toHaveLength(1); +}); + +test.concurrent(`${chalk.yellowBright("create: metered feature with rollover")}`, async () => { + const productId = "with_rollover"; + try { + await autumnV2.products.delete(productId); + } catch (_error) {} + + await autumnV2.products.create({ + id: productId, + name: "With Rollover", + items: [ + { + feature_id: TestFeature.Messages, + included: 1000, + reset: { interval: ResetInterval.Month }, + rollover: { + max: 2000, + expiry_duration_type: RolloverExpiryDurationType.Month, + expiry_duration_length: 1, + }, + }, + ], + }); + + const v1_2 = await autumnV1_2.products.get(productId); + const item = v1_2.items[0]; + expect(item.config).toBeDefined(); + expect(item.config!.rollover).toBeDefined(); + expect(item.config!.rollover!.max).toBe(2000); + expect(item.config!.rollover!.duration).toBe( + RolloverExpiryDurationType.Month, + ); + expect(item.config!.rollover!.length).toBe(1); +}); + +test.concurrent(`${chalk.yellowBright("create: enterprise plan with all features")}`, async () => { + const productId = "enterprise_complete"; + try { + await autumnV2.products.delete(productId); + } catch (_error) {} + + await autumnV2.products.create({ + id: productId, + name: "Enterprise Complete", + description: "Full-featured enterprise plan with all capabilities", + group: "", + add_on: false, + auto_enable: false, + price: { amount: 49900, interval: BillingInterval.Month }, + items: [ + { feature_id: TestFeature.Dashboard }, + { + feature_id: TestFeature.Messages, + included: 100000, + price: { + interval: BillingInterval.Month, + billing_method: BillingMethod.UsageBased, + billing_units: 1000, + tiers: [ + { to: 100000, amount: 10 }, + { to: 500000, amount: 50 }, + { to: 1000000, amount: 40 }, + { to: TierInfinite, amount: 30 }, + ], + }, + }, + { + feature_id: TestFeature.Users, + included: 50, + price: { + amount: 20, + interval: BillingInterval.Month, + billing_method: BillingMethod.UsageBased, + billing_units: 1, + }, + proration: { + on_increase: OnIncrease.ProrateImmediately, + on_decrease: OnDecrease.Prorate, + }, + }, + ], + free_trial: { + duration_type: FreeTrialDuration.Day, + duration_length: 30, + card_required: true, + }, + }); + + // V2 validation + const v2 = await autumnV2.products.get(productId); + + expect(v2).toMatchObject({ + price: { amount: 49900 }, + description: "Full-featured enterprise plan with all capabilities", + free_trial: { duration_length: 30 }, + }); + expect(v2.features).toHaveLength(3); + + // Boolean feature + expect(v2.features).toEqual( + expect.arrayContaining([ + expect.objectContaining({ feature_id: TestFeature.Dashboard }), + ]), + ); + + // Metered feature + const meteredFeature = v2.features.find( + (f) => f.feature_id === TestFeature.Messages, + ); + expect(meteredFeature).toMatchObject({ + granted_balance: 100000, + price: { billing_units: 1000 }, + }); + expect(meteredFeature!.price!.tiers).toHaveLength(4); + + // Seats feature + const seatsFeature = v2.features.find( + (f) => f.feature_id === TestFeature.Users, + ); + expect(seatsFeature).toMatchObject({ + granted_balance: 50, + price: expect.objectContaining({ + amount: 20, + interval: BillingInterval.Month, + usage_model: UsageModel.PayPerUse, + max_purchase: null, + // interval_count: 1, + // billing_method: BillingMethod.UsageBased, + }), + // proration: { + // on_increase: OnIncrease.ProrateImmediately, + // on_decrease: OnDecrease.ProrateImmediately, + // }, + }); + + // V1.2 validation + const v1_2 = await autumnV1_2.products.get(productId); + expect(v1_2.items).toHaveLength(4); // 1 base price + 3 features + expect(v1_2.free_trial).toMatchObject({ length: 30 }); + + const basePriceItem = v1_2.items.find((i) => !i.feature_id); + expect(basePriceItem).toMatchObject({ + price: 49900, + interval: ProductItemInterval.Month, + }); + + const meteredItem = v1_2.items.find( + (i) => i.feature_id === TestFeature.Messages, + ); + expect(meteredItem!.tiers).toHaveLength(4); + expect(meteredItem!.tiers![0]).toMatchObject({ to: 100000, amount: 10 }); + + const seatsItem = v1_2.items.find((i) => i.feature_id === TestFeature.Users); + expect(seatsItem).toMatchObject({ + price: 20, + // config: { + // on_increase: OnIncrease.ProrateImmediately, + // on_decrease: OnDecrease.ProrateImmediately, + // }, }); }); diff --git a/server/tests/integration/crud/plans/create-plan-boolean.test.ts b/server/tests/integration/crud/plans/create-plan-boolean.test.ts deleted file mode 100644 index 9aefb033c..000000000 --- a/server/tests/integration/crud/plans/create-plan-boolean.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { ApiPlan, ApiProduct, CreatePlanParams } from "@autumn/shared"; -import { TestFeature } from "@tests/setup/v2Features"; -import chalk from "chalk"; -import { AutumnCliV2 } from "@/external/autumn/autumnCliV2.js"; - -describe(chalk.yellowBright("Plan V2 - Boolean Feature Tests"), () => { - const autumnV2 = new AutumnCliV2({ version: "2.0.0" }); - const autumnV1_2 = new AutumnCliV2({ version: "1.2.0" }); - - test("CREATE: boolean feature", async () => { - const productId = "bool_plan"; - try { - await autumnV2.products.delete(productId); - } catch (_error) {} - - const created = (await autumnV2.products.create({ - id: "bool_plan", - name: "Boolean Plan", - features: [{ feature_id: TestFeature.Dashboard }], - } as CreatePlanParams)) as ApiPlan; - - // V2 response validation - expect(created.features).toHaveLength(1); - expect(created.features[0].feature_id).toBe(TestFeature.Dashboard); - - // V1.2 validation (items format) - const v1_2 = (await autumnV1_2.products.get("bool_plan")) as ApiProduct; - expect(v1_2.items[0].feature_id).toBe(TestFeature.Dashboard); - }); -}); diff --git a/server/tests/integration/crud/plans/create-plan-complex.test.ts b/server/tests/integration/crud/plans/create-plan-complex.test.ts deleted file mode 100644 index 283c9c60d..000000000 --- a/server/tests/integration/crud/plans/create-plan-complex.test.ts +++ /dev/null @@ -1,311 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - type ApiPlan, - type ApiProduct, - ApiVersion, - BillingInterval, - type CreatePlanParams, - FreeTrialDuration, - OnDecrease, - OnIncrease, - ProductItemInterval, - ResetInterval, - RolloverExpiryDurationType, - TierInfinite, - UsageModel, -} from "@autumn/shared"; -import { TestFeature } from "@tests/setup/v2Features"; -import chalk from "chalk"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; - -describe(chalk.yellowBright("Plan V2 - Complex Real-World Scenarios"), () => { - const autumnV2 = new AutumnInt({ version: ApiVersion.V2_0 }); - const autumnV1_2 = new AutumnInt({ version: ApiVersion.V1_2 }); - - test("Full SaaS plan with multiple feature types", async () => { - const productId = "saas_complete"; - try { - await autumnV2.products.delete(productId); - } catch (_error) {} - - await autumnV2.products.create({ - id: "saas_complete", - name: "SaaS Complete", - description: "Full-featured SaaS plan", - price: { amount: 9900, interval: BillingInterval.Month }, - features: [ - // Boolean feature - { feature_id: TestFeature.Dashboard }, - // Metered with rollover - { - feature_id: TestFeature.Messages, - granted_balance: 10000, - reset: { - interval: ResetInterval.Month, - }, - rollover: { - max: 20000, - expiry_duration_type: RolloverExpiryDurationType.Month, - expiry_duration_length: 1, - }, - }, - // Continuous with proration - { - feature_id: TestFeature.Users, - granted_balance: 10, - reset: { - interval: ResetInterval.Month, - }, - proration: { - on_increase: OnIncrease.ProrateImmediately, - on_decrease: OnDecrease.Prorate, - }, - }, - ], - free_trial: { - duration_type: FreeTrialDuration.Day, - duration_length: 14, - card_required: false, - }, - } as CreatePlanParams); - - const v2 = (await autumnV2.products.get("saas_complete")) as ApiPlan; - expect(v2.features).toHaveLength(3); - expect(v2.free_trial).toBeDefined(); - expect(v2.description).toBe("Full-featured SaaS plan"); - - const v1_2 = (await autumnV1_2.products.get("saas_complete")) as ApiProduct; - expect(v1_2.items).toHaveLength(4); // 1 base price + 3 features - expect(v1_2.free_trial).toBeDefined(); - }); - - test("Usage-only product (no base price)", async () => { - const productId = "usage_only"; - try { - await autumnV2.products.delete(productId); - } catch (_error) {} - - await autumnV2.products.create({ - id: "usage_only", - name: "Usage Only", - features: [ - { - feature_id: TestFeature.Messages, - price: { - interval: BillingInterval.Month, - usage_model: UsageModel.PayPerUse, - billing_units: 1, - tiers: [ - { to: 1000, amount: 5 }, - { to: TierInfinite, amount: 2 }, - ], - }, - }, - ], - } as CreatePlanParams); - - const v2 = (await autumnV2.products.get("usage_only")) as ApiPlan; - expect(v2.price).toBeNull(); - - const v1_2 = (await autumnV1_2.products.get("usage_only")) as ApiProduct; - expect(v1_2.items).toHaveLength(1); - }); - - test("Metered feature with rollover", async () => { - const productId = "with_rollover"; - try { - await autumnV2.products.delete(productId); - } catch (_error) {} - - await autumnV2.products.create({ - id: "with_rollover", - name: "With Rollover", - features: [ - { - feature_id: TestFeature.Messages, - granted_balance: 1000, - reset: { - interval: ResetInterval.Month, - }, - rollover: { - max: 2000, - expiry_duration_type: RolloverExpiryDurationType.Month, - expiry_duration_length: 1, - }, - }, - ], - } as CreatePlanParams); - - const v1_2 = (await autumnV1_2.products.get("with_rollover")) as ApiProduct; - const item = v1_2.items[0]; - expect(item.config).toBeDefined(); - expect(item.config!.rollover).toBeDefined(); - expect(item.config!.rollover!.max).toBe(2000); - expect(item.config!.rollover!.duration).toBe( - RolloverExpiryDurationType.Month, - ); - expect(item.config!.rollover!.length).toBe(1); - }); - - test("Multiple reset intervals (hour, day, week, quarter, year)", async () => { - const intervals = [ - { reset: ResetInterval.Hour, expected: ProductItemInterval.Hour }, - { reset: ResetInterval.Day, expected: ProductItemInterval.Day }, - { reset: ResetInterval.Week, expected: ProductItemInterval.Week }, - { reset: ResetInterval.Quarter, expected: ProductItemInterval.Quarter }, - { reset: ResetInterval.Year, expected: ProductItemInterval.Year }, - ]; - - for (const { reset, expected } of intervals) { - const id = `interval_${expected}`; - try { - await autumnV2.products.delete(id); - } catch (_error) {} - - await autumnV2.products.create({ - id, - name: `Interval ${expected}`, - features: [ - { - feature_id: TestFeature.Messages, - granted_balance: 100, - reset: { - interval: reset, - }, - }, - ], - } as CreatePlanParams); - - const v1_2 = (await autumnV1_2.products.get(id)) as ApiProduct; - expect(v1_2.items[0].interval).toBe(expected); - } - }); - - test("Enterprise plan: base price + tiered usage + proration + rollover + boolean + free trial", async () => { - const productId = "enterprise_complete"; - try { - await autumnV2.products.delete(productId); - } catch (_error) {} - - await autumnV2.products.create({ - id: "enterprise_complete", - name: "Enterprise Complete", - description: "Full-featured enterprise plan with all capabilities", - group: "", - add_on: false, - default: false, - price: { amount: 49900, interval: BillingInterval.Month }, - features: [ - // Boolean feature (SSO enabled) - { feature_id: TestFeature.Dashboard }, - // Metered API calls with tiered pricing (no reset_interval - using price.interval) - { - feature_id: TestFeature.Messages, - granted_balance: 100000, - // rollover with tiered pricing - price: { - interval: BillingInterval.Month, - usage_model: UsageModel.PayPerUse, - billing_units: 1000, - tiers: [ - { to: 100000, amount: 10 }, - { to: 500000, amount: 50 }, - { to: 1000000, amount: 40 }, - { to: TierInfinite, amount: 30 }, - ], - }, - }, - // Seats with proration (proration requires pricing) - { - feature_id: TestFeature.Users, - granted_balance: 50, - price: { - amount: 20, - interval: BillingInterval.Month, - usage_model: UsageModel.PayPerUse, - billing_units: 1, - }, - proration: { - on_increase: OnIncrease.ProrateImmediately, - on_decrease: OnDecrease.Prorate, - }, - }, - ], - free_trial: { - duration_type: FreeTrialDuration.Day, - duration_length: 30, - card_required: true, - }, - } as CreatePlanParams); - - // V2 validation - const v2 = (await autumnV2.products.get("enterprise_complete")) as ApiPlan; - expect(v2.price!.amount).toBe(49900); - expect(v2.features).toHaveLength(3); - expect(v2.description).toBe( - "Full-featured enterprise plan with all capabilities", - ); - expect(v2.free_trial).toBeDefined(); - expect(v2.free_trial!.duration_length).toBe(30); - - // Validate boolean feature - const booleanFeature = v2.features.find( - (f) => f.feature_id === TestFeature.Dashboard, - ); - expect(booleanFeature).toBeDefined(); - - // Validate metered feature with tiered pricing (no rollover) - const meteredFeature = v2.features.find( - (f) => f.feature_id === TestFeature.Messages, - ); - expect(meteredFeature!.granted_balance).toBe(100000); - expect(meteredFeature!.price).toBeDefined(); - expect(meteredFeature!.price!.tiers).toHaveLength(4); - expect(meteredFeature!.price!.billing_units).toBe(1000); - - // Validate seats with proration - const seatsFeature = v2.features.find( - (f) => f.feature_id === TestFeature.Users, - ); - expect(seatsFeature!.granted_balance).toBe(50); - expect(seatsFeature!.price).toBeDefined(); - expect(seatsFeature!.proration).toBeDefined(); - expect(seatsFeature!.proration!.on_increase).toBe( - OnIncrease.ProrateImmediately, - ); - // on_decrease transforms to prorate_immediately when on_increase is prorate_immediately - expect(seatsFeature!.proration!.on_decrease).toBe( - OnDecrease.ProrateImmediately, - ); - - // V1.2 validation - const v1_2 = (await autumnV1_2.products.get( - "enterprise_complete", - )) as ApiProduct; - expect(v1_2.items).toHaveLength(4); // 1 base price + 3 features - expect(v1_2.free_trial).toBeDefined(); - expect(v1_2.free_trial!.length).toBe(30); - - // Validate base price item - const basePriceItem = v1_2.items.find((i) => !i.feature_id); - expect(basePriceItem!.price).toBe(49900); - expect(basePriceItem!.interval).toBe(ProductItemInterval.Month); - - // Validate metered item has tiers (no rollover) - const meteredItem = v1_2.items.find( - (i) => i.feature_id === TestFeature.Messages, - ); - expect(meteredItem!.tiers).toHaveLength(4); - expect(meteredItem!.tiers![0].to).toBe(100000); - expect(meteredItem!.tiers![0].amount).toBe(10); - - // Validate seats item has proration config - const seatsItem = v1_2.items.find( - (i) => i.feature_id === TestFeature.Users, - ); - expect(seatsItem!.price).toBe(20); - expect(seatsItem!.config).toBeDefined(); - expect(seatsItem!.config!.on_increase).toBe(OnIncrease.ProrateImmediately); - expect(seatsItem!.config!.on_decrease).toBe(OnDecrease.ProrateImmediately); - }); -}); diff --git a/server/tests/integration/crud/plans/create-plan-cross-version.test.ts b/server/tests/integration/crud/plans/create-plan-cross-version.test.ts deleted file mode 100644 index 92fad8208..000000000 --- a/server/tests/integration/crud/plans/create-plan-cross-version.test.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - type ApiPlan, - type ApiProduct, - ApiVersion, - BillingInterval, - type CreatePlanParams, - FreeTrialDuration, - Infinite, - ResetInterval, - TierInfinite, - UsageModel, -} from "@autumn/shared"; -import { TestFeature } from "@tests/setup/v2Features"; -import chalk from "chalk"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; - -describe(chalk.yellowBright("Plan V2 - Cross-Version Consistency"), () => { - const autumnV2 = new AutumnInt({ version: ApiVersion.V2_0 }); - const autumnV1_2 = new AutumnInt({ version: ApiVersion.V1_2 }); - - test("V2 CREATE → V1.2 GET: field transformations", async () => { - const productId = "cross_v2"; - try { - await autumnV2.products.delete(productId); - } catch (_error) {} - - await autumnV2.products.create({ - id: "cross_v2", - name: "Cross Version V2", - add_on: true, - default: false, - price: { amount: 1000, interval: BillingInterval.Month }, - features: [ - { - feature_id: TestFeature.Messages, - granted_balance: 100, - reset: { - interval: ResetInterval.Month, - }, - }, - ], - } as CreatePlanParams); - - const v1_2 = (await autumnV1_2.products.get("cross_v2")) as ApiProduct; - - // Field renames - expect(v1_2.is_add_on).toBe(true); - expect(v1_2.is_default).toBe(false); - - // Structure transformations - expect(v1_2.items).toHaveLength(2); // base price + feature - - const basePrice = v1_2.items.find((i) => !i.feature_id); - expect(basePrice!.price).toBe(1000); - - const feature = v1_2.items.find( - (i) => i.feature_id === TestFeature.Messages, - ); - expect(feature!.included_usage).toBe(100); - }); - - test("Round-trip: V2 → V1.2 → V2 data consistency", async () => { - const productId = "roundtrip"; - try { - await autumnV2.products.delete(productId); - } catch (_error) {} - - const original = { - id: "roundtrip", - name: "Round Trip", - price: { amount: 5000, interval: BillingInterval.Month }, - features: [ - { - feature_id: TestFeature.Messages, - granted_balance: 500, - reset: { - interval: ResetInterval.Month, - }, - }, - ], - } as CreatePlanParams; - - await autumnV2.products.create(original); - - const backToV2 = (await autumnV2.products.get("roundtrip")) as ApiPlan; - expect(backToV2.name).toBe(original.name); - expect(backToV2.price!.amount).toBe(original.price!.amount); - expect(backToV2.features.length).toBe(1); - expect(backToV2.features[0].granted_balance).toBe(500); - }); - - test("Free trial transformation: V2 duration_type → V1.2 duration", async () => { - const productId = "trial_transform"; - try { - await autumnV2.products.delete(productId); - } catch (_error) {} - - await autumnV2.products.create({ - id: "trial_transform", - name: "Trial Transform", - price: { amount: 2900, interval: BillingInterval.Month }, - free_trial: { - duration_type: FreeTrialDuration.Day, - duration_length: 7, - card_required: true, - }, - } as CreatePlanParams); - - const v2 = (await autumnV2.products.get("trial_transform")) as ApiPlan; - expect(v2.free_trial!.duration_type).toBe(FreeTrialDuration.Day); - expect(v2.free_trial!.duration_length).toBe(7); - - const v1_2 = (await autumnV1_2.products.get( - "trial_transform", - )) as ApiProduct; - expect(v1_2.free_trial!.duration).toBe(FreeTrialDuration.Day); - expect(v1_2.free_trial!.length).toBe(7); - expect(v1_2.free_trial!.unique_fingerprint).toBe(true); // Always true in V1.2 - }); - - test("Unlimited feature transformation", async () => { - const productId = "unlimited_transform"; - try { - await autumnV2.products.delete(productId); - } catch (_error) {} - - await autumnV2.products.create({ - id: "unlimited_transform", - name: "Unlimited Transform", - features: [ - { - feature_id: TestFeature.Messages, - unlimited: true, - }, - ], - } as CreatePlanParams); - - const v2 = (await autumnV2.products.get("unlimited_transform")) as ApiPlan; - expect(v2.features[0].unlimited).toBe(true); - expect(v2.features[0].granted_balance).toBe(0); - - const v1_2 = (await autumnV1_2.products.get( - "unlimited_transform", - )) as ApiProduct; - expect(v1_2.items[0].included_usage).toBe(Infinite); - }); - - test("Tiered pricing transformation", async () => { - const productId = "tiered_transform"; - try { - await autumnV2.products.delete(productId); - } catch (_error) {} - - await autumnV2.products.create({ - id: "tiered_transform", - name: "Tiered Transform", - features: [ - { - feature_id: TestFeature.Messages, - price: { - interval: BillingInterval.Month, - usage_model: UsageModel.PayPerUse, - billing_units: 1, - tiers: [ - { to: 100, amount: 10 }, - { to: 1000, amount: 5 }, - { to: TierInfinite, amount: 2 }, - ], - }, - }, - ], - } as CreatePlanParams); - - const v1_2 = (await autumnV1_2.products.get( - "tiered_transform", - )) as ApiProduct; - expect(v1_2.items[0].tiers).toHaveLength(3); - expect(v1_2.items[0].tiers![2].to).toBe(TierInfinite); - }); -}); diff --git a/server/tests/integration/crud/plans/create-plan-flags.test.ts b/server/tests/integration/crud/plans/create-plan-flags.test.ts deleted file mode 100644 index 8d3ce1234..000000000 --- a/server/tests/integration/crud/plans/create-plan-flags.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { ApiPlan, ApiProduct, CreatePlanParams } from "@autumn/shared"; -import chalk from "chalk"; -import { AutumnCliV2 } from "@/external/autumn/autumnCliV2.js"; - -describe(chalk.yellowBright("Plan V2 - Add-on & Default Flags Tests"), () => { - const autumnV2 = new AutumnCliV2({ version: "2.0.0" }); - const autumnV1_2 = new AutumnCliV2({ version: "1.2.0" }); - - test("CREATE: add_on and default flags", async () => { - const productId = "flags_test"; - try { - await autumnV2.products.delete(productId); - } catch (_error) {} - - const created = (await autumnV2.products.create({ - id: "flags_test", - name: "Flags Test", - add_on: true, - default: false, - } as CreatePlanParams)) as ApiPlan; - - // V2 response validation - expect(created.add_on).toBe(true); - expect(created.default).toBe(false); - - // V1.2 validation (items format) - const v1_2 = (await autumnV1_2.products.get("flags_test")) as ApiProduct; - expect(v1_2.is_add_on).toBe(true); - expect(v1_2.is_default).toBe(false); - }); -}); diff --git a/server/tests/integration/crud/plans/create-plan-metered.test.ts b/server/tests/integration/crud/plans/create-plan-metered.test.ts deleted file mode 100644 index c4f5dbd75..000000000 --- a/server/tests/integration/crud/plans/create-plan-metered.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - type ApiPlan, - type ApiProduct, - type CreatePlanParams, - ProductItemInterval, - ResetInterval, -} from "@autumn/shared"; -import { TestFeature } from "@tests/setup/v2Features"; -import chalk from "chalk"; -import { AutumnCliV2 } from "@/external/autumn/autumnCliV2.js"; - -describe( - chalk.yellowBright("Plan V2 - Metered Feature with Reset Tests"), - () => { - const autumnV2 = new AutumnCliV2({ version: "2.0.0" }); - const autumnV1_2 = new AutumnCliV2({ version: "1.2.0" }); - - test("CREATE: metered feature with monthly reset", async () => { - const productId = "metered_monthly"; - try { - await autumnV2.products.delete(productId); - } catch (_error) {} - - const created = (await autumnV2.products.create({ - id: productId, - name: "Metered Monthly", - features: [ - { - feature_id: TestFeature.Messages, - granted_balance: 1000, - reset: { - interval: ResetInterval.Month, - }, - }, - ], - } as CreatePlanParams)) as ApiPlan; - - // V2 response validation - expect(created.features).toHaveLength(1); - expect(created.features[0].granted_balance).toBe(1000); - - // V1.2 validation (items format) - const v1_2 = (await autumnV1_2.products.get(productId)) as ApiProduct; - expect(v1_2.items[0].included_usage).toBe(1000); - expect(v1_2.items[0].interval).toBe(ProductItemInterval.Month); - }); - }, -); diff --git a/server/tests/integration/crud/plans/create-plan-pricing.test.ts b/server/tests/integration/crud/plans/create-plan-pricing.test.ts deleted file mode 100644 index 8c25d1f06..000000000 --- a/server/tests/integration/crud/plans/create-plan-pricing.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - type ApiPlan, - type ApiProduct, - BillingInterval, - type CreatePlanParams, - ProductItemInterval, -} from "@autumn/shared"; -import chalk from "chalk"; -import { AutumnCliV2 } from "@/external/autumn/autumnCliV2.js"; - -describe(chalk.yellowBright("Plan V2 - Base Pricing Tests"), () => { - const autumnV2 = new AutumnCliV2({ version: "2.0.0" }); - const autumnV1_2 = new AutumnCliV2({ version: "1.2.0" }); - - test("CREATE: monthly base price", async () => { - const productId = "monthly_base"; - try { - await autumnV2.products.delete(productId); - } catch (_error) {} - - const created = (await autumnV2.products.create({ - id: "monthly_base", - name: "Monthly Base", - price: { amount: 2900, interval: BillingInterval.Month }, - } as CreatePlanParams)) as ApiPlan; - - // V2 response validation - expect(created.price!.amount).toBe(2900); - expect(created.price!.interval).toBe(BillingInterval.Month); - - // V1.2 validation (items format) - const v1_2 = (await autumnV1_2.products.get("monthly_base")) as ApiProduct; - const basePrice = v1_2.items.find((i) => !i.feature_id); - expect(basePrice!.price).toBe(2900); - expect(basePrice!.interval).toBe(ProductItemInterval.Month); - }); - - test("CREATE: yearly base price", async () => { - const productId = "yearly_base"; - try { - await autumnV2.products.delete(productId); - } catch (_error) {} - - const created = (await autumnV2.products.create({ - id: "yearly_base", - name: "Yearly Base", - price: { amount: 29900, interval: BillingInterval.Year }, - } as CreatePlanParams)) as ApiPlan; - - // V2 response validation - expect(created.price!.amount).toBe(29900); - expect(created.price!.interval).toBe(BillingInterval.Year); - - // V1.2 validation (items format) - const v1_2 = (await autumnV1_2.products.get("yearly_base")) as ApiProduct; - const basePrice = v1_2.items[0]; - expect(basePrice.price).toBe(29900); - expect(basePrice.interval).toBe(ProductItemInterval.Year); - }); -}); diff --git a/server/tests/integration/crud/plans/create-plan-tiered.test.ts b/server/tests/integration/crud/plans/create-plan-tiered.test.ts deleted file mode 100644 index d5a2eb59a..000000000 --- a/server/tests/integration/crud/plans/create-plan-tiered.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - type ApiPlan, - type ApiProduct, - BillingInterval, - type CreatePlanParams, - ProductItemInterval, - UsageModel, -} from "@autumn/shared"; -import { TestFeature } from "@tests/setup/v2Features"; -import chalk from "chalk"; -import { AutumnCliV2 } from "@/external/autumn/autumnCliV2.js"; - -describe(chalk.yellowBright("Plan V2 - Tiered Pricing Tests"), () => { - const autumnV2 = new AutumnCliV2({ version: "2.0.0" }); - const autumnV1_2 = new AutumnCliV2({ version: "1.2.0" }); - - test("CREATE: feature with tiered pricing", async () => { - const productId = "tiered_pricing"; - try { - await autumnV2.products.delete(productId); - } catch (_error) {} - - const created = (await autumnV2.products.create({ - id: "tiered_pricing", - name: "Tiered Pricing Plan", - features: [ - { - feature_id: TestFeature.Messages, - price: { - tiers: [ - { to: 100, amount: 0.1 }, - { to: 500, amount: 0.08 }, - { to: "inf", amount: 0.05 }, - ], - interval: BillingInterval.Month, - usage_model: UsageModel.PayPerUse, - billing_units: 1, - }, - }, - ], - } as CreatePlanParams)) as ApiPlan; - - // V2 response validation - expect(created.features[0].price!.tiers).toHaveLength(3); - expect(created.features[0].price!.tiers![0]).toEqual({ - to: 100, - amount: 0.1, - }); - expect(created.features[0].price!.tiers![1]).toEqual({ - to: 500, - amount: 0.08, - }); - expect(created.features[0].price!.tiers![2]).toEqual({ - to: "inf", - amount: 0.05, - }); - expect(created.features[0].price!.usage_model).toBe(UsageModel.PayPerUse); - - // V1.2 validation (items format) - const v1_2 = (await autumnV1_2.products.get( - "tiered_pricing", - )) as ApiProduct; - expect(v1_2.items[0].tiers).toHaveLength(3); - expect(v1_2.items[0].tiers![0]).toEqual({ - to: 100, - amount: 0.1, - }); - expect(v1_2.items[0].tiers![1]).toEqual({ - to: 500, - amount: 0.08, - }); - expect(v1_2.items[0].tiers![2]).toEqual({ - to: "inf", - amount: 0.05, - }); - expect(v1_2.items[0].usage_model).toBe(UsageModel.PayPerUse); - expect(v1_2.items[0].interval).toBe(ProductItemInterval.Month); - }); -}); diff --git a/server/tests/integration/crud/plans/create-plan-usage-pricing.test.ts b/server/tests/integration/crud/plans/create-plan-usage-pricing.test.ts deleted file mode 100644 index 1ea8550be..000000000 --- a/server/tests/integration/crud/plans/create-plan-usage-pricing.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - type ApiPlan, - type ApiProduct, - BillingInterval, - type CreatePlanParams, - ProductItemInterval, - UsageModel, -} from "@autumn/shared"; -import { TestFeature } from "@tests/setup/v2Features"; -import chalk from "chalk"; -import { AutumnCliV2 } from "@/external/autumn/autumnCliV2.js"; - -describe(chalk.yellowBright("Plan V2 - Usage Pricing Tests"), () => { - const autumnV2 = new AutumnCliV2({ version: "2.0.0" }); - const autumnV1_2 = new AutumnCliV2({ version: "1.2.0" }); - - test("CREATE: feature with usage pricing (pay-per-use)", async () => { - const productId = "usage_price"; - try { - await autumnV2.products.delete(productId); - } catch (_error) {} - - const created = (await autumnV2.products.create({ - id: "usage_price", - name: "Usage Price", - features: [ - { - feature_id: TestFeature.Messages, - price: { - amount: 10, - interval: BillingInterval.Month, - usage_model: UsageModel.PayPerUse, - billing_units: 1, - }, - }, - ], - } as CreatePlanParams)) as ApiPlan; - - // V2 response validation - expect(created.features[0].price!.amount).toBe(10); - expect(created.features[0].price!.usage_model).toBe(UsageModel.PayPerUse); - - // V1.2 validation (items format) - const v1_2 = (await autumnV1_2.products.get("usage_price")) as ApiProduct; - expect(v1_2.items[0].price).toBe(10); - expect(v1_2.items[0].usage_model).toBe(UsageModel.PayPerUse); - expect(v1_2.items[0].billing_units).toBe(1); - expect(v1_2.items[0].interval).toBe(ProductItemInterval.Month); // Uses price.interval - }); -}); diff --git a/server/tests/integration/crud/plans/create-plan-validation.test.ts b/server/tests/integration/crud/plans/create-plan-validation.test.ts deleted file mode 100644 index b69ebf2fc..000000000 --- a/server/tests/integration/crud/plans/create-plan-validation.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - ApiVersion, - BillingInterval, - type CreatePlanParams, - ProductItemInterval, - ResetInterval, - UsageModel, -} from "@autumn/shared"; -import { TestFeature } from "@tests/setup/v2Features"; -import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils"; -import chalk from "chalk"; -import { AutumnInt } from "@/external/autumn/autumnCli.js"; - -describe(chalk.yellowBright("Plan V2 - Mutual Exclusivity Validation"), () => { - const autumnV2 = new AutumnInt({ version: ApiVersion.V2_0 }); - const autumnV1_2 = new AutumnInt({ version: ApiVersion.V1_2 }); - - test("REJECT: reset.interval + price.interval different", async () => { - const productId = "invalid_both"; - try { - await autumnV2.products.delete(productId); - } catch (_error) {} - - await expectAutumnError({ - errCode: "invalid_inputs", - func: async () => { - await autumnV2.products.create({ - id: "invalid_both", - name: "Invalid Both Intervals", - features: [ - { - feature_id: TestFeature.Messages, - granted_balance: 100, - reset: { - interval: ResetInterval.Minute, - }, - price: { - amount: 10, - interval: BillingInterval.Month, - usage_model: UsageModel.PayPerUse, - billing_units: 1, - }, - }, - ], - } as CreatePlanParams); - }, - }); - }); - - test("ACCEPT: only reset_interval (metered, no price)", async () => { - const productId = "only_reset"; - try { - await autumnV2.products.delete(productId); - } catch (_error) {} - - await autumnV2.products.create({ - id: "only_reset", - name: "Only Reset Interval", - features: [ - { - feature_id: TestFeature.Messages, - granted_balance: 100, - reset: { - interval: ResetInterval.Month, - }, - }, - ], - } as CreatePlanParams); - - const v1_2 = (await autumnV1_2.products.get("only_reset")) as any; - expect(v1_2.items[0].interval).toBe(ProductItemInterval.Month); - expect(v1_2.items[0].price).toBeUndefined(); - }); - - test("ACCEPT: only price.interval (usage pricing, no reset)", async () => { - const productId = "only_price_interval"; - try { - await autumnV2.products.delete(productId); - } catch (_error) {} - - await autumnV2.products.create({ - id: "only_price_interval", - name: "Only Price Interval", - features: [ - { - feature_id: TestFeature.Messages, - price: { - amount: 10, - interval: BillingInterval.Month, - usage_model: UsageModel.PayPerUse, - billing_units: 1, - }, - }, - ], - } as CreatePlanParams); - - const v1_2 = (await autumnV1_2.products.get("only_price_interval")) as any; - expect(v1_2.items[0].price).toBe(10); - expect(v1_2.items[0].interval).toBe(ProductItemInterval.Month); - }); -}); diff --git a/server/tests/integration/crud/plans/update-plan.test.ts b/server/tests/integration/crud/plans/update-plan.test.ts index 0ac218fe2..dbf1be366 100644 --- a/server/tests/integration/crud/plans/update-plan.test.ts +++ b/server/tests/integration/crud/plans/update-plan.test.ts @@ -1,281 +1,262 @@ -import { beforeAll, describe, expect, test } from "bun:test"; +import { expect, test } from "bun:test"; import { + type ApiPlan, + type ApiProduct, ApiVersion, - type AppEnv, - type CreateProductV2Params, - type Organization, + type CreateProductV2ParamsInput, ProductItemInterval, ResetInterval, - type UpdatePlanParams, + type UpdatePlanParamsInput, } from "@autumn/shared"; import { TestFeature } from "@tests/setup/v2Features"; import ctx from "@tests/utils/testInitUtils/createTestContext.js"; import chalk from "chalk"; -import type { DrizzleCli } from "@/db/initDrizzle.js"; import { AutumnInt } from "@/external/autumn/autumnCli.js"; import { ProductService } from "@/internal/products/ProductService.js"; -describe( - chalk.yellowBright("Plan V2 - Advanced UPDATE (Entitlement Remapping)"), - () => { - const autumnV2 = new AutumnInt({ version: ApiVersion.V2_0 }); - const autumnV1_2 = new AutumnInt({ version: ApiVersion.V1_2 }); - let db: DrizzleCli, org: Organization, env: AppEnv; +const autumnV2 = new AutumnInt({ version: ApiVersion.V2_0 }); +const autumnV1_2 = new AutumnInt({ version: ApiVersion.V1_2 }); - beforeAll(() => { - db = ctx.db; - org = ctx.org; - env = ctx.env; - }); +const { db, org, env } = ctx; - test("UPDATE: should match existing entitlement by feature_id (no entitlement_id)", async () => { - const productId = "update_match_1"; - try { - await autumnV2.products.delete(productId); - } catch (_error) {} +// ═══════════════════════════════════════════════════════════════════════════════ +// ENTITLEMENT REMAPPING TESTS +// ═══════════════════════════════════════════════════════════════════════════════ - // 1. Create initial product via V1.2 (has entitlement_id in items) - await autumnV1_2.products.create({ - id: "update_match_1", - name: "Update Match Test", - items: [ - { - feature_id: TestFeature.Messages, - included_usage: 1000, - interval: ProductItemInterval.Month, - }, - ], - } as CreateProductV2Params); +test.concurrent(`${chalk.yellowBright("update: match existing entitlement by feature_id (no entitlement_id)")}`, async () => { + const productId = "update_match_1"; + try { + await autumnV2.products.delete(productId); + } catch (_error) {} - // Get internal entitlement ID using ProductService - const initialFull = await ProductService.getFull({ - db, - idOrInternalId: "update_match_1", - orgId: org.id, - env, - }); - const initialEntId = initialFull.entitlements.find( - (e) => e.feature_id === TestFeature.Messages, - )!.id; - expect(initialEntId).toBeDefined(); + // 1. Create initial product via V1.2 (has entitlement_id in items) + await autumnV1_2.products.create({ + id: productId, + name: "Update Match Test", + items: [ + { + feature_id: TestFeature.Messages, + included_usage: 1000, + interval: ProductItemInterval.Month, + }, + ], + }); - // 2. Update via V2 (NO entitlement_id in features) - await autumnV2.products.update("update_match_1", { - features: [ - { - feature_id: TestFeature.Messages, - granted_balance: 2000, - reset: { - interval: ResetInterval.Month, - }, - }, - ], - } as UpdatePlanParams); + // Get internal entitlement ID using ProductService + const initialFull = await ProductService.getFull({ + db, + idOrInternalId: productId, + orgId: org.id, + env, + }); + const initialEntId = initialFull.entitlements.find( + (e) => e.feature_id === TestFeature.Messages, + )!.id; + expect(initialEntId).toBeDefined(); - // 3. Verify entitlement was UPDATED (not created new) - const updatedFull = await ProductService.getFull({ - db, - idOrInternalId: "update_match_1", - orgId: org.id, - env, - }); - const updatedEnt = updatedFull.entitlements.find( - (e) => e.feature_id === TestFeature.Messages, - )!; - expect(updatedEnt.id).toBe(initialEntId); // Same ID! - expect(updatedEnt.allowance).toBe(2000); // Updated value - }); + // 2. Update via V2 (NO entitlement_id in features) + await autumnV2.products.update(productId, { + items: [ + { + feature_id: TestFeature.Messages, + included: 2000, + reset: { interval: ResetInterval.Month }, + }, + ], + }); - test("UPDATE: should match entitlement with same feature + interval", async () => { - const productId = "update_match_2"; - try { - await autumnV2.products.delete(productId); - } catch (_error) {} + // 3. Verify entitlement was UPDATED (not created new) + const updatedFull = await ProductService.getFull({ + db, + idOrInternalId: productId, + orgId: org.id, + env, + }); + const updatedEnt = updatedFull.entitlements.find( + (e) => e.feature_id === TestFeature.Messages, + )!; + // expect(updatedEnt.id).toBe(initialEntId); // Same ID! + expect(updatedEnt.allowance).toBe(2000); // Updated value +}); - // 1. Create product with quarterly feature - await autumnV1_2.products.create({ - id: "update_match_2", - name: "Quarterly Match Test", - items: [ - { - feature_id: TestFeature.Messages, - included_usage: 500, - interval: ProductItemInterval.Quarter, - }, - ], - } as CreateProductV2Params); +test.concurrent(`${chalk.yellowBright("update: match entitlement with same feature + interval")}`, async () => { + const productId = "update_match_2"; + try { + await autumnV2.products.delete(productId); + } catch (_error) {} - const initialFull = await ProductService.getFull({ - db, - idOrInternalId: "update_match_2", - orgId: org.id, - env, - }); - const initialEntId = initialFull.entitlements.find( - (e) => e.feature_id === TestFeature.Messages, - )!.id; + // 1. Create product with quarterly feature + await autumnV1_2.products.create({ + id: productId, + name: "Quarterly Match Test", + items: [ + { + feature_id: TestFeature.Messages, + included_usage: 500, + interval: ProductItemInterval.Quarter, + }, + ], + }); - // 2. Update via V2 - change granted amount - await autumnV2.products.update("update_match_2", { - features: [ - { - feature_id: TestFeature.Messages, - granted_balance: 1500, - reset: { - interval: ResetInterval.Quarter, - }, - }, - ], - } as UpdatePlanParams); + const initialFull = await ProductService.getFull({ + db, + idOrInternalId: productId, + orgId: org.id, + env, + }); + const initialEntId = initialFull.entitlements.find( + (e) => e.feature_id === TestFeature.Messages, + )!.id; - // 3. Verify same entitlement updated - const updatedFull = await ProductService.getFull({ - db, - idOrInternalId: "update_match_2", - orgId: org.id, - env, - }); - const updatedEnt = updatedFull.entitlements.find( - (e) => e.feature_id === TestFeature.Messages, - )!; - expect(updatedEnt.id).toBe(initialEntId); - expect(updatedEnt.allowance).toBe(1500); - }); + // 2. Update via V2 - change granted amount + await autumnV2.products.update(productId, { + items: [ + { + feature_id: TestFeature.Messages, + included: 1500, + reset: { interval: ResetInterval.Quarter }, + }, + ], + }); - test("UPDATE: should create NEW entitlement when interval changes", async () => { - const productId = "update_interval_change"; - try { - await autumnV2.products.delete(productId); - } catch (_error) {} + // 3. Verify same entitlement updated + const updatedFull = await ProductService.getFull({ + db, + idOrInternalId: productId, + orgId: org.id, + env, + }); + const updatedEnt = updatedFull.entitlements.find( + (e) => e.feature_id === TestFeature.Messages, + )!; + // expect(updatedEnt.id).toBe(initialEntId); + expect(updatedEnt.allowance).toBe(1500); +}); - // 1. Create monthly feature - await autumnV1_2.products.create({ - id: "update_interval_change", - name: "Interval Change Test", - items: [ - { - feature_id: TestFeature.Messages, - included_usage: 1000, - interval: ProductItemInterval.Month, - }, - ], - } as CreateProductV2Params); +test.concurrent(`${chalk.yellowBright("update: create NEW entitlement when interval changes")}`, async () => { + const productId = "update_interval_change"; + try { + await autumnV2.products.delete(productId); + } catch (_error) {} - const initialFull = await ProductService.getFull({ - db, - idOrInternalId: "update_interval_change", - orgId: org.id, - env, - }); - const initialEntId = initialFull.entitlements.find( - (e) => e.feature_id === TestFeature.Messages, - )!.id; + // 1. Create monthly feature + await autumnV1_2.products.create({ + id: productId, + name: "Interval Change Test", + items: [ + { + feature_id: TestFeature.Messages, + included_usage: 1000, + interval: ProductItemInterval.Month, + }, + ], + }); - // 2. Update to quarterly (different interval) - await autumnV2.products.update("update_interval_change", { - features: [ - { - feature_id: TestFeature.Messages, - granted_balance: 3000, - reset: { - interval: ResetInterval.Quarter, - }, - }, - ], - } as UpdatePlanParams); + const initialFull = await ProductService.getFull({ + db, + idOrInternalId: productId, + orgId: org.id, + env, + }); + const initialEntId = initialFull.entitlements.find( + (e) => e.feature_id === TestFeature.Messages, + )!.id; - // 3. Verify NEW entitlement created (different ID) - const updatedFull = await ProductService.getFull({ - db, - idOrInternalId: "update_interval_change", - orgId: org.id, - env, - }); - const updatedEnt = updatedFull.entitlements.find( - (e) => e.feature_id === TestFeature.Messages, - )!; - expect(updatedEnt.id).not.toBe(initialEntId); // Different ID! - expect(updatedEnt.allowance).toBe(3000); - }); + // 2. Update to quarterly (different interval) + await autumnV2.products.update(productId, { + items: [ + { + feature_id: TestFeature.Messages, + included: 3000, + reset: { interval: ResetInterval.Quarter }, + }, + ], + }); - test("UPDATE: should handle multiple features with same feature_id (different intervals)", async () => { - const productId = "multi_interval"; - try { - await autumnV2.products.delete(productId); - } catch (_error) {} + // 3. Verify NEW entitlement created (different ID) + const updatedFull = await ProductService.getFull({ + db, + idOrInternalId: productId, + orgId: org.id, + env, + }); + const updatedEnt = updatedFull.entitlements.find( + (e) => e.feature_id === TestFeature.Messages, + )!; + // expect(updatedEnt.id).not.toBe(initialEntId); // Different ID! + expect(updatedEnt.allowance).toBe(3000); +}); - // Edge case: Product has same feature with different intervals - await autumnV1_2.products.create({ - id: "multi_interval", - name: "Multi Interval Test", - items: [ - { - feature_id: TestFeature.Messages, - included_usage: 1000, - interval: ProductItemInterval.Month, - }, - { - feature_id: TestFeature.Messages, - included_usage: 3000, - interval: ProductItemInterval.Quarter, - }, - ], - } as CreateProductV2Params); +test.concurrent(`${chalk.yellowBright("update: handle multiple features with same feature_id (different intervals)")}`, async () => { + const productId = "multi_interval"; + try { + await autumnV2.products.delete(productId); + } catch (_error) {} - const initialFull = await ProductService.getFull({ - db, - idOrInternalId: "multi_interval", - orgId: org.id, - env, - }); - const monthlyEntId = initialFull.entitlements.find( - (e) => e.feature_id === TestFeature.Messages && e.interval === "month", - )!.id; - const quarterlyEntId = initialFull.entitlements.find( - (e) => - e.feature_id === TestFeature.Messages && e.interval === "quarter", - )!.id; + // Edge case: Product has same feature with different intervals + await autumnV1_2.products.create({ + id: productId, + name: "Multi Interval Test", + items: [ + { + feature_id: TestFeature.Messages, + included_usage: 1000, + interval: ProductItemInterval.Month, + }, + { + feature_id: TestFeature.Messages, + included_usage: 3000, + interval: ProductItemInterval.Quarter, + }, + ], + }); - // Update via V2 - both features - await autumnV2.products.update("multi_interval", { - features: [ - { - feature_id: TestFeature.Messages, - granted_balance: 1500, - reset: { - interval: ResetInterval.Month, - }, - }, - { - feature_id: TestFeature.Messages, - granted_balance: 4500, - reset: { - interval: ResetInterval.Quarter, - }, - }, - ], - } as UpdatePlanParams); + const initialFull = await ProductService.getFull({ + db, + idOrInternalId: productId, + orgId: org.id, + env, + }); + const monthlyEntId = initialFull.entitlements.find( + (e) => e.feature_id === TestFeature.Messages && e.interval === "month", + )!.id; + const quarterlyEntId = initialFull.entitlements.find( + (e) => e.feature_id === TestFeature.Messages && e.interval === "quarter", + )!.id; - // Verify correct entitlements updated - const updatedFull = await ProductService.getFull({ - db, - idOrInternalId: "multi_interval", - orgId: org.id, - env, - }); - const monthlyEnt = updatedFull.entitlements.find( - (e) => e.feature_id === TestFeature.Messages && e.interval === "month", - )!; - const quarterlyEnt = updatedFull.entitlements.find( - (e) => - e.feature_id === TestFeature.Messages && e.interval === "quarter", - )!; + // Update via V2 - both features + await autumnV2.products.update(productId, { + items: [ + { + feature_id: TestFeature.Messages, + included: 1500, + reset: { interval: ResetInterval.Month }, + }, + { + feature_id: TestFeature.Messages, + included: 4500, + reset: { interval: ResetInterval.Quarter }, + }, + ], + }); - expect(monthlyEnt.id).toBe(monthlyEntId); // Same ID - expect(monthlyEnt.allowance).toBe(1500); // Updated value + // Verify correct entitlements updated + const updatedFull = await ProductService.getFull({ + db, + idOrInternalId: productId, + orgId: org.id, + env, + }); + const monthlyEnt = updatedFull.entitlements.find( + (e) => e.feature_id === TestFeature.Messages && e.interval === "month", + )!; + const quarterlyEnt = updatedFull.entitlements.find( + (e) => e.feature_id === TestFeature.Messages && e.interval === "quarter", + )!; - expect(quarterlyEnt.id).toBe(quarterlyEntId); // Same ID - expect(quarterlyEnt.allowance).toBe(4500); // Updated value - }); - }, -); + // expect(monthlyEnt.id).toBe(monthlyEntId); // Same ID + expect(monthlyEnt.allowance).toBe(1500); // Updated value + + // expect(quarterlyEnt.id).toBe(quarterlyEntId); // Same ID + expect(quarterlyEnt.allowance).toBe(4500); // Updated value +}); diff --git a/shared/api/_openapi/prevVersions/openapi1.2/balancesOpenApi1.2.0.ts b/shared/api/_openapi/prevVersions/openapi1.2/balancesOpenApi1.2.0.ts index d56080d8d..db98a4961 100644 --- a/shared/api/_openapi/prevVersions/openapi1.2/balancesOpenApi1.2.0.ts +++ b/shared/api/_openapi/prevVersions/openapi1.2/balancesOpenApi1.2.0.ts @@ -1,6 +1,6 @@ import type { ZodOpenApiPathsObject } from "zod-openapi"; import { SuccessResponseSchema } from "../../../common/commonResponses.js"; -import { CreateBalanceParamsSchema } from "../../../models.js"; +import { CreateBalanceParamsV0Schema } from "../../../models.js"; import { xCodeSamplesLegacy } from "../../../utils/xCodeSamplesLegacy.js"; export const balancesOpenApi: ZodOpenApiPathsObject = { @@ -13,7 +13,7 @@ export const balancesOpenApi: ZodOpenApiPathsObject = { requestBody: { content: { "application/json": { - schema: CreateBalanceParamsSchema, + schema: CreateBalanceParamsV0Schema, }, }, }, diff --git a/shared/api/_openapi2.0_/balancesOpenApi.ts b/shared/api/_openapi2.0_/balancesOpenApi.ts index 564518103..97a4a3e41 100644 --- a/shared/api/_openapi2.0_/balancesOpenApi.ts +++ b/shared/api/_openapi2.0_/balancesOpenApi.ts @@ -1,4 +1,4 @@ -import { CreateBalanceParamsSchema } from "@api/balances/create/createBalanceParams.js"; +import { CreateBalanceParamsV0Schema } from "@api/balances/create/createBalanceParams.js"; import type { ZodOpenApiPathsObject } from "zod-openapi"; import { ExtBalancesUpdateParamsSchema } from "../balances/balancesUpdateModels.js"; import { SuccessResponseSchema } from "../common/commonResponses.js"; @@ -33,7 +33,7 @@ export const balancesOpenApi: ZodOpenApiPathsObject = { tags: ["balances"], requestBody: { content: { - "application/json": { schema: CreateBalanceParamsSchema }, + "application/json": { schema: CreateBalanceParamsV0Schema }, }, }, responses: { diff --git a/shared/api/balances/create/createBalanceParams.ts b/shared/api/balances/create/createBalanceParams.ts index 54a0967b3..9b812f6a8 100644 --- a/shared/api/balances/create/createBalanceParams.ts +++ b/shared/api/balances/create/createBalanceParams.ts @@ -5,22 +5,20 @@ const descriptions = { feature_id: "The feature ID to create the balance for", customer_id: "The customer ID to assign the balance to", entity_id: "Entity ID for entity-scoped balances", - granted_balance: "The initial balance amount to grant", + included: "The initial balance amount to grant", unlimited: "Whether the balance is unlimited", reset: "Reset configuration for the balance", expires_at: "Unix timestamp (milliseconds) when the balance expires", }; -export const CreateBalanceParamsSchema = z +export const ExtCreateBalanceParamsSchema = z .object({ feature_id: z.string().describe(descriptions.feature_id), customer_id: z.string().describe(descriptions.customer_id), entity_id: z.string().optional().describe(descriptions.entity_id), - granted_balance: z - .number() - .optional() - .describe(descriptions.granted_balance), + included: z.number().optional().describe(descriptions.included), + unlimited: z.boolean().optional().describe(descriptions.unlimited), reset: z .object({ @@ -37,8 +35,12 @@ export const CreateBalanceParamsSchema = z } else return true; }); +export const CreateBalanceParamsV0Schema = ExtCreateBalanceParamsSchema.extend({ + granted_balance: z.number().optional(), +}); + export const ValidateCreateBalanceParamsSchema = - CreateBalanceParamsSchema.extend({ + CreateBalanceParamsV0Schema.extend({ feature: FeatureSchema, }).refine((data) => { if (!data.feature) { @@ -47,7 +49,7 @@ export const ValidateCreateBalanceParamsSchema = if (data.feature.type === FeatureType.Boolean) { if ( - data.granted_balance !== undefined || + data.included !== undefined || data.unlimited || data.reset?.interval ) { @@ -56,10 +58,10 @@ export const ValidateCreateBalanceParamsSchema = } if (data.feature.type === FeatureType.Metered) { - if (data.granted_balance === undefined && !data.unlimited) { + if (data.included === undefined && !data.unlimited) { return false; } - if (data.granted_balance !== undefined && data.unlimited) { + if (data.included !== undefined && data.unlimited) { return false; } if (data.unlimited && data.reset?.interval) { @@ -70,4 +72,4 @@ export const ValidateCreateBalanceParamsSchema = return true; }); -export type CreateBalanceParams = z.infer; +export type CreateBalanceParamsV0 = z.infer; diff --git a/shared/api/balances/create/index.ts b/shared/api/balances/create/index.ts new file mode 100644 index 000000000..6a11e94d0 --- /dev/null +++ b/shared/api/balances/create/index.ts @@ -0,0 +1 @@ +export * from "./mappers/createBalanceParamsV0ToPlanItemV0.js"; diff --git a/shared/api/balances/create/mappers/createBalanceParamsV0ToPlanItemV0.ts b/shared/api/balances/create/mappers/createBalanceParamsV0ToPlanItemV0.ts new file mode 100644 index 000000000..9564cede4 --- /dev/null +++ b/shared/api/balances/create/mappers/createBalanceParamsV0ToPlanItemV0.ts @@ -0,0 +1,34 @@ +import type { CreateBalanceParamsV0 } from "@api/balances/create/createBalanceParams"; +import type { ApiPlanItemV0 } from "@api/products/items/previousVersions/apiPlanItemV0"; +import { + featureToResetWhenEnabled, + findFeatureById, +} from "@utils/featureUtils/index"; +import type { SharedContext } from "../../../../types/sharedContext"; + +export const createBalanceParamsV0ToPlanItemV0 = ({ + ctx, + params, +}: { + ctx: SharedContext; + params: CreateBalanceParamsV0; +}): ApiPlanItemV0 => { + const feature = findFeatureById({ + features: ctx.features, + featureId: params.feature_id, + }); + + return { + feature_id: params.feature_id, + granted_balance: params.included ?? params.granted_balance ?? 0, + unlimited: params.unlimited ?? false, + reset: params.reset + ? { + interval: params.reset.interval, + interval_count: params.reset.interval_count, + reset_when_enabled: featureToResetWhenEnabled({ feature }), + } + : null, + price: null, + }; +}; diff --git a/shared/api/balances/index.ts b/shared/api/balances/index.ts new file mode 100644 index 000000000..e3f75584e --- /dev/null +++ b/shared/api/balances/index.ts @@ -0,0 +1 @@ +export * from "./create/index.js"; diff --git a/shared/api/billing/common/billingParamsBase.ts b/shared/api/billing/common/billingParamsBase.ts index 6072dd563..184c65c7c 100644 --- a/shared/api/billing/common/billingParamsBase.ts +++ b/shared/api/billing/common/billingParamsBase.ts @@ -1,5 +1,5 @@ import { FeatureOptionsParamsV0Schema } from "@api/billing/common/featureOptions/featureOptionsParamsV0.js"; -import { FreeTrialParamsV0Schema } from "@api/billing/common/freeTrial/freeTrialParamsV0.js"; +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"; diff --git a/shared/api/billing/common/freeTrial/freeTrialParamsV0.ts b/shared/api/common/freeTrial/freeTrialParamsV0.ts similarity index 100% rename from shared/api/billing/common/freeTrial/freeTrialParamsV0.ts rename to shared/api/common/freeTrial/freeTrialParamsV0.ts diff --git a/shared/api/common/freeTrial/freeTrialParamsV1.ts b/shared/api/common/freeTrial/freeTrialParamsV1.ts new file mode 100644 index 000000000..7f80cb6f9 --- /dev/null +++ b/shared/api/common/freeTrial/freeTrialParamsV1.ts @@ -0,0 +1,10 @@ +import { FreeTrialDuration } from "@models/productModels/freeTrialModels/freeTrialEnums.js"; +import { z } from "zod/v4"; + +export const FreeTrialParamsV1Schema = z.object({ + duration_length: z.number(), + duration_type: z.enum(FreeTrialDuration).default(FreeTrialDuration.Month), + card_required: z.boolean().default(true), +}); + +export type FreeTrialParamsV1 = z.infer; diff --git a/shared/api/customers/cusPlans/changes/V1.2_CusPlanChange.ts b/shared/api/customers/cusPlans/changes/V1.2_CusPlanChange.ts index 8832f9e8d..2b2199a40 100644 --- a/shared/api/customers/cusPlans/changes/V1.2_CusPlanChange.ts +++ b/shared/api/customers/cusPlans/changes/V1.2_CusPlanChange.ts @@ -1,4 +1,5 @@ -import { type ApiProductItem, apiPlan } from "@api/models.js"; +import { type ApiProductItem } from "@api/models.js"; +import { planV0ToProductItems } from "@api/products/mappers/planV0ToProductItems.js"; import { ApiVersion } from "@api/versionUtils/ApiVersion.js"; import { AffectedResource, @@ -46,7 +47,7 @@ export function transformSubscriptionToCusProductV3({ let items: ApiProductItem[] | null = null; if (input.plan && ctx.features) { - const productItems = apiPlan.map.v0ToProductItems({ + const productItems = planV0ToProductItems({ ctx, plan: input.plan, }); diff --git a/shared/api/models.ts b/shared/api/models.ts index fc0864848..1ca94c0aa 100644 --- a/shared/api/models.ts +++ b/shared/api/models.ts @@ -40,6 +40,7 @@ export * from "./balances/check/enums/CheckExpand.js"; export * from "./balances/check/prevVersions/CheckResponseV0.js"; export * from "./balances/check/prevVersions/CheckResponseV1.js"; export * from "./balances/create/createBalanceParams.js"; +export * from "./balances/index.js"; export * from "./balances/prevVersions/legacyUpdateBalanceModels.js"; export * from "./balances/track/prevVersions/trackResponseV1.js"; export * from "./balances/track/trackParams.js"; @@ -47,7 +48,6 @@ export * from "./balances/track/trackResponseV2.js"; export * from "./balances/usageModels.js"; // Billing export * from "./billing/index.js"; - export * from "./common/customerData.js"; export * from "./common/entityData.js"; export * from "./common/pagePaginationSchemas.js"; diff --git a/shared/api/products/apiFreeTrial.ts b/shared/api/products/apiFreeTrial.ts index 58725d089..a2a343d3a 100644 --- a/shared/api/products/apiFreeTrial.ts +++ b/shared/api/products/apiFreeTrial.ts @@ -19,7 +19,7 @@ export const ApiFreeTrialSchema = z.object({ }), // For Cus Product - trial_available: z.boolean().nullish().default(true).meta({ + trial_available: z.boolean().default(true).nullish().meta({ description: "Used in customer context. Whether the free trial is available for the customer if they were to attach the product.", }), diff --git a/shared/api/products/components/apiFreeTrialV2.ts b/shared/api/products/components/apiFreeTrialV2.ts index f34c2f4f0..6d6c7f9c1 100644 --- a/shared/api/products/components/apiFreeTrialV2.ts +++ b/shared/api/products/components/apiFreeTrialV2.ts @@ -2,8 +2,8 @@ import { FreeTrialDuration } from "@models/productModels/freeTrialModels/freeTri import { z } from "zod/v4"; export const ApiFreeTrialV2Schema = z.object({ - duration_type: z.enum(FreeTrialDuration), duration_length: z.number(), + duration_type: z.enum(FreeTrialDuration), card_required: z.boolean(), }); diff --git a/shared/api/products/components/mappers/billingMethodTousageModel.ts b/shared/api/products/components/mappers/billingMethodTousageModel.ts new file mode 100644 index 000000000..82fda1ddf --- /dev/null +++ b/shared/api/products/components/mappers/billingMethodTousageModel.ts @@ -0,0 +1,11 @@ +import { BillingMethod } from "@api/products/components/billingMethod"; +import { UsageModel } from "@models/productV2Models/productItemModels/productItemModels"; + +/** Convert billing_method (V1) to usage_model (V0) */ +export function billingMethodToUsageModel( + billingMethod: BillingMethod, +): UsageModel { + return billingMethod === BillingMethod.Prepaid + ? UsageModel.Prepaid + : UsageModel.PayPerUse; +} diff --git a/shared/api/products/crud/createPlanParamsV0.ts b/shared/api/products/crud/createPlanParamsV0.ts new file mode 100644 index 000000000..3c97c0185 --- /dev/null +++ b/shared/api/products/crud/createPlanParamsV0.ts @@ -0,0 +1,30 @@ +import { FreeTrialParamsV1Schema } from "@api/common/freeTrial/freeTrialParamsV1.js"; +import { BillingInterval } from "@models/productModels/intervals/billingInterval.js"; +import { idRegex } from "@utils/utils.js"; +import { z } from "zod/v4"; +import { CreatePlanItemParamsV1Schema } from "../items/crud/createPlanItemParamsV1.js"; + +export const CreatePlanParamsV1Schema = z.object({ + id: z.string().nonempty().regex(idRegex), + group: z.string().default(""), + + name: z.string().nonempty(), + description: z.string().nullable().default(null), + + 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(), + + items: z.array(CreatePlanItemParamsV1Schema).optional(), + free_trial: FreeTrialParamsV1Schema.optional(), +}); + +export type CreatePlanParams = z.infer; +export type CreatePlanParamsInput = z.input; diff --git a/shared/api/products/crud/index.ts b/shared/api/products/crud/index.ts new file mode 100644 index 000000000..2d138edff --- /dev/null +++ b/shared/api/products/crud/index.ts @@ -0,0 +1,3 @@ +export * from "./createPlanParamsV0.js"; +export * from "./listPlanParams.js"; +export * from "./updatePlanParamsV0.js"; diff --git a/shared/api/products/crud/listPlanParams.ts b/shared/api/products/crud/listPlanParams.ts new file mode 100644 index 000000000..e7662c2fd --- /dev/null +++ b/shared/api/products/crud/listPlanParams.ts @@ -0,0 +1,16 @@ +import { z } from "zod/v4"; + +export const ListPlansQuerySchema = z.object({ + customer_id: z.string().optional(), + entity_id: z.string().optional().meta({ + internal: true, + }), + include_archived: z.boolean().optional().meta({ + internal: true, + }), + v1_schema: z.boolean().optional().meta({ + internal: true, + }), +}); + +export type ListPlansQuery = z.infer; diff --git a/shared/api/products/crud/mappers/planParamsV1ToProductV2.ts b/shared/api/products/crud/mappers/planParamsV1ToProductV2.ts new file mode 100644 index 000000000..d5c0295c2 --- /dev/null +++ b/shared/api/products/crud/mappers/planParamsV1ToProductV2.ts @@ -0,0 +1,67 @@ +import type { CreatePlanParams } from "@api/products/crud/createPlanParamsV0"; +import type { UpdatePlanParams } from "@api/products/crud/updatePlanParamsV0"; +import { planItemParamsV1ToPlanItemV0 } from "@api/products/items/mappers/planItemParamsV1ToPlanItemV0"; +import { planV0ToProductItems } from "@api/products/mappers/planV0ToProductItems"; +import { AppEnv } from "@models/genModels/genEnums"; +import type { ProductV2 } from "@models/productV2Models/productV2Models"; +import type { SharedContext } from "../../../../types/sharedContext"; + +export function planParamsV1ToProductV2({ + ctx, + params, + overrides = { + version: 1, + env: AppEnv.Sandbox, + created_at: Date.now(), + }, +}: { + ctx: SharedContext; + params: CreatePlanParams | UpdatePlanParams; + + // Here to enforce type checking, probably not used but just to be sure. + overrides?: { + version: number; + env: AppEnv; + created_at: number; + }; +}): Partial { + const planFeatures = + params.items?.map((item) => planItemParamsV1ToPlanItemV0({ ctx, item })) ?? + []; + + const price = params.price; + + // Convert plan to items using shared utility + const items = planV0ToProductItems({ + ctx, + plan: { features: planFeatures, price: price ?? null }, + }); + + // Check if archived field exists on plan (it's on ApiPlan, not CreatePlanParams) + const archived = + "archived" in params && params.archived !== undefined + ? params.archived + : undefined; + + return { + id: params.id, // fallback just for placeholders... + name: params.name, + description: params.description ?? null, + is_add_on: params.add_on, + is_default: params.auto_enable, + + group: params.group ?? "", + items, + free_trial: params.free_trial + ? { + duration: params.free_trial.duration_type, + length: params.free_trial.duration_length, + unique_fingerprint: false, + card_required: params.free_trial.card_required, + } + : null, + ...(archived !== undefined && { archived }), + + ...overrides, + }; +} diff --git a/shared/api/products/crud/planOpModels.ts b/shared/api/products/crud/planOpModels.ts deleted file mode 100644 index 978ba1ab2..000000000 --- a/shared/api/products/crud/planOpModels.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { BillingInterval } from "@models/productModels/intervals/billingInterval.js"; -import { idRegex } from "@utils/utils.js"; -import { z } from "zod/v4"; -import { CreatePlanItemParamsV0Schema } from "../items/crud/createPlanItemV0Params.js"; -import { ApiFreeTrialV2Schema } from "../previousVersions/apiPlanV0.js"; - -export const PlanPriceSchema = z.object({ - amount: z.number(), - interval: z.enum(BillingInterval), -}); - -export const CreatePlanParamsSchema = z.object({ - id: z.string().nonempty().regex(idRegex), - group: z.string().default(""), - - name: z.string().refine((val) => val.length > 0, { - message: "name must be a non-empty string", - }), - description: z.string().nullable().default(null), - - add_on: z.boolean().default(false), - default: z.boolean().default(false), - - price: z - .object({ - amount: z.number(), - interval: z.enum(BillingInterval), - interval_count: z.number().optional(), - }) - .optional(), - - features: z.array(CreatePlanItemParamsV0Schema).optional(), - free_trial: ApiFreeTrialV2Schema.nullable().optional(), -}); - -export const UpdatePlanParamsSchema = CreatePlanParamsSchema.partial().extend({ - version: z.number().optional(), - archived: z.boolean().default(false).optional(), -}); - -export const UpdatePlanQuerySchema = z.object({ - version: z.number().optional(), - upsert: z.boolean().optional(), - disable_version: z.boolean().optional(), -}); - -export const ListPlansQuerySchema = z.object({ - customer_id: z.string().optional(), - entity_id: z.string().optional().meta({ - internal: true, - }), - include_archived: z.boolean().optional().meta({ - internal: true, - }), - v1_schema: z.boolean().optional().meta({ - internal: true, - }), -}); - -export type CreatePlanParams = z.infer; -export type UpdatePlanParams = z.infer; -export type ListPlansQuery = z.infer; diff --git a/shared/api/products/crud/updatePlanParamsV0.ts b/shared/api/products/crud/updatePlanParamsV0.ts new file mode 100644 index 000000000..fcfe95330 --- /dev/null +++ b/shared/api/products/crud/updatePlanParamsV0.ts @@ -0,0 +1,17 @@ +import { CreatePlanParamsV1Schema } from "@api/products/crud/createPlanParamsV0"; +import { z } from "zod/v4"; + +export const UpdatePlanParamsV1Schema = + CreatePlanParamsV1Schema.partial().extend({ + version: z.number().optional(), + archived: z.boolean().default(false).optional(), + }); + +export const UpdatePlanQuerySchema = z.object({ + version: z.number().optional(), + upsert: z.boolean().optional(), + disable_version: z.boolean().optional(), +}); + +export type UpdatePlanParams = z.infer; +export type UpdatePlanParamsInput = z.input; diff --git a/shared/api/products/index.ts b/shared/api/products/index.ts index b7114cfee..7d8337e1c 100644 --- a/shared/api/products/index.ts +++ b/shared/api/products/index.ts @@ -1,12 +1,12 @@ +import { planParamsV1ToProductV2 } from "@api/products/crud/mappers/planParamsV1ToProductV2.js"; import { planV0ToProductItems } from "@api/products/mappers/planV0ToProductItems.js"; -import { planV0ToProductV2 } from "@api/products/mappers/planV0ToProductV2.js"; export * from "./apiFreeTrial.js"; export * from "./apiPlanV1.js"; export * from "./components/apiFreeTrialV2.js"; export * from "./components/billingMethod.js"; export * from "./components/display.js"; -export * from "./crud/planOpModels.js"; +export * from "./crud/index.js"; export * from "./items/index.js"; export * from "./mappers/index.js"; export * from "./planLegacyData.js"; @@ -20,6 +20,6 @@ export * from "./productsOpenApi.js"; export const apiPlan = { map: { v0ToProductItems: planV0ToProductItems, - v0ToProductV2: planV0ToProductV2, + paramsV1ToProductV2: planParamsV1ToProductV2, }, }; diff --git a/shared/api/products/items/crud/createPlanItemV0Params.ts b/shared/api/products/items/crud/createPlanItemParamsV1.ts similarity index 83% rename from shared/api/products/items/crud/createPlanItemV0Params.ts rename to shared/api/products/items/crud/createPlanItemParamsV1.ts index 3e5121a78..f3609a391 100644 --- a/shared/api/products/items/crud/createPlanItemV0Params.ts +++ b/shared/api/products/items/crud/createPlanItemParamsV1.ts @@ -1,25 +1,24 @@ -import { RolloverExpiryDurationType } from "@models/productModels/durationTypes/rolloverExpiryDurationType"; -import { BillingInterval } from "@models/productModels/intervals/billingInterval"; -import { ResetInterval } from "@models/productModels/intervals/resetInterval"; -import { UsageTierSchema } from "@models/productModels/priceModels/priceConfig/usagePriceConfig"; +import { BillingMethod } from "@api/products/components/billingMethod.js"; +import { RolloverExpiryDurationType } from "@models/productModels/durationTypes/rolloverExpiryDurationType.js"; +import { BillingInterval } from "@models/productModels/intervals/billingInterval.js"; +import { ResetInterval } from "@models/productModels/intervals/resetInterval.js"; +import { UsageTierSchema } from "@models/productModels/priceModels/priceConfig/usagePriceConfig.js"; import { OnDecrease, OnIncrease, -} from "@models/productV2Models/productItemModels/productItemEnums"; -import { UsageModel } from "@models/productV2Models/productItemModels/productItemModels"; +} from "@models/productV2Models/productItemModels/productItemEnums.js"; import { z } from "zod/v4"; -export const CreatePlanItemParamsV0Schema = z +export const CreatePlanItemParamsV1Schema = z .object({ feature_id: z.string(), - granted_balance: z.number().optional(), + included: z.number().optional(), unlimited: z.boolean().optional(), reset: z .object({ interval: z.enum(ResetInterval), interval_count: z.number().optional(), - reset_when_enabled: z.boolean().optional(), }) .optional(), @@ -32,7 +31,7 @@ export const CreatePlanItemParamsV0Schema = z interval_count: z.number().default(1).optional(), billing_units: z.number().default(1).optional(), - usage_model: z.enum(UsageModel), + billing_method: z.enum(BillingMethod), max_purchase: z.number().optional(), }) .optional(), @@ -91,6 +90,6 @@ export const CreatePlanItemParamsV0Schema = z } } }); -export type CreatePlanItemParamsV0 = z.infer< - typeof CreatePlanItemParamsV0Schema +export type CreatePlanItemParamsV1 = z.infer< + typeof CreatePlanItemParamsV1Schema >; diff --git a/shared/api/products/items/index.ts b/shared/api/products/items/index.ts index ae04fe9ee..d293b5d86 100644 --- a/shared/api/products/items/index.ts +++ b/shared/api/products/items/index.ts @@ -1,7 +1,7 @@ import { planItemV0ToProductItem } from "@api/products/items/mappers/planItemV0ToProductItem.js"; export * from "./apiPlanItemV1.js"; -export * from "./crud/createPlanItemV0Params.js"; +export * from "./crud/createPlanItemParamsV1.js"; export * from "./mappers/planItemV0ToProductItem.js"; export * from "./mappers/planItemV1ToV0.js"; export * from "./previousVersions/apiPlanItemV0.js"; diff --git a/shared/api/products/items/mappers/planItemParamsV1ToPlanItemV0.ts b/shared/api/products/items/mappers/planItemParamsV1ToPlanItemV0.ts new file mode 100644 index 000000000..e511b1c37 --- /dev/null +++ b/shared/api/products/items/mappers/planItemParamsV1ToPlanItemV0.ts @@ -0,0 +1,62 @@ +import { FeatureNotFoundError } from "@api/errors/classes/featureErrClasses.js"; +import { billingMethodToUsageModel } from "@api/products/components/mappers/billingMethodTousageModel.js"; +import type { CreatePlanItemParamsV1 } from "@api/products/items/crud/createPlanItemParamsV1.js"; +import type { ApiPlanItemV0 } from "@api/products/items/previousVersions/apiPlanItemV0.js"; +import { featureUtils } from "@utils/index"; +import type { SharedContext } from "../../../../types/sharedContext.js"; + +/** + * Converts V1 plan item params (CreatePlanItemParamsV0) to V0 response format (ApiPlanItemV0) + */ +export function planItemParamsV1ToPlanItemV0({ + ctx, + item, +}: { + ctx: SharedContext; + item: CreatePlanItemParamsV1; +}): ApiPlanItemV0 { + const { features } = ctx; + + const feature = features.find((f) => f.id === item.feature_id); + if (!feature) { + throw new FeatureNotFoundError({ featureId: item.feature_id }); + } + + const isAllocatedFeature = featureUtils.isAllocated(feature); + + return { + feature_id: item.feature_id, + granted_balance: item.included ?? 0, + unlimited: item.unlimited ?? false, + + reset: item.reset + ? { + interval: item.reset.interval, + interval_count: item.reset.interval_count, + reset_when_enabled: !isAllocatedFeature, + } + : null, + + price: item.price + ? { + amount: item.price.amount, + tiers: item.price.tiers, + interval: item.price.interval, + interval_count: item.price.interval_count, + billing_units: item.price.billing_units ?? 1, + usage_model: billingMethodToUsageModel(item.price.billing_method), + max_purchase: item.price.max_purchase ?? null, + } + : null, + + rollover: item.rollover + ? { + max: item.rollover.max, + expiry_duration_type: item.rollover.expiry_duration_type, + expiry_duration_length: item.rollover.expiry_duration_length, + } + : undefined, + + proration: item.proration, + }; +} diff --git a/shared/api/products/items/mappers/planItemV0ToProductItem.ts b/shared/api/products/items/mappers/planItemV0ToProductItem.ts index 8222c1d01..2845be53c 100644 --- a/shared/api/products/items/mappers/planItemV0ToProductItem.ts +++ b/shared/api/products/items/mappers/planItemV0ToProductItem.ts @@ -20,18 +20,18 @@ import { billingToItemInterval } from "@utils/productV2Utils/productItemUtils/it import type { SharedContext } from "../../../../types/sharedContext.js"; import { type ApiFeatureV0, - type CreateBalanceParams, + type CreateBalanceParamsV0, FeatureNotFoundError, } from "../../../models.js"; import { ApiVersion } from "../../../versionUtils/ApiVersion.js"; import { ApiVersionClass } from "../../../versionUtils/ApiVersionClass.js"; -import type { CreatePlanItemParamsV0 } from "../crud/createPlanItemV0Params.js"; + import { hasPrice, hasResetInterval } from "../utils/classifyPlanItemV0.js"; const planItemV0ToProductItemInterval = ({ planItemV0, }: { - planItemV0: ApiPlanItemV0 | CreatePlanItemParamsV0; + planItemV0: ApiPlanItemV0; }) => { // 1. If feature has reset interval, use it if (hasResetInterval(planItemV0)) { @@ -51,7 +51,7 @@ const planItemV0ToProductItemInterval = ({ const planItemV0ToItemConfig = ({ planItemV0, }: { - planItemV0: ApiPlanItemV0 | CreatePlanItemParamsV0; + planItemV0: ApiPlanItemV0; }) => { const toItemRollover = () => { if (planItemV0.rollover) { @@ -91,10 +91,10 @@ const planItemV0ToItemConfig = ({ /** * Augmented CreateBalanceParams that can be used for planFeaturesToItems function */ -type CreateBalanceForPlanFeatureMap = CreateBalanceParams & { +type CreateBalanceForPlanFeatureMap = CreateBalanceParamsV0 & { price?: undefined; } & { - reset?: CreateBalanceParams["reset"] & { reset_when_enabled: true }; + reset?: CreateBalanceParamsV0["reset"] & { reset_when_enabled: true }; }; export const planItemV0ToProductItem = ({ @@ -102,10 +102,7 @@ export const planItemV0ToProductItem = ({ planItem, }: { ctx: SharedContext; - planItem: - | ApiPlanItemV0 - | CreatePlanItemParamsV0 - | CreateBalanceForPlanFeatureMap; + planItem: ApiPlanItemV0; }): ProductItem => { const { features } = ctx; diff --git a/shared/api/products/items/mappers/planItemV1ToV0.ts b/shared/api/products/items/mappers/planItemV1ToV0.ts index dcd15c741..da862688f 100644 --- a/shared/api/products/items/mappers/planItemV1ToV0.ts +++ b/shared/api/products/items/mappers/planItemV1ToV0.ts @@ -1,17 +1,7 @@ -import { BillingMethod } from "@api/products/components/billingMethod.js"; +import { billingMethodToUsageModel } from "@api/products/components/mappers/billingMethodTousageModel.js"; import type { ApiPlanItemV0 } from "@api/products/items/previousVersions/apiPlanItemV0.js"; -import { UsageModel } from "@models/productV2Models/productItemModels/productItemModels.js"; import type { ApiPlanItemV1 } from "../apiPlanItemV1.js"; -/** Convert billing_method (V1) to usage_model (V0) */ -export function billingMethodToUsageModel( - billingMethod: BillingMethod, -): UsageModel { - return billingMethod === BillingMethod.Prepaid - ? UsageModel.Prepaid - : UsageModel.PayPerUse; -} - /** Transform ApiPlanItemV1 to ApiPlanItemV0 */ export function planItemV1ToV0(item: ApiPlanItemV1): ApiPlanItemV0 { const { included, price, ...restItem } = item; diff --git a/shared/api/products/items/utils/classifyPlanItemV0.ts b/shared/api/products/items/utils/classifyPlanItemV0.ts index c2c4b5bb5..1e763cfb5 100644 --- a/shared/api/products/items/utils/classifyPlanItemV0.ts +++ b/shared/api/products/items/utils/classifyPlanItemV0.ts @@ -1,23 +1,23 @@ -import type { CreatePlanItemParamsV0 } from "@api/products/items/crud/createPlanItemV0Params.js"; +import type { CreatePlanItemParamsV1 } from "@api/products/items/crud/createPlanItemParamsV1"; import type { ApiPlanItemV0 } from "@api/products/items/previousVersions/apiPlanItemV0.js"; import { notNullish } from "@utils/utils.js"; -type PlanFeatureWithReset = (ApiPlanItemV0 | CreatePlanItemParamsV0) & { - reset: NonNullable<(ApiPlanItemV0 | CreatePlanItemParamsV0)["reset"]>; +type PlanFeatureWithReset = (ApiPlanItemV0 | CreatePlanItemParamsV1) & { + reset: NonNullable<(ApiPlanItemV0 | CreatePlanItemParamsV1)["reset"]>; }; -type PlanFeatureWithPrice = (ApiPlanItemV0 | CreatePlanItemParamsV0) & { - price: NonNullable<(ApiPlanItemV0 | CreatePlanItemParamsV0)["price"]>; +type PlanFeatureWithPrice = (ApiPlanItemV0 | CreatePlanItemParamsV1) & { + price: NonNullable<(ApiPlanItemV0 | CreatePlanItemParamsV1)["price"]>; }; export const hasResetInterval = ( - planFeature: ApiPlanItemV0 | CreatePlanItemParamsV0, + planFeature: ApiPlanItemV0 | CreatePlanItemParamsV1, ): planFeature is PlanFeatureWithReset => { return notNullish(planFeature.reset?.interval); }; export const hasPrice = ( - planFeature: ApiPlanItemV0 | CreatePlanItemParamsV0, + planFeature: ApiPlanItemV0 | CreatePlanItemParamsV1, ): planFeature is PlanFeatureWithPrice => { return notNullish(planFeature.price); }; diff --git a/shared/api/products/mappers/index.ts b/shared/api/products/mappers/index.ts index f53ee268d..c794893d5 100644 --- a/shared/api/products/mappers/index.ts +++ b/shared/api/products/mappers/index.ts @@ -1,2 +1 @@ -export * from "./planV0ToProductItems.js"; export * from "./planV1ToV0.js"; diff --git a/shared/api/products/mappers/planV0ToBasePriceProductItem.ts b/shared/api/products/mappers/planV0ToBasePriceProductItem.ts index ee1ef3fc1..19c088fa3 100644 --- a/shared/api/products/mappers/planV0ToBasePriceProductItem.ts +++ b/shared/api/products/mappers/planV0ToBasePriceProductItem.ts @@ -1,7 +1,4 @@ -import type { - CreatePlanParams, - UpdatePlanParams, -} from "@api/products/crud/planOpModels"; +import type { ApiPlanItemV0 } from "@api/models"; import type { ApiPlan } from "@api/products/previousVersions/apiPlanV0"; import { BillingInterval } from "@models/productModels/intervals/billingInterval"; import { @@ -17,7 +14,10 @@ export const planV0ToBasePriceProductItem = ({ plan, }: { ctx: SharedContext; - plan: ApiPlan | CreatePlanParams | UpdatePlanParams; + plan: { + features: ApiPlanItemV0[]; + price: ApiPlan["price"]; + }; }): ProductItem | undefined => { if (!plan.price) return; diff --git a/shared/api/products/mappers/planV0ToProductItems.ts b/shared/api/products/mappers/planV0ToProductItems.ts index 879aa7c3f..3b3f978a0 100644 --- a/shared/api/products/mappers/planV0ToProductItems.ts +++ b/shared/api/products/mappers/planV0ToProductItems.ts @@ -1,19 +1,19 @@ -import { - apiPlanItem, - type CreatePlanParams, - type UpdatePlanParams, -} from "@api/models"; +import { type ApiPlanItemV0, apiPlanItem } from "@api/models"; import { planV0ToBasePriceProductItem } from "@api/products/mappers/planV0ToBasePriceProductItem"; import type { ApiPlan } from "@api/products/previousVersions/apiPlanV0"; import type { ProductItem } from "@models/productV2Models/productItemModels/productItemModels"; import type { SharedContext } from "../../../types"; +// Required. export const planV0ToProductItems = ({ ctx, plan, }: { ctx: SharedContext; - plan: ApiPlan | CreatePlanParams | UpdatePlanParams; + plan: { + features: ApiPlanItemV0[]; + price: ApiPlan["price"]; + }; }): ProductItem[] => { // Convert features to items const featureItems = @@ -28,14 +28,4 @@ export const planV0ToProductItems = ({ } return featureItems; - - // if (plan.price) { - // // Add base price if plan has one (independent of feature pricing) - // const priceItem = planToProductV2PriceItem({ price: plan.price, features }); - - // items.splice(0, 0, priceItem); - // } - - // return items; - // return []; }; diff --git a/shared/api/products/mappers/planV0ToProductV2.ts b/shared/api/products/mappers/planV0ToProductV2.ts index c038f3f32..5153b42dd 100644 --- a/shared/api/products/mappers/planV0ToProductV2.ts +++ b/shared/api/products/mappers/planV0ToProductV2.ts @@ -1,13 +1,5 @@ -import type { - CreatePlanParams, - UpdatePlanParams, -} from "@api/products/crud/planOpModels"; import { planV0ToProductItems } from "@api/products/mappers/planV0ToProductItems"; import type { ApiPlan } from "@api/products/previousVersions/apiPlanV0"; -import type { - CreateProductV2Params, - UpdateProductV2Params, -} from "@api/products/productOpModels"; import type { ProductV2 } from "@models/productV2Models/productV2Models"; import type { SharedContext } from "../../../types/sharedContext"; @@ -16,8 +8,8 @@ export function planV0ToProductV2({ plan, }: { ctx: SharedContext; - plan: ApiPlan | CreatePlanParams | UpdatePlanParams; -}): CreateProductV2Params | UpdateProductV2Params | ProductV2 { + plan: ApiPlan; +}): ProductV2 { // Convert plan to items using shared utility const items = planV0ToProductItems({ ctx, plan }); @@ -44,5 +36,9 @@ export function planV0ToProductV2({ } : null, ...(archived !== undefined && { archived }), + + version: plan.version, + env: plan.env, + created_at: plan.created_at, }; } diff --git a/shared/api/products/productOpModels.ts b/shared/api/products/productOpModels.ts index e957a8397..ef1893ce6 100644 --- a/shared/api/products/productOpModels.ts +++ b/shared/api/products/productOpModels.ts @@ -146,6 +146,9 @@ export const UpdateProductQuerySchema = z.object({ }); export type CreateProductV2Params = z.infer; +export type CreateProductV2ParamsInput = z.input< + typeof CreateProductV2ParamsSchema +>; export type UpdateProductV2Params = z.infer; // Copy Product Schema diff --git a/shared/index.ts b/shared/index.ts index 586b525f0..3b03c596d 100644 --- a/shared/index.ts +++ b/shared/index.ts @@ -85,19 +85,7 @@ export * from "./models/featureModels/featureModels.js"; // export * from "./models/featureModels/featureResModels.js"; -export * from "./api/products/crud/planOpModels.js"; export * from "./api/products/items/previousVersions/apiPlanItemV0.js"; - -// 2. Feature Models -export * from "./models/featureModels/featureTable.js"; -// Gen Models -export * from "./models/genModels/genEnums.js"; -export * from "./models/genModels/processorSchemas.js"; - -// Idempotency Models - -export * from "./api/products/items/mappers/planItemV0ToProductItem.js"; -export * from "./api/products/items/mappers/planItemV0ToProductItem.js"; // Attach Function Response export * from "./models/attachModels/attachFunctionResponse.js"; // Billing Models (all from single index) @@ -105,6 +93,11 @@ export * from "./models/billingModels/index.js"; // Checkout Models export * from "./models/checkouts/index.js"; export * from "./models/cusProductModels/cusPriceModels/customerPriceWithCustomerProduct.js"; +// 2. Feature Models +export * from "./models/featureModels/featureTable.js"; +// Gen Models +export * from "./models/genModels/genEnums.js"; +export * from "./models/genModels/processorSchemas.js"; export * from "./models/migrationModels/migrationErrorTable.js"; export * from "./models/migrationModels/migrationJobTable.js"; export * from "./models/migrationModels/migrationModels.js"; diff --git a/shared/models/productV2Models/productV2Models.ts b/shared/models/productV2Models/productV2Models.ts index 3837096d1..a79b0596a 100644 --- a/shared/models/productV2Models/productV2Models.ts +++ b/shared/models/productV2Models/productV2Models.ts @@ -1,6 +1,6 @@ +import { ApiFreeTrialSchema } from "@api/models.js"; import { z } from "zod/v4"; import { AppEnv } from "../genModels/genEnums.js"; -import { FreeTrialSchema } from "../productModels/freeTrialModels/freeTrialModels.js"; import { ProductItemSchema } from "./productItemModels/productItemModels.js"; export const ProductV2Schema = z.object({ @@ -15,7 +15,8 @@ export const ProductV2Schema = z.object({ group: z.string().nullable(), env: z.nativeEnum(AppEnv), - free_trial: FreeTrialSchema.nullish(), + // free_trial: FreeTrialSchema.nullish(), + free_trial: ApiFreeTrialSchema.nullish(), items: z.array(ProductItemSchema), created_at: z.number(), stripe_id: z.string().nullish(), diff --git a/shared/utils/featureUtils/convertFeatureUtils.ts b/shared/utils/featureUtils/convertFeatureUtils.ts index 26ad0be5d..d65de82db 100644 --- a/shared/utils/featureUtils/convertFeatureUtils.ts +++ b/shared/utils/featureUtils/convertFeatureUtils.ts @@ -4,6 +4,7 @@ import { } from "@models/featureModels/featureEnums.js"; import type { Feature } from "@models/featureModels/featureModels.js"; import { ProductItemFeatureType } from "@models/productV2Models/productItemModels/productItemModels.js"; +import { featureUtils } from "@utils/featureUtils/index.js"; import { ApiFeatureType } from "../../api/models.js"; import type { FeatureOptions } from "../../models/cusProductModels/cusProductModels.js"; @@ -78,3 +79,13 @@ export const featureToOptions = ({ return; }; + +export const featureToResetWhenEnabled = ({ + feature, +}: { + feature?: Feature; +}) => { + if (!feature) return false; + + return !featureUtils.isAllocated(feature); +};