chore: cleaned up current api plan files to prepare for new schema

This commit is contained in:
John Yeo
2026-02-08 17:49:55 -08:00
parent 0b9619990a
commit 3d83472397
52 changed files with 535 additions and 466 deletions

View File

@@ -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!,

View File

@@ -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,
}),

View File

@@ -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

View File

@@ -37,7 +37,7 @@ export const handleCreateFeature = createRoute({
}
return c.json(
dbToApiFeatureV1({ dbFeature, targetVersion: ctx.apiVersion }),
dbToApiFeatureV1({ ctx, dbFeature, targetVersion: ctx.apiVersion }),
);
},
});

View File

@@ -33,6 +33,7 @@ export const handleGetFeature = createRoute({
}
const apiFeature = dbToApiFeatureV1({
ctx,
dbFeature: feature,
targetVersion: ctx.apiVersion,
});

View File

@@ -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 });

View File

@@ -65,6 +65,7 @@ export const handleUpdateFeature = createRoute({
return c.json(
dbToApiFeatureV1({
ctx,
dbFeature: updatedFeature,
targetVersion: ctx.apiVersion,
}),

View File

@@ -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;

View File

@@ -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);

View File

@@ -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([

View File

@@ -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,

View File

@@ -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,
},

View File

@@ -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,

View File

@@ -5,7 +5,7 @@ import {
CreatePlanParamsSchema,
ListPlansQuerySchema,
UpdatePlanParamsSchema,
} from "../products/planOpModels.js";
} from "../products/crud/planOpModels.js";
export const ApiPlanWithMeta = ApiPlanSchema.meta({
id: "Plan",

View File

@@ -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({

View File

@@ -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({

View File

@@ -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(

View File

@@ -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<typeof ApiProductItemSchema>;
item: z.infer<typeof ApiProductItemV0Schema>;
}) => {
const singleTier =
isPriceItem(item) ||

View File

@@ -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<typeof ApiSubscriptionSchema>;
legacyData?: z.infer<typeof CusProductLegacyDataSchema>;
ctx: VersionContext;
ctx: SharedContext;
}): z.infer<typeof ApiCusProductV3Schema> {
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) =>

View File

@@ -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(),
});

View File

@@ -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",

View File

@@ -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";

View File

@@ -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

View File

@@ -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,

View File

@@ -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({

View File

@@ -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,
},
};

View File

@@ -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<typeof ApiPlanFeatureSchema>;
export type ApiPlanItemV0 = z.infer<typeof ApiPlanItemV0Schema>;
export const ApiPlanFeatureWithMeta = ApiPlanFeatureSchema.meta({
export const ApiPlanItemV0WithMeta = ApiPlanItemV0Schema.meta({
id: "PlanFeature",
description: "Plan feature object returned by the API",
example: {

View File

@@ -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<typeof UpdatePlanFeatureSchema>;
export type CreatePlanItemParamsV0 = z.infer<
typeof CreatePlanItemParamsV0Schema
>;

View File

@@ -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,
},
};

View File

@@ -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);
};

View File

@@ -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<typeof ApiProductItemSchema>;
export type ApiProductItem = z.infer<typeof ApiProductItemV0Schema>;

View File

@@ -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);
};

View File

@@ -0,0 +1 @@
export * from "./planV0ToProductItems.js";

View File

@@ -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,
};
}
};

View File

@@ -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 [];
};

View File

@@ -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 }),
};
}

View File

@@ -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",
),

View File

@@ -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<TLegacyDataSchema>
: never;
ctx?: VersionContext;
ctx?: SharedContext;
}): z.infer<TOldSchema> {
// Default: no-op (override if change affects responses)
return input as unknown as z.infer<TOldSchema>;
@@ -256,7 +256,7 @@ export interface VersionChangeConfig<
legacyData?: TLegacyDataSchema extends ZodType
? z.infer<TLegacyDataSchema>
: never;
ctx: VersionContext;
ctx: SharedContext;
}) => z.infer<TOldSchema>;
}
@@ -325,7 +325,7 @@ export function defineVersionChange<
legacyData?: TLegacyDataSchema extends ZodType
? z.infer<TLegacyDataSchema>
: never;
ctx: VersionContext;
ctx: SharedContext;
}): z.infer<TOldSchema> {
if (config.transformResponse) {
const result = config.transformResponse(

View File

@@ -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<T = any, TLegacyData = any>({
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<T = any, TLegacyData = any>({
currentVersion?: ApiVersionClass;
targetVersion: ApiVersionClass;
resource: AffectedResource;
ctx: VersionContext;
ctx: SharedContext;
}): T[] {
return inputArray.map((item) =>
applyResponseVersionChanges({

View File

@@ -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";

View File

@@ -33,7 +33,6 @@ export {
AffectedResource,
VersionChange,
type VersionChangeConstructor,
type VersionContext,
} from "./versionChangeUtils/VersionChange.js";
export { VersionChangeRegistryClass } from "./versionChangeUtils/VersionChangeRegistryClass.js";
// Version registry

View File

@@ -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";

2
shared/types/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export * from "./logger";
export * from "./sharedContext";

7
shared/types/logger.ts Normal file
View File

@@ -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<string, unknown>; onlyProd?: boolean }) => AutumnLogger;
};

View File

@@ -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<string, unknown>;
// testOptions?: {
// skipCacheDeletion?: boolean;
// skipWebhooks?: boolean;
// eventId?: string;
// };
};

View File

@@ -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,
});
};

View File

@@ -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);
};

View File

@@ -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);
});
};

View File

@@ -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);
});
};

View File

@@ -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;
};

View File

@@ -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 }),
};
}

View File

@@ -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,