Files
2026-07-13 13:32:57 +08:00

414 lines
12 KiB
TypeScript

import type { BillingLimitResult } from "~/services/billingLimit.schemas";
export type BillingAlertsFormData = {
/** Stored base amount in dollars (from API cents / 100). Used when converting legacy alerts. */
amount: number;
emails: string[];
alertLevels: number[];
};
/** $1 base (in cents) for absolute spend alerts when billing limit mode is none. */
export const ABSOLUTE_ALERT_BASE_CENTS = 100;
export const MAX_PERCENTAGE_ALERTS = 5;
export const MAX_ABSOLUTE_ALERTS = 10;
export const MAX_PERCENTAGE_THRESHOLD = 100;
export type BillingLimitMode = "plan" | "custom" | "none";
export function getBillingLimitMode(billingLimit: BillingLimitResult): BillingLimitMode {
if (!billingLimit.isConfigured) {
return "none";
}
return billingLimit.mode;
}
export function isPercentageAlertMode(mode: BillingLimitMode): boolean {
return mode === "plan" || mode === "custom";
}
export function shouldClearAlertsOnLimitChange(
previousMode: BillingLimitMode,
nextMode: BillingLimitMode
): boolean {
return shouldResetAlertsOnLimitChange(previousMode, nextMode);
}
/** Alert format changes when crossing between percentage (plan/custom) and dollar (none) modes. */
export function shouldResetAlertsOnLimitChange(
previousMode: BillingLimitMode | null,
nextMode: BillingLimitMode
): boolean {
if (previousMode === null) {
return false;
}
return isPercentageAlertMode(previousMode) !== isPercentageAlertMode(nextMode);
}
/** Configured billing limit mode before a save; null when billing limit was never configured. */
export function getPreviousBillingLimitModeForAlertSync(
billingLimit: BillingLimitResult
): BillingLimitMode | null {
if (!billingLimit.isConfigured) {
return null;
}
return billingLimit.mode;
}
export function hasConfiguredAlerts(
alerts: BillingAlertsFormData,
billingLimit: BillingLimitResult,
planLimitCents: number
): boolean {
const mode = getBillingLimitMode(billingLimit);
const effectiveLimitCents = getEffectiveLimitCents(billingLimit, planLimitCents);
return storedAlertsToThresholds(alerts, mode, effectiveLimitCents, planLimitCents).length > 0;
}
export function hasSavedAlertThresholds(alerts: BillingAlertsFormData): boolean {
return alerts.alertLevels.length > 0;
}
/** Saved thresholds that would be cleared when the billing limit alert format changes. */
export function hadSavedAlertsToClearOnLimitChange(
alerts: BillingAlertsFormData,
billingLimit: BillingLimitResult,
planLimitCents: number
): boolean {
return hasConfiguredAlerts(alerts, billingLimit, planLimitCents);
}
export function normalizeThresholdValues(values: number[]): number[] {
return [...values].sort((a, b) => a - b);
}
export function thresholdValuesAreUnique(values: number[]): boolean {
const normalized = normalizeThresholdValues(values);
return new Set(normalized).size === normalized.length;
}
export function normalizeEmailValues(values: string[]): string[] {
return values.map((value) => value.trim()).filter(Boolean);
}
export function thresholdsMatchSaved(current: number[], saved: number[]): boolean {
return (
JSON.stringify(normalizeThresholdValues(current)) ===
JSON.stringify(normalizeThresholdValues(saved))
);
}
export function emailsMatchSaved(current: string[], saved: string[]): boolean {
return (
JSON.stringify(normalizeEmailValues(current)) === JSON.stringify(normalizeEmailValues(saved))
);
}
export function clearedAlertsPayload(emails: string[] = []): {
amount: number;
alertLevels: number[];
emails: string[];
} {
return {
amount: ABSOLUTE_ALERT_BASE_CENTS,
alertLevels: [],
emails,
};
}
export function resetAlertsPayloadForLimitMode(
nextMode: BillingLimitMode,
effectiveLimitCents: number,
emails: string[] = []
): { amount: number; alertLevels: number[]; emails: string[] } {
if (nextMode === "none") {
return clearedAlertsPayload(emails);
}
return {
amount: effectiveLimitCents,
alertLevels: [],
emails,
};
}
export function getEffectiveLimitCents(
billingLimit: BillingLimitResult,
planLimitCents: number
): number {
if (!billingLimit.isConfigured) {
return planLimitCents;
}
if (billingLimit.mode === "custom") {
return billingLimit.amountCents;
}
if (billingLimit.mode === "plan") {
return billingLimit.effectiveAmountCents ?? planLimitCents;
}
return planLimitCents;
}
/** Billing limit in cents when configured (plan/custom); undefined for none or unconfigured. */
export function getConfiguredBillingLimitCents(
billingLimit: BillingLimitResult | undefined,
planLimitCents: number
): number | undefined {
if (!billingLimit?.isConfigured || billingLimit.mode === "none") {
return undefined;
}
return getEffectiveLimitCents(billingLimit, planLimitCents);
}
/** Dollars for UsageBar marker; omitted when no limit or same as plan included usage. */
export function getUsageBarBillingLimitDollars(
billingLimit: BillingLimitResult | undefined,
planLimitCents: number
): number | undefined {
const limitCents = getConfiguredBillingLimitCents(billingLimit, planLimitCents);
if (limitCents === undefined || limitCents === planLimitCents) {
return undefined;
}
return limitCents / 100;
}
function getSavedAlertAmountCents(alerts: BillingAlertsFormData): number {
return Math.round(alerts.amount * 100);
}
function usesFractionAlertLevelFormat(levels: number[]): boolean {
return levels.some((level) => level > 0 && level <= 1);
}
export type NormalizeBillingAlertsOptions = {
planLimitCents: number;
effectiveLimitCents: number;
};
function isAbsoluteDollarAlertAmount(rawAmount: number, alertLevels: number[]): boolean {
if (rawAmount !== ABSOLUTE_ALERT_BASE_CENTS || alertLevels.length === 0) {
return false;
}
// None-mode absolute alerts use the $1 base (100 cents) with dollar thresholds (e.g. 100, 250).
// Legacy percentage alerts at amount=100 use UI percent values, with at least one below 100.
const hasTypicalPercentLevel = alertLevels.some((level) => level > 0 && level < 100);
return !hasTypicalPercentLevel;
}
/** Detect legacy alerts that stored the limit base in dollars with whole-number percents. */
export function isLegacyDollarAmountField(
rawAmount: number,
alertLevels: number[],
options: NormalizeBillingAlertsOptions
): boolean {
if (isAbsoluteDollarAlertAmount(rawAmount, alertLevels)) {
return false;
}
if (!Number.isFinite(rawAmount) || rawAmount < 10) {
return false;
}
if (alertLevels.length === 0 || usesFractionAlertLevelFormat(alertLevels)) {
return false;
}
// Platform migrated $10+ limits to cents (e.g. 10_000 for $100).
if (rawAmount >= 1000) {
return false;
}
if (rawAmount === options.planLimitCents || rawAmount === options.effectiveLimitCents) {
return false;
}
const planDollars = Math.round(options.planLimitCents / 100);
const effectiveDollars = Math.round(options.effectiveLimitCents / 100);
return rawAmount === planDollars || rawAmount === effectiveDollars;
}
export function isAbsoluteSavedAlerts(alerts: BillingAlertsFormData): boolean {
return getSavedAlertAmountCents(alerts) === ABSOLUTE_ALERT_BASE_CENTS;
}
/** Build a cleaned alerts payload when saving billing limits in the same alert format. */
export function buildCleanedAlertsPayloadForLimitSave(
alerts: BillingAlertsFormData,
nextMode: BillingLimitMode,
effectiveLimitCents: number,
planLimitCents: number
): { amount: number; alertLevels: number[]; emails: string[] } | null {
if (alerts.alertLevels.length === 0) {
return null;
}
const thresholds = storedAlertsToThresholds(
alerts,
nextMode,
effectiveLimitCents,
planLimitCents
);
return {
emails: alerts.emails,
...thresholdsToAlertPayload(thresholds, nextMode, effectiveLimitCents),
};
}
/** Convert stored percentage alert levels to UI percent values (10, 50, 80). */
export function percentageAlertLevelsToUiThresholds(levels: number[]): number[] {
const normalized = levels.filter((level) => Number.isFinite(level) && level > 0);
if (normalized.length === 0) {
return [];
}
if (usesFractionAlertLevelFormat(normalized)) {
return normalized.filter((level) => level <= 1).map((level) => Math.round(level * 100));
}
return normalized
.filter((level) => level <= MAX_PERCENTAGE_THRESHOLD)
.map((level) => Math.round(level));
}
export function normalizeBillingAlertsFromApi(
apiAlerts: {
amount: number;
emails?: string[];
alertLevels?: number[];
},
options: NormalizeBillingAlertsOptions
): BillingAlertsFormData {
const rawAmount = Number(apiAlerts.amount);
const alertLevels = (apiAlerts.alertLevels ?? []).map(Number).filter(Number.isFinite);
// Platform API stores amount in cents.
let amountDollars = rawAmount / 100;
if (isLegacyDollarAmountField(rawAmount, alertLevels, options)) {
amountDollars = rawAmount;
}
return {
amount: amountDollars,
emails: apiAlerts.emails ?? [],
alertLevels,
};
}
/** Legacy alerts used plan included usage; new alerts use the billing limit amount. */
function percentageAlertAmountMatches(
amountCents: number,
effectiveLimitCents: number,
planLimitCents: number
): boolean {
return amountCents === effectiveLimitCents || amountCents === planLimitCents;
}
/** Cents base for dollar preview when displaying saved percentage alerts. */
export function getAlertPreviewLimitCents(
alerts: BillingAlertsFormData,
effectiveLimitCents: number,
planLimitCents: number
): number {
const amountCents = getSavedAlertAmountCents(alerts);
if (amountCents > 0 && percentageAlertLevelsToUiThresholds(alerts.alertLevels).length > 0) {
return amountCents;
}
if (percentageAlertAmountMatches(amountCents, effectiveLimitCents, planLimitCents)) {
return amountCents;
}
return effectiveLimitCents;
}
/** Convert stored API alerts to UI threshold values (percent or dollars). */
export function storedAlertsToThresholds(
alerts: BillingAlertsFormData,
mode: BillingLimitMode,
effectiveLimitCents: number,
planLimitCents: number
): number[] {
const amountCents = getSavedAlertAmountCents(alerts);
if (mode === "none") {
if (alerts.alertLevels.length === 0) {
return [];
}
// Absolute dollar alerts: API amount is the $1 base marker (100 cents).
if (
amountCents === ABSOLUTE_ALERT_BASE_CENTS ||
alerts.amount === ABSOLUTE_ALERT_BASE_CENTS / 100
) {
return alerts.alertLevels.slice(0, MAX_ABSOLUTE_ALERTS);
}
return [];
}
const uiThresholds = percentageAlertLevelsToUiThresholds(alerts.alertLevels);
if (uiThresholds.length === 0) {
return [];
}
// Saved percentage alerts keep their thresholds whenever a positive base amount is stored.
if (amountCents > 0) {
return uiThresholds.slice(0, MAX_PERCENTAGE_ALERTS);
}
return [];
}
export function thresholdsToAlertPayload(
thresholds: number[],
mode: BillingLimitMode,
effectiveLimitCents: number
): { amount: number; alertLevels: number[] } {
if (mode === "none") {
return {
amount: ABSOLUTE_ALERT_BASE_CENTS,
alertLevels: thresholds,
};
}
return {
amount: effectiveLimitCents,
alertLevels: thresholds.map((percent) => percent / 100),
};
}
export function isEmptyThreshold(value: number): boolean {
return !Number.isFinite(value) || value <= 0;
}
export function previewDollarAmountForPercent(
percent: number,
effectiveLimitCents: number
): number {
if (!Number.isFinite(percent) || percent <= 0) {
return 0;
}
return (effectiveLimitCents * percent) / 100 / 100;
}
/** Legacy percentage alerts may include spike multipliers above 100%. */
export function hasLegacySpikeAlertLevels(
alerts: BillingAlertsFormData,
mode: BillingLimitMode,
effectiveLimitCents: number,
planLimitCents: number
): boolean {
if (!isPercentageAlertMode(mode)) {
return false;
}
if (usesFractionAlertLevelFormat(alerts.alertLevels)) {
return alerts.alertLevels.some((level) => level > 1);
}
return alerts.alertLevels.some((level) => level > MAX_PERCENTAGE_THRESHOLD);
}