From a15f00c5687785c8de4045c801f4a1f34c368a15 Mon Sep 17 00:00:00 2001 From: null Date: Fri, 3 Jul 2026 12:46:22 -0500 Subject: [PATCH] refactor(client): single source of truth for money formatting (IMP-CODE-01) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- client/components/BillMerchantRules.jsx | 4 +- client/components/BillModal.jsx | 9 +-- client/components/BillsTableInner.jsx | 3 +- client/components/IncomeBreakdownModal.jsx | 3 +- client/components/data/AutoMatchReview.jsx | 4 +- client/components/data/BankSyncSection.jsx | 8 +-- client/components/snowball/PayoffChart.jsx | 5 +- .../components/snowball/PlanHistoryPanel.jsx | 9 +-- .../components/snowball/PlanStatusBanner.jsx | 5 +- .../transactions/MatchBillDialog.jsx | 8 +-- client/lib/money.js | 62 ++++++++++++++++ client/lib/money.test.js | 71 +++++++++++++++++++ client/lib/utils.js | 8 ++- client/pages/AnalyticsPage.jsx | 13 +--- client/pages/PayoffPage.jsx | 8 +-- client/pages/SnowballPage.jsx | 10 +-- client/pages/SpendingPage.jsx | 3 +- client/pages/SubscriptionsPage.jsx | 4 +- 18 files changed, 173 insertions(+), 64 deletions(-) create mode 100644 client/lib/money.js create mode 100644 client/lib/money.test.js diff --git a/client/components/BillMerchantRules.jsx b/client/components/BillMerchantRules.jsx index b2e29c4..cfb0372 100644 --- a/client/components/BillMerchantRules.jsx +++ b/client/components/BillMerchantRules.jsx @@ -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 }) {s.label} - ${amountVal.toFixed(2)} + {fmt(amountVal)} ); diff --git a/client/components/BillModal.jsx b/client/components/BillModal.jsx index 1aed291..e11e3ba 100644 --- a/client/components/BillModal.jsx +++ b/client/components/BillModal.jsx @@ -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) { diff --git a/client/components/BillsTableInner.jsx b/client/components/BillsTableInner.jsx index b112110..dd19edc 100644 --- a/client/components/BillsTableInner.jsx +++ b/client/components/BillsTableInner.jsx @@ -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)) && ·} - ${Number(bill.current_balance).toLocaleString(undefined, { maximumFractionDigits: 0 })} balance + {formatUSDWhole(bill.current_balance)} balance )} diff --git a/client/components/IncomeBreakdownModal.jsx b/client/components/IncomeBreakdownModal.jsx index e96b7d6..2b0fb30 100644 --- a/client/components/IncomeBreakdownModal.jsx +++ b/client/components/IncomeBreakdownModal.jsx @@ -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 }) { diff --git a/client/components/data/AutoMatchReview.jsx b/client/components/data/AutoMatchReview.jsx index b66b56e..38e39f0 100644 --- a/client/components/data/AutoMatchReview.jsx +++ b/client/components/data/AutoMatchReview.jsx @@ -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) { diff --git a/client/components/data/BankSyncSection.jsx b/client/components/data/BankSyncSection.jsx index c8c19f7..181aca2 100644 --- a/client/components/data/BankSyncSection.jsx +++ b/client/components/data/BankSyncSection.jsx @@ -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 = {} }) {acc.org_name ? `${acc.org_name} — ` : ''}{acc.name} {acc.balance_dollars !== null && ( - ${acc.balance_dollars.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} + {formatUSD(acc.balance_dollars)} )} diff --git a/client/components/snowball/PayoffChart.jsx b/client/components/snowball/PayoffChart.jsx index 1055503..6faee4f 100644 --- a/client/components/snowball/PayoffChart.jsx +++ b/client/components/snowball/PayoffChart.jsx @@ -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) { diff --git a/client/components/snowball/PlanHistoryPanel.jsx b/client/components/snowball/PlanHistoryPanel.jsx index f9b6bc8..b84d9b6 100644 --- a/client/components/snowball/PlanHistoryPanel.jsx +++ b/client/components/snowball/PlanHistoryPanel.jsx @@ -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) { diff --git a/client/components/snowball/PlanStatusBanner.jsx b/client/components/snowball/PlanStatusBanner.jsx index 3e35b55..e05de15 100644 --- a/client/components/snowball/PlanStatusBanner.jsx +++ b/client/components/snowball/PlanStatusBanner.jsx @@ -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) { diff --git a/client/components/transactions/MatchBillDialog.jsx b/client/components/transactions/MatchBillDialog.jsx index eaed470..4c78613 100644 --- a/client/components/transactions/MatchBillDialog.jsx +++ b/client/components/transactions/MatchBillDialog.jsx @@ -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 }) { diff --git a/client/lib/money.js b/client/lib/money.js new file mode 100644 index 0000000..5e01599 --- /dev/null +++ b/client/lib/money.js @@ -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; +} diff --git a/client/lib/money.test.js b/client/lib/money.test.js new file mode 100644 index 0000000..2605308 --- /dev/null +++ b/client/lib/money.test.js @@ -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'); + }); +}); diff --git a/client/lib/utils.js b/client/lib/utils.js index afbf7c3..e0ea3a2 100644 --- a/client/lib/utils.js +++ b/client/lib/utils.js @@ -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) { diff --git a/client/pages/AnalyticsPage.jsx b/client/pages/AnalyticsPage.jsx index 7340d35..a4ee68d 100644 --- a/client/pages/AnalyticsPage.jsx +++ b/client/pages/AnalyticsPage.jsx @@ -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) { diff --git a/client/pages/PayoffPage.jsx b/client/pages/PayoffPage.jsx index a2beb4f..f762b40 100644 --- a/client/pages/PayoffPage.jsx +++ b/client/pages/PayoffPage.jsx @@ -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) { diff --git a/client/pages/SnowballPage.jsx b/client/pages/SnowballPage.jsx index 189fdb9..90eadb7 100644 --- a/client/pages/SnowballPage.jsx +++ b/client/pages/SnowballPage.jsx @@ -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); diff --git a/client/pages/SpendingPage.jsx b/client/pages/SpendingPage.jsx index c13c9ca..d80a995 100644 --- a/client/pages/SpendingPage.jsx +++ b/client/pages/SpendingPage.jsx @@ -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) { diff --git a/client/pages/SubscriptionsPage.jsx b/client/pages/SubscriptionsPage.jsx index 7f30cb8..bbe910d 100644 --- a/client/pages/SubscriptionsPage.jsx +++ b/client/pages/SubscriptionsPage.jsx @@ -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'}` : ''}

- ${dollars} + {dollars} {!isMatched && (