diff --git a/src/shared/lib/display.test.ts b/src/shared/lib/display.test.ts index 46a458b..ceb8644 100644 --- a/src/shared/lib/display.test.ts +++ b/src/shared/lib/display.test.ts @@ -56,6 +56,16 @@ describe("shared display helpers", () => { )).toBe("0,00 ₽"); }); + it("rounds to the currency's minor units and drops the sign of a rounded zero", () => { + const rounded = { withRoundedCents: true }; + expect(formatMoney({ currency: "RUB", value: "-0.004" }, rounded)).toBe("0,00 ₽"); + expect(formatMoney({ currency: "RUB", value: "-0.005" }, rounded)).toBe("-0,01 ₽"); + expect(formatMoney({ currency: "RUB", value: "999.995" }, rounded)).toBe("1 000,00 ₽"); + expect(formatMoney({ currency: "JPY", value: "1500.5" }, rounded)).toBe("1 501 JPY"); + expect(formatMoney({ currency: "KWD", value: "1.2345" }, rounded)).toBe("1,235 KWD"); + expect(formatMoney({ currency: "ZZZ", value: "1.234" }, rounded)).toBe("1,23 ZZZ"); + }); + it("builds the brand logo url from an upstream file name", () => { expect(instrumentLogoUrl("sber3.png")).toBe( "https://invest-brands.cdn-tinkoff.ru/sber3x160.png", diff --git a/src/shared/lib/display.ts b/src/shared/lib/display.ts index 5040211..0ea6e22 100644 --- a/src/shared/lib/display.ts +++ b/src/shared/lib/display.ts @@ -1,5 +1,5 @@ const DECIMAL_PART_COUNT = 2; -const CENTS_PRECISION = 2; +const DEFAULT_MINOR_UNITS = 2; type FormatMoneyOptions = { withRoundedCents?: boolean; @@ -23,6 +23,17 @@ const dateFormatter = new Intl.DateTimeFormat("ru-RU", { year: "numeric", }); +// ISO 4217 minor units (JPY 0, KWD 3); unknown codes fall back to 2. +function currencyMinorUnits(currency: string): number { + try { + return new Intl.NumberFormat("en", { currency, style: "currency" }) + .resolvedOptions().maximumFractionDigits ?? DEFAULT_MINOR_UNITS; + } + catch { + return DEFAULT_MINOR_UNITS; + } +} + function formatDate(value: string): string { return dateFormatter.format(new Date(value)); } @@ -56,8 +67,10 @@ function formatMoney( ): string { if (!money) return "—"; + const minorUnits = currencyMinorUnits(money.currency); + const value = withRoundedCents - ? roundDecimalToCents(money.value) + ? roundDecimal(money.value, minorUnits) : money.value; const [integerPart = "", fractionPart = ""] = value.split( @@ -66,7 +79,7 @@ function formatMoney( ); const normalizedFraction = withRoundedCents || withZeroCents - ? fractionPart.padEnd(CENTS_PRECISION, "0") + ? fractionPart.padEnd(minorUnits, "0") : fractionPart; const decimal = normalizedFraction @@ -95,21 +108,25 @@ function isZeroDecimal(value: string) { && fraction.replaceAll("0", "") === ""; } -function roundDecimalToCents(value: string): string { +function roundDecimal(value: string, places: number): string { const [integer = "", fraction = ""] = value.split(".", DECIMAL_PART_COUNT); - const sign = integer.startsWith("-") ? "-" : ""; - const digits = sign ? integer.slice(1) : integer; - const roundedFraction = fraction.padEnd(CENTS_PRECISION + 1, "0"); - const cents = `${digits}${roundedFraction.slice(0, CENTS_PRECISION)}`; - const roundingDigit = roundedFraction.at(CENTS_PRECISION) ?? "0"; + const digits = integer.startsWith("-") ? integer.slice(1) : integer; + const paddedFraction = fraction.padEnd(places + 1, "0"); + const units = `${digits}${paddedFraction.slice(0, places)}`; + const roundingDigit = paddedFraction.at(places) ?? "0"; - const rounded = roundingDigit >= "5" - ? (BigInt(cents) + 1n).toString().padStart(cents.length, "0") - : cents; + const rounded = (roundingDigit >= "5" ? BigInt(units) + 1n : BigInt(units)) + .toString() + .padStart(places + 1, "0"); - const roundedInteger = rounded.slice(0, -CENTS_PRECISION) || "0"; + // A value rounded to zero has no sign: "-0.004" → "0.00". + const sign = integer.startsWith("-") && /[1-9]/.test(rounded) ? "-" : ""; + const roundedInteger = rounded.slice(0, rounded.length - places); + const roundedFraction = rounded.slice(rounded.length - places); - return `${sign}${roundedInteger}.${rounded.slice(-CENTS_PRECISION)}`; + return roundedFraction + ? `${sign}${roundedInteger}.${roundedFraction}` + : `${sign}${roundedInteger}`; } // Без timeZone Intl берёт часовой пояс браузера пользователя.