BillTracker/client/lib/money.js

78 lines
3.0 KiB
JavaScript
Raw Normal View History

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