diff --git a/.gitea/workflows/verify.yml b/.gitea/workflows/verify.yml index bbef2a1..518324b 100644 --- a/.gitea/workflows/verify.yml +++ b/.gitea/workflows/verify.yml @@ -66,7 +66,7 @@ jobs: - name: Verify pre-commit secret blocking and redaction run: PATH=/tmp/gitleaks-bin:$PATH node --test scripts/test-pre-commit.mjs - name: Audit runtime and development dependencies - run: npm audit --include=dev --audit-level=low + run: npm audit --include=dev --audit-level=moderate verify: needs: [commits, security] diff --git a/README.md b/README.md index c136094..7ea5714 100644 --- a/README.md +++ b/README.md @@ -123,10 +123,12 @@ Chromium при ширинах 375 и 1280 px. Browser project не исполь Новый запуск отменяет устаревший для того же PR/ветки и типа события (требуется Gitea 1.26+). Ежедневно в 05:17 UTC выполняется только `security`. Job `security` проверяет полную доступную историю Gitleaks 8.30.1 и выполняет -`npm audit --include=dev --audit-level=low`. Любой advisory блокирует security job; -исключений frontend нет. Локально можно выполнить те же команды: +`npm audit --include=dev --audit-level=moderate`. Advisory уровня moderate и выше +блокирует security job; low не блокирует, т.к. для них часто нет исправленной версии +(например, GHSA-gcqx-w48m-w2h4 в build-only deepmerge). Других исключений нет. +Локально можно выполнить те же команды: `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 включает имя 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 берёт часовой пояс браузера пользователя.