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 && (