diff --git a/src/api.rs b/src/api.rs index cfe7eed..9195885 100644 --- a/src/api.rs +++ b/src/api.rs @@ -686,9 +686,9 @@ fn group_error(group: &mut String) -> Option<&'static str> { /// Normalizes the currency and billing cycle, or names the one that cannot be /// stored. The currency is held to the ISO 4217 form of three letters, the only -/// one `Intl.NumberFormat` accepts, so a theme may pass it on without guarding -/// against a throw. Each cycle length is stored in a single spelling, see -/// `cycle_name`. +/// one `Intl.NumberFormat` accepts. Hubs before 1.3.1 stored it unchecked, so a +/// theme still guards the call against a throw. Each cycle length is stored in a +/// single spelling, see `cycle_name`. fn billing_error(currency: Option<&mut String>, cycle: Option<&mut String>) -> Option<&'static str> { if let Some(code) = currency { *code = code.trim().to_ascii_uppercase(); diff --git a/web-admin/src/lib/format.ts b/web-admin/src/lib/format.ts index eab8779..25d99ff 100644 --- a/web-admin/src/lib/format.ts +++ b/web-admin/src/lib/format.ts @@ -31,10 +31,30 @@ export function uptime(seconds: number): string { */ export const FOREVER = "∞" -const SYMBOLS: Record = { USD: "$", CNY: "¥", EUR: "€", GBP: "£", JPY: "¥" } +const MONEY = new Map() +/** + * A price as zh-CN writes it: ¥12.00, US$12.00, HK$12.00, JP¥1,200, and the code + * ahead of the amount where the locale has no symbol, as in SGD 12.00. The + * locale is fixed so the figure does not vary with the browser's language, and + * so JPY reads JP¥, apart from CNY. Fractions stop at two places, the precision + * the price is entered in, not at the currency's minor unit: rounding to whole + * yen would show a price of 0.4 as JP¥0. A formatter costs about 100 µs to + * build, hence one per currency. + */ export function money(amount: number, currency: string): string { - return `${SYMBOLS[currency] ?? ""}${amount.toFixed(2)}${SYMBOLS[currency] ? "" : ` ${currency}`}` + try { + let format = MONEY.get(currency) + if (!format) { + format = new Intl.NumberFormat("zh-CN", { style: "currency", currency, maximumFractionDigits: 2 }) + MONEY.set(currency, format) + } + return format.format(amount) + } catch { + // Intl throws on anything but three letters, which hubs before 1.3.1 stored + // unchecked when written through the API. + return `${currency} ${amount.toFixed(2)}`.trim() + } } // The hub stores these lengths under a name and any other as `m`.