78 lines
3.0 KiB
JavaScript
78 lines
3.0 KiB
JavaScript
// 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 '';
|
|
}
|