Remove the AI-credit-specific plan editor UI (USD budget input, dollar-sign display, model-usage billing copy, hidden price tiers) so AI credit systems are configured and displayed exactly like classic credit systems. The AI credit feature editor (model markups, schema) is unchanged.
370 lines
9.8 KiB
TypeScript
370 lines
9.8 KiB
TypeScript
import {
|
|
FeatureType,
|
|
FeatureUsageType,
|
|
} from "../models/featureModels/featureEnums.js";
|
|
import type { Feature } from "../models/featureModels/featureModels.js";
|
|
import { TierBehavior } from "../models/productModels/priceModels/priceConfig/usagePriceConfig.js";
|
|
import { Infinite } from "../models/productModels/productEnums.js";
|
|
import type { ProductItem } from "../models/productV2Models/productItemModels/productItemModels.js";
|
|
import { formatAmount } from "./common/formatUtils/formatAmount.js";
|
|
import { formatInterval } from "./common/formatUtils/formatInterval.js";
|
|
import { getFeatureName, numberWithCommas } from "./displayUtils.js";
|
|
import {
|
|
isFeatureItem,
|
|
isFeaturePriceItem,
|
|
isPriceItem,
|
|
} from "./productV2Utils/productItemUtils/getItemType.js";
|
|
import { notNullish, nullish } from "./utils.js";
|
|
|
|
// ============================================================================
|
|
// Types
|
|
// ============================================================================
|
|
|
|
interface DisplayResult {
|
|
primary_text: string;
|
|
secondary_text?: string;
|
|
}
|
|
|
|
interface FormatTiersParams {
|
|
item: ProductItem;
|
|
currency?: string | null;
|
|
amountFormatOptions?: Intl.NumberFormatOptions;
|
|
useFlatAmount?: boolean;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Helpers
|
|
// ============================================================================
|
|
|
|
const getIntervalDisplay = (item: ProductItem): string | undefined => {
|
|
if (!item.interval) return undefined;
|
|
|
|
return formatInterval({
|
|
interval: item.interval,
|
|
intervalCount: item.interval_count ?? undefined,
|
|
});
|
|
};
|
|
|
|
const getIncludedUsageText = (item: ProductItem, feature: Feature): string => {
|
|
const featureName = getFeatureName({
|
|
feature,
|
|
units: item.included_usage,
|
|
});
|
|
|
|
if (item.included_usage === Infinite) {
|
|
return `Unlimited ${featureName}`;
|
|
}
|
|
if (nullish(item.included_usage) || item.included_usage === 0) {
|
|
return `0 ${featureName}`;
|
|
}
|
|
|
|
return `${numberWithCommas(item.included_usage)} ${featureName}`;
|
|
};
|
|
|
|
const isSingleUseFeature = (feature: Feature): boolean => {
|
|
return feature.config?.usage_type === FeatureUsageType.Single;
|
|
};
|
|
|
|
/** Volume-based tiers where pricing is a flat amount per tier band (not per-unit). */
|
|
const isVolumeFlatAmountItem = (item: ProductItem): boolean => {
|
|
if (item.tier_behavior !== TierBehavior.VolumeBased) return false;
|
|
if (!item.tiers || item.tiers.length === 0) return false;
|
|
return (
|
|
item.tiers.every((t) => t.amount === 0) &&
|
|
item.tiers.some((t) => (t.flat_amount ?? 0) > 0)
|
|
);
|
|
};
|
|
|
|
// ============================================================================
|
|
// Tier Formatting
|
|
// ============================================================================
|
|
|
|
export const formatTiers = ({
|
|
item,
|
|
currency,
|
|
amountFormatOptions,
|
|
useFlatAmount = false,
|
|
}: FormatTiersParams): string | undefined => {
|
|
const tiers = item.tiers;
|
|
if (!tiers) return undefined;
|
|
|
|
const format = (amount: number) =>
|
|
formatAmount({ currency, amount, amountFormatOptions });
|
|
|
|
const getAmount = (tier: (typeof tiers)[number]) =>
|
|
useFlatAmount ? (tier.flat_amount ?? 0) : tier.amount;
|
|
|
|
if (tiers.length === 1) {
|
|
return format(getAmount(tiers[0]));
|
|
}
|
|
|
|
const firstPrice = getAmount(tiers[0]);
|
|
const lastPrice = getAmount(tiers[tiers.length - 1]);
|
|
|
|
return `${format(firstPrice)} - ${format(lastPrice)}`;
|
|
};
|
|
|
|
// ============================================================================
|
|
// Feature Item Display (no pricing, just entitlement)
|
|
// ============================================================================
|
|
|
|
export const getFeatureItemDisplay = ({
|
|
item,
|
|
feature,
|
|
entityFeature,
|
|
fullDisplay = false,
|
|
}: {
|
|
item: ProductItem;
|
|
feature?: Feature;
|
|
entityFeature?: Feature;
|
|
fullDisplay?: boolean;
|
|
}): DisplayResult => {
|
|
if (!feature) {
|
|
// Return fallback display when feature is not found (e.g., during feature ID rename)
|
|
return { primary_text: item.feature_id || "Loading..." };
|
|
}
|
|
|
|
// Boolean features just show the name
|
|
if (feature.type === FeatureType.Boolean) {
|
|
return { primary_text: feature.name };
|
|
}
|
|
|
|
const primaryText = getIncludedUsageText(item, feature);
|
|
|
|
// Determine secondary text: per-entity scope + interval.
|
|
// e.g. "per user per month" when entity_feature_id is set, otherwise "per month".
|
|
let secondaryText: string | undefined;
|
|
if (fullDisplay) {
|
|
const parts: string[] = [];
|
|
|
|
if (item.entity_feature_id && entityFeature) {
|
|
const entityName = getFeatureName({
|
|
feature: entityFeature,
|
|
units: 1,
|
|
});
|
|
if (entityName) {
|
|
parts.push(`per ${entityName}`);
|
|
}
|
|
}
|
|
|
|
const intervalDisplay = getIntervalDisplay(item);
|
|
if (intervalDisplay) {
|
|
parts.push(intervalDisplay);
|
|
} else if (
|
|
isSingleUseFeature(feature) &&
|
|
item.included_usage !== Infinite
|
|
) {
|
|
parts.push("one-off");
|
|
}
|
|
|
|
if (parts.length > 0) {
|
|
secondaryText = parts.join(" ");
|
|
}
|
|
}
|
|
|
|
return {
|
|
primary_text: primaryText,
|
|
secondary_text: secondaryText,
|
|
};
|
|
};
|
|
|
|
// ============================================================================
|
|
// Price Item Display (flat price, no feature)
|
|
// ============================================================================
|
|
|
|
export const getPriceItemDisplay = ({
|
|
item,
|
|
currency,
|
|
}: {
|
|
item: ProductItem;
|
|
currency?: string | null;
|
|
}): DisplayResult => {
|
|
const primaryText = formatAmount({
|
|
currency,
|
|
amount: item.price as number,
|
|
});
|
|
|
|
const secondaryText = getIntervalDisplay(item);
|
|
|
|
return {
|
|
primary_text: primaryText,
|
|
secondary_text: secondaryText,
|
|
};
|
|
};
|
|
|
|
// ============================================================================
|
|
// Feature + Price Item Display (usage-based pricing)
|
|
// ============================================================================
|
|
|
|
export const getFeaturePriceItemDisplay = ({
|
|
feature,
|
|
item,
|
|
currency,
|
|
isMainPrice = false,
|
|
amountFormatOptions,
|
|
fullDisplay = false,
|
|
}: {
|
|
feature?: Feature;
|
|
item: ProductItem;
|
|
currency?: string | null;
|
|
isMainPrice?: boolean;
|
|
amountFormatOptions?: Intl.NumberFormatOptions;
|
|
fullDisplay?: boolean;
|
|
}): DisplayResult => {
|
|
if (!feature) {
|
|
throw new Error(`Feature ${item.feature_id} not found`);
|
|
}
|
|
|
|
// Build included usage string (e.g., "100 credits")
|
|
const includedUsage = item.included_usage as number | null;
|
|
const hasIncludedUsage = notNullish(includedUsage) && includedUsage > 0;
|
|
|
|
const includedFeatureName = getFeatureName({
|
|
feature,
|
|
units: item.included_usage,
|
|
});
|
|
let includedUsageStr = "";
|
|
if (hasIncludedUsage) {
|
|
includedUsageStr = `${numberWithCommas(includedUsage)} ${includedFeatureName}`;
|
|
}
|
|
|
|
const volumeFlatAmount = isVolumeFlatAmountItem(item);
|
|
|
|
// For volume flat-amount items, tier.amount is always 0 — the real price
|
|
// lives in tier.flat_amount, so we must pass useFlatAmount: true.
|
|
let priceStr: string;
|
|
if (volumeFlatAmount) {
|
|
priceStr =
|
|
formatTiers({
|
|
item,
|
|
currency,
|
|
amountFormatOptions,
|
|
useFlatAmount: true,
|
|
}) ?? "";
|
|
} else if (item.tiers) {
|
|
priceStr = formatTiers({ item, currency, amountFormatOptions }) ?? "";
|
|
} else {
|
|
priceStr = notNullish(item.price)
|
|
? formatAmount({ currency, amount: item.price, amountFormatOptions })
|
|
: "";
|
|
}
|
|
|
|
// Build billing unit string (e.g., "credit" or "100 credits")
|
|
const billingUnits = item.billing_units ?? 1;
|
|
const billingFeatureName = getFeatureName({
|
|
feature,
|
|
units: billingUnits,
|
|
});
|
|
const perUnitStr =
|
|
billingUnits > 1
|
|
? `${numberWithCommas(billingUnits)} ${billingFeatureName}`
|
|
: billingFeatureName;
|
|
|
|
// Build interval string
|
|
const showInterval = isMainPrice || fullDisplay;
|
|
let intervalStr = "";
|
|
if (showInterval) {
|
|
const intervalDisplay = getIntervalDisplay(item);
|
|
if (intervalDisplay) {
|
|
intervalStr = intervalDisplay;
|
|
} else if (isSingleUseFeature(feature)) {
|
|
intervalStr = "one-off";
|
|
}
|
|
}
|
|
|
|
// Format output based on what we have
|
|
if (hasIncludedUsage) {
|
|
if (volumeFlatAmount) {
|
|
const featureName = getFeatureName({ feature, units: 2 });
|
|
return {
|
|
primary_text: includedUsageStr,
|
|
secondary_text:
|
|
`then ${priceStr} for ${featureName} ${intervalStr}`.trim(),
|
|
};
|
|
}
|
|
return {
|
|
primary_text: includedUsageStr,
|
|
secondary_text:
|
|
`then ${priceStr} per ${perUnitStr} ${intervalStr}`.trim(),
|
|
};
|
|
}
|
|
|
|
if (volumeFlatAmount) {
|
|
const featureName = getFeatureName({ feature, units: 2 });
|
|
if (showInterval) {
|
|
return {
|
|
primary_text: priceStr,
|
|
secondary_text: `for ${featureName} ${intervalStr}`.trim(),
|
|
};
|
|
}
|
|
return {
|
|
primary_text: `${priceStr} for ${featureName}`.trim(),
|
|
secondary_text: undefined,
|
|
};
|
|
}
|
|
|
|
if (showInterval) {
|
|
return {
|
|
primary_text: priceStr,
|
|
secondary_text: `per ${perUnitStr} ${intervalStr}`.trim(),
|
|
};
|
|
}
|
|
|
|
return {
|
|
primary_text: `${priceStr} per ${perUnitStr}`.trim(),
|
|
secondary_text: undefined,
|
|
};
|
|
};
|
|
|
|
// ============================================================================
|
|
// Main Entry Point
|
|
// ============================================================================
|
|
|
|
export const getProductItemDisplay = ({
|
|
item,
|
|
features,
|
|
currency = "usd",
|
|
fullDisplay = false,
|
|
amountFormatOptions,
|
|
}: {
|
|
item: ProductItem;
|
|
features: Feature[];
|
|
currency?: string | null;
|
|
fullDisplay?: boolean;
|
|
amountFormatOptions?: Intl.NumberFormatOptions;
|
|
}): DisplayResult => {
|
|
const findFeature = () => features.find((f) => f.id === item.feature_id);
|
|
const findEntityFeature = () =>
|
|
item.entity_feature_id
|
|
? features.find((f) => f.id === item.entity_feature_id)
|
|
: undefined;
|
|
|
|
if (isFeatureItem(item)) {
|
|
return getFeatureItemDisplay({
|
|
item,
|
|
feature: findFeature(),
|
|
entityFeature: findEntityFeature(),
|
|
fullDisplay,
|
|
});
|
|
}
|
|
|
|
if (isPriceItem(item)) {
|
|
return getPriceItemDisplay({ item, currency });
|
|
}
|
|
|
|
if (isFeaturePriceItem(item)) {
|
|
return getFeaturePriceItemDisplay({
|
|
item,
|
|
feature: findFeature(),
|
|
currency,
|
|
fullDisplay,
|
|
amountFormatOptions,
|
|
});
|
|
}
|
|
|
|
return {
|
|
primary_text: "couldn't detect item type",
|
|
secondary_text: undefined,
|
|
};
|
|
};
|