refactor(client): single source of truth for money formatting (IMP-CODE-01)

The client had ~15 hand-rolled currency formatters (local `fmt`/`money`/
`fmtFull`/`fmtDollars`/…) plus a canonical `fmt` in lib/utils used at ~190
sites — same rules copy-pasted, with inconsistent handling of negatives,
whole-dollar, cents vs dollars, and blank input.

Add client/lib/money.js as the one implementation:
  - formatUSD(dollars)      — "$1,234.56" (whole/dash options)
  - formatUSDWhole(dollars) — "$1,235"
  - formatCentsUSD(cents)   — from integer cents; signed "+/-" and dash options
Inputs are coerced so null/''/NaN never render as "$NaN", and -0 is
normalized so it never shows as "-$0.00".

lib/utils.fmt now delegates to formatUSD (byte-identical — the existing
utils.test.js fmt suite is the regression guard), and the 15 local
formatters delegate to money.js. No display currency formatting remains
outside money.js; the /100 conversions left behind are calculations
(form prefill), not display.

Tests: client/lib/money.test.js (13). Full client suite 46 pass; build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-03 12:46:22 -05:00
parent 6f5ad9a015
commit a15f00c568
18 changed files with 173 additions and 64 deletions

View File

@ -6,7 +6,7 @@ import {
AlertTriangle, Building2, CheckCircle2, CalendarDays, Loader2, Plus, Tag, Trash2, X,
} from 'lucide-react';
import { api } from '@/api';
import { cn } from '@/lib/utils';
import { cn, fmt } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import BillHistoricalImportDialog from '@/components/BillHistoricalImportDialog';
@ -344,7 +344,7 @@ export default function BillMerchantRules({ billId, billName, onRulesChanged })
<Building2 className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate font-medium">{s.label}</span>
<span className="shrink-0 font-mono text-muted-foreground tabular-nums">
${amountVal.toFixed(2)}
{fmt(amountVal)}
</span>
</button>
);

View File

