364 lines
9.7 KiB
TypeScript
364 lines
9.7 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,
|
|
});
|
|
const includedUsageStr = hasIncludedUsage
|
|
? `${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,
|
|
};
|
|
};
|