Files
chaos-api/web/default/src/lib/currency.ts
T
2026-05-26 20:28:28 +08:00

574 lines
18 KiB
TypeScript
Vendored
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
/**
* ============================================================================
* Currency Formatting Library
* ============================================================================
*
* This module provides currency formatting utilities that handle the conversion
* between system USD amounts, local currency, and token displays based on
* admin-configured settings.
*
* ## Key Concepts
*
* 1. **System USD**: Internal currency unit used throughout the system (e.g., 10 USD)
* 2. **Local Currency**: Admin-configured display currency (e.g., CNY, custom currency)
* 3. **Exchange Rate (usdExchangeRate)**: Conversion rate from USD to local currency
* - Example: usdExchangeRate = 7 means 1 USD = 7 CNY
* 4. **Recharge Price (priceRatio)**: Cost in local currency to purchase 1 system USD
* - Example: priceRatio = 5 means user pays 5 CNY to get 1 USD credit
* 5. **Tokens**: Alternative display unit (e.g., 500,000 tokens = 1 USD)
*
* ## When to Use Each Function
*
* - `formatCurrencyFromUSD()`: Use for quota/balance display (stored as USD, converted for display)
* - `formatBillingCurrencyFromUSD()`: Use for billing/pricing displays (never shows tokens)
* - `formatLocalCurrencyAmount()`: Use for payment amounts already in local currency
* - `formatQuotaWithCurrency()`: Use for raw quota values (converts to USD first)
*
* ## Example Scenario
*
* Admin Configuration:
* - quotaDisplayType: 'CNY'
* - usdExchangeRate: 7 (1 USD = 7 CNY)
* - priceRatio: 5 (5 CNY per 1 USD credit)
* - quotaPerUnit: 500000 (tokens per USD)
*
* User Flow:
* 1. Recharge option: 10 USD
* - Display: formatCurrencyFromUSD(10) → "¥70"
* 2. Payment amount: 10 × 5 = 50 (already in CNY)
* - Display: formatLocalCurrencyAmount(50) → "¥50"
* 3. User receives: 10 USD credit
* - Balance display: formatCurrencyFromUSD(10) → "¥70"
*
* ## Quick Reference Guide
*
* | Scenario | Input Type | Function to Use | Why |
* |----------|-----------|-----------------|-----|
* | User balance display | USD (from DB) | `formatCurrencyFromUSD()` | Needs conversion to display currency |
* | Recharge option button | USD | `formatCurrencyFromUSD()` | Needs conversion to local currency |
* | Payment confirmation | Already local currency | `formatLocalCurrencyAmount()` | Already converted via priceRatio |
* | Billing history Amount | USD (from DB) | `formatCurrencyFromUSD()` | Historical USD needs conversion |
* | Billing history Payment | Local currency | `formatNumber()` | Just show number, no symbol |
* | Model pricing | USD | `formatBillingCurrencyFromUSD()` | Never show as tokens |
* | Raw quota from API | Tokens | `formatQuotaWithCurrency()` | Convert tokens → USD → display |
*
* ## Critical Rules
*
* 1. **Never double-convert**: If you multiply by exchangeRate, use formatLocalCurrencyAmount()
* 2. **Database USD values**: Always use formatCurrencyFromUSD() for amounts stored as USD
* 3. **Payment amounts**: Always use formatLocalCurrencyAmount() for priceRatio-calculated values
* 4. **Billing displays**: Use formatBillingCurrencyFromUSD() to avoid token display
* 5. **Effective exchange rate**: When quotaDisplayType is 'USD', use rate of 1 regardless of config
*/
import {
useSystemConfigStore,
DEFAULT_CURRENCY_CONFIG,
type CurrencyConfig,
type CurrencyDisplayType,
} from '@/stores/system-config-store'
export interface CurrencyFormatOptions {
/** Fraction digits to use when |value| >= 1 */
digitsLarge?: number
/** Fraction digits to use when |value| < 1 */
digitsSmall?: number
/** Whether to abbreviate thousands with k suffix */
abbreviate?: boolean
/** Minimal absolute value to display when rounding would produce zero */
minimumNonZero?: number
}
type DisplayMeta =
| {
kind: 'currency'
symbol: string
currencyCode: string
exchangeRate: number
}
| {
kind: 'custom'
symbol: string
exchangeRate: number
}
| {
kind: 'tokens'
/** Number of tokens per USD */
quotaPerUnit: number
}
const DEFAULT_FORMAT_OPTIONS: Required<CurrencyFormatOptions> = {
digitsLarge: 2,
digitsSmall: 4,
abbreviate: true,
minimumNonZero: 0,
}
const DISPLAY_TYPE_VALUES = ['USD', 'CNY', 'TOKENS', 'CUSTOM'] as const
type DisplayTypeLiteral = (typeof DISPLAY_TYPE_VALUES)[number]
export function isCurrencyDisplayType(
value: unknown
): value is CurrencyDisplayType {
return (
typeof value === 'string' &&
DISPLAY_TYPE_VALUES.includes(value as DisplayTypeLiteral)
)
}
export function parseCurrencyDisplayType(
value: unknown,
fallback: CurrencyDisplayType = 'USD'
): CurrencyDisplayType {
return isCurrencyDisplayType(value) ? value : fallback
}
function getConfig(): CurrencyConfig {
const { config } = useSystemConfigStore.getState()
const currency = config?.currency ?? DEFAULT_CURRENCY_CONFIG
return {
...DEFAULT_CURRENCY_CONFIG,
...currency,
quotaPerUnit:
currency?.quotaPerUnit && currency.quotaPerUnit > 0
? currency.quotaPerUnit
: DEFAULT_CURRENCY_CONFIG.quotaPerUnit,
usdExchangeRate:
currency?.usdExchangeRate && currency.usdExchangeRate > 0
? currency.usdExchangeRate
: DEFAULT_CURRENCY_CONFIG.usdExchangeRate,
customCurrencyExchangeRate:
currency?.customCurrencyExchangeRate &&
currency.customCurrencyExchangeRate > 0
? currency.customCurrencyExchangeRate
: DEFAULT_CURRENCY_CONFIG.customCurrencyExchangeRate,
customCurrencySymbol:
currency?.customCurrencySymbol?.trim() ||
DEFAULT_CURRENCY_CONFIG.customCurrencySymbol,
}
}
function getDisplayMeta(config: CurrencyConfig): DisplayMeta {
switch (config.quotaDisplayType) {
case 'CNY':
return {
kind: 'currency',
symbol: '¥',
currencyCode: 'CNY',
exchangeRate: config.usdExchangeRate,
}
case 'CUSTOM':
return {
kind: 'custom',
symbol: config.customCurrencySymbol,
exchangeRate: config.customCurrencyExchangeRate,
}
case 'TOKENS':
return {
kind: 'tokens',
quotaPerUnit: config.quotaPerUnit,
}
case 'USD':
default:
return {
kind: 'currency',
symbol: '$',
currencyCode: 'USD',
exchangeRate: 1,
}
}
}
function getBillingDisplayMeta(config: CurrencyConfig): DisplayMeta {
const meta = getDisplayMeta(config)
if (meta.kind === 'tokens') {
return {
kind: 'currency',
symbol: '$',
currencyCode: 'USD',
exchangeRate: 1,
}
}
return meta
}
function mergeOptions(
options?: CurrencyFormatOptions
): Required<CurrencyFormatOptions> {
if (!options) return DEFAULT_FORMAT_OPTIONS
return {
digitsLarge: options.digitsLarge ?? DEFAULT_FORMAT_OPTIONS.digitsLarge,
digitsSmall: options.digitsSmall ?? DEFAULT_FORMAT_OPTIONS.digitsSmall,
abbreviate: options.abbreviate ?? DEFAULT_FORMAT_OPTIONS.abbreviate,
minimumNonZero:
options.minimumNonZero ?? DEFAULT_FORMAT_OPTIONS.minimumNonZero,
}
}
function removeTrailingZeros(str: string): string {
if (!str.includes('.')) return str
return str.replace(/(\.[0-9]*?)0+$/, '$1').replace(/\.$/, '')
}
function formatNumberWithSuffix(
value: number,
digitsLarge: number,
digitsSmall: number,
abbreviate: boolean
): string {
const abs = Math.abs(value)
if (abbreviate && abs >= 1000) {
const result = value / 1000
return removeTrailingZeros(result.toFixed(1)) + 'k'
}
const digits = abs >= 1 ? digitsLarge : digitsSmall
return removeTrailingZeros(value.toFixed(digits))
}
function adjustForMinimum(
value: number,
digits: number,
minimumNonZero: number
): number {
if (value === 0) return value
const threshold = minimumNonZero > 0 ? minimumNonZero : Math.pow(10, -digits)
const abs = Math.abs(value)
if (abs > 0 && abs < threshold) {
return value > 0 ? threshold : -threshold
}
return value
}
function formatCurrencyValue(
value: number,
options: Required<CurrencyFormatOptions>,
meta: DisplayMeta
): string {
if (meta.kind === 'tokens') {
return formatNumberWithSuffix(
value,
options.digitsLarge,
options.digitsSmall,
options.abbreviate
)
}
const digits =
Math.abs(value) >= 1 ? options.digitsLarge : options.digitsSmall
const adjustedValue = adjustForMinimum(value, digits, options.minimumNonZero)
if (meta.kind === 'currency') {
const formatted = new Intl.NumberFormat(undefined, {
style: 'currency',
currency: meta.currencyCode,
currencyDisplay: 'narrowSymbol',
minimumFractionDigits: 0,
maximumFractionDigits: digits,
}).format(adjustedValue)
return formatted
}
const decimal = new Intl.NumberFormat(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: digits,
}).format(adjustedValue)
return `${meta.symbol} ${decimal}`
}
/**
* Get the current currency configuration and display metadata.
*
* @returns Object containing config and display metadata
*
* @internal
* This is primarily for internal use. Most consumers should use the
* higher-level formatting functions instead.
*/
export function getCurrencyDisplay() {
const config = getConfig()
const meta = getDisplayMeta(config)
return { config, meta }
}
/**
* Format a USD amount according to the admin-configured display settings.
*
* This is the PRIMARY function for displaying quota/balance/credit amounts
* that are stored in the system as USD values.
*
* @param amountUSD - Amount in system USD units (e.g., user balance, quota)
* @param options - Optional formatting configuration
* @returns Formatted string with currency symbol or token count
*
* @example
* // With quotaDisplayType: 'USD'
* formatCurrencyFromUSD(10) → "$10"
*
* @example
* // With quotaDisplayType: 'CNY', usdExchangeRate: 7
* formatCurrencyFromUSD(10) → "¥70"
*
* @example
* // With quotaDisplayType: 'TOKENS', quotaPerUnit: 500000
* formatCurrencyFromUSD(10) → "5,000,000"
*
* @example
* // With quotaDisplayType: 'CUSTOM', customCurrencySymbol: '€', customCurrencyExchangeRate: 0.9
* formatCurrencyFromUSD(10) → "€9"
*
* @remarks
* Use this function for:
* - User balance/quota display
* - Recharge option amounts (before exchange rate applied)
* - Transaction amounts in billing history
* - Any value stored in database as USD
*
* DO NOT use for:
* - Payment amounts already converted via priceRatio → use formatLocalCurrencyAmount()
* - Raw token values → use formatQuotaWithCurrency()
*/
export function formatCurrencyFromUSD(
amountUSD: number | null | undefined,
options?: CurrencyFormatOptions
): string {
if (amountUSD == null || Number.isNaN(amountUSD)) return '-'
const { config, meta } = getCurrencyDisplay()
const merged = mergeOptions(options)
if (meta.kind === 'tokens') {
const tokens = amountUSD * config.quotaPerUnit
return formatNumberWithSuffix(
tokens,
0,
merged.digitsSmall,
merged.abbreviate
)
}
const value =
meta.kind === 'currency'
? amountUSD * meta.exchangeRate
: amountUSD * meta.exchangeRate
return formatCurrencyValue(value, merged, meta)
}
/**
* Format USD amounts for billing/payment contexts (never shows tokens).
*
* Similar to formatCurrencyFromUSD, but NEVER displays in token units.
* Always shows real currency values (USD, CNY, etc.) even when the system
* is configured to display quotas as tokens elsewhere.
*
* @param amountUSD - Amount in system USD units
* @param options - Optional formatting configuration
* @returns Formatted string with currency symbol (never tokens)
*
* @example
* // With quotaDisplayType: 'TOKENS' - still shows currency
* formatBillingCurrencyFromUSD(10) → "$10" (not "5,000,000 tokens")
*
* @example
* // With quotaDisplayType: 'CNY', usdExchangeRate: 7
* formatBillingCurrencyFromUSD(10) → "¥70"
*
* @remarks
* Use this function for:
* - Model pricing displays
* - API usage costs
* - Billing/invoice amounts
* - Any monetary value where tokens don't make sense
*
* DO NOT use for:
* - User balance/quota → use formatCurrencyFromUSD()
* - Payment amounts already in local currency → use formatLocalCurrencyAmount()
*/
export function formatBillingCurrencyFromUSD(
amountUSD: number | null | undefined,
options?: CurrencyFormatOptions
): string {
if (amountUSD == null || Number.isNaN(amountUSD)) return '-'
const { config } = getCurrencyDisplay()
const meta = getBillingDisplayMeta(config)
const merged = mergeOptions(options)
const value =
meta.kind === 'currency' || meta.kind === 'custom'
? amountUSD * meta.exchangeRate
: amountUSD
return formatCurrencyValue(value, merged, meta)
}
/**
* Format raw quota values (token units) to display currency.
*
* Converts raw quota/token amounts to USD first, then formats according
* to display settings. Use when you have quota in token units (e.g., 5000000)
* and need to display it as currency (e.g., "$10").
*
* @param quota - Raw quota amount in token units (e.g., 5000000)
* @param options - Optional formatting configuration
* @returns Formatted string with currency symbol or token count
*
* @example
* // With quotaPerUnit: 500000, quotaDisplayType: 'USD'
* formatQuotaWithCurrency(5000000) → "$10"
*
* @example
* // With quotaPerUnit: 500000, quotaDisplayType: 'CNY', usdExchangeRate: 7
* formatQuotaWithCurrency(5000000) → "¥70"
*
* @remarks
* Use this function for:
* - Raw quota values from database (stored as tokens)
* - When you need to convert tokens → USD → display currency
*
* DO NOT use for:
* - Values already in USD → use formatCurrencyFromUSD()
* - Payment amounts → use formatLocalCurrencyAmount()
*/
export function formatQuotaWithCurrency(
quota: number | null | undefined,
options?: CurrencyFormatOptions
): string {
if (quota == null || Number.isNaN(quota)) return '-'
const { config } = getCurrencyDisplay()
const amountUSD = quota / config.quotaPerUnit
return formatCurrencyFromUSD(amountUSD, options)
}
/**
* Get the current currency label for UI display.
*
* Returns a simple string label representing the current display currency.
* Useful for labels, tooltips, and UI text.
*
* @returns Currency label string (e.g., "USD", "CNY", "Tokens")
*
* @example
* getCurrencyLabel() → "USD"
* getCurrencyLabel() → "CNY"
* getCurrencyLabel() → "Tokens"
*
* @remarks
* Use this for:
* - Currency selector labels
* - Table column headers
* - Form field labels
*/
export function getCurrencyLabel(): string {
const { config, meta } = getCurrencyDisplay()
if (meta.kind === 'tokens') {
return 'Tokens'
}
switch (config.quotaDisplayType) {
case 'CNY':
return 'CNY'
case 'CUSTOM':
return meta.kind === 'custom' ? meta.symbol : 'Custom'
case 'USD':
default:
return 'USD'
}
}
/**
* Check if currency display is enabled (not in token-only mode).
*
* @returns True if displaying in actual currency (USD/CNY/etc), false if tokens only
*
* @example
* // With quotaDisplayType: 'USD' or 'CNY'
* isCurrencyDisplayEnabled() → true
*
* // With quotaDisplayType: 'TOKENS'
* isCurrencyDisplayEnabled() → false
*
* @remarks
* Use this to conditionally show currency-specific UI elements
*/
export function isCurrencyDisplayEnabled(): boolean {
const { meta } = getCurrencyDisplay()
return meta.kind !== 'tokens'
}
/**
* Format an amount that is ALREADY in local currency.
*
* ⚠️ CRITICAL: This function does NOT apply exchange rate conversion.
* Only use this for values that have already been converted to local currency
* via priceRatio or other means.
*
* @param amount - Amount already in local currency units
* @param options - Optional formatting configuration
* @returns Formatted string with appropriate currency symbol
*
* @example
* // Payment amount already calculated: 10 USD × priceRatio(5) = 50 CNY
* // With quotaDisplayType: 'CNY'
* formatLocalCurrencyAmount(50) → "¥50"
* // NOT "¥350" (which would be 50 × 7 exchangeRate)
*
* @example
* // With quotaDisplayType: 'USD'
* formatLocalCurrencyAmount(10) → "$10"
*
* @remarks
* Use this function for:
* - Payment amounts calculated via priceRatio (amount × price)
* - Actual money charged to user's payment method
* - Values that are already in the target currency
*
* DO NOT use for:
* - USD values that need conversion → use formatCurrencyFromUSD()
* - Raw quota values → use formatQuotaWithCurrency()
*
* Common mistake:
* ```ts
* // ❌ WRONG - Double conversion
* const payment = usdAmount * exchangeRate
* formatLocalCurrencyAmount(payment) // Will apply exchange rate again!
*
* // ✅ CORRECT - Already in local currency
* const payment = usdAmount * priceRatio
* formatLocalCurrencyAmount(payment) // Just formats with symbol
* ```
*/
export function formatLocalCurrencyAmount(
amount: number | null | undefined,
options?: CurrencyFormatOptions
): string {
if (amount == null || Number.isNaN(amount)) return '-'
const { config } = getCurrencyDisplay()
const meta = getBillingDisplayMeta(config)
const merged = mergeOptions(options)
return formatCurrencyValue(amount, merged, meta)
}