diff --git a/client/lib/money.js b/client/lib/money.js deleted file mode 100644 index 7f824e3..0000000 --- a/client/lib/money.js +++ /dev/null @@ -1,77 +0,0 @@ -// 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 (the app is USD-only, -// and this matches the server). Inputs are coerced defensively so null, '', -// undefined, or NaN never render as "$NaN". - -const DASH = '—'; - -function toNumber(value) { - 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) { - return value === null || value === undefined || value === ''; -} - -/** - * Format a DOLLAR amount → "$1,234.56". - * @param {number|string|null} dollars - * @param {{ whole?: boolean, dash?: boolean }} [opts] - * whole — drop the cents ("$1,235"); dash — render blank input as "—" not "$0.00". - */ -export function formatUSD(dollars, { whole = false, dash = false } = {}) { - if (dash && isBlank(dollars)) return DASH; - return toNumber(dollars).toLocaleString('en-US', { - style: 'currency', - currency: 'USD', - minimumFractionDigits: whole ? 0 : 2, - maximumFractionDigits: whole ? 0 : 2, - }); -} - -/** Whole-dollar convenience → "$1,235". */ -export function formatUSDWhole(dollars, opts = {}) { - return formatUSD(dollars, { ...opts, whole: true }); -} - -/** - * Format an integer-CENTS amount (e.g. a bank transaction) → "$12.34". - * @param {number|string|null} cents - * @param {{ signed?: boolean, dash?: boolean, currency?: string }} [opts] - * signed — prefix "+"/"-" (income vs expense); dash — blank input → "—"; - * currency — ISO code (defaults USD). - */ -export function formatCentsUSD(cents, { signed = false, dash = false, currency = 'USD' } = {}) { - if (dash && isBlank(cents)) return DASH; - const c = toNumber(cents); - const body = (Math.abs(c) / 100).toLocaleString('en-US', { - style: 'currency', - currency: currency || 'USD', - 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. - * @param {number|string|null} val - * @param {string} [label] — field name for the message (e.g. "Amount", "Balance") - */ -export function validateNonNegativeMoney(val, label = 'Amount') { - if (val === '' || val === null || val === undefined) return ''; - const num = parseFloat(val); - if (isNaN(num) || num < 0) return `${label} must be a non-negative number`; - return ''; -} diff --git a/client/lib/money.ts b/client/lib/money.ts new file mode 100644 index 0000000..14344b3 --- /dev/null +++ b/client/lib/money.ts @@ -0,0 +1,108 @@ +// 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. */ +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 = '—'; + +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('en-US', { + style: 'currency', + currency: 'USD', + 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 = 'USD' }: { signed?: boolean; dash?: boolean; currency?: string } = {}, +): string { + if (dash && isBlank(cents)) return DASH; + const c = toNumber(cents); + const body = (Math.abs(c) / 100).toLocaleString('en-US', { + style: 'currency', + currency: currency || 'USD', + 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 ''; +} diff --git a/client/lib/money.type-test.ts b/client/lib/money.type-test.ts new file mode 100644 index 0000000..943b919 --- /dev/null +++ b/client/lib/money.type-test.ts @@ -0,0 +1,22 @@ +// Compile-time guard for the branded money types. Not imported anywhere (so it +// never ships in a bundle); `npm run typecheck` type-checks it via the tsconfig +// include, and each `@ts-expect-error` asserts the following line IS a genuine +// type error. If the cents/dollars branding ever regresses, one of these lines +// stops erroring and typecheck fails loudly ("unused @ts-expect-error"). +import { formatUSD, formatCentsUSD, asCents, asDollars, centsToDollars } from './money'; + +const cents = asCents(1234); // $12.34 as integer cents +const dollars = asDollars(12.34); // $12.34 as dollars + +// ✅ Correct unit → compiles fine. +formatUSD(dollars); +formatCentsUSD(cents); +formatUSD(centsToDollars(cents)); // cross the boundary explicitly + +// ❌ Unit mixups → compile errors (the class of bug we keep fixing). +// @ts-expect-error cents can't be formatted as dollars (would render 100× too big) +formatUSD(cents); +// @ts-expect-error dollars can't be formatted as cents +formatCentsUSD(dollars); +// @ts-expect-error a raw number must be branded (asDollars/asCents) first +formatUSD(1234);