From 84bd17da4e9b5d375cc7db0fd8c05ae339f7c870 Mon Sep 17 00:00:00 2001 From: stqfdyr <89149493+stqfdyr@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:19:56 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=E4=BB=B7=E6=A0=BC=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E6=8C=89=E4=B8=AD=E6=96=87=E5=86=99=E6=B3=95=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=EF=BC=8C=E8=B4=A7=E5=B8=81=E7=AC=A6=E5=8F=B7=E6=88=96=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E4=B8=80=E5=BE=8B=E5=9C=A8=E6=95=B0=E5=AD=97=E5=89=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api.rs | 6 +++--- web-admin/src/lib/format.ts | 22 ++++++++++++++++++++-- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/src/api.rs b/src/api.rs index cfe7eed6..91958852 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 eab87797..3623ffeb 100644 --- a/web-admin/src/lib/format.ts +++ b/web-admin/src/lib/format.ts @@ -31,10 +31,28 @@ 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. 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 }) + 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`. From 117886a1714462b1760fdbf40eddf4acc682c0c8 Mon Sep 17 00:00:00 2001 From: stqfdyr <89149493+stqfdyr@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:37:19 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=E4=BB=B7=E6=A0=BC=E6=9C=80=E5=A4=9A?= =?UTF-8?q?=E4=B8=A4=E4=BD=8D=E5=B0=8F=E6=95=B0=EF=BC=8C=E6=97=A5=E5=85=83?= =?UTF-8?q?=E7=AD=89=E6=97=A0=E5=B0=8F=E6=95=B0=E4=BD=8D=E7=9A=84=E8=B4=A7?= =?UTF-8?q?=E5=B8=81=E5=A1=AB=E4=BA=86=E5=B0=8F=E6=95=B0=E4=B9=9F=E7=85=A7?= =?UTF-8?q?=E5=AE=9E=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web-admin/src/lib/format.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/web-admin/src/lib/format.ts b/web-admin/src/lib/format.ts index 3623ffeb..25d99ff1 100644 --- a/web-admin/src/lib/format.ts +++ b/web-admin/src/lib/format.ts @@ -37,14 +37,16 @@ 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. A formatter costs about 100 µs to build, - * hence one per currency. + * 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 { try { let format = MONEY.get(currency) if (!format) { - format = new Intl.NumberFormat("zh-CN", { style: "currency", currency }) + format = new Intl.NumberFormat("zh-CN", { style: "currency", currency, maximumFractionDigits: 2 }) MONEY.set(currency, format) } return format.format(amount)