Files
cfw-autumn/server/src/internal/features/featureUtils.ts
2026-06-10 15:46:14 +01:00

214 lines
5.9 KiB
TypeScript

import {
ApiFeatureType,
type CreditSystemConfig,
cusProductsToCusPrices,
ErrCode,
type Feature,
FeatureType,
FeatureUsageType,
type FullCustomer,
isAiCreditSystem,
isAllocatedPrice,
type MeteredConfig,
type UsagePriceConfig,
} from "@autumn/shared";
import { ACTIVE_STATUSES } from "@server/internal/customers/cusProducts/CusProductService";
import RecaseError from "@server/utils/errorUtils";
import { StatusCodes } from "http-status-codes";
export const validateFeatureId = (featureId: string) => {
if (!featureId.match(/^[a-zA-Z0-9_-]+$/)) {
throw new RecaseError({
message:
"Feature ID can only contain alphanumeric characters, underscores, and hyphens",
code: ErrCode.InvalidFeature,
statusCode: 400,
});
}
return;
};
export const validateMeteredConfig = (config: MeteredConfig) => {
const newConfig = { ...config };
if (!config.usage_type) {
throw new RecaseError({
message: `Usage type (single or continuous) is required for metered feature`,
code: ErrCode.InvalidFeature,
statusCode: StatusCodes.BAD_REQUEST,
});
}
return newConfig as MeteredConfig;
};
export const validateCreditSystem = (
config: CreditSystemConfig,
featureType: FeatureType = FeatureType.CreditSystem,
) => {
const schema = Array.isArray(config?.schema) ? config.schema : [];
if (!isAiCreditSystem(featureType) && schema.length === 0) {
throw new RecaseError({
message: `At least one metered feature is required for credit system`,
code: ErrCode.InvalidFeature,
statusCode: 400,
});
}
if (isAiCreditSystem(featureType) && schema.length > 0) {
throw new RecaseError({
message: `AI credit systems are leaf features and cannot define a schema. Model rates live in model_markups.`,
code: ErrCode.InvalidFeature,
statusCode: 400,
});
}
const meteredFeatureIds = schema.map(
(schemaItem) => schemaItem.metered_feature_id,
);
const uniqueMeteredFeatureIds = Array.from(new Set(meteredFeatureIds));
if (meteredFeatureIds.length !== uniqueMeteredFeatureIds.length) {
throw new RecaseError({
message: `Credit system contains multiple of the same metered_feature_id`,
code: ErrCode.InvalidFeature,
statusCode: 400,
});
}
const newConfig = { ...config, schema, usage_type: FeatureUsageType.Single };
const defaultMarkup = newConfig.default_markup;
if (defaultMarkup != null) {
const parsedDefaultMarkup = Number(defaultMarkup);
if (Number.isNaN(parsedDefaultMarkup) || parsedDefaultMarkup < -100) {
throw new RecaseError({
message:
"Default markup must be -100 or greater (-100 makes usage free)",
code: ErrCode.InvalidFeature,
statusCode: 400,
});
}
newConfig.default_markup = parsedDefaultMarkup;
}
const providerMarkups = newConfig.provider_markups;
if (providerMarkups != null) {
for (const [provider, entry] of Object.entries(providerMarkups)) {
const markup = Number(entry?.markup);
if (!provider || Number.isNaN(markup) || markup < -100) {
throw new RecaseError({
message:
"Provider markups must be -100 or greater (-100 makes usage free)",
code: ErrCode.InvalidFeature,
statusCode: 400,
});
}
entry.markup = markup;
}
}
for (let i = 0; i < newConfig.schema.length; i++) {
const creditAmount = parseFloat(
newConfig.schema[i].credit_amount.toString(),
);
if (Number.isNaN(creditAmount)) {
throw new RecaseError({
message: `Credit amount should be a number`,
code: ErrCode.InvalidFeature,
statusCode: 400,
});
}
newConfig.schema[i].credit_amount = creditAmount;
}
return newConfig;
};
/**
* Validates that every feature referenced by a credit system's schema is a
* Metered or AiCreditSystem feature. Rejects nesting one CreditSystem inside
* another — composition is capped at two levels (parent credit system → leaf).
*
* Self-references are tolerated (the create flow doesn't yet have the new id
* in the features list, and updates simply read back as the feature itself).
*/
export const validateCreditSystemSchemaReferences = ({
config,
allFeatures,
selfFeatureId,
}: {
config: CreditSystemConfig;
allFeatures: Feature[];
selfFeatureId?: string;
}) => {
const schema = Array.isArray(config?.schema) ? config.schema : [];
if (schema.length === 0) return;
for (const item of schema) {
const referencedId = item.metered_feature_id;
if (!referencedId || referencedId === selfFeatureId) continue;
const referenced = allFeatures.find((f) => f.id === referencedId);
if (!referenced) continue;
if (referenced.type === FeatureType.CreditSystem) {
throw new RecaseError({
message: `Credit system schema cannot reference another credit system (${referencedId}). Only metered or AI credit features are allowed.`,
code: ErrCode.InvalidFeature,
statusCode: 400,
});
}
}
};
const getCusFeatureType = ({ feature }: { feature: Feature }) => {
if (feature.type === FeatureType.Boolean) {
return ApiFeatureType.Static;
} else if (feature.type === FeatureType.Metered) {
if (feature.config.usage_type === FeatureUsageType.Single) {
return ApiFeatureType.SingleUsage;
} else {
return ApiFeatureType.ContinuousUse;
}
} else {
return ApiFeatureType.SingleUsage;
}
};
const isCreditSystem = ({ feature }: { feature: Feature }) => {
return feature.type === FeatureType.CreditSystem;
};
export const isPaidContinuousUse = ({
feature,
fullCus,
}: {
feature: Feature;
fullCus: FullCustomer;
}) => {
const isContinuous =
feature.config?.usage_type === FeatureUsageType.Continuous;
if (!isContinuous) {
return false;
}
const cusPrices = cusProductsToCusPrices({
cusProducts: fullCus.customer_products,
inStatuses: ACTIVE_STATUSES,
});
const hasPaid = cusPrices.some((cp) => {
const config = cp.price.config as UsagePriceConfig;
const featureIdMatches = config.internal_feature_id === feature.internal_id;
const allocatedPrice = isAllocatedPrice(cp.price);
return featureIdMatches && allocatedPrice;
});
return hasPaid;
};