/* 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 . 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 = { 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 { 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, 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) }