// Currency formatting for the client, mirroring the server's utils/money.js. // // The API sends money in two units: bill / summary values are serialized as // DOLLARS (the server calls fromCents before responding), while raw bank // transaction amounts arrive as integer CENTS. So there are two entry points — // formatUSD(dollars) and formatCentsUSD(cents) — matching the server's // formatUSD / formatCentsUSD split. USD / en-US throughout. Inputs are coerced // defensively so null, '', undefined, or NaN never render as "$NaN". // // The two units are *branded* below: a Dollars value can't be passed where Cents // is expected (or vice-versa) without an explicit conversion, so the cents↔dollars // mixups that have caused real money bugs (e.g. displaying cents as dollars → // 100× wrong) become compile errors in typed code. /** Integer cents, e.g. a raw bank transaction amount (1234 = $12.34). */ export type Cents = number & { readonly __unit: 'cents' }; /** Dollars, e.g. an API-serialized bill amount (12.34 = $12.34). */ export type Dollars = number & { readonly __unit: 'dollars' }; /** Brand a raw number as cents (the sanctioned way to enter the typed world). */ export const asCents = (n: number): Cents => n as Cents; /** Brand a raw number as dollars. */ export const asDollars = (n: number): Dollars => n as Dollars; /** Convert cents → dollars (the only way to cross the unit boundary). */ export const centsToDollars = (c: Cents): Dollars => (c / 100) as Dollars; /** * Convert dollars → cents, rounding to the nearest cent. * @public API symmetry with centsToDollars — the sanctioned crossing in this * direction, kept exported so no caller reinvents an ad-hoc `* 100`. */ export const dollarsToCents = (d: Dollars): Cents => Math.round(d * 100) as Cents; // A money value as it may still arrive untyped from a form field or legacy code: // the branded unit, or a numeric string, or blank. Note bare `number` is NOT // included — that's deliberate, so typed callers must brand (asDollars/asCents) // and can't accidentally hand cents to a dollars formatter. type DollarsInput = Dollars | string | null | undefined; type CentsInput = Cents | string | null | undefined; const DASH = '—'; // ── Active display currency (a user preference; display-only, no conversion) ── // Read synchronously from localStorage at module load so the very first paint is // already in the right currency (the settings query resolves later; FormatSync // reconciles server→local). Amounts are entered/stored in a single currency — // this only changes the symbol/locale used to render them. const CURRENCY_LOCALES: Record = { USD: 'en-US', EUR: 'en-IE', GBP: 'en-GB', CAD: 'en-CA', AUD: 'en-AU', JPY: 'ja-JP', INR: 'en-IN', CHF: 'de-CH', }; const CURRENCY_STORAGE_KEY = 'bt-currency'; function loadCurrency(): string { try { const c = localStorage.getItem(CURRENCY_STORAGE_KEY); if (c && CURRENCY_LOCALES[c]) return c; } catch { /* localStorage unavailable */ } return 'USD'; } let activeCurrency = loadCurrency(); let activeLocale = CURRENCY_LOCALES[activeCurrency] ?? 'en-US'; /** Set the active display currency + persist it (called from Settings + FormatSync). */ export function setMoneyFormat(currency: string): void { if (!CURRENCY_LOCALES[currency]) return; activeCurrency = currency; activeLocale = CURRENCY_LOCALES[currency]!; try { localStorage.setItem(CURRENCY_STORAGE_KEY, currency); } catch { /* localStorage unavailable */ } } export function getActiveCurrency(): string { return activeCurrency; } function toNumber(value: unknown): number { const n = Number(value); if (!Number.isFinite(n)) return 0; return n === 0 ? 0 : n; // normalize -0 → +0 so it never renders as "-$0.00" } function isBlank(value: unknown): value is null | undefined | '' { return value === null || value === undefined || value === ''; } /** * Format a DOLLAR amount → "$1,234.56". * `whole` drops the cents ("$1,235"); `dash` renders blank input as "—". */ export function formatUSD( dollars: DollarsInput, { whole = false, dash = false }: { whole?: boolean; dash?: boolean } = {}, ): string { if (dash && isBlank(dollars)) return DASH; return toNumber(dollars).toLocaleString(activeLocale, { style: 'currency', currency: activeCurrency, minimumFractionDigits: whole ? 0 : 2, maximumFractionDigits: whole ? 0 : 2, }); } /** Whole-dollar convenience → "$1,235". */ export function formatUSDWhole(dollars: DollarsInput, opts: { dash?: boolean } = {}): string { return formatUSD(dollars, { ...opts, whole: true }); } /** * Format an integer-CENTS amount (e.g. a bank transaction) → "$12.34". * `signed` prefixes "+"/"-" (income vs expense); `dash` renders blank → "—"; * `currency` is an ISO code (defaults USD). */ export function formatCentsUSD( cents: CentsInput, { signed = false, dash = false, currency, }: { signed?: boolean; dash?: boolean; currency?: string } = {}, ): string { if (dash && isBlank(cents)) return DASH; const c = toNumber(cents); const body = (Math.abs(c) / 100).toLocaleString(activeLocale, { style: 'currency', currency: currency || activeCurrency, minimumFractionDigits: 2, maximumFractionDigits: 2, }); if (signed) return (c < 0 ? '-' : '+') + body; return (c < 0 ? '-' : '') + body; } /** * Shared form validator for a non-negative money field (dollars). Blank is * allowed (returns ''); otherwise the value must parse to a number ≥ 0. Returns * '' when valid, or an error string labelled for the field. Zero is allowed — * these are non-negative, not strictly-positive, amounts. */ export function validateNonNegativeMoney( val: string | number | null | undefined, label = 'Amount', ): string { if (val === '' || val === null || val === undefined) return ''; const num = parseFloat(String(val)); if (isNaN(num) || num < 0) return `${label} must be a non-negative number`; return ''; }