diff --git a/src/lib/currency.test.ts b/src/lib/currency.test.ts new file mode 100644 index 0000000..3071fc7 --- /dev/null +++ b/src/lib/currency.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { + CURRENCIES, + DEFAULT_CURRENCY, + formatCurrency, + formatCurrencyShort, +} from "./currency"; + +describe("formatCurrency", () => { + it("formats whole amounts with no minor units", () => { + // Use a non-breaking-space-tolerant check: Intl may insert NBSP. + const out = formatCurrency(1234, "USD"); + expect(out).toContain("1,234"); + expect(out).not.toContain(".00"); + }); + + it("defaults to USD when no currency is given", () => { + expect(formatCurrency(10)).toBe(formatCurrency(10, DEFAULT_CURRENCY)); + }); + + it("treats an empty-string currency as the default", () => { + expect(formatCurrency(10, "")).toBe(formatCurrency(10, DEFAULT_CURRENCY)); + }); + + it("coerces non-finite values to 0", () => { + expect(formatCurrency(Number.NaN, "USD")).toContain("0"); + }); + + it("renders a well-formed but unknown ISO code without throwing", () => { + // Intl is lenient here — it uses the code as the symbol. + const out = formatCurrency(1234, "ZZZ"); + expect(out).toContain("ZZZ"); + expect(out).toContain("1,234"); + }); + + it("never throws on a structurally invalid code (no DB CHECK on deals.currency)", () => { + for (const bad of ["United States", "US", "USDD", "12", "u$d"]) { + expect(() => formatCurrency(1234, bad)).not.toThrow(); + expect(formatCurrency(1234, bad)).toContain("1,234"); + } + }); + + it("formats every offered currency without throwing", () => { + for (const c of CURRENCIES) { + expect(() => formatCurrency(1000, c.code)).not.toThrow(); + } + }); +}); + +describe("formatCurrencyShort", () => { + it("abbreviates millions and thousands with the currency symbol", () => { + expect(formatCurrencyShort(2_500_000, "USD")).toBe("$2.5M"); + expect(formatCurrencyShort(3_400, "USD")).toBe("$3.4k"); + expect(formatCurrencyShort(900, "USD")).toBe("$900"); + }); + + it("uses the matching symbol for non-USD currencies", () => { + expect(formatCurrencyShort(1_000, "EUR")).toBe("€1.0k"); + expect(formatCurrencyShort(1_000, "INR")).toBe("₹1.0k"); + }); + + it("falls back to the code prefix for unknown currencies (no throw)", () => { + expect(formatCurrencyShort(1_000, "ZZZ")).toBe("ZZZ 1.0k"); + }); +}); diff --git a/src/lib/currency.ts b/src/lib/currency.ts index 46df731..c85f3eb 100644 --- a/src/lib/currency.ts +++ b/src/lib/currency.ts @@ -49,17 +49,34 @@ export const CURRENCIES: CurrencyOption[] = [ * (no minor units) — deal values are tracked to the dollar across * the app. `currency` defaults to USD so callers with nothing better * stay safe, but pass the account/deal currency wherever known. + * + * Total by design: `Intl.NumberFormat` throws a RangeError on a + * structurally invalid currency code, and `deals.currency` carries + * NO DB CHECK (only `accounts.default_currency` does), so legacy + * rows, imports, or hand-edited data can hold malformed values like + * "United States". We never let that crash a render — on a bad code + * we fall back to "CODE 1,234". */ export function formatCurrency( value: number, currency: string = DEFAULT_CURRENCY, ): string { - return new Intl.NumberFormat(undefined, { - style: "currency", - currency: currency || DEFAULT_CURRENCY, - minimumFractionDigits: 0, - maximumFractionDigits: 0, - }).format(Number(value || 0)); + const code = (currency || DEFAULT_CURRENCY).trim(); + const amount = Number(value) || 0; + try { + return new Intl.NumberFormat(undefined, { + style: "currency", + currency: code, + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(amount); + } catch { + // Invalid ISO code — show the raw code + grouped number so the + // value is still legible instead of throwing. + return `${code} ${new Intl.NumberFormat(undefined, { + maximumFractionDigits: 0, + }).format(amount)}`; + } } /**