fix: round money to currency minor units without negative zero (WealthLedger/frontend#118) (#119)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 9s
Frontend verify / verify (push) Failing after 5m26s

This commit was merged in pull request #119.
This commit is contained in:
2026-10-28 08:45:55 +03:00
parent 431559be0f
commit 1d4990491f
4 changed files with 47 additions and 18 deletions
+1 -1
View File
@@ -66,7 +66,7 @@ jobs:
- name: Verify pre-commit secret blocking and redaction - name: Verify pre-commit secret blocking and redaction
run: PATH=/tmp/gitleaks-bin:$PATH node --test scripts/test-pre-commit.mjs run: PATH=/tmp/gitleaks-bin:$PATH node --test scripts/test-pre-commit.mjs
- name: Audit runtime and development dependencies - name: Audit runtime and development dependencies
run: npm audit --include=dev --audit-level=low run: npm audit --include=dev --audit-level=moderate
verify: verify:
needs: [commits, security] needs: [commits, security]
+5 -3
View File
@@ -123,10 +123,12 @@ Chromium при ширинах 375 и 1280 px. Browser project не исполь
Новый запуск отменяет устаревший для того же PR/ветки и типа события Новый запуск отменяет устаревший для того же PR/ветки и типа события
(требуется Gitea 1.26+). Ежедневно в 05:17 UTC выполняется только `security`. (требуется Gitea 1.26+). Ежедневно в 05:17 UTC выполняется только `security`.
Job `security` проверяет полную доступную историю Gitleaks 8.30.1 и выполняет Job `security` проверяет полную доступную историю Gitleaks 8.30.1 и выполняет
`npm audit --include=dev --audit-level=low`. Любой advisory блокирует security job; `npm audit --include=dev --audit-level=moderate`. Advisory уровня moderate и выше
исключений frontend нет. Локально можно выполнить те же команды: блокирует security job; low не блокирует, т.к. для них часто нет исправленной версии
(например, GHSA-gcqx-w48m-w2h4 в build-only deepmerge). Других исключений нет.
Локально можно выполнить те же команды:
`gitleaks git --log-opts=--all --redact=100 --no-banner` и `gitleaks git --log-opts=--all --redact=100 --no-banner` и
`npm audit --include=dev --audit-level=low`. Audit требует доступа к npm registry; `npm audit --include=dev --audit-level=moderate`. Audit требует доступа к npm registry;
проверка секретов выполняется локально и не выводит их значения. проверка секретов выполняется локально и не выводит их значения.
Имена jobs `commits`, `security`, `verify` сохранены, но Gitea включает имя Имена jobs `commits`, `security`, `verify` сохранены, но Gitea включает имя
+10
View File
@@ -56,6 +56,16 @@ describe("shared display helpers", () => {
)).toBe("0,00 ₽"); )).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", () => { it("builds the brand logo url from an upstream file name", () => {
expect(instrumentLogoUrl("sber3.png")).toBe( expect(instrumentLogoUrl("sber3.png")).toBe(
"https://invest-brands.cdn-tinkoff.ru/sber3x160.png", "https://invest-brands.cdn-tinkoff.ru/sber3x160.png",
+31 -14
View File
@@ -1,5 +1,5 @@
const DECIMAL_PART_COUNT = 2; const DECIMAL_PART_COUNT = 2;
const CENTS_PRECISION = 2; const DEFAULT_MINOR_UNITS = 2;
type FormatMoneyOptions = { type FormatMoneyOptions = {
withRoundedCents?: boolean; withRoundedCents?: boolean;
@@ -23,6 +23,17 @@ const dateFormatter = new Intl.DateTimeFormat("ru-RU", {
year: "numeric", 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 { function formatDate(value: string): string {
return dateFormatter.format(new Date(value)); return dateFormatter.format(new Date(value));
} }
@@ -56,8 +67,10 @@ function formatMoney(
): string { ): string {
if (!money) return "—"; if (!money) return "—";
const minorUnits = currencyMinorUnits(money.currency);
const value = withRoundedCents const value = withRoundedCents
? roundDecimalToCents(money.value) ? roundDecimal(money.value, minorUnits)
: money.value; : money.value;
const [integerPart = "", fractionPart = ""] = value.split( const [integerPart = "", fractionPart = ""] = value.split(
@@ -66,7 +79,7 @@ function formatMoney(
); );
const normalizedFraction = withRoundedCents || withZeroCents const normalizedFraction = withRoundedCents || withZeroCents
? fractionPart.padEnd(CENTS_PRECISION, "0") ? fractionPart.padEnd(minorUnits, "0")
: fractionPart; : fractionPart;
const decimal = normalizedFraction const decimal = normalizedFraction
@@ -95,21 +108,25 @@ function isZeroDecimal(value: string) {
&& fraction.replaceAll("0", "") === ""; && fraction.replaceAll("0", "") === "";
} }
function roundDecimalToCents(value: string): string { function roundDecimal(value: string, places: number): string {
const [integer = "", fraction = ""] = value.split(".", DECIMAL_PART_COUNT); const [integer = "", fraction = ""] = value.split(".", DECIMAL_PART_COUNT);
const sign = integer.startsWith("-") ? "-" : ""; const digits = integer.startsWith("-") ? integer.slice(1) : integer;
const digits = sign ? integer.slice(1) : integer; const paddedFraction = fraction.padEnd(places + 1, "0");
const roundedFraction = fraction.padEnd(CENTS_PRECISION + 1, "0"); const units = `${digits}${paddedFraction.slice(0, places)}`;
const cents = `${digits}${roundedFraction.slice(0, CENTS_PRECISION)}`; const roundingDigit = paddedFraction.at(places) ?? "0";
const roundingDigit = roundedFraction.at(CENTS_PRECISION) ?? "0";
const rounded = roundingDigit >= "5" const rounded = (roundingDigit >= "5" ? BigInt(units) + 1n : BigInt(units))
? (BigInt(cents) + 1n).toString().padStart(cents.length, "0") .toString()
: cents; .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 берёт часовой пояс браузера пользователя. // Без timeZone Intl берёт часовой пояс браузера пользователя.