1 Commits
Author SHA1 Message Date
ksv741 f1ccd3c7ae fix: round money to currency minor units without negative zero (WealthLedger/frontend#118)
Frontend verify / commits (pull_request) Successful in 29s
Frontend verify / security (pull_request) Failing after 9s
Frontend verify / verify (pull_request) Skipped
2026-10-28 08:08:04 +03:00
2 changed files with 41 additions and 14 deletions
+10
View File
@@ -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",
+31 -14
View File
@@ -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 берёт часовой пояс браузера пользователя.