| 1 | const DEFAULT_CURRENCY_SYMBOL = "\u00A5"; |
| 2 | |
| 3 | export function currencySymbol(currency?: string): string { |
| 4 | const value = (currency || DEFAULT_CURRENCY_SYMBOL).trim(); |
| 5 | const lower = value.toLowerCase(); |
| 6 | |
| 7 | if (/^(cny|rmb|yuan|renminbi|cnh)$/.test(lower)) return DEFAULT_CURRENCY_SYMBOL; |
| 8 | if (/^(usd|dollar|dollars|us dollar|us dollars|us\$)$/.test(lower)) return "$"; |
| 9 | if (/^(eur|euro|euros)$/.test(lower)) return "\u20AC"; |
| 10 | if (/^(gbp|pound|pounds|sterling)$/.test(lower)) return "\u00A3"; |
| 11 | if (/^(jpy|yen)$/.test(lower)) return DEFAULT_CURRENCY_SYMBOL; |
| 12 | if (value === "\uFFE5" || value === "\u00A5") return DEFAULT_CURRENCY_SYMBOL; |
| 13 | // contains, not equals \u2014 keep multi-char symbols (A$, HK$) off the \u00A5 default. |
| 14 | if (/\p{Sc}/u.test(value)) return value; |
| 15 | if (/^[a-z]{3}$/i.test(value)) return `${value.toUpperCase()} `; |
| 16 | |
| 17 | return DEFAULT_CURRENCY_SYMBOL; |
| 18 | } |
| 19 | |
| 20 | export function formatMoney(amount?: number, currency?: string, empty: "zero" | "dash" = "zero"): string { |
| 21 | const symbol = currencySymbol(currency); |
| 22 | if (typeof amount !== "number" || amount <= 0) { |
| 23 | // Prefer dash over a fake zero when cost cannot be estimated. |
| 24 | return empty === "dash" ? "-" : empty === "zero" ? `${symbol}0.0000` : "-"; |
| 25 | } |
| 26 | return `${symbol}${amount < 1 ? amount.toFixed(4) : amount.toFixed(2)}`; |
| 27 | } |
| 28 | |
| 29 | /** Format an estimated cost with the product-wide "≈" cue. */ |
| 30 | export function formatEstimatedCost(amount?: number, currency?: string, incomplete?: boolean): string { |
| 31 | if (incomplete || typeof amount !== "number" || amount <= 0) { |
| 32 | return "-"; |
| 33 | } |
| 34 | return `≈${formatMoney(amount, currency, "dash")}`; |
| 35 | } |
| 36 | |
| 37 | export function formatMoneyAmount(amount?: string, currency?: string, empty: "zero" | "dash" = "dash"): string { |
| 38 | if (!amount || amount === "0") { |
| 39 | return empty === "dash" ? "-" : formatMoney(0, currency, empty); |
| 40 | } |
| 41 | const n = Number(amount); |
| 42 | if (!Number.isFinite(n) || n <= 0) { |
| 43 | return empty === "dash" ? "-" : formatMoney(0, currency, empty); |
| 44 | } |
| 45 | return formatMoney(n, currency, empty); |
| 46 | } |
| 47 | |
| 48 | interface MoneyFormatOptions { |
| 49 | locale?: string; |
| 50 | empty?: "zero" | "dash"; |
| 51 | fractionDigits?: 2 | 4; |
| 52 | } |
| 53 | |
| 54 | function isoCurrencyCode(currency?: string): string | null { |
| 55 | const value = (currency || "").trim(); |
| 56 | if (!/^[a-z]{3}$/i.test(value)) return null; |
| 57 | const code = value.toUpperCase(); |
| 58 | try { |
| 59 | new Intl.NumberFormat("en", { style: "currency", currency: code }).format(0); |
| 60 | return code; |
| 61 | } catch { |
| 62 | return null; |
| 63 | } |
| 64 | } |
| 65 | |
| 66 | export function formatMoneyLocalized(amount?: number, currency?: string, options: MoneyFormatOptions = {}): string { |
| 67 | const empty = options.empty ?? "zero"; |
| 68 | if (typeof amount !== "number" || amount <= 0) { |
| 69 | return empty === "dash" ? "-" : options.fractionDigits === undefined |
| 70 | ? formatMoney(0, currency, empty) : `${currencySymbol(currency)}${(0).toFixed(options.fractionDigits)}`; |
| 71 | } |
| 72 | |
| 73 | const code = isoCurrencyCode(currency); |
| 74 | if (!code) return options.fractionDigits === undefined ? formatMoney(amount, currency, empty) |
| 75 | : `${currencySymbol(currency)}${amount.toFixed(options.fractionDigits)}`; |
| 76 | |
| 77 | const digits = options.fractionDigits ?? (amount < 1 ? 4 : 2); |
| 78 | return new Intl.NumberFormat(options.locale, { |
| 79 | style: "currency", |
| 80 | currency: code, |
| 81 | minimumFractionDigits: digits, |
| 82 | maximumFractionDigits: digits, |
| 83 | }).format(amount); |
| 84 | } |
| 85 |