diff --git a/server/src/internal/balances/createBalance/prepareNewBalanceForInsertion.ts b/server/src/internal/balances/createBalance/prepareNewBalanceForInsertion.ts index f45ed71f2..c4ba0287b 100644 --- a/server/src/internal/balances/createBalance/prepareNewBalanceForInsertion.ts +++ b/server/src/internal/balances/createBalance/prepareNewBalanceForInsertion.ts @@ -1,9 +1,9 @@ import { + apiPlanItem, type CreateBalanceParams, enrichEntitlementWithFeature, type Feature, type FullCustomer, - planFeaturesToItems, } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv"; import { initCusEntitlement } from "@/internal/customers/add-product/initCusEnt"; @@ -21,13 +21,13 @@ export const prepareNewBalanceForInsertion = async ({ fullCustomer: FullCustomer; params: CreateBalanceParams; }) => { - const inputAsItem = planFeaturesToItems({ - features: [feature], - planFeatures: [params], + const inputAsItem = apiPlanItem.map.v0ToProductItem({ + ctx, + planItem: params, }); const { ent: newEntitlement } = toFeature({ - item: inputAsItem[0], + item: inputAsItem, orgId: ctx.org.id, isCustom: true, internalFeatureId: feature.internal_id!, diff --git a/server/src/internal/balances/utils/handleThresholdReached.ts b/server/src/internal/balances/utils/handleThresholdReached.ts index 397a39d54..100403890 100644 --- a/server/src/internal/balances/utils/handleThresholdReached.ts +++ b/server/src/internal/balances/utils/handleThresholdReached.ts @@ -93,6 +93,7 @@ const handleAllowanceUsed = async ({ legacyData: newLegacyData, }), feature: dbToApiFeatureV1({ + ctx, dbFeature: feature, targetVersion: ctx.apiVersion, }), @@ -157,6 +158,7 @@ export const handleThresholdReached = async ({ legacyData: newLegacyData, }), feature: dbToApiFeatureV1({ + ctx, dbFeature: feature, targetVersion: ctx.apiVersion, }), diff --git a/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalance.ts b/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalance.ts index 33a2b4bc0..cb7417356 100644 --- a/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalance.ts +++ b/server/src/internal/customers/cusUtils/apiCusUtils/getApiBalance/getApiBalance.ts @@ -135,7 +135,7 @@ export const getApiBalance = ({ expand: ctx.expand, includes: [CheckExpand.BalanceFeature, CusExpand.BalancesFeature], }) - ? dbToApiFeatureV1({ dbFeature: feature }) + ? dbToApiFeatureV1({ ctx, dbFeature: feature }) : undefined; // 1. If feature is boolean diff --git a/server/src/internal/features/handlers/handleCreateFeature.ts b/server/src/internal/features/handlers/handleCreateFeature.ts index 380c4e76a..6dd72d876 100644 --- a/server/src/internal/features/handlers/handleCreateFeature.ts +++ b/server/src/internal/features/handlers/handleCreateFeature.ts @@ -37,7 +37,7 @@ export const handleCreateFeature = createRoute({ } return c.json( - dbToApiFeatureV1({ dbFeature, targetVersion: ctx.apiVersion }), + dbToApiFeatureV1({ ctx, dbFeature, targetVersion: ctx.apiVersion }), ); }, }); diff --git a/server/src/internal/features/handlers/handleGetFeature.ts b/server/src/internal/features/handlers/handleGetFeature.ts index ccef8fcad..65908fbc7 100644 --- a/server/src/internal/features/handlers/handleGetFeature.ts +++ b/server/src/internal/features/handlers/handleGetFeature.ts @@ -33,6 +33,7 @@ export const handleGetFeature = createRoute({ } const apiFeature = dbToApiFeatureV1({ + ctx, dbFeature: feature, targetVersion: ctx.apiVersion, }); diff --git a/server/src/internal/features/handlers/handleListFeatures.ts b/server/src/internal/features/handlers/handleListFeatures.ts index b6b721224..b4b4a8f1d 100644 --- a/server/src/internal/features/handlers/handleListFeatures.ts +++ b/server/src/internal/features/handlers/handleListFeatures.ts @@ -7,7 +7,11 @@ export const handleListFeatures = createRoute({ const ctx = c.get("ctx"); const apiFeatures = ctx.features.map((feature) => - dbToApiFeatureV1({ dbFeature: feature, targetVersion: ctx.apiVersion }), + dbToApiFeatureV1({ + ctx, + dbFeature: feature, + targetVersion: ctx.apiVersion, + }), ); return c.json({ list: apiFeatures }); diff --git a/server/src/internal/features/handlers/handleUpdateFeature.ts b/server/src/internal/features/handlers/handleUpdateFeature.ts index e9d079be5..73cedc747 100644 --- a/server/src/internal/features/handlers/handleUpdateFeature.ts +++ b/server/src/internal/features/handlers/handleUpdateFeature.ts @@ -65,6 +65,7 @@ export const handleUpdateFeature = createRoute({ return c.json( dbToApiFeatureV1({ + ctx, dbFeature: updatedFeature, targetVersion: ctx.apiVersion, }), diff --git a/server/src/internal/products/handlers/handleCreatePlan.ts b/server/src/internal/products/handlers/handleCreatePlan.ts index 99371fb67..c62c57e51 100644 --- a/server/src/internal/products/handlers/handleCreatePlan.ts +++ b/server/src/internal/products/handlers/handleCreatePlan.ts @@ -2,6 +2,7 @@ import { AffectedResource, type ApiPlan, ApiVersion, + apiPlan, applyResponseVersionChanges, CreatePlanParamsSchema, type CreateProductV2Params, @@ -11,7 +12,6 @@ import { type FullProduct, type Price, ProductAlreadyExistsError, - planToProductV2, } from "@autumn/shared"; import { createRoute } from "@/honoMiddlewares/routeHandler.js"; @@ -23,6 +23,7 @@ import { handleNewFreeTrial, validateOneOffTrial, } from "../free-trials/freeTrialUtils.js"; + import { ProductService } from "../ProductService.js"; import { handleNewProductItems } from "../product-items/productItemUtils/handleNewProductItems.js"; import { getPlanResponse } from "../productUtils/productResponseUtils/getPlanResponse.js"; @@ -45,7 +46,7 @@ export const handleCreatePlan = createRoute({ const v1_2Body = ( ctx.apiVersion.gte(ApiVersion.V2_0) - ? planToProductV2({ plan: body, features: ctx.features }) + ? apiPlan.map.v0ToProductV2({ ctx, plan: body }) : body ) as CreateProductV2Params; diff --git a/server/src/internal/products/handlers/handlePlanHasCustomersV2.ts b/server/src/internal/products/handlers/handlePlanHasCustomersV2.ts index 5d4f1be9d..5858998c0 100644 --- a/server/src/internal/products/handlers/handlePlanHasCustomersV2.ts +++ b/server/src/internal/products/handlers/handlePlanHasCustomersV2.ts @@ -1,9 +1,8 @@ import { ApiVersion, - type CreatePlanParams, + apiPlan, ProductNotFoundError, type ProductV2, - planToProductV2, productsAreSame, } from "@autumn/shared"; import { createRoute } from "@/honoMiddlewares/routeHandler"; @@ -38,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) - ? (planToProductV2({ - plan: body as CreatePlanParams, - features, + ? (apiPlan.map.v0ToProductV2({ + ctx, + plan: 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 b3f09fa72..4b659a08e 100644 --- a/server/src/internal/products/handlers/handleUpdateProduct/handleUpdatePlan.ts +++ b/server/src/internal/products/handlers/handleUpdateProduct/handleUpdatePlan.ts @@ -3,13 +3,13 @@ import { type ApiPlan, ApiVersion, ApiVersionClass, + apiPlan, applyResponseVersionChanges, type FreeTrial, mapToProductV2, notNullish, ProductNotFoundError, type ProductV2, - planToProductV2, productsAreSame, RecaseError, UpdatePlanParamsSchema, @@ -59,7 +59,10 @@ export const handleUpdatePlan = createRoute({ // V1.2 clients already send ProductV2, no conversion needed const v1_2Body = ctx.apiVersion.gte(new ApiVersionClass(ApiVersion.V2_0)) - ? planToProductV2({ plan: body as ApiPlan, features: ctx.features }) + ? (apiPlan.map.v0ToProductV2({ + ctx, + plan: body, + }) as UpdateProductV2Params) : (body as UpdateProductV2Params); const [fullProduct, rewardPrograms, _defaultProds] = await Promise.all([ diff --git a/server/src/internal/products/productUtils/productResponseUtils/getProductResponse.ts b/server/src/internal/products/productUtils/productResponseUtils/getProductResponse.ts index c64a05e34..d1dad41ce 100644 --- a/server/src/internal/products/productUtils/productResponseUtils/getProductResponse.ts +++ b/server/src/internal/products/productUtils/productResponseUtils/getProductResponse.ts @@ -1,7 +1,7 @@ import { type ApiFreeTrial, ApiFreeTrialSchema, - ApiProductItemSchema, + ApiProductItemV0Schema, ApiProductPropertiesSchema, ApiProductSchema, AttachScenario, @@ -71,7 +71,7 @@ export const getProductItemResponse = ({ } const feature = features.find((f) => f.id === item.feature_id); - return ApiProductItemSchema.parse({ + return ApiProductItemV0Schema.parse({ type, ...item, feature: feature ? toApiFeature({ feature }) : null, diff --git a/shared/api/_openapi/prevVersions/openapi1.2/openapi1.2.0.ts b/shared/api/_openapi/prevVersions/openapi1.2/openapi1.2.0.ts index ec154af41..9e3dfb4fe 100644 --- a/shared/api/_openapi/prevVersions/openapi1.2/openapi1.2.0.ts +++ b/shared/api/_openapi/prevVersions/openapi1.2/openapi1.2.0.ts @@ -7,7 +7,7 @@ import { EntityDataSchema } from "../../../common/entityData.js"; import { ApiCusProductV3Schema } from "../../../customers/cusPlans/previousVersions/apiCusProductV3.js"; import { ApiCusFeatureV3Schema, - ApiProductItemSchema, + ApiProductItemV0Schema, } from "../../../models.js"; import { balancesOpenApi } from "./balancesOpenApi1.2.0.js"; import { coreOpenApi } from "./coreOpenApi.js"; @@ -54,7 +54,7 @@ const OPENAPI_1_2_0 = createDocument( description: "Customer feature object returned by the API", }), Product: ApiProductWithMeta, - ProductItem: ApiProductItemSchema, + ProductItem: ApiProductItemV0Schema, Feature: ApiFeatureWithMeta, Entity: ApiEntityWithMeta, }, diff --git a/shared/api/_openapi2.0_/openapi2.0.ts b/shared/api/_openapi2.0_/openapi2.0.ts index 9a8e23802..50f06a20f 100644 --- a/shared/api/_openapi2.0_/openapi2.0.ts +++ b/shared/api/_openapi2.0_/openapi2.0.ts @@ -1,5 +1,5 @@ import { writeFileSync } from "node:fs"; -import { ApiPlanFeatureWithMeta } from "@api/products/planFeature/apiPlanFeature.js"; +import { ApiPlanItemV0WithMeta } from "@api/products/items/apiPlanItemV0.js"; import yaml from "yaml"; import { createDocument } from "zod-openapi"; import { CustomerDataSchema } from "../common/customerData.js"; @@ -41,7 +41,7 @@ const openapi2_0 = createDocument( CustomerData: CustomerDataSchema, EntityData: EntityDataSchema, Plan: ApiPlanWithMeta, - PlanFeature: ApiPlanFeatureWithMeta, + PlanFeature: ApiPlanItemV0WithMeta, Customer: ApiCustomerSchema, BaseCustomer: BaseApiCustomerSchema, Entity: ApiEntityWithMeta, diff --git a/shared/api/_openapi2.0_/plansOpenApi.ts b/shared/api/_openapi2.0_/plansOpenApi.ts index 8e3cf4460..6f87defc0 100644 --- a/shared/api/_openapi2.0_/plansOpenApi.ts +++ b/shared/api/_openapi2.0_/plansOpenApi.ts @@ -5,7 +5,7 @@ import { CreatePlanParamsSchema, ListPlansQuerySchema, UpdatePlanParamsSchema, -} from "../products/planOpModels.js"; +} from "../products/crud/planOpModels.js"; export const ApiPlanWithMeta = ApiPlanSchema.meta({ id: "Plan", diff --git a/shared/api/balances/check/checkFeaturePreview.ts b/shared/api/balances/check/checkFeaturePreview.ts index 6b1ead40a..5420e75e7 100644 --- a/shared/api/balances/check/checkFeaturePreview.ts +++ b/shared/api/balances/check/checkFeaturePreview.ts @@ -1,5 +1,5 @@ +import { ApiProductSchema } from "@api/products/previousVersions/apiProduct"; import { z } from "zod/v4"; -import { ApiProductSchema } from "../../models.js"; // Check Feature Preview Schemas export const CheckFeaturePreviewSchema = z.object({ diff --git a/shared/api/billing/checkout/prevVersions/checkoutResponseV0.ts b/shared/api/billing/checkout/prevVersions/checkoutResponseV0.ts index 60e65ecaa..d3bc8794b 100644 --- a/shared/api/billing/checkout/prevVersions/checkoutResponseV0.ts +++ b/shared/api/billing/checkout/prevVersions/checkoutResponseV0.ts @@ -1,4 +1,4 @@ -import { ApiProductItemSchema } from "@api/products/planFeature/previousVersions/apiProductItem.js"; +import { ApiProductItemV0Schema } from "@api/products/items/previousVersions/apiProductItemV0.js"; import { ApiProductSchema } from "@api/products/previousVersions/apiProduct.js"; import { FeatureOptionsSchema } from "@models/cusProductModels/cusProductModels.js"; import { z } from "zod/v4"; @@ -7,7 +7,7 @@ import { z } from "zod/v4"; export const CheckoutLineV0Schema = z.object({ description: z.string(), amount: z.number(), - item: ApiProductItemSchema.nullish(), + item: ApiProductItemV0Schema.nullish(), }); export const CheckoutResponseV0Schema = z.object({ diff --git a/shared/api/billing/common/planOverride.ts b/shared/api/billing/common/planOverride.ts index 7efd6e97c..551512e6e 100644 --- a/shared/api/billing/common/planOverride.ts +++ b/shared/api/billing/common/planOverride.ts @@ -1,12 +1,12 @@ +import { CreatePlanItemParamsV0Schema } from "@api/products/items/crud/createPlanItemV0Params.js"; import { z } from "zod/v4"; import { ApiFreeTrialV2Schema } from "../../models.js"; -import { UpdatePlanFeatureSchema } from "../../products/planFeature/planFeatureOpModels.js"; -import { PlanPriceSchema } from "../../products/planOpModels.js"; +import { PlanPriceSchema } from "../../products/crud/planOpModels.js"; export const PlanOverrideSchema = z .object({ price: PlanPriceSchema.optional(), - features: z.array(UpdatePlanFeatureSchema).optional(), + features: z.array(CreatePlanItemParamsV0Schema).optional(), free_trial: ApiFreeTrialV2Schema.nullable().optional(), }) .refine( diff --git a/shared/api/customers/cusPlans/changes/V0.2_CusProductChange.ts b/shared/api/customers/cusPlans/changes/V0.2_CusProductChange.ts index 4483d8ba4..33c00b698 100644 --- a/shared/api/customers/cusPlans/changes/V0.2_CusProductChange.ts +++ b/shared/api/customers/cusPlans/changes/V0.2_CusProductChange.ts @@ -1,4 +1,4 @@ -import type { ApiProductItemSchema } from "@api/products/planFeature/previousVersions/apiProductItem.js"; +import type { ApiProductItemV0Schema } from "@api/products/items/previousVersions/apiProductItemV0.js"; import { ApiVersion } from "@api/versionUtils/ApiVersion.js"; import { AffectedResource, @@ -25,7 +25,7 @@ import { ApiCusProductV2Schema } from "../previousVersions/apiCusProductV2.js"; const transformItemToPrice = ({ item, }: { - item: z.infer; + item: z.infer; }) => { const singleTier = isPriceItem(item) || diff --git a/shared/api/customers/cusPlans/changes/V1.2_CusPlanChange.ts b/shared/api/customers/cusPlans/changes/V1.2_CusPlanChange.ts index 474c68c63..8832f9e8d 100644 --- a/shared/api/customers/cusPlans/changes/V1.2_CusPlanChange.ts +++ b/shared/api/customers/cusPlans/changes/V1.2_CusPlanChange.ts @@ -1,14 +1,13 @@ -import type { ApiProductItem } from "@api/models.js"; +import { type ApiProductItem, apiPlan } from "@api/models.js"; import { ApiVersion } from "@api/versionUtils/ApiVersion.js"; import { AffectedResource, defineVersionChange, - type VersionContext, } from "@api/versionUtils/versionChangeUtils/VersionChange.js"; import { CusProductStatus } from "@models/cusProductModels/cusProductEnums.js"; -import { convertPlanToItems } from "@utils/planFeatureUtils/planToItems.js"; import { getProductItemResponse } from "@utils/productV2Utils/productItemUtils/getProductItemRes.js"; import type { z } from "zod/v4"; +import type { SharedContext } from "../../../../types/sharedContext.js"; import { type ApiSubscription, ApiSubscriptionSchema, @@ -27,7 +26,7 @@ export function transformSubscriptionToCusProductV3({ }: { input: z.infer; legacyData?: z.infer; - ctx: VersionContext; + ctx: SharedContext; }): z.infer { const cusPlanToCusProductV3Status = (plan: ApiSubscription) => { if (plan.status === CusProductStatus.Active) { @@ -46,13 +45,10 @@ export function transformSubscriptionToCusProductV3({ let items: ApiProductItem[] | null = null; - // Get features - - // console.log("Features:", ctx.features); if (input.plan && ctx.features) { - const productItems = convertPlanToItems({ + const productItems = apiPlan.map.v0ToProductItems({ + ctx, plan: input.plan, - features: ctx.features, }); const itemResponses = productItems.map((item) => diff --git a/shared/api/customers/cusPlans/previousVersions/apiCusProductV2.ts b/shared/api/customers/cusPlans/previousVersions/apiCusProductV2.ts index 246795576..2b861c4f4 100644 --- a/shared/api/customers/cusPlans/previousVersions/apiCusProductV2.ts +++ b/shared/api/customers/cusPlans/previousVersions/apiCusProductV2.ts @@ -1,4 +1,4 @@ -import { ApiProductItemSchema } from "@api/products/planFeature/previousVersions/apiProductItem.js"; +import { ApiProductItemV0Schema } from "@api/products/items/previousVersions/apiProductItemV0.js"; import { z } from "zod/v4"; /** @@ -26,7 +26,7 @@ export const ApiCusProductV2Schema = z.object({ entity_id: z.string().nullish(), - items: z.array(ApiProductItemSchema).nullish(), + items: z.array(ApiProductItemV0Schema).nullish(), quantity: z.number().optional(), }); diff --git a/shared/api/customers/cusPlans/previousVersions/apiCusProductV3.ts b/shared/api/customers/cusPlans/previousVersions/apiCusProductV3.ts index d07f0af94..5cc6c808c 100644 --- a/shared/api/customers/cusPlans/previousVersions/apiCusProductV3.ts +++ b/shared/api/customers/cusPlans/previousVersions/apiCusProductV3.ts @@ -1,4 +1,4 @@ -import { ApiProductItemSchema } from "@api/products/planFeature/previousVersions/apiProductItem.js"; +import { ApiProductItemV0Schema } from "@api/products/items/previousVersions/apiProductItemV0.js"; import { z } from "zod/v4"; export const ApiCusProductV3Schema = z.object({ @@ -62,7 +62,7 @@ export const ApiCusProductV3Schema = z.object({ example: "entity_1234abcd", }), items: z - .array(ApiProductItemSchema) + .array(ApiProductItemV0Schema) .nullish() .meta({ description: "Array of product items defining the features and pricing", diff --git a/shared/api/models.ts b/shared/api/models.ts index cc6fc12f8..85f9196ef 100644 --- a/shared/api/models.ts +++ b/shared/api/models.ts @@ -41,13 +41,7 @@ export * from "./features/prevVersions/featureV0OpModels.js"; export * from "./others/apiDiscount.js"; export * from "./others/apiInvoice/apiInvoiceV1.js"; // Product -export * from "./products/apiFreeTrial.js"; -export * from "./products/apiPlan.js"; -export * from "./products/planFeature/previousVersions/apiProductItem.js"; -export * from "./products/planOpModels.js"; -export * from "./products/previousVersions/apiProduct.js"; -export * from "./products/productOpModels.js"; -export * from "./products/productsOpenApi.js"; +export * from "./products/index.js"; // Referrals export * from "./referrals/apiReferralCode.js"; export * from "./referrals/referralOpModels.js"; @@ -100,4 +94,3 @@ export * from "./others/apiInvoice/apiInvoiceItem.js"; export * from "./others/apiInvoice/apiInvoiceV1.js"; // Models export * from "./platform/platformModels.js"; -export * from "./products/planLegacyData.js"; diff --git a/shared/api/products/apiPlan.ts b/shared/api/products/apiPlan.ts index 31ff5c720..58775cd87 100644 --- a/shared/api/products/apiPlan.ts +++ b/shared/api/products/apiPlan.ts @@ -4,7 +4,7 @@ import { FreeTrialDuration } from "@models/productModels/freeTrialModels/freeTri import { BillingInterval } from "@models/productModels/intervals/billingInterval.js"; import { z } from "zod/v4"; import { DisplaySchema } from "./components/display.js"; -import { ApiPlanFeatureSchema } from "./planFeature/apiPlanFeature.js"; +import { ApiPlanItemV0Schema } from "./items/apiPlanItemV0.js"; export const ApiFreeTrialV2Schema = z.object({ duration_type: z.enum(FreeTrialDuration), @@ -34,7 +34,7 @@ export const ApiPlanSchema = z.object({ }) .nullable(), - features: z.array(ApiPlanFeatureSchema), + features: z.array(ApiPlanItemV0Schema), free_trial: ApiFreeTrialV2Schema.nullable().optional(), // Misc diff --git a/shared/api/products/changes/V1.2_ProductChanges.ts b/shared/api/products/changes/V1.2_ProductChanges.ts index 3138a1990..735c14b54 100644 --- a/shared/api/products/changes/V1.2_ProductChanges.ts +++ b/shared/api/products/changes/V1.2_ProductChanges.ts @@ -1,3 +1,4 @@ +import { planV0ToProductItems } from "@api/products/mappers/planV0ToProductItems.js"; import { ApiVersion } from "@api/versionUtils/ApiVersion.js"; import { AffectedResource, @@ -5,13 +6,10 @@ import { } from "@api/versionUtils/versionChangeUtils/VersionChange.js"; import type { PriceItem } from "@models/productV2Models/productItemModels/priceItem.js"; import { isPriceItem } from "@utils/index.js"; -import { convertPlanToItems } from "@utils/planFeatureUtils/planToItems.js"; +import type { SharedContext } from "../../../types/sharedContext.js"; import { productV2ToProperties } from "../../../utils/productV2Utils/productV2ToProperties.js"; import { type ApiPlan, ApiPlanSchema } from "../apiPlan.js"; -import { - type PlanLegacyData, - PlanLegacyDataSchema, -} from "../planLegacyData.js"; +import { PlanLegacyDataSchema } from "../planLegacyData.js"; import { type ApiProduct, ApiProductSchema, @@ -65,25 +63,26 @@ export const V1_2_ProductChanges = defineVersionChange({ // Response: V2 Plan -> V1.2 ProductV2 transformResponse: ({ + ctx, input, - legacyData, }: { + ctx: SharedContext; input: ApiPlan; - legacyData?: PlanLegacyData; }): ApiProduct => { // Convert plan to items using shared utility (handles base price + features) - const items = convertPlanToItems({ + const productItems = planV0ToProductItems({ + ctx, plan: input, - features: legacyData?.features || [], - }).filter((x) => { - if (isPriceItem(x)) { - const y: PriceItem = x as unknown as PriceItem; - return y.price > 0; - } else { - return true; - } - }); + }) + .filter((x) => { + if (isPriceItem(x)) { + const y: PriceItem = x as unknown as PriceItem; + return y.price > 0; + } else { + return true; + } + }); const productV2 = { id: input.id, @@ -96,7 +95,7 @@ export const V1_2_ProductChanges = defineVersionChange({ archived: input.archived, version: input.version, created_at: input.created_at, - items: items, // Already includes base price and features + items: productItems, // Already includes base price and features free_trial: input.free_trial ? { duration: input.free_trial.duration_type, diff --git a/shared/api/products/planOpModels.ts b/shared/api/products/crud/planOpModels.ts similarity index 64% rename from shared/api/products/planOpModels.ts rename to shared/api/products/crud/planOpModels.ts index c7b65bd6e..69e31a782 100644 --- a/shared/api/products/planOpModels.ts +++ b/shared/api/products/crud/planOpModels.ts @@ -1,8 +1,8 @@ import { BillingInterval } from "@models/productModels/intervals/billingInterval.js"; import { idRegex } from "@utils/utils.js"; import { z } from "zod/v4"; -import { ApiFreeTrialV2Schema } from "./apiPlan.js"; -import { UpdatePlanFeatureSchema } from "./planFeature/planFeatureOpModels.js"; +import { ApiFreeTrialV2Schema } from "../apiPlan.js"; +import { CreatePlanItemParamsV0Schema } from "../items/crud/createPlanItemV0Params.js"; export const PlanPriceSchema = z.object({ amount: z.number(), @@ -25,40 +25,17 @@ export const CreatePlanParamsSchema = z.object({ .object({ amount: z.number(), interval: z.enum(BillingInterval), + interval_count: z.number().optional(), }) .optional(), - features: z.array(UpdatePlanFeatureSchema).optional(), + features: z.array(CreatePlanItemParamsV0Schema).optional(), free_trial: ApiFreeTrialV2Schema.nullable().optional(), }); -export const UpdatePlanParamsSchema = z.object({ - id: z.string().nonempty().regex(idRegex).optional(), - group: z.string().default("").optional(), - - name: z - .string() - .refine((val) => val.length > 0, { - message: "name must be a non-empty string", - }) - .optional(), - description: z.string().nullable().optional(), - +export const UpdatePlanParamsSchema = CreatePlanParamsSchema.partial().extend({ version: z.number().optional(), - - add_on: z.boolean().default(false).optional(), - default: z.boolean().default(false).optional(), archived: z.boolean().default(false).optional(), - - price: z - .object({ - amount: z.number().optional(), - interval: z.enum(BillingInterval).optional(), - }) - .optional(), - - features: z.array(UpdatePlanFeatureSchema).optional(), - free_trial: ApiFreeTrialV2Schema.nullish(), }); export const UpdatePlanQuerySchema = z.object({ diff --git a/shared/api/products/index.ts b/shared/api/products/index.ts new file mode 100644 index 000000000..e55dc1f43 --- /dev/null +++ b/shared/api/products/index.ts @@ -0,0 +1,22 @@ +import { planV0ToProductItems } from "@api/products/mappers/planV0ToProductItems.js"; +import { planV0ToProductV2 } from "@api/products/mappers/planV0ToProductV2.js"; + +export * from "./apiFreeTrial.js"; +export * from "./apiPlan.js"; +export * from "./components/display.js"; +export * from "./crud/planOpModels.js"; +export * from "./items/index.js"; +export * from "./mappers/index.js"; +export * from "./planLegacyData.js"; +export * from "./previousVersions/apiProduct.js"; +export * from "./productOpModels.js"; +export * from "./productsOpenApi.js"; +// Note: V1.2_ProductChanges.js is NOT exported here to avoid circular deps +// It's only imported directly by versionChangeRegistry.ts + +export const apiPlan = { + map: { + v0ToProductItems: planV0ToProductItems, + v0ToProductV2: planV0ToProductV2, + }, +}; diff --git a/shared/api/products/planFeature/apiPlanFeature.ts b/shared/api/products/items/apiPlanItemV0.ts similarity index 93% rename from shared/api/products/planFeature/apiPlanFeature.ts rename to shared/api/products/items/apiPlanItemV0.ts index 0659d8285..460d35f6b 100644 --- a/shared/api/products/planFeature/apiPlanFeature.ts +++ b/shared/api/products/items/apiPlanItemV0.ts @@ -11,7 +11,7 @@ import { import { ApiFeatureV0Schema } from "../../features/prevVersions/apiFeatureV0.js"; import { DisplaySchema } from "../components/display.js"; -export const ApiPlanFeatureSchema = z +export const ApiPlanItemV0Schema = z .object({ feature_id: z.string(), feature: ApiFeatureV0Schema.optional(), @@ -89,9 +89,9 @@ export const ApiPlanFeatureSchema = z } }); -export type ApiPlanFeature = z.infer; +export type ApiPlanItemV0 = z.infer; -export const ApiPlanFeatureWithMeta = ApiPlanFeatureSchema.meta({ +export const ApiPlanItemV0WithMeta = ApiPlanItemV0Schema.meta({ id: "PlanFeature", description: "Plan feature object returned by the API", example: { diff --git a/shared/api/products/planFeature/planFeatureOpModels.ts b/shared/api/products/items/crud/createPlanItemV0Params.ts similarity index 84% rename from shared/api/products/planFeature/planFeatureOpModels.ts rename to shared/api/products/items/crud/createPlanItemV0Params.ts index c8887b44e..3e5121a78 100644 --- a/shared/api/products/planFeature/planFeatureOpModels.ts +++ b/shared/api/products/items/crud/createPlanItemV0Params.ts @@ -1,15 +1,15 @@ -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 { UsageModel } from "@models/productV2Models/productItemModels/productItemModels.js"; -import { z } from "zod/v4"; -import { RolloverExpiryDurationType } from "../../../models/productModels/durationTypes/rolloverExpiryDurationType.js"; +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 { OnDecrease, OnIncrease, -} from "../../../models/productV2Models/productItemModels/productItemEnums.js"; +} from "@models/productV2Models/productItemModels/productItemEnums"; +import { UsageModel } from "@models/productV2Models/productItemModels/productItemModels"; +import { z } from "zod/v4"; -export const UpdatePlanFeatureSchema = z +export const CreatePlanItemParamsV0Schema = z .object({ feature_id: z.string(), granted_balance: z.number().optional(), @@ -91,5 +91,6 @@ export const UpdatePlanFeatureSchema = z } } }); - -export type UpdatePlanFeatureParams = z.infer; +export type CreatePlanItemParamsV0 = z.infer< + typeof CreatePlanItemParamsV0Schema +>; diff --git a/shared/api/products/items/index.ts b/shared/api/products/items/index.ts new file mode 100644 index 000000000..eecf64777 --- /dev/null +++ b/shared/api/products/items/index.ts @@ -0,0 +1,12 @@ +import { planItemV0ToProductItem } from "@api/products/items/mappers/planItemV0ToProductItem.js"; + +export * from "./apiPlanItemV0.js"; +export * from "./crud/createPlanItemV0Params.js"; +export * from "./mappers/planItemV0ToProductItem.js"; +export * from "./previousVersions/apiProductItemV0.js"; + +export const apiPlanItem = { + map: { + v0ToProductItem: planItemV0ToProductItem, + }, +}; diff --git a/shared/api/products/items/mappers/planItemV0ToProductItem.ts b/shared/api/products/items/mappers/planItemV0ToProductItem.ts new file mode 100644 index 000000000..063adccf4 --- /dev/null +++ b/shared/api/products/items/mappers/planItemV0ToProductItem.ts @@ -0,0 +1,162 @@ +import type { ApiPlanItemV0 } from "@api/products/items/apiPlanItemV0.js"; +import type { ProrationConfig } from "@models/productModels/priceModels/priceModels.js"; +import { Infinite } from "@models/productModels/productEnums.js"; +import { + OnDecrease, + OnIncrease, +} from "@models/productV2Models/productItemModels/productItemEnums.js"; +import { + type ProductItem, + type ProductItemConfig, + ProductItemSchema, + ProductItemType, + type RolloverConfig, +} from "@models/productV2Models/productItemModels/productItemModels.js"; +import { dbToApiFeatureV1 } from "@utils/featureUtils/apiFeatureToDbFeature.js"; +import { featureToItemFeatureType } from "@utils/featureUtils/convertFeatureUtils.js"; + +import { resetIntvToItemIntv } from "@utils/planFeatureUtils/planFeatureIntervals.js"; +import { billingToItemInterval } from "@utils/productV2Utils/productItemUtils/itemIntervalUtils.js"; +import type { SharedContext } from "../../../../types/sharedContext.js"; +import { + type ApiFeatureV0, + type CreateBalanceParams, + 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; +}) => { + // 1. If feature has reset interval, use it + if (hasResetInterval(planItemV0)) { + return resetIntvToItemIntv(planItemV0.reset.interval); + } + + // 2. If feature has price interval, use it + if (hasPrice(planItemV0)) { + return billingToItemInterval({ + billingInterval: planItemV0.price.interval, + }); + } + + return null; +}; + +const planItemV0ToItemConfig = ({ + planItemV0, +}: { + planItemV0: ApiPlanItemV0 | CreatePlanItemParamsV0; +}) => { + const toItemRollover = () => { + if (planItemV0.rollover) { + return { + max: planItemV0.rollover.max, + duration: planItemV0.rollover.expiry_duration_type, + length: planItemV0.rollover.expiry_duration_length ?? 1, + } satisfies RolloverConfig; + } + return undefined; + }; + + const toItemProration = () => { + if (planItemV0.proration) { + return { + on_increase: + planItemV0.proration.on_increase ?? OnIncrease.ProrateImmediately, + on_decrease: planItemV0.proration.on_decrease ?? OnDecrease.Prorate, + } satisfies ProrationConfig; + } + return undefined; + }; + + const rollover = toItemRollover(); + const proration = toItemProration(); + + if (rollover || proration) { + return { + rollover, + on_increase: proration?.on_increase, + on_decrease: proration?.on_decrease, + } satisfies ProductItemConfig; + } + return undefined; +}; + +/** + * Augmented CreateBalanceParams that can be used for planFeaturesToItems function + */ +type CreateBalanceForPlanFeatureMap = CreateBalanceParams & { + price?: undefined; +} & { + reset?: CreateBalanceParams["reset"] & { reset_when_enabled: true }; +}; + +export const planItemV0ToProductItem = ({ + ctx, + planItem, +}: { + ctx: SharedContext; + planItem: + | ApiPlanItemV0 + | CreatePlanItemParamsV0 + | CreateBalanceForPlanFeatureMap; +}): ProductItem => { + const { features } = ctx; + + const feature = features.find((f) => f.id === planItem.feature_id); + if (!feature) { + throw new FeatureNotFoundError({ featureId: planItem.feature_id }); + } + + // Get interval + const interval = planItemV0ToProductItemInterval({ planItemV0: planItem }); + const config = planItemV0ToItemConfig({ planItemV0: planItem }); + + const type = planItem.price + ? ProductItemType.FeaturePrice + : ProductItemType.Feature; + + return ProductItemSchema.parse({ + type, + + feature_id: planItem.feature_id, + feature_type: featureToItemFeatureType({ feature }), + feature: dbToApiFeatureV1({ + ctx, + dbFeature: feature, + targetVersion: new ApiVersionClass(ApiVersion.V1_2), + }) as unknown as ApiFeatureV0, + + included_usage: planItem.unlimited ? Infinite : planItem.granted_balance, + + interval, + interval_count: planItem.reset?.interval_count, + + entity_feature_id: null, + + price: planItem.price?.amount, + + tiers: planItem.price?.tiers?.map((tier) => ({ + amount: tier.amount, + to: tier.to, + })), + + usage_model: planItem.price?.usage_model, + billing_units: planItem.price?.billing_units, + usage_limit: planItem.price?.max_purchase + ? planItem.price.max_purchase + (planItem.granted_balance ?? 0) + : undefined, + + reset_usage_when_enabled: planItem.reset?.reset_when_enabled, + + config, + + display: "display" in planItem ? planItem.display : undefined, + } satisfies ProductItem); +}; diff --git a/shared/api/products/planFeature/previousVersions/apiProductItem.ts b/shared/api/products/items/previousVersions/apiProductItemV0.ts similarity index 96% rename from shared/api/products/planFeature/previousVersions/apiProductItem.ts rename to shared/api/products/items/previousVersions/apiProductItemV0.ts index 3fe60d79d..fff0c744c 100644 --- a/shared/api/products/planFeature/previousVersions/apiProductItem.ts +++ b/shared/api/products/items/previousVersions/apiProductItemV0.ts @@ -14,7 +14,7 @@ import { } from "@models/productV2Models/productItemModels/productItemModels.js"; import { z } from "zod/v4"; -export const ApiProductItemSchema = z +export const ApiProductItemV0Schema = z .object({ // Feature stuff type: z @@ -118,4 +118,4 @@ export const ApiProductItemSchema = z description: "Product item defining features and pricing within a product", }); -export type ApiProductItem = z.infer; +export type ApiProductItem = z.infer; diff --git a/shared/api/products/items/utils/classifyPlanItemV0.ts b/shared/api/products/items/utils/classifyPlanItemV0.ts new file mode 100644 index 000000000..8b24743c5 --- /dev/null +++ b/shared/api/products/items/utils/classifyPlanItemV0.ts @@ -0,0 +1,23 @@ +import type { ApiPlanItemV0 } from "@api/products/items/apiPlanItemV0.js"; +import type { CreatePlanItemParamsV0 } from "@api/products/items/crud/createPlanItemV0Params.js"; +import { notNullish } from "@utils/utils.js"; + +type PlanFeatureWithReset = (ApiPlanItemV0 | CreatePlanItemParamsV0) & { + reset: NonNullable<(ApiPlanItemV0 | CreatePlanItemParamsV0)["reset"]>; +}; + +type PlanFeatureWithPrice = (ApiPlanItemV0 | CreatePlanItemParamsV0) & { + price: NonNullable<(ApiPlanItemV0 | CreatePlanItemParamsV0)["price"]>; +}; + +export const hasResetInterval = ( + planFeature: ApiPlanItemV0 | CreatePlanItemParamsV0, +): planFeature is PlanFeatureWithReset => { + return notNullish(planFeature.reset?.interval); +}; + +export const hasPrice = ( + planFeature: ApiPlanItemV0 | CreatePlanItemParamsV0, +): planFeature is PlanFeatureWithPrice => { + return notNullish(planFeature.price); +}; diff --git a/shared/api/products/mappers/index.ts b/shared/api/products/mappers/index.ts new file mode 100644 index 000000000..02a3d5a67 --- /dev/null +++ b/shared/api/products/mappers/index.ts @@ -0,0 +1 @@ +export * from "./planV0ToProductItems.js"; diff --git a/shared/api/products/mappers/planV0ToBasePriceProductItem.ts b/shared/api/products/mappers/planV0ToBasePriceProductItem.ts new file mode 100644 index 000000000..bca5a642e --- /dev/null +++ b/shared/api/products/mappers/planV0ToBasePriceProductItem.ts @@ -0,0 +1,50 @@ +import type { ApiPlan } from "@api/products/apiPlan"; +import type { + CreatePlanParams, + UpdatePlanParams, +} from "@api/products/crud/planOpModels"; +import { BillingInterval } from "@models/productModels/intervals/billingInterval"; +import { + type ProductItem, + ProductItemType, +} from "@models/productV2Models/productItemModels/productItemModels"; +import { getProductItemDisplay } from "@utils/productDisplayUtils"; +import { billingToItemInterval } from "@utils/productV2Utils/productItemUtils/itemIntervalUtils"; +import type { SharedContext } from "../../../types"; + +export const planV0ToBasePriceProductItem = ({ + ctx, + plan, +}: { + ctx: SharedContext; + plan: ApiPlan | CreatePlanParams | UpdatePlanParams; +}): ProductItem | undefined => { + if (!plan.price) return; + + const basePrice = plan.price; + + const basePriceDisplay = + "display" in basePrice ? basePrice.display : undefined; + + if (basePrice) { + const item = { + type: ProductItemType.Price, + feature_id: null, + feature: null, + interval: billingToItemInterval({ + billingInterval: basePrice.interval ?? BillingInterval.Month, + }), + interval_count: basePrice.interval_count ?? 1, + price: basePrice.amount ?? 0, + } satisfies ProductItem; + + const display = + basePriceDisplay ?? + getProductItemDisplay({ item, features: ctx.features }); + + return { + ...item, + display, + }; + } +}; diff --git a/shared/api/products/mappers/planV0ToProductItems.ts b/shared/api/products/mappers/planV0ToProductItems.ts new file mode 100644 index 000000000..4ca91c2a2 --- /dev/null +++ b/shared/api/products/mappers/planV0ToProductItems.ts @@ -0,0 +1,41 @@ +import { + apiPlanItem, + type CreatePlanParams, + type UpdatePlanParams, +} from "@api/models"; +import type { ApiPlan } from "@api/products/apiPlan"; +import { planV0ToBasePriceProductItem } from "@api/products/mappers/planV0ToBasePriceProductItem"; +import type { ProductItem } from "@models/productV2Models/productItemModels/productItemModels"; +import type { SharedContext } from "../../../types"; + +export const planV0ToProductItems = ({ + ctx, + plan, +}: { + ctx: SharedContext; + plan: ApiPlan | CreatePlanParams | UpdatePlanParams; +}): ProductItem[] => { + // Convert features to items + const featureItems = + plan.features?.map((planItem) => + apiPlanItem.map.v0ToProductItem({ ctx, planItem }), + ) ?? []; + + const basePriceItem = planV0ToBasePriceProductItem({ ctx, plan }); + + if (basePriceItem) { + featureItems.splice(0, 0, basePriceItem); + } + + 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 new file mode 100644 index 000000000..3301bdb77 --- /dev/null +++ b/shared/api/products/mappers/planV0ToProductV2.ts @@ -0,0 +1,48 @@ +import type { ApiPlan } from "@api/products/apiPlan"; +import type { + CreatePlanParams, + UpdatePlanParams, +} from "@api/products/crud/planOpModels"; +import { planV0ToProductItems } from "@api/products/mappers/planV0ToProductItems"; +import type { + CreateProductV2Params, + UpdateProductV2Params, +} from "@api/products/productOpModels"; +import type { ProductV2 } from "@models/productV2Models/productV2Models"; +import type { SharedContext } from "../../../types/sharedContext"; + +export function planV0ToProductV2({ + ctx, + plan, +}: { + ctx: SharedContext; + plan: ApiPlan | CreatePlanParams | UpdatePlanParams; +}): CreateProductV2Params | UpdateProductV2Params | ProductV2 { + // Convert plan to items using shared utility + const items = planV0ToProductItems({ ctx, plan }); + + // Check if archived field exists on plan (it's on ApiPlan, not CreatePlanParams) + const archived = + "archived" in plan && plan.archived !== undefined + ? plan.archived + : undefined; + + return { + id: plan.id, + name: plan.name, + description: plan.description ?? null, + is_add_on: plan.add_on, + is_default: plan.default, + group: plan.group ?? "", + items, + free_trial: plan.free_trial + ? { + duration: plan.free_trial.duration_type, + length: plan.free_trial.duration_length, + unique_fingerprint: false, + card_required: plan.free_trial.card_required, + } + : null, + ...(archived !== undefined && { archived }), + }; +} diff --git a/shared/api/products/previousVersions/apiProduct.ts b/shared/api/products/previousVersions/apiProduct.ts index e3602ff06..39a08d402 100644 --- a/shared/api/products/previousVersions/apiProduct.ts +++ b/shared/api/products/previousVersions/apiProduct.ts @@ -2,7 +2,7 @@ import { AttachScenario } from "@models/checkModels/checkPreviewModels.js"; import { AppEnv } from "@models/genModels/genEnums.js"; import { z } from "zod/v4"; import { ApiFreeTrialSchema } from "../apiFreeTrial.js"; -import { ApiProductItemSchema } from "../planFeature/previousVersions/apiProductItem.js"; +import { ApiProductItemV0Schema } from "../items/previousVersions/apiProductItemV0.js"; export const PRODUCT_EXAMPLE = { id: "Pro Product", @@ -167,7 +167,7 @@ export const ApiProductSchema = z.object({ ), items: z - .array(ApiProductItemSchema) + .array(ApiProductItemV0Schema) .describe( "Array of product items that define the product's features and pricing", ), diff --git a/shared/api/versionUtils/versionChangeUtils/VersionChange.ts b/shared/api/versionUtils/versionChangeUtils/VersionChange.ts index c1de0119f..65ef6bda3 100644 --- a/shared/api/versionUtils/versionChangeUtils/VersionChange.ts +++ b/shared/api/versionUtils/versionChangeUtils/VersionChange.ts @@ -1,13 +1,13 @@ import type { ZodType, z } from "zod/v4"; -import type { Feature } from "../../../models/featureModels/featureModels.js"; +import type { SharedContext } from "../../../types/sharedContext.js"; import type { ApiVersion } from "../ApiVersion.js"; -/** - * Context passed to version transforms for accessing runtime data - */ -export interface VersionContext { - features: Feature[]; -} +// /** +// * Context passed to version transforms for accessing runtime data +// */ +// export interface VersionContext { +// features: Feature[]; +// } /** * Resources that can be affected by version changes @@ -157,7 +157,7 @@ export abstract class VersionChange< legacyData?: TLegacyDataSchema extends ZodType ? z.infer : never; - ctx?: VersionContext; + ctx?: SharedContext; }): z.infer { // Default: no-op (override if change affects responses) return input as unknown as z.infer; @@ -256,7 +256,7 @@ export interface VersionChangeConfig< legacyData?: TLegacyDataSchema extends ZodType ? z.infer : never; - ctx: VersionContext; + ctx: SharedContext; }) => z.infer; } @@ -325,7 +325,7 @@ export function defineVersionChange< legacyData?: TLegacyDataSchema extends ZodType ? z.infer : never; - ctx: VersionContext; + ctx: SharedContext; }): z.infer { if (config.transformResponse) { const result = config.transformResponse( diff --git a/shared/api/versionUtils/versionChangeUtils/applyVersionChanges.ts b/shared/api/versionUtils/versionChangeUtils/applyVersionChanges.ts index fec49ae54..5e5f4faed 100644 --- a/shared/api/versionUtils/versionChangeUtils/applyVersionChanges.ts +++ b/shared/api/versionUtils/versionChangeUtils/applyVersionChanges.ts @@ -1,3 +1,4 @@ +import type { SharedContext } from "../../../types/sharedContext.js"; import { type ApiVersion, LATEST_VERSION } from "../ApiVersion.js"; import { ApiVersionClass } from "../ApiVersionClass.js"; import { @@ -8,7 +9,6 @@ import type { AffectedResource, VersionChange, VersionChangeConstructor, - VersionContext, } from "./VersionChange.js"; import { VersionChangeRegistryClass } from "./VersionChangeRegistryClass.js"; @@ -39,7 +39,7 @@ export function applyResponseVersionChanges({ currentVersion?: ApiVersionClass; targetVersion: ApiVersionClass; resource: AffectedResource; - ctx: VersionContext; + ctx: SharedContext; }): T { // Default currentVersion to latest if not provided const _currentVersion = currentVersion || new ApiVersionClass(LATEST_VERSION); @@ -253,7 +253,7 @@ export function applyResponseVersionChangesToArray({ currentVersion?: ApiVersionClass; targetVersion: ApiVersionClass; resource: AffectedResource; - ctx: VersionContext; + ctx: SharedContext; }): T[] { return inputArray.map((item) => applyResponseVersionChanges({ diff --git a/shared/api/versionUtils/versionChangeUtils/versionChangeRegistry.ts b/shared/api/versionUtils/versionChangeUtils/versionChangeRegistry.ts index 3cdaa0717..4eef1a131 100644 --- a/shared/api/versionUtils/versionChangeUtils/versionChangeRegistry.ts +++ b/shared/api/versionUtils/versionChangeUtils/versionChangeRegistry.ts @@ -19,7 +19,9 @@ import { V1_2_FeatureChange } from "@api/features/changes/V1.2_FeatureChange.js" import { V1_2_CreateFeatureChange } from "@api/features/changes/V1.2_FeatureParamsChange.js"; // Import invoice changes import { V1_2_InvoiceChange } from "@api/others/apiInvoice/changes/V1.2_InvoiceChange.js"; + // Import product changes + import { V1_2_ProductChanges } from "@api/products/changes/V1.2_ProductChanges.js"; import { V0_2_CheckChange } from "../../balances/check/changes/V0.2_CheckChange.js"; import { V1_2_CheckChange } from "../../balances/check/changes/V1.2_CheckChange.js"; diff --git a/shared/api/versionUtils/versionUtils.ts b/shared/api/versionUtils/versionUtils.ts index 9ab234b53..bc464be36 100644 --- a/shared/api/versionUtils/versionUtils.ts +++ b/shared/api/versionUtils/versionUtils.ts @@ -33,7 +33,6 @@ export { AffectedResource, VersionChange, type VersionChangeConstructor, - type VersionContext, } from "./versionChangeUtils/VersionChange.js"; export { VersionChangeRegistryClass } from "./versionChangeUtils/VersionChangeRegistryClass.js"; // Version registry diff --git a/shared/index.ts b/shared/index.ts index a7bf58386..d781c38e7 100644 --- a/shared/index.ts +++ b/shared/index.ts @@ -85,8 +85,8 @@ export * from "./models/featureModels/featureModels.js"; // export * from "./models/featureModels/featureResModels.js"; -export * from "./api/products/planFeature/apiPlanFeature.js"; -export * from "./api/products/planOpModels.js"; +export * from "./api/products/crud/planOpModels.js"; +export * from "./api/products/items/apiPlanItemV0.js"; // 2. Feature Models export * from "./models/featureModels/featureTable.js"; @@ -96,12 +96,12 @@ 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) export * from "./models/billingModels/index.js"; - // Checkout Models export * from "./models/checkouts/index.js"; export * from "./models/cusProductModels/cusPriceModels/customerPriceWithCustomerProduct.js"; @@ -173,6 +173,7 @@ export * from "./models/rewardModels/rewardProgramModels/rewardProgramModels.js" export * from "./models/rewardModels/rewardProgramModels/rewardProgramTable.js"; export * from "./models/subModels/subModels.js"; export * from "./models/subModels/subTable.js"; +export * from "./types"; // Agent Types (for pricing agent AI) export * from "./utils/agentTypes.js"; export * from "./utils/billingUtils/index.js"; @@ -192,12 +193,8 @@ export * from "./utils/planFeatureUtils/itemsToPlanFeatures.js"; export * from "./utils/planFeatureUtils/itemsToPlanFeatures.js"; export * from "./utils/planFeatureUtils/planFeatureIntervals.js"; export * from "./utils/planFeatureUtils/planFeatureIntervals.js"; -export * from "./utils/planFeatureUtils/planFeaturesToItems.js"; -export * from "./utils/planFeatureUtils/planFeaturesToItems.js"; export * from "./utils/planFeatureUtils/planToDbFreeTrial.js"; -export * from "./utils/planFeatureUtils/planToItems.js"; -export * from "./utils/planFeatureUtils/planToItems.js"; -export * from "./utils/planFeatureUtils/planToProductV2.js"; + export * from "./utils/productDisplayUtils/sortProductItems.js"; export * from "./utils/productDisplayUtils.js"; export * from "./utils/productUtils/convertProductUtils.js"; diff --git a/shared/types/index.ts b/shared/types/index.ts new file mode 100644 index 000000000..c49dd5585 --- /dev/null +++ b/shared/types/index.ts @@ -0,0 +1,2 @@ +export * from "./logger"; +export * from "./sharedContext"; diff --git a/shared/types/logger.ts b/shared/types/logger.ts new file mode 100644 index 000000000..490fedbf9 --- /dev/null +++ b/shared/types/logger.ts @@ -0,0 +1,7 @@ +export type AutumnLogger = { + debug: (...args: unknown[]) => void; + info: (...args: unknown[]) => void; + warn: (...args: unknown[]) => void; + error: (...args: unknown[]) => void; + child: (opts: { context: Record; onlyProd?: boolean }) => AutumnLogger; +}; diff --git a/shared/types/sharedContext.ts b/shared/types/sharedContext.ts new file mode 100644 index 000000000..97da4ab88 --- /dev/null +++ b/shared/types/sharedContext.ts @@ -0,0 +1,35 @@ +import type { Feature } from "@models/featureModels/featureModels"; +import type { AppEnv } from "@models/genModels/genEnums"; +import type { Organization } from "@models/orgModels/orgTable"; +import type { AutumnLogger } from "./logger"; + +export type SharedContext = { + // Variables + org: Organization; + env: AppEnv; + features: Feature[]; + + // Objects + logger: AutumnLogger; + // db: DrizzleCli; + // clickhouseClient?: ClickHouseClient; + + // // Info + // id: string; + // isPublic: boolean; + // authType: AuthType; + // apiVersion: ApiVersionClass; + // timestamp: number; + + // // Query params + // expand: string[]; + // skipCache: boolean; + + // extraLogs: Record; + + // testOptions?: { + // skipCacheDeletion?: boolean; + // skipWebhooks?: boolean; + // eventId?: string; + // }; +}; diff --git a/shared/utils/featureUtils/apiFeatureToDbFeature.ts b/shared/utils/featureUtils/apiFeatureToDbFeature.ts index d923bb5ca..8da0627f7 100644 --- a/shared/utils/featureUtils/apiFeatureToDbFeature.ts +++ b/shared/utils/featureUtils/apiFeatureToDbFeature.ts @@ -21,6 +21,7 @@ import { LATEST_VERSION, } from "../../api/versionUtils/versionUtils.js"; import type { CreditSchemaItem } from "../../models/featureModels/featureConfig/creditConfig.js"; +import type { SharedContext } from "../../types/sharedContext.js"; import { notNullish, nullish } from "../utils.js"; export const apiFeatureToDbFeature = ({ @@ -177,9 +178,11 @@ export const featureV1ToDbFeature = ({ * - For API version V1_Beta and older, responses are automatically converted to ApiFeatureV0 format */ export const dbToApiFeatureV1 = ({ + ctx, dbFeature, targetVersion, }: { + ctx: SharedContext; dbFeature: Feature; targetVersion?: ApiVersionClass; }) => { @@ -212,9 +215,7 @@ export const dbToApiFeatureV1 = ({ input: result, targetVersion: targetVersion ?? new ApiVersionClass(LATEST_VERSION), resource: AffectedResource.Feature, - ctx: { - features: [dbFeature], - }, + ctx, }); }; diff --git a/shared/utils/planFeatureUtils/classifyPlanFeature.ts b/shared/utils/planFeatureUtils/classifyPlanFeature.ts deleted file mode 100644 index fb0fdb755..000000000 --- a/shared/utils/planFeatureUtils/classifyPlanFeature.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { ApiPlanFeature } from "../../api/products/planFeature/apiPlanFeature.js"; -import type { UpdatePlanFeatureParams } from "../../api/products/planFeature/planFeatureOpModels.js"; -import { notNullish } from "../utils.js"; - -type PlanFeatureWithReset = (ApiPlanFeature | UpdatePlanFeatureParams) & { - reset: NonNullable<(ApiPlanFeature | UpdatePlanFeatureParams)["reset"]>; -}; - -type PlanFeatureWithPrice = (ApiPlanFeature | UpdatePlanFeatureParams) & { - price: NonNullable<(ApiPlanFeature | UpdatePlanFeatureParams)["price"]>; -}; - -export const hasResetInterval = ( - planFeature: ApiPlanFeature | UpdatePlanFeatureParams, -): planFeature is PlanFeatureWithReset => { - return notNullish(planFeature.reset?.interval); -}; - -export const hasPrice = ( - planFeature: ApiPlanFeature | UpdatePlanFeatureParams, -): planFeature is PlanFeatureWithPrice => { - return notNullish(planFeature.price); -}; diff --git a/shared/utils/planFeatureUtils/itemsToPlanFeatures.ts b/shared/utils/planFeatureUtils/itemsToPlanFeatures.ts index 918e129c8..e308e7964 100644 --- a/shared/utils/planFeatureUtils/itemsToPlanFeatures.ts +++ b/shared/utils/planFeatureUtils/itemsToPlanFeatures.ts @@ -1,7 +1,7 @@ import { - type ApiPlanFeature, - ApiPlanFeatureSchema, -} from "@api/products/planFeature/apiPlanFeature.js"; + type ApiPlanItemV0, + ApiPlanItemV0Schema, +} from "@api/products/items/apiPlanItemV0.js"; import { CusExpand } from "@models/cusModels/cusExpand.js"; import { Infinite } from "@models/productModels/productEnums.js"; import { @@ -63,7 +63,7 @@ const itemToReset = ({ ? item.interval_count : undefined, reset_when_enabled: item.reset_usage_when_enabled ?? false, - } satisfies ApiPlanFeature["reset"]; + } satisfies ApiPlanItemV0["reset"]; }; const itemToPlanFeaturePrice = ({ item }: { item: ProductItem }) => { @@ -101,7 +101,7 @@ const itemToPlanFeaturePrice = ({ item }: { item: ProductItem }) => { billing_units: item.billing_units ?? 1, usage_model: item.usage_model || UsageModel.PayPerUse, max_purchase: maxPurchase, - } satisfies ApiPlanFeature["price"]; + } satisfies ApiPlanItemV0["price"]; }; const itemToPlanFeatureRollover = ({ item }: { item: ProductItem }) => { @@ -111,7 +111,7 @@ const itemToPlanFeatureRollover = ({ item }: { item: ProductItem }) => { max: item.config.rollover.max ?? null, expiry_duration_type: item.config.rollover.duration, expiry_duration_length: item.config.rollover.length, - } satisfies ApiPlanFeature["rollover"]; + } satisfies ApiPlanItemV0["rollover"]; }; const itemToPlanFeatureProration = ({ item }: { item: ProductItem }) => { @@ -122,7 +122,7 @@ const itemToPlanFeatureProration = ({ item }: { item: ProductItem }) => { return { on_increase: item.config.on_increase, on_decrease: item.config.on_decrease, - } satisfies ApiPlanFeature["proration"]; + } satisfies ApiPlanItemV0["proration"]; }; export const itemsToPlanFeatures = ({ @@ -133,7 +133,7 @@ export const itemsToPlanFeatures = ({ items: ProductItem[]; features: Feature[]; expand?: string[]; -}): ApiPlanFeature[] => { +}): ApiPlanItemV0[] => { if (!items) return []; const shouldExpandFeature = expandIncludes({ @@ -164,7 +164,7 @@ export const itemsToPlanFeatures = ({ ? toApiFeature({ feature }) : undefined; - return ApiPlanFeatureSchema.parse({ + return ApiPlanItemV0Schema.parse({ feature_id: item.feature_id, feature: apiFeature, granted_balance: grantedBalance, @@ -180,7 +180,7 @@ export const itemsToPlanFeatures = ({ // Other fields // entity_feature_id: item.entity_feature_id, - } satisfies ApiPlanFeature); + } satisfies ApiPlanItemV0); }); }; diff --git a/shared/utils/planFeatureUtils/planFeaturesToItems.ts b/shared/utils/planFeatureUtils/planFeaturesToItems.ts deleted file mode 100644 index a4d84fd61..000000000 --- a/shared/utils/planFeatureUtils/planFeaturesToItems.ts +++ /dev/null @@ -1,165 +0,0 @@ -import type { ApiPlanFeature } from "@api/products/planFeature/apiPlanFeature.js"; -import { - type ProductItem, - type ProductItemConfig, - ProductItemSchema, - ProductItemType, - type RolloverConfig, -} from "@models/productV2Models/productItemModels/productItemModels.js"; -import { - type ApiFeatureV0, - type CreateBalanceParams, - FeatureNotFoundError, -} from "../../api/models.js"; -import type { UpdatePlanFeatureParams } from "../../api/products/planFeature/planFeatureOpModels.js"; -import { ApiVersion } from "../../api/versionUtils/ApiVersion.js"; -import { ApiVersionClass } from "../../api/versionUtils/ApiVersionClass.js"; -import type { Feature } from "../../models/featureModels/featureModels.js"; -import type { ProrationConfig } from "../../models/productModels/priceModels/priceModels.js"; -import { Infinite } from "../../models/productModels/productEnums.js"; -import { - OnDecrease, - OnIncrease, -} from "../../models/productV2Models/productItemModels/productItemEnums.js"; -import { dbToApiFeatureV1 } from "../featureUtils/apiFeatureToDbFeature.js"; -import { featureToItemFeatureType } from "../featureUtils/convertFeatureUtils.js"; -import { billingToItemInterval } from "../productV2Utils/productItemUtils/itemIntervalUtils.js"; -import { hasPrice, hasResetInterval } from "./classifyPlanFeature.js"; -import { resetIntvToItemIntv } from "./planFeatureIntervals.js"; - -const planFeatureToItemInterval = ({ - planFeature, -}: { - planFeature: ApiPlanFeature | UpdatePlanFeatureParams; -}) => { - // 1. If feature has reset interval, use it - if (hasResetInterval(planFeature)) { - return resetIntvToItemIntv(planFeature.reset.interval); - } - - // 2. If feature has price interval, use it - if (hasPrice(planFeature)) { - return billingToItemInterval({ - billingInterval: planFeature.price.interval, - }); - } - - return null; -}; - -const planFeatureToItemConfig = ({ - planFeature, -}: { - planFeature: ApiPlanFeature | UpdatePlanFeatureParams; -}) => { - const toItemRollover = () => { - if (planFeature.rollover) { - return { - max: planFeature.rollover.max, - duration: planFeature.rollover.expiry_duration_type, - length: planFeature.rollover.expiry_duration_length ?? 1, - } satisfies RolloverConfig; - } - return undefined; - }; - - const toItemProration = () => { - if (planFeature.proration) { - return { - on_increase: - planFeature.proration.on_increase ?? OnIncrease.ProrateImmediately, - on_decrease: planFeature.proration.on_decrease ?? OnDecrease.Prorate, - } satisfies ProrationConfig; - } - return undefined; - }; - - const rollover = toItemRollover(); - const proration = toItemProration(); - - if (rollover || proration) { - return { - rollover, - on_increase: proration?.on_increase, - on_decrease: proration?.on_decrease, - } satisfies ProductItemConfig; - } - return undefined; -}; - -/** - * Augmented CreateBalanceParams that can be used for planFeaturesToItems function - */ -type CreateBalanceForPlanFeatureMap = CreateBalanceParams & { - price?: undefined; -} & { - reset?: CreateBalanceParams["reset"] & { reset_when_enabled: true }; -}; - -export const planFeaturesToItems = ({ - planFeatures, - features, -}: { - planFeatures: ( - | ApiPlanFeature - | UpdatePlanFeatureParams - | CreateBalanceForPlanFeatureMap - )[]; - features: Feature[]; -}): ProductItem[] => { - if (!planFeatures) return []; - - return planFeatures.map((planFeature) => { - const feature = features.find((f) => f.id === planFeature.feature_id); - if (!feature) { - throw new FeatureNotFoundError({ featureId: planFeature.feature_id }); - } - - // Get interval - const interval = planFeatureToItemInterval({ planFeature }); - const config = planFeatureToItemConfig({ planFeature }); - - const type = planFeature.price - ? ProductItemType.FeaturePrice - : ProductItemType.Feature; - - return ProductItemSchema.parse({ - type, - - feature_id: planFeature.feature_id, - feature_type: featureToItemFeatureType({ feature }), - feature: dbToApiFeatureV1({ - dbFeature: feature, - targetVersion: new ApiVersionClass(ApiVersion.V1_2), - }) as unknown as ApiFeatureV0, - - included_usage: planFeature.unlimited - ? Infinite - : planFeature.granted_balance, - - interval, - interval_count: planFeature.reset?.interval_count, - - entity_feature_id: null, - - price: planFeature.price?.amount, - - tiers: planFeature.price?.tiers?.map((tier) => ({ - amount: tier.amount, - to: tier.to, - })), - - usage_model: planFeature.price?.usage_model, - billing_units: planFeature.price?.billing_units, - usage_limit: planFeature.price?.max_purchase - ? planFeature.price.max_purchase + (planFeature.granted_balance ?? 0) - : undefined, - - reset_usage_when_enabled: planFeature.reset?.reset_when_enabled, - - config, - - display: "display" in planFeature ? planFeature.display : undefined, - } satisfies ProductItem); - }); -}; diff --git a/shared/utils/planFeatureUtils/planToItems.ts b/shared/utils/planFeatureUtils/planToItems.ts deleted file mode 100644 index eecbf604e..000000000 --- a/shared/utils/planFeatureUtils/planToItems.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { ApiPlan } from "@api/products/apiPlan.js"; -import { BillingInterval } from "@models/productModels/intervals/billingInterval.js"; -import { - type ProductItem, - ProductItemType, -} from "@models/productV2Models/productItemModels/productItemModels.js"; -import type { CreatePlanParams } from "../../api/products/planOpModels.js"; -import type { Feature } from "../../models/featureModels/featureModels.js"; -import { getProductItemDisplay } from "../productDisplayUtils.js"; -import { billingToItemInterval } from "../productV2Utils/productItemUtils/itemIntervalUtils.js"; -import { planFeaturesToItems } from "./planFeaturesToItems.js"; - -/** - * Construct a price item (base price with no feature) - */ -export const planToProductV2PriceItem = ({ - price, - features, -}: { - price: ApiPlan["price"]; - features: Feature[]; -}): ProductItem => { - const item = { - type: ProductItemType.Price, - feature_id: null, - feature: null, - interval: billingToItemInterval({ - billingInterval: price?.interval ?? BillingInterval.Month, - }), - interval_count: price?.interval_count ?? 1, - price: price?.amount ?? 0, - } satisfies ProductItem; - - const display = price?.display ?? getProductItemDisplay({ item, features }); - - return { - ...item, - display, - }; -}; - -/** - * Convert ApiPlan to ProductItem[] (features + base price) - * This is the shared logic used by both planToProductV2 and V1.2_ProductChanges - */ -export const convertPlanToItems = ({ - plan, - features, -}: { - plan: ApiPlan | CreatePlanParams; - features: Feature[]; -}): ProductItem[] => { - // Convert features to items - const featureItems = planFeaturesToItems({ - planFeatures: plan.features || [], - features, - }); - - const items = [...featureItems]; - - // Add base price if plan has one (independent of feature pricing) - if (plan.price) { - const priceItem = planToProductV2PriceItem({ price: plan.price, features }); - - items.splice(0, 0, priceItem); - } - - return items; -}; diff --git a/shared/utils/planFeatureUtils/planToProductV2.ts b/shared/utils/planFeatureUtils/planToProductV2.ts deleted file mode 100644 index 14e3b9589..000000000 --- a/shared/utils/planFeatureUtils/planToProductV2.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { ApiPlan } from "@api/products/apiPlan.js"; -import type { - CreateProductV2Params, - UpdateProductV2Params, -} from "@api/products/productOpModels.js"; -import type { CreatePlanParams } from "../../api/products/planOpModels.js"; -import type { Feature } from "../../models/featureModels/featureModels.js"; -import { convertPlanToItems } from "./planToItems.js"; - -/** - * Convert Plan format to ProductV2 format - * - * This function converts the newer Plan API format to the internal ProductV2 format - * used by handlers. Works for both CREATE and UPDATE operations. - * - * @param plan - ApiPlan (validated at API boundary) - * @returns CreateProductV2Params | UpdateProductV2Params - */ -export function planToProductV2({ - plan, - features, -}: { - plan: ApiPlan | CreatePlanParams; - features: Feature[]; -}): CreateProductV2Params | UpdateProductV2Params { - // Convert plan to items using shared utility - const items = convertPlanToItems({ plan, features }); - - // Check if archived field exists on plan (it's on ApiPlan, not CreatePlanParams) - const archived = - "archived" in plan && plan.archived !== undefined - ? plan.archived - : undefined; - - return { - id: plan.id, - name: plan.name, - description: plan.description ?? null, - is_add_on: plan.add_on, - is_default: plan.default, - group: plan.group ?? "", - items, - free_trial: plan.free_trial - ? { - duration: plan.free_trial.duration_type, - length: plan.free_trial.duration_length, - unique_fingerprint: false, - card_required: plan.free_trial.card_required, - } - : null, - ...(archived !== undefined && { archived }), - }; -} diff --git a/shared/utils/productV2Utils/productItemUtils/getProductItemRes.ts b/shared/utils/productV2Utils/productItemUtils/getProductItemRes.ts index 293a471e9..fce6affea 100644 --- a/shared/utils/productV2Utils/productItemUtils/getProductItemRes.ts +++ b/shared/utils/productV2Utils/productItemUtils/getProductItemRes.ts @@ -1,4 +1,4 @@ -import { ApiProductItemSchema } from "@api/products/planFeature/previousVersions/apiProductItem.js"; +import { ApiProductItemV0Schema } from "@api/products/items/previousVersions/apiProductItemV0.js"; import type { FeatureOptions } from "@models/cusProductModels/cusProductModels.js"; import type { Feature } from "@models/featureModels/featureModels.js"; import { @@ -140,7 +140,7 @@ export const getProductItemResponse = ({ } const feature = features.find((f) => f.id === item.feature_id); - return ApiProductItemSchema.parse({ + return ApiProductItemV0Schema.parse({ type, ...item, feature: feature ? toApiFeature({ feature }) : null,