mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
fix(currency): make formatCurrency total + add unit tests
deals.currency has no DB CHECK (only accounts.default_currency does), so legacy rows, imports, or hand-edited data can hold a structurally invalid code (e.g. "United States"). Intl.NumberFormat throws a RangeError on such codes, which would crash any deal-card / contact render that passes deal.currency straight through. Guard formatCurrency with a try/catch that falls back to "CODE 1,234" so a bad value degrades gracefully instead of taking down the view. Add currency.test.ts locking in the defaults, the no-throw contract, and formatCurrencyShort's symbol/fallback behavior. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3abecb433b
commit
c076b3cfe1
65
src/lib/currency.test.ts
Normal file
65
src/lib/currency.test.ts
Normal file
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -49,17 +49,34 @@ export const CURRENCIES: CurrencyOption[] = [
|
|||||||
* (no minor units) — deal values are tracked to the dollar across
|
* (no minor units) — deal values are tracked to the dollar across
|
||||||
* the app. `currency` defaults to USD so callers with nothing better
|
* the app. `currency` defaults to USD so callers with nothing better
|
||||||
* stay safe, but pass the account/deal currency wherever known.
|
* 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(
|
export function formatCurrency(
|
||||||
value: number,
|
value: number,
|
||||||
currency: string = DEFAULT_CURRENCY,
|
currency: string = DEFAULT_CURRENCY,
|
||||||
): string {
|
): string {
|
||||||
return new Intl.NumberFormat(undefined, {
|
const code = (currency || DEFAULT_CURRENCY).trim();
|
||||||
style: "currency",
|
const amount = Number(value) || 0;
|
||||||
currency: currency || DEFAULT_CURRENCY,
|
try {
|
||||||
minimumFractionDigits: 0,
|
return new Intl.NumberFormat(undefined, {
|
||||||
maximumFractionDigits: 0,
|
style: "currency",
|
||||||
}).format(Number(value || 0));
|
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)}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user