BillTracker/client/lib/money.ts

109 lines
4.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 '';
}