@ -1,5 +1,6 @@
import { useActionState, useEffect, useState } from 'react';
import { ChevronDown, Copy, Layers, Link2, Link2Off, Loader2, Pencil, Plus, RefreshCw, Trash2 } from 'lucide-react';
import { formatCentsUSD } from '@/lib/money';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@ -62,13 +63,7 @@ const SUBSCRIPTION_TYPES = [
];
function fmtTransactionAmount(amount, currency = 'USD') {
const cents = Number(amount || 0);
const value = Math.abs(cents) / 100;
const sign = cents < 0 ? '-' : '+';
return `${sign}${new Intl.NumberFormat(undefined, {
style: 'currency',
currency: currency || 'USD',
}).format(value)}`;
return formatCentsUSD(amount, { signed: true, currency });
}
function transactionDate(tx) {

View File

@ -1,6 +1,7 @@
import { ArrowDown, ArrowUp, Copy, GripVertical, PenLine, EyeOff, Eye, Clock, Trash2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { scheduleLabel } from '@/lib/billingSchedule';
import { formatUSDWhole } from '@/lib/money';
import { MobileBillRow } from '@/components/MobileBillRow';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
@ -178,7 +179,7 @@ function BillCard({ bill, prefs = ALL_ON, onEdit, onToggle, onDelete, onHistory,
<>
{(prefs.showCycle || prefs.showDueDay || (prefs.showApr && bill.interest_rate != null)) && <span className="text-border">·</span>}
<span className="tracker-number text-[11px] font-medium text-muted-foreground">
${Number(bill.current_balance).toLocaleString(undefined, { maximumFractionDigits: 0 })} balance
{formatUSDWhole(bill.current_balance)} balance
</span>
</>
)}

View File

@ -1,5 +1,6 @@
import React, { useState, useEffect, useCallback } from 'react';
import { toast } from 'sonner';
import { formatUSD } from '@/lib/money';
import { TrendingUp, EyeOff, Eye, ArrowRight, Loader2 } from 'lucide-react';
import { api } from '@/api';
import { Button } from '@/components/ui/button';
@ -8,7 +9,7 @@ import {
} from '@/components/ui/dialog';
function fmt(n) {
return Number(n || 0).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
return formatUSD(n);
}
export default function IncomeBreakdownModal({ open, onClose, year, month, bankTracking }) {

View File

@ -4,10 +4,10 @@ import { toast } from 'sonner';
import { api } from '@/api';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { formatUSD } from '@/lib/money';
function fmtAmt(dollars) {
if (dollars == null) return '—';
return `$${Number(dollars).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
return formatUSD(dollars, { dash: true });
}
function fmtDate(iso) {

View File

@ -6,6 +6,7 @@ import {
} from 'lucide-react';
import { api } from '@/api';
import { cn } from '@/lib/utils';
import { formatUSD, formatCentsUSD } from '@/lib/money';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
@ -77,10 +78,7 @@ function fmtShortDate(date) {
}
function fmtDollars(cents) {
if (cents == null) return '—';
const abs = Math.abs(cents) / 100;
const sign = cents < 0 ? '-' : '';
return `${sign}$${abs.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
return formatCentsUSD(cents, { dash: true });
}
function MatchBadge({ status, billName }) {
@ -867,7 +865,7 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} })
<span>{acc.org_name ? `${acc.org_name}` : ''}{acc.name}</span>
{acc.balance_dollars !== null && (
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
${acc.balance_dollars.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
{formatUSD(acc.balance_dollars)}
</span>
)}
</div>

View File

@ -1,4 +1,5 @@
import React from 'react';
import { formatUSD } from '@/lib/money';
const W = 720;
const H = 300;
@ -13,9 +14,7 @@ function money(v) {
}
function fullMoney(v) {
return (Number(v) || 0).toLocaleString(undefined, {
style: 'currency', currency: 'USD', maximumFractionDigits: 2,
});
return formatUSD(v);
}
function buildPoints(track, startBalance, maxMonths) {

View File

@ -2,17 +2,14 @@ import React, { useState } from 'react';
import { ChevronDown, History } from 'lucide-react';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { cn } from '@/lib/utils';
import { formatUSD, formatUSDWhole } from '@/lib/money';
function fmt(v) {
return (Number(v) || 0).toLocaleString(undefined, {
style: 'currency', currency: 'USD', maximumFractionDigits: 0,
});
return formatUSDWhole(v);
}
function fmtFull(v) {
return (Number(v) || 0).toLocaleString(undefined, {
style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2,
});
return formatUSD(v);
}
function dateRange(plan) {

View File

@ -7,11 +7,10 @@ import {
} from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { formatUSDWhole } from '@/lib/money';
function fmt(v) {
return (Number(v) || 0).toLocaleString(undefined, {
style: 'currency', currency: 'USD', maximumFractionDigits: 0,
});
return formatUSDWhole(v);
}
function dateLabel(iso) {

View File

@ -3,6 +3,7 @@ import {
CheckCircle2, Loader2, Link2, Search, Plus,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { formatCentsUSD } from '@/lib/money';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
@ -19,12 +20,7 @@ export function transactionTitle(tx) {
}
export function formatTransactionAmount(amount, currency = 'USD') {
const value = Math.abs(Number(amount || 0)) / 100;
const sign = Number(amount || 0) < 0 ? '-' : '+';
return `${sign}${new Intl.NumberFormat(undefined, {
style: 'currency',
currency: currency || 'USD',
}).format(value)}`;
return formatCentsUSD(amount, { signed: true, currency });
}
export function MatchBillDialog({ open, onOpenChange, transaction, bills, onConfirm, loading, onCreateBill }) {

62
client/lib/money.js Normal file
View File

@ -0,0 +1,62 @@
// 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;
}

71
client/lib/money.test.js Normal file
View File

@ -0,0 +1,71 @@
import { describe, it, expect } from 'vitest';
import { formatUSD, formatUSDWhole, formatCentsUSD } from './money';
describe('formatUSD (dollars in)', () => {
it('formats to two decimals with grouping', () => {
expect(formatUSD(1234.5)).toBe('$1,234.50');
expect(formatUSD(0)).toBe('$0.00');
expect(formatUSD(8.99)).toBe('$8.99');
});
it('formats negatives', () => {
expect(formatUSD(-12.34)).toBe('-$12.34');
});
it('normalizes negative zero to "$0.00" (not "-$0.00")', () => {
expect(formatUSD(-0)).toBe('$0.00');
expect(formatUSD(Math.round(-0.2))).toBe('$0.00'); // Math.round(-0.2) === -0
});
it('coerces blank / non-numeric to $0.00 (never NaN)', () => {
expect(formatUSD(null)).toBe('$0.00');
expect(formatUSD(undefined)).toBe('$0.00');
expect(formatUSD('')).toBe('$0.00');
expect(formatUSD('not a number')).toBe('$0.00');
});
it('accepts numeric strings', () => {
expect(formatUSD('1234.5')).toBe('$1,234.50');
});
it('whole:true drops the cents', () => {
expect(formatUSD(1234.56, { whole: true })).toBe('$1,235');
expect(formatUSDWhole(1234.4)).toBe('$1,234');
});
it('dash:true renders blank input as an em dash, but 0 as $0.00', () => {
expect(formatUSD(null, { dash: true })).toBe('—');
expect(formatUSD(undefined, { dash: true })).toBe('—');
expect(formatUSD('', { dash: true })).toBe('—');
expect(formatUSD(0, { dash: true })).toBe('$0.00');
});
});
describe('formatCentsUSD (integer cents in)', () => {
it('converts cents to dollars', () => {
expect(formatCentsUSD(1234)).toBe('$12.34');
expect(formatCentsUSD(0)).toBe('$0.00');
expect(formatCentsUSD(99)).toBe('$0.99');
});
it('unsigned negatives get a leading minus only', () => {
expect(formatCentsUSD(-1234)).toBe('-$12.34');
});
it('signed:true adds +/- for income vs expense', () => {
expect(formatCentsUSD(1234, { signed: true })).toBe('+$12.34');
expect(formatCentsUSD(-1234, { signed: true })).toBe('-$12.34');
expect(formatCentsUSD(0, { signed: true })).toBe('+$0.00');
});
it('dash:true renders blank input as an em dash', () => {
expect(formatCentsUSD(null, { dash: true })).toBe('—');
expect(formatCentsUSD(undefined, { dash: true })).toBe('—');
expect(formatCentsUSD(0, { dash: true })).toBe('$0.00');
});
it('coerces non-numeric to $0.00 (never NaN)', () => {
expect(formatCentsUSD('abc')).toBe('$0.00');
expect(formatCentsUSD(null)).toBe('$0.00');
});
});

View File

@ -1,14 +1,16 @@
import { clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
import { formatUSD } from './money';
export function cn(...inputs) {
return twMerge(clsx(inputs));
}
// Canonical dollar formatter for the app ("$1,234.50"). Kept here for the many
// existing call sites; the implementation lives in ./money (formatUSD) so all
// currency formatting has a single source of truth.
export function fmt(amount) {
const n = Number(amount) || 0;
const sign = n < 0 ? '-' : '';
return sign + '$' + Math.abs(n).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
return formatUSD(amount);
}
export function fmtDate(dateStr) {

View File

@ -1,4 +1,5 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { formatUSD, formatUSDWhole } from '@/lib/money';
import { Printer, RefreshCw, RotateCcw } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api';
@ -27,19 +28,11 @@ function currentMonth() {
}
function money(value) {
return (Number(value) || 0).toLocaleString(undefined, {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 0,
});
return formatUSDWhole(value);
}
function fullMoney(value) {
return (Number(value) || 0).toLocaleString(undefined, {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 2,
});
return formatUSD(value);
}
function formatRange(range) {

View File

@ -10,6 +10,7 @@ import {
SelectSeparator, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
import { formatUSD, formatUSDWhole } from '@/lib/money';
import PayoffChart from '@/components/snowball/PayoffChart';
// Print isolation
@ -36,14 +37,11 @@ const PRINT_STYLES = `
// Helpers
function fmt(v) {
return (Number(v) || 0).toLocaleString(undefined, {
style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2,
});
return formatUSD(v);
}
function fmtShort(v) {
const n = Number(v) || 0;
return n.toLocaleString(undefined, { style: 'currency', currency: 'USD', maximumFractionDigits: 0 });
return formatUSDWhole(v);
}
function buildPayoffSchedule(balance, annualRatePct, monthlyPayment, oneTimeExtra = 0) {

View File

@ -14,19 +14,15 @@ import { makeBillDraft } from '@/lib/billDrafts';
import PlanStatusBanner from '@/components/snowball/PlanStatusBanner';
import PlanHistoryPanel from '@/components/snowball/PlanHistoryPanel';
import * as AlertDialog from '@radix-ui/react-alert-dialog';
import { formatUSD, formatUSDWhole } from '@/lib/money';
// formatters
function fmt(val) {
if (val == null) return '—';
return Number(val).toLocaleString(undefined, {
style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2,
});
return formatUSD(val, { dash: true });
}
function fmtCompact(val) {
if (val == null || val === 0) return '—';
return Number(val).toLocaleString(undefined, {
style: 'currency', currency: 'USD', maximumFractionDigits: 0,
});
return formatUSDWhole(val);
}
function ordinal(n) {
const d = Number(n);

View File

@ -14,11 +14,12 @@ import {
DropdownMenuSeparator, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { CategoryPicker } from '@/components/transactions/CategoryPicker';
import { formatUSD } from '@/lib/money';
const MONTH_NAMES = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
function fmt(n) {
return Number(n || 0).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
return formatUSD(n);
}
function settingEnabled(value, fallback = true) {

View File

@ -498,7 +498,7 @@ function BillPickerDialog({ open, onClose, recommendation, bills, onConfirm, bus
}
function TxResultRow({ tx, onTrack }) {
const dollars = (Math.abs(tx.amount) / 100).toFixed(2);
const dollars = fmt(Math.abs(tx.amount) / 100);
const label = tx.payee || tx.description || tx.memo || '—';
const account = tx.account_name || tx.data_source_name || null;
const isMatched = tx.match_status === 'matched';
@ -545,7 +545,7 @@ function TxResultRow({ tx, onTrack }) {
{catalogMatch ? ` · ${TYPE_LABELS[catalogMatch.subscription_type] || 'Other'}` : ''}
</p>
</div>
<span className="font-mono text-sm font-semibold tabular-nums shrink-0">${dollars}</span>
<span className="font-mono text-sm font-semibold tabular-nums shrink-0">{dollars}</span>
{!isMatched && (
<Button size="sm" variant="outline" onClick={() => onTrack(tx)}
className="h-8 shrink-0 gap-1.5 px-2.5 text-xs">