From 31bafb0e554398c62f5dee38a6a2bc78216f0a24 Mon Sep 17 00:00:00 2001
From: null
Date: Sun, 31 May 2026 15:06:10 -0500
Subject: [PATCH] 0.34.3
---
HISTORY.md | 49 +-
client/components/ReleaseNotesDialog.jsx | 2 +-
client/components/data/BankSyncSection.jsx | 2 +-
.../data/ImportSpreadsheetSection.jsx | 4 +-
.../data/TransactionMatchingSection.jsx | 2 +-
client/components/layout/Layout.jsx | 2 +-
.../tracker/AutopaySuggestionActions.jsx | 51 +
client/components/tracker/EditableCell.jsx | 90 +
client/components/tracker/FilterChip.jsx | 18 +
.../tracker/LowerThisMonthButton.jsx | 51 +
.../components/tracker/MobileTrackerRow.jsx | 376 +++
client/components/tracker/NotesCell.jsx | 60 +
.../tracker/PaymentLedgerDialog.jsx | 185 ++
client/components/tracker/PaymentProgress.jsx | 61 +
client/components/tracker/StatusBadge.jsx | 44 +
client/components/tracker/SummaryCards.jsx | 138 ++
client/components/tracker/TrackerBucket.jsx | 248 ++
client/components/tracker/TrackerRow.jsx | 585 +++++
client/hooks/useAuth.jsx | 2 +-
client/lib/trackerUtils.js | 106 +
client/pages/LoginPage.jsx | 4 +-
client/pages/SnowballPage.jsx | 2 +-
client/pages/SubscriptionsPage.jsx | 4 +-
client/pages/TrackerPage.jsx | 2102 +----------------
db/database.js | 22 +
package.json | 2 +-
routes/admin.js | 72 +-
routes/auth.js | 76 +-
routes/monthly-starting-amounts.js | 12 +-
routes/notifications.js | 3 +-
routes/payments.js | 14 +-
routes/profile.js | 61 +-
routes/summary.js | 20 +-
server.js | 7 +-
services/notificationService.js | 13 +-
services/statusService.js | 1 +
services/trackerService.js | 24 +-
37 files changed, 2373 insertions(+), 2142 deletions(-)
create mode 100644 client/components/tracker/AutopaySuggestionActions.jsx
create mode 100644 client/components/tracker/EditableCell.jsx
create mode 100644 client/components/tracker/FilterChip.jsx
create mode 100644 client/components/tracker/LowerThisMonthButton.jsx
create mode 100644 client/components/tracker/MobileTrackerRow.jsx
create mode 100644 client/components/tracker/NotesCell.jsx
create mode 100644 client/components/tracker/PaymentLedgerDialog.jsx
create mode 100644 client/components/tracker/PaymentProgress.jsx
create mode 100644 client/components/tracker/StatusBadge.jsx
create mode 100644 client/components/tracker/SummaryCards.jsx
create mode 100644 client/components/tracker/TrackerBucket.jsx
create mode 100644 client/components/tracker/TrackerRow.jsx
create mode 100644 client/lib/trackerUtils.js
diff --git a/HISTORY.md b/HISTORY.md
index a7c5bff..d6e83af 100644
--- a/HISTORY.md
+++ b/HISTORY.md
@@ -1,5 +1,51 @@
# Bill Tracker β Changelog
+## v0.34.3
+
+### π§ Changed
+
+- **Bump** β `0.34.2.1` β `0.34.3`
+
+- **TrackerPage refactored into focused components** β `TrackerPage.jsx` was a 2 386-line monolith containing ~13 co-located sub-components. Each has been extracted to its own file in `client/components/tracker/`:
+ - `FilterChip`, `StatusBadge`, `SummaryCards` (TrendIndicator / SummaryCard / TrendCard) β visual primitives
+ - `EditableCell`, `PaymentProgress`, `LowerThisMonthButton`, `PaymentLedgerDialog`, `NotesCell` β payment sub-components
+ - `AutopaySuggestionActions`, `TrackerRow`, `MobileTrackerRow` β bill row (desktop and mobile)
+ - `TrackerBucket` β bucket container
+ - Helper functions and constants (`rowEffectiveStatus`, `paymentSummary`, `ROW_STATUS_CLS`, etc.) extracted to `client/lib/trackerUtils.js`
+ - `TrackerPage.jsx` is now **477 lines** (page layout + routing only)
+
+- **TrackerPage filter/nav state is now URL-first** β `year`, `month`, `search`, and all 8 filter flags (`autopay`, `firstBucket`, `fifteenthBucket`, `unpaid`, `overdue`, `debt`, `category`, `cycle`) are stored in URL search params instead of local React state. Views are now bookmarkable, shareable, and survive back/forward navigation. Example: `/tracker?year=2026&month=5&ov=1&un=1`. The `search` param was previously partially URL-backed; this completes the pattern.
+
+---
+
+## v0.34.2.1
+
+### π Features
+
+- **Overview quick-add bill** β The Monthly Overview header now includes a plus-button shortcut that opens the existing Add Bill modal and refreshes the tracker after saving.
+
+### π§ Changed
+
+- **Bump** β `0.34.2` β `0.34.2.1`
+
+### π Bug Fixes
+
+- **Async error handling hardened** β Five route handlers that called `bcrypt.compare()` or `hashPassword()` without a surrounding try/catch now return a clean 500 instead of leaving the promise rejection unhandled. Affected routes: `POST /api/auth/change-password`, `POST /api/admin/users` (auth router), `POST /api/admin/users` (admin router), `PUT /api/admin/users/:id/password`, `POST /api/profile/change-password`. A `process.on('unhandledRejection')` safety-net logger was also added to `server.js`. All other routes cited in the original bug report already had try/catch and required no changes.
+
+- **SMTP password encrypted at rest** β `notify_smtp_password` was stored as plaintext in the `settings` table, exposing credentials in database backups or direct file access. It is now encrypted with AES-256-GCM via the existing `encryptionService` (same mechanism as SimpleFIN tokens). The route encrypts on save, the notification service decrypts on read with a legacy plaintext fallback, and migration v0.77 encrypts any existing plaintext password at startup. The masked `β’β’β’β’β’β’β’β’` API response is unchanged.
+
+- **User deletion now cleans up audit_log** β The `DELETE /api/admin/users/:id` route was not explicitly deleting rows from `audit_log` (which has no `ON DELETE CASCADE` foreign key to `users`). Deleting a user left their audit trail orphaned in the database, referencing a non-existent user id. The route now explicitly deletes `audit_log`, `import_sessions`, and `import_history` rows for the user before removing the user row; the remaining ~20 user-owned tables are handled by `ON DELETE CASCADE`.
+
+- **Payment mutations scoped to owner** β The `PUT`, `DELETE`, and `POST /:id/restore` handlers in `routes/payments.js` performed their `UPDATE payments` and bill balance `SELECT` queries using only the payment id, without re-asserting ownership in the SQL itself. Ownership was already verified via a JOIN before each mutation (not exploitable in practice), but the SQL provided no independent protection. All three mutation statements now include `AND bill_id IN (SELECT id FROM bills WHERE user_id = ? AND deleted_at IS NULL)`, and the bills balance re-fetch in DELETE and restore now includes `AND user_id = ?`. The response `SELECT` at the end of PUT and restore was also updated to use the ownership JOIN consistent with `GET /api/payments/:id`.
+
+- **JSON body limit made explicit** β `express.json()` in `server.js` now declares `{ limit: '100kb' }` explicitly rather than relying on Express's implicit default. No behaviour change; import routes continue to override this per-endpoint (2 MB β 10 MB).
+
+- **Silent `.catch(() => {})` replaced with console logging** β Twelve instances of empty catch handlers across the client codebase swallowed errors with no logging or user feedback: `LoginPage.jsx` (authMode/session pre-checks), `useAuth.jsx` (authMode check), `SnowballPage.jsx` (plan history load), `SubscriptionsPage.jsx` (bills load on mount and after reorder), `BankSyncSection.jsx` (bills load for match picker), `ImportSpreadsheetSection.jsx` (bills and categories load for import controls), `TransactionMatchingSection.jsx` (categories load), `Layout.jsx` (SimpleFIN status badge), and `ReleaseNotesDialog.jsx` (acknowledge-version fire-and-forget). All are background ambient data loads whose fallback silent state is correct for the user experience; they now log `console.error('[ComponentName] context message', err)` so failures are visible to developers without surfacing disruptive toasts.
+
+- **Monetary aggregation rounding hardened** β Floating-point rounding was already applied per-payment in `statusService`, `billsService`, and `payments.js`, but the aggregation layer was unprotected: `reduce()` sums and subtraction results in `trackerService`, `routes/summary.js`, and `routes/monthly-starting-amounts.js` were returned without rounding, allowing IEEE 754 artifacts (e.g. `12.10 - 0.20 = 12.100000000000001`) to leak into API responses. All computed monetary aggregates (`total_paid`, `total_expected`, `paid_toward_due`, `remaining`, `total_remaining`, `overdue`, `combined_amount`, `*_remaining`, `expense_total`, `result`, etc.) are now passed through `Math.round(x * 100) / 100` before being returned. `roundMoney` is also now exported from `statusService` so other modules can share the same implementation instead of re-implementing it.
+
+---
+
## v0.34.2
### π§ Changed
@@ -8,6 +54,7 @@
- **Subscription badge on Tracker** β The "S" subscription badge now appears consistently on all bill rows in the Tracker page. The reorder-mode row variant was missing the badge even though it showed the "AP" autopay badge β both badges now render in all row contexts.
+- **Data page workflow tabs** β Data now groups tools into Sync & Match, Import Data, and Export & History tabs, with remembered collapsible cards and a compact status strip.
---
## v0.34.1.3
@@ -26,7 +73,7 @@
- **Summary bill ordering** β Summary expenses now use the persisted bill order and support tracker-style drag/up/down reordering, while hiding reorder controls from printed/PDF summaries.
- **Unified bill schedule editing** β Edit Bill now uses one canonical "Billing Schedule" field instead of separate Billing Cycle and Cycle Type controls.
-- **Data page workflow tabs** β Data now groups tools into Sync & Match, Import Data, and Export & History tabs, with remembered collapsible cards and a compact status strip.
+
### π§ Changed
diff --git a/client/components/ReleaseNotesDialog.jsx b/client/components/ReleaseNotesDialog.jsx
index 14373f9..67a5123 100644
--- a/client/components/ReleaseNotesDialog.jsx
+++ b/client/components/ReleaseNotesDialog.jsx
@@ -18,7 +18,7 @@ export function ReleaseNotesDialog() {
const handleClose = () => {
setOpen(false);
setHasNewVersion(false); // optimistic β don't wait for the server
- api.acknowledgeVersion().catch(() => {}); // backend stores the seen release version
+ api.acknowledgeVersion().catch(err => console.error('[ReleaseNotesDialog] failed to acknowledge version', err)); // backend stores the seen release version
const prev = document.activeElement;
if (prev?.focus) setTimeout(() => prev.focus(), 0);
};
diff --git a/client/components/data/BankSyncSection.jsx b/client/components/data/BankSyncSection.jsx
index a4d8349..7218579 100644
--- a/client/components/data/BankSyncSection.jsx
+++ b/client/components/data/BankSyncSection.jsx
@@ -344,7 +344,7 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} })
// Load bills once when connections become available (for the match picker)
useEffect(() => {
if (connections.length > 0 && bills.length === 0) {
- api.allBills().then(b => setBills(Array.isArray(b) ? b : [])).catch(() => {});
+ api.allBills().then(b => setBills(Array.isArray(b) ? b : [])).catch(err => console.error('[BankSyncSection] failed to load bills', err));
}
}, [connections, bills.length]);
diff --git a/client/components/data/ImportSpreadsheetSection.jsx b/client/components/data/ImportSpreadsheetSection.jsx
index 453b019..57a3d96 100644
--- a/client/components/data/ImportSpreadsheetSection.jsx
+++ b/client/components/data/ImportSpreadsheetSection.jsx
@@ -885,8 +885,8 @@ export default function ImportSpreadsheetSection({ onHistoryRefresh, cardProps =
// Load bills/categories for the decision controls
useEffect(() => {
- api.bills().then(setAllBills).catch(() => {});
- api.categories().then(setCategories).catch(() => {});
+ api.bills().then(setAllBills).catch(err => console.error('[ImportSpreadsheetSection] failed to load bills', err));
+ api.categories().then(setCategories).catch(err => console.error('[ImportSpreadsheetSection] failed to load categories', err));
}, []);
const opt = (k, v) => setOptions(prev => ({ ...prev, [k]: v }));
diff --git a/client/components/data/TransactionMatchingSection.jsx b/client/components/data/TransactionMatchingSection.jsx
index 93151d1..3bb3e0d 100644
--- a/client/components/data/TransactionMatchingSection.jsx
+++ b/client/components/data/TransactionMatchingSection.jsx
@@ -435,7 +435,7 @@ export default function TransactionMatchingSection({ refreshKey, simplefinConn,
useEffect(() => { loadTransactions(); }, [filter, refreshKey]);
useEffect(() => { loadSuggestions(); }, [refreshKey]);
useEffect(() => {
- api.categories().then(data => setCategories(data || [])).catch(() => {});
+ api.categories().then(data => setCategories(data || [])).catch(err => console.error('[TransactionMatchingSection] failed to load categories', err));
}, []);
const openMatchDialog = (tx) => {
diff --git a/client/components/layout/Layout.jsx b/client/components/layout/Layout.jsx
index 394c482..e08bb0a 100644
--- a/client/components/layout/Layout.jsx
+++ b/client/components/layout/Layout.jsx
@@ -9,7 +9,7 @@ function SimplefinBadge() {
useEffect(() => {
api.simplefinStatus()
.then(d => setEnabled(!!d.enabled))
- .catch(() => {});
+ .catch(err => console.error('[Layout] simplefinStatus check failed', err));
}, []);
if (!enabled) return null;
diff --git a/client/components/tracker/AutopaySuggestionActions.jsx b/client/components/tracker/AutopaySuggestionActions.jsx
new file mode 100644
index 0000000..9b88377
--- /dev/null
+++ b/client/components/tracker/AutopaySuggestionActions.jsx
@@ -0,0 +1,51 @@
+import { Clock, X, CheckCircle2, Loader2 } from 'lucide-react';
+import { cn, fmt, fmtDate } from '@/lib/utils';
+import { Button } from '@/components/ui/button';
+
+export function AutopaySuggestionActions({ row, loading, onConfirm, onDismiss, compact = false }) {
+ const suggestion = row.autopay_suggestion;
+ if (!suggestion) return null;
+
+ const title = `${fmt(suggestion.amount)} due ${fmtDate(suggestion.paid_date)}`;
+
+ return (
+
+
+
+ {compact ? `Suggested ${fmt(suggestion.amount)}` : 'Suggested'}
+
+
+
+
+
+ {loading ? : }
+
+
+ );
+}
diff --git a/client/components/tracker/EditableCell.jsx b/client/components/tracker/EditableCell.jsx
new file mode 100644
index 0000000..e3474b7
--- /dev/null
+++ b/client/components/tracker/EditableCell.jsx
@@ -0,0 +1,90 @@
+import { useState, useRef } from 'react';
+import { toast } from 'sonner';
+import { api } from '@/api.js';
+import { cn, fmt, fmtDate } from '@/lib/utils';
+import { Input } from '@/components/ui/input';
+
+// `threshold` = actual_amount ?? expected_amount for this bill/month
+export function EditableCell({ row, field, threshold, defaultPaymentDate, refresh }) {
+ const [editing, setEditing] = useState(false);
+ const [value, setValue] = useState('');
+ const inputRef = useRef(null);
+
+ const displayVal = field === 'amount'
+ ? (row.total_paid > 0 ? fmt(row.total_paid) : 'β')
+ : (row.last_paid_date ? fmtDate(row.last_paid_date) : 'β');
+
+ const isEmpty = field === 'amount' ? row.total_paid <= 0 : !row.last_paid_date;
+ // Mismatch when paid amount differs from the effective threshold for this month
+ const mismatch = field === 'amount' && row.total_paid > 0 && row.total_paid !== threshold;
+
+ function startEdit() {
+ if (editing) return;
+ setValue(field === 'amount'
+ ? (row.total_paid > 0 ? String(row.total_paid) : '')
+ : (row.last_paid_date || ''));
+ setEditing(true);
+ setTimeout(() => { inputRef.current?.focus(); inputRef.current?.select(); }, 0);
+ }
+
+ async function commit() {
+ setEditing(false);
+ const val = value.trim();
+ if (!val) return;
+ try {
+ if (row.payments && row.payments.length > 0) {
+ const update = {};
+ if (field === 'amount') update.amount = parseFloat(val);
+ if (field === 'date') update.paid_date = val;
+ await api.updatePayment(row.payments[0].id, update);
+ } else {
+ await api.createPayment({
+ bill_id: row.id,
+ amount: field === 'amount' ? parseFloat(val) : threshold,
+ paid_date: field === 'date' ? val : defaultPaymentDate,
+ });
+ }
+ toast.success('Saved');
+ refresh();
+ } catch (err) {
+ toast.error(err.message);
+ }
+ }
+
+ function onKeyDown(e) {
+ if (e.key === 'Enter') inputRef.current?.blur();
+ if (e.key === 'Escape') { setValue(''); setEditing(false); }
+ }
+
+ if (editing) {
+ return (
+ setValue(e.target.value)}
+ onBlur={commit}
+ onKeyDown={onKeyDown}
+ className="h-7 w-28 text-right font-mono text-sm bg-background/80 border-border/60"
+ />
+ );
+ }
+
+ return (
+
+ {displayVal}
+
+ );
+}
diff --git a/client/components/tracker/FilterChip.jsx b/client/components/tracker/FilterChip.jsx
new file mode 100644
index 0000000..747568f
--- /dev/null
+++ b/client/components/tracker/FilterChip.jsx
@@ -0,0 +1,18 @@
+import { cn } from '@/lib/utils';
+
+export function FilterChip({ active, children, onClick }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/client/components/tracker/LowerThisMonthButton.jsx b/client/components/tracker/LowerThisMonthButton.jsx
new file mode 100644
index 0000000..e97bdbf
--- /dev/null
+++ b/client/components/tracker/LowerThisMonthButton.jsx
@@ -0,0 +1,51 @@
+import { useState } from 'react';
+import { toast } from 'sonner';
+import { api } from '@/api.js';
+import { cn, fmt } from '@/lib/utils';
+import { Button } from '@/components/ui/button';
+import { MONTHS, rowThreshold, paymentSummary } from '@/lib/trackerUtils';
+
+export function LowerThisMonthButton({ row, year, month, refresh, compact = false }) {
+ const threshold = rowThreshold(row);
+ const summary = paymentSummary(row, threshold);
+ const [saving, setSaving] = useState(false);
+
+ if (row.is_skipped || !summary.partial) return null;
+
+ async function handleClick() {
+ setSaving(true);
+ try {
+ await api.saveBillMonthlyState(row.id, {
+ year,
+ month,
+ actual_amount: summary.paid,
+ notes: row.monthly_notes || null,
+ is_skipped: row.is_skipped,
+ });
+ toast.success(`${MONTHS[month - 1]} amount set to ${fmt(summary.paid)}`);
+ refresh?.();
+ } catch (err) {
+ toast.error(err.message || 'Failed to update monthly amount');
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ return (
+
+ {saving ? 'Saving...' : 'Bill was lower'}
+
+ );
+}
diff --git a/client/components/tracker/MobileTrackerRow.jsx b/client/components/tracker/MobileTrackerRow.jsx
new file mode 100644
index 0000000..89b6b64
--- /dev/null
+++ b/client/components/tracker/MobileTrackerRow.jsx
@@ -0,0 +1,376 @@
+import { useState, useRef } from 'react';
+import { ArrowDown, ArrowUp, GripVertical, Pencil } from 'lucide-react';
+import { toast } from 'sonner';
+import { api } from '@/api.js';
+import { cn, fmt, fmtDate } from '@/lib/utils';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import {
+ AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle,
+ AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction,
+} from '@/components/ui/alert-dialog';
+import MonthlyStateDialog from '@/components/tracker/MonthlyStateDialog';
+import PaymentModal from '@/components/tracker/PaymentModal';
+import { paymentDateForTrackerMonth, paymentSummary, ROW_STATUS_CLS } from '@/lib/trackerUtils';
+import { StatusBadge } from '@/components/tracker/StatusBadge';
+import { PaymentProgress } from '@/components/tracker/PaymentProgress';
+import { LowerThisMonthButton } from '@/components/tracker/LowerThisMonthButton';
+import { PaymentLedgerDialog } from '@/components/tracker/PaymentLedgerDialog';
+import { NotesCell } from '@/components/tracker/NotesCell';
+import { AutopaySuggestionActions } from '@/components/tracker/AutopaySuggestionActions';
+
+export function MobileTrackerRow({ row, year, month, refresh, index, onEditBill, moveControls, dragProps }) {
+ const amountRef = useRef(null);
+ const [editPayment, setEditPayment] = useState(null);
+ const [paymentLedgerOpen, setPaymentLedgerOpen] = useState(false);
+ const [showMbs, setShowMbs] = useState(false);
+ const [confirmUnpay, setConfirmUnpay] = useState(false);
+ const [suggestionLoading, setSuggestionLoading] = useState(false);
+
+ const threshold = row.actual_amount != null ? row.actual_amount : row.expected_amount;
+ const defaultPaymentDate = paymentDateForTrackerMonth(year, month, row.due_day);
+ const isSkipped = !!row.is_skipped;
+ const hasAutopaySuggestion = !!row.autopay_suggestion && !isSkipped;
+ const isPaidByThreshold = row.total_paid > 0 && row.total_paid >= threshold;
+ const isPaid = row.status === 'paid' || (row.status === 'autodraft' && !hasAutopaySuggestion) || isPaidByThreshold;
+ const effectiveStatus = isSkipped
+ ? 'skipped'
+ : (isPaidByThreshold && row.status !== 'paid' && row.status !== 'autodraft')
+ ? 'paid'
+ : row.status;
+ const rowBg = isSkipped ? '' : (ROW_STATUS_CLS[effectiveStatus] || '');
+ const remaining = Math.max((threshold || 0) - (row.total_paid || 0), 0);
+ const summary = paymentSummary(row, threshold);
+
+ async function handleQuickPay() {
+ const val = parseFloat(amountRef.current?.value);
+ if (!val || val <= 0) { toast.error('Enter a payment amount'); return; }
+ try {
+ await api.quickPay({ bill_id: row.id, amount: val, paid_date: defaultPaymentDate });
+ toast.success('Payment added');
+ refresh();
+ } catch (err) {
+ toast.error(err.message);
+ }
+ }
+
+ async function performTogglePaid() {
+ try {
+ const result = await api.togglePaid(row.id, {
+ amount: isPaid ? undefined : threshold,
+ year: year,
+ month: month,
+ });
+ if (isPaid && result.paymentId) {
+ toast.success('Payment moved to recovery', {
+ action: {
+ label: 'Undo',
+ onClick: async () => {
+ try {
+ await api.restorePayment(result.paymentId);
+ toast.success('Payment restored');
+ refresh();
+ } catch (err) {
+ toast.error(err.message || 'Failed to restore payment');
+ }
+ },
+ },
+ });
+ } else {
+ toast.success('Payment recorded');
+ }
+ refresh();
+ } catch (err) {
+ toast.error(err.message || 'Failed to toggle payment status');
+ }
+ }
+
+ function handleTogglePaid() {
+ if (isPaid) {
+ setConfirmUnpay(true);
+ return;
+ }
+ performTogglePaid();
+ }
+
+ async function handleConfirmSuggestion() {
+ setSuggestionLoading(true);
+ try {
+ const result = await api.confirmAutopaySuggestion(row.id, { year, month });
+ toast.success(result.created ? 'Autopay payment confirmed' : 'Autopay already recorded');
+ refresh();
+ } catch (err) {
+ toast.error(err.message || 'Failed to confirm autopay suggestion');
+ } finally {
+ setSuggestionLoading(false);
+ }
+ }
+
+ async function handleDismissSuggestion() {
+ setSuggestionLoading(true);
+ try {
+ await api.dismissAutopaySuggestion(row.id, { year, month });
+ toast.success('Autopay suggestion dismissed');
+ refresh();
+ } catch (err) {
+ toast.error(err.message || 'Failed to dismiss autopay suggestion');
+ } finally {
+ setSuggestionLoading(false);
+ }
+ }
+
+ return (
+ <>
+
+
+
+
+
+
+ {row.website ? (
+
+ {row.name}
+
+ ) : (
+
+ {row.name}
+
+ )}
+ {row.autopay_enabled && (
+
+ AP
+
+ )}
+ {row.is_subscription && (
+
+ S
+
+ )}
+
onEditBill?.(row)}
+ >
+
+
+
+ {row.monthly_notes && (
+
+ {row.monthly_notes}
+
+ )}
+
+
+
+
+
+
+
+
Due
+
{fmtDate(row.due_date)}
+
+
+
Category
+
{row.category_name || 'Uncategorized'}
+
+
+
Expected
+
+ {fmt(threshold)}
+
+
+
+
Last Month
+
+ {fmt(row.previous_month_paid)}
+
+
+
+
Remaining
+
0 ? 'text-foreground' : 'text-emerald-300')}>
+ {fmt(remaining)}
+
+
+
+
+
+
setPaymentLedgerOpen(true)} compact />
+
+
+
+
+
+ Paid
+ {row.total_paid > 0 ? fmt(row.total_paid) : 'β'}
+
+
+ Date
+ setPaymentLedgerOpen(true)}
+ className="tracker-number rounded font-medium text-foreground underline-offset-2 hover:underline"
+ >
+ {row.last_paid_date ? fmtDate(row.last_paid_date) : 'β'}
+ {summary.count > 1 && ({summary.count}) }
+
+
+
+
+
+ {hasAutopaySuggestion && (
+
+ )}
+ {!isPaid && !isSkipped && !hasAutopaySuggestion && (
+
+
+
+ Add
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ {editPayment && (
+ setEditPayment(null)}
+ onSave={refresh}
+ />
+ )}
+
+ {paymentLedgerOpen && (
+ setPaymentLedgerOpen(false)}
+ onSaved={refresh}
+ />
+ )}
+
+ {showMbs && (
+
+ )}
+
+
+
+
+ Mark this bill unpaid?
+
+ This removes the current payment record for this month and moves it into recovery.
+
+
+
+ Cancel
+
+ Remove Payment
+
+
+
+
+ >
+ );
+}
diff --git a/client/components/tracker/NotesCell.jsx b/client/components/tracker/NotesCell.jsx
new file mode 100644
index 0000000..da7611a
--- /dev/null
+++ b/client/components/tracker/NotesCell.jsx
@@ -0,0 +1,60 @@
+import { useState } from 'react';
+import { toast } from 'sonner';
+import { api } from '@/api.js';
+import { cn } from '@/lib/utils';
+
+// Monthly notes stored in monthly_bill_state β per-month, not per-bill.
+export function NotesCell({ row, refresh }) {
+ const savedNote = row.monthly_notes || '';
+ const [value, setValue] = useState(savedNote);
+ const [saving, setSaving] = useState(false);
+
+ async function handleBlur() {
+ const trimmed = value.trim();
+ if (trimmed === savedNote) return;
+
+ const year = row.year;
+ const month = row.month;
+
+ if (!year || !month) {
+ toast.error('Cannot save notes without year/month context');
+ setValue(savedNote);
+ return;
+ }
+
+ setSaving(true);
+ try {
+ await api.saveBillMonthlyState(row.id, {
+ year,
+ month,
+ notes: trimmed || null,
+ is_skipped: row.is_skipped,
+ actual_amount: row.actual_amount,
+ });
+ refresh();
+ } catch (err) {
+ toast.error(err.message);
+ setValue(savedNote);
+ } finally { setSaving(false); }
+ }
+
+ return (
+ setValue(e.target.value)}
+ onBlur={handleBlur}
+ onKeyDown={e => { if (e.key === 'Enter') e.currentTarget.blur(); }}
+ placeholder='Add monthly notesβ¦'
+ disabled={saving}
+ className={cn(
+ 'w-full bg-transparent text-sm placeholder:text-muted-foreground/40',
+ 'border-0 outline-none ring-0',
+ 'text-muted-foreground focus:text-foreground',
+ 'transition-colors duration-150',
+ 'disabled:cursor-not-allowed disabled:opacity-40',
+ value && 'text-foreground/80',
+ )}
+ />
+ );
+}
diff --git a/client/components/tracker/PaymentLedgerDialog.jsx b/client/components/tracker/PaymentLedgerDialog.jsx
new file mode 100644
index 0000000..103775a
--- /dev/null
+++ b/client/components/tracker/PaymentLedgerDialog.jsx
@@ -0,0 +1,185 @@
+import { useState } from 'react';
+import { Plus } from 'lucide-react';
+import { toast } from 'sonner';
+import { api } from '@/api.js';
+import { fmt, fmtDate } from '@/lib/utils';
+import { METHOD_NONE, paymentSummary } from '@/lib/trackerUtils';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import {
+ Dialog, DialogContent, DialogHeader, DialogTitle,
+} from '@/components/ui/dialog';
+import {
+ Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
+} from '@/components/ui/select';
+import PaymentModal from '@/components/tracker/PaymentModal';
+import { PaymentProgress } from '@/components/tracker/PaymentProgress';
+import { LowerThisMonthButton } from '@/components/tracker/LowerThisMonthButton';
+
+export function PaymentLedgerDialog({ row, year, month, threshold, defaultPaymentDate, onClose, onSaved }) {
+ const summary = paymentSummary(row, threshold);
+ const [amount, setAmount] = useState(String(summary.remaining || summary.target || ''));
+ const [date, setDate] = useState(defaultPaymentDate);
+ const [method, setMethod] = useState(METHOD_NONE);
+ const [notes, setNotes] = useState('');
+ const [busy, setBusy] = useState(false);
+ const [editPayment, setEditPayment] = useState(null);
+ const payments = [...(row.payments || [])].sort((a, b) => String(b.paid_date).localeCompare(String(a.paid_date)));
+
+ async function handleAdd(e) {
+ e.preventDefault();
+ const parsedAmount = parseFloat(amount);
+ if (!Number.isFinite(parsedAmount) || parsedAmount <= 0) {
+ toast.error('Enter a positive payment amount');
+ return;
+ }
+ if (!date) {
+ toast.error('Choose a payment date');
+ return;
+ }
+
+ setBusy(true);
+ try {
+ await api.createPayment({
+ bill_id: row.id,
+ amount: parsedAmount,
+ paid_date: date,
+ method: method === METHOD_NONE ? null : method,
+ notes: notes || null,
+ });
+ toast.success('Partial payment added');
+ onSaved?.();
+ onClose?.();
+ } catch (err) {
+ toast.error(err.message || 'Failed to add payment');
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ return (
+ <>
+ { if (!value) onClose(); }}>
+
+
+ {row.name} Payments
+
+
+
+
+
+
+
+
Payment History
+ {payments.length > 0 ? (
+
+ {payments.map(payment => (
+
+
+
{fmt(payment.amount)}
+
+ {fmtDate(payment.paid_date)}
+ {payment.method ? ` Β· ${payment.method}` : ''}
+
+ {payment.notes && (
+
{payment.notes}
+ )}
+
+
setEditPayment(payment)}>
+ Edit
+
+
+ ))}
+
+ ) : (
+
+ No payments recorded for this month.
+
+ )}
+
+
+
+
+
+
+
+
+ {editPayment && (
+ setEditPayment(null)}
+ onSave={() => {
+ onSaved?.();
+ setEditPayment(null);
+ }}
+ />
+ )}
+ >
+ );
+}
diff --git a/client/components/tracker/PaymentProgress.jsx b/client/components/tracker/PaymentProgress.jsx
new file mode 100644
index 0000000..3620078
--- /dev/null
+++ b/client/components/tracker/PaymentProgress.jsx
@@ -0,0 +1,61 @@
+import { cn, fmt } from '@/lib/utils';
+import { paymentSummary } from '@/lib/trackerUtils';
+
+export function PaymentProgress({ row, threshold, onOpen, onMarkFullAmount, compact = false }) {
+ const summary = paymentSummary(row, threshold);
+ const barTone = summary.remaining === 0
+ ? 'bg-emerald-500'
+ : summary.paid > 0
+ ? 'bg-amber-500'
+ : 'bg-muted-foreground/40';
+
+ const amountLabel = (() => {
+ if (summary.paid === 0) return 'β';
+ if (summary.overpaid > 0) return `${fmt(summary.paidTowardDue)} Β· overpaid`;
+ if (summary.remaining > 0) return `${fmt(summary.paidTowardDue)} paid`;
+ return fmt(summary.paidTowardDue);
+ })();
+
+ const showQuickFix = onMarkFullAmount && summary.partial && summary.paid > 0;
+
+ return (
+
+
+
+ 0 ? 'text-emerald-300' : 'text-muted-foreground/85')}>
+ {amountLabel}
+
+ {summary.count > 1 && (
+
+ {summary.count}Γ
+
+ )}
+
+
+
+ {showQuickFix && (
+
+ β {fmt(summary.paidTowardDue)} is the full amount
+
+ )}
+
+ );
+}
diff --git a/client/components/tracker/StatusBadge.jsx b/client/components/tracker/StatusBadge.jsx
new file mode 100644
index 0000000..3a2c560
--- /dev/null
+++ b/client/components/tracker/StatusBadge.jsx
@@ -0,0 +1,44 @@
+import React, { useMemo } from 'react';
+import { Loader2, AlertCircle } from 'lucide-react';
+import { cn } from '@/lib/utils';
+import { STATUS_META } from '@/lib/trackerUtils';
+
+export const StatusBadge = React.memo(function StatusBadge({ status, clickable, onClick, loading }) {
+ const meta = useMemo(() => STATUS_META[status] || STATUS_META.upcoming, [status]);
+
+ const isSkipped = status === 'skipped';
+ const isUrgent = status === 'late' || status === 'missed';
+ const canClick = clickable && !isSkipped && !loading;
+
+ return (
+
+ {loading ? (
+ <>
+
+ {meta.label}
+ >
+ ) : (
+ <>
+ {isUrgent && }
+ {meta.label}
+ >
+ )}
+
+ );
+});
diff --git a/client/components/tracker/SummaryCards.jsx b/client/components/tracker/SummaryCards.jsx
new file mode 100644
index 0000000..cc683b1
--- /dev/null
+++ b/client/components/tracker/SummaryCards.jsx
@@ -0,0 +1,138 @@
+import { TrendingUp, AlertCircle, Clock, CheckCircle2, Settings2 } from 'lucide-react';
+import { cn, fmt } from '@/lib/utils';
+
+const CARD_DEFS = {
+ starting: {
+ label: 'Starting',
+ icon: TrendingUp,
+ bar: 'from-slate-400 to-slate-300',
+ glow: '',
+ valueClass: 'text-foreground',
+ activateWhen: () => true,
+ },
+ paid: {
+ label: 'Total Paid',
+ icon: CheckCircle2,
+ bar: 'from-emerald-500 to-emerald-300',
+ glow: 'shadow-[0_4px_20px_rgba(16,185,129,0.15)]',
+ borderActive: 'border-emerald-400/40',
+ valueClass: 'text-emerald-600 dark:text-emerald-200',
+ activateWhen: (v) => v > 0,
+ },
+ remaining: {
+ label: 'Remaining',
+ icon: Clock,
+ bar: 'from-blue-400 to-indigo-300',
+ glow: '',
+ valueClass: 'text-foreground',
+ activateWhen: () => true,
+ },
+ overdue: {
+ label: 'Overdue',
+ icon: AlertCircle,
+ bar: 'from-rose-400 to-orange-300',
+ glow: 'shadow-[0_4px_20px_rgba(251,113,133,0.10)]',
+ borderActive: 'border-rose-400/35',
+ valueClass: 'text-red-500 dark:text-rose-200',
+ activateWhen: (v) => v > 0,
+ },
+};
+
+export function TrendIndicator({ trend }) {
+ if (!trend) return null;
+
+ const { direction, percent_change } = trend;
+
+ let icon, color, text;
+ switch (direction) {
+ case 'up':
+ icon = 'β';
+ color = 'text-emerald-500';
+ text = `${icon} ${percent_change}%`;
+ break;
+ case 'down':
+ icon = 'β';
+ color = 'text-red-500';
+ text = `${icon} ${Math.abs(percent_change)}%`;
+ break;
+ default:
+ icon = 'β';
+ color = 'text-muted-foreground';
+ text = `${icon} ${percent_change}%`;
+ }
+
+ return (
+
+
+ {text}
+
+
+ vs 3-mo avg
+
+
+ );
+}
+
+export function SummaryCard({ type, value, onEdit, hint, label }) {
+ const def = CARD_DEFS[type];
+ const isActive = def.activateWhen(value || 0);
+ const Icon = def.icon;
+ const displayLabel = label || def.label;
+
+ return (
+
+
+
+
+
+ {displayLabel}
+
+ {type === 'starting' && onEdit && (
+
+
+
+ )}
+
+
+ {fmt(value)}
+
+ {hint &&
{hint}
}
+
+ );
+}
+
+export function TrendCard({ trend }) {
+ if (!trend) return null;
+
+ return (
+
+ );
+}
diff --git a/client/components/tracker/TrackerBucket.jsx b/client/components/tracker/TrackerBucket.jsx
new file mode 100644
index 0000000..41d0d8f
--- /dev/null
+++ b/client/components/tracker/TrackerBucket.jsx
@@ -0,0 +1,248 @@
+import { useState } from 'react';
+import { cn, fmt } from '@/lib/utils';
+import {
+ Table, TableHeader, TableBody, TableHead, TableRow, TableCell,
+} from '@/components/ui/table';
+import { moveInArray } from '@/lib/trackerUtils';
+import { TrackerRow as Row } from '@/components/tracker/TrackerRow';
+import { MobileTrackerRow } from '@/components/tracker/MobileTrackerRow';
+
+export function TrackerBucket({ label, rows, year, month, refresh, onEditBill, loading, onReorderRows, reorderEnabled, movingBillId }) {
+ const [draggingId, setDraggingId] = useState(null);
+ const [dropTargetId, setDropTargetId] = useState(null);
+ // Use actual_amount (if set) as the per-row threshold; exclude skipped rows from totals
+ const activeRows = rows.filter(r => !r.is_skipped);
+ const totalThreshold = activeRows.reduce((s, r) => s + (r.actual_amount ?? r.expected_amount ?? 0), 0);
+ const totalPaid = activeRows.reduce((s, r) => s + (r.total_paid || 0), 0);
+ const totalPaidTowardDue = activeRows.reduce((s, r) => {
+ const threshold = Number(r.actual_amount ?? r.expected_amount ?? 0) || 0;
+ const cappedPaid = Number(r.paid_toward_due);
+ return s + (Number.isFinite(cappedPaid) ? cappedPaid : Math.min(Number(r.total_paid) || 0, threshold));
+ }, 0);
+ const totalOverpaid = Math.max(totalPaid - totalPaidTowardDue, 0);
+ const totalRemaining = Math.max(totalThreshold - totalPaidTowardDue, 0);
+ const skippedCount = rows.length - activeRows.length;
+ const pct = totalThreshold > 0 ? Math.min((totalPaidTowardDue / totalThreshold) * 100, 100) : 0;
+ const allPaid = pct >= 100;
+
+ function reorderByIndex(fromIndex, toIndex) {
+ if (!reorderEnabled || fromIndex === toIndex || fromIndex < 0 || toIndex < 0) return;
+ onReorderRows?.(moveInArray(rows, fromIndex, toIndex));
+ }
+
+ function dragPropsFor(row, index) {
+ if (!reorderEnabled) return { draggable: false };
+ return {
+ draggable: true,
+ isDragging: draggingId === row.id,
+ isDropTarget: dropTargetId === row.id && draggingId !== row.id,
+ onDragStart: (event) => {
+ setDraggingId(row.id);
+ event.dataTransfer.effectAllowed = 'move';
+ event.dataTransfer.setData('text/plain', String(row.id));
+ },
+ onDragEnter: () => {
+ if (draggingId && draggingId !== row.id) setDropTargetId(row.id);
+ },
+ onDragOver: (event) => {
+ event.preventDefault();
+ event.dataTransfer.dropEffect = 'move';
+ if (draggingId && draggingId !== row.id) setDropTargetId(row.id);
+ },
+ onDrop: (event) => {
+ event.preventDefault();
+ const sourceId = Number(event.dataTransfer.getData('text/plain') || draggingId);
+ const fromIndex = rows.findIndex(item => item.id === sourceId);
+ reorderByIndex(fromIndex, index);
+ setDraggingId(null);
+ setDropTargetId(null);
+ },
+ onDragEnd: () => {
+ setDraggingId(null);
+ setDropTargetId(null);
+ },
+ };
+ }
+
+ function moveControlsFor(row, index) {
+ return {
+ enabled: !!reorderEnabled,
+ moving: movingBillId === row.id,
+ canMoveUp: index > 0,
+ canMoveDown: index < rows.length - 1,
+ onMoveUp: () => reorderByIndex(index, index - 1),
+ onMoveDown: () => reorderByIndex(index, index + 1),
+ };
+ }
+
+ return (
+
+
+ {/* Bucket header */}
+
+
+
+ {label}
+
+ {skippedCount > 0 && (
+
+ ({skippedCount} skipped)
+
+ )}
+
+
+
+ {Math.round(pct)}%
+
+
+
+
+
+
+ {fmt(totalPaidTowardDue)}
+
+ /
+ {fmt(totalThreshold)}
+ {totalOverpaid > 0 && (
+ +{fmt(totalOverpaid)}
+ )}
+
+ {!allPaid && totalRemaining > 0 && (
+ {fmt(totalRemaining)} left
+ )}
+ {allPaid && (
+ Done
+ )}
+ {!reorderEnabled && rows.length > 1 && (
+ Clear filters to reorder
+ )}
+
+
+
+
+ {loading ? (
+ Array.from({ length: 3 }).map((_, i) => (
+
+ ))
+ ) : rows.length === 0 ? (
+
+ No bills match this bucket and filter set.
+
+ ) : (
+ rows.map((r, i) => (
+
+ ))
+ )}
+
+
+
+
+
+
+
+ Bill
+ Due
+ Expected
+ Last Month
+ Paid
+ Paid Date
+ Status
+
+
+ Notes
+
+
+
+
+ {loading ? (
+ Array.from({ length: 5 }).map((_, i) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))
+ ) : rows.length === 0 ? (
+
+
+ No bills match this bucket and filter set.
+
+
+ ) : (
+ rows.map((r, i) => (
+
+ ))
+ )}
+
+
+
+
+
+ );
+}
diff --git a/client/components/tracker/TrackerRow.jsx b/client/components/tracker/TrackerRow.jsx
new file mode 100644
index 0000000..9df4567
--- /dev/null
+++ b/client/components/tracker/TrackerRow.jsx
@@ -0,0 +1,585 @@
+import React, { useState, useRef, useTransition } from 'react';
+import { ArrowDown, ArrowUp, GripVertical, Pencil, X } from 'lucide-react';
+import { toast } from 'sonner';
+import { api } from '@/api.js';
+import { cn, fmt, fmtDate } from '@/lib/utils';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import {
+ TableRow, TableCell,
+} from '@/components/ui/table';
+import {
+ AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle,
+ AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction,
+} from '@/components/ui/alert-dialog';
+import MonthlyStateDialog from '@/components/tracker/MonthlyStateDialog';
+import PaymentModal from '@/components/tracker/PaymentModal';
+import { paymentDateForTrackerMonth, paymentSummary, ROW_STATUS_CLS } from '@/lib/trackerUtils';
+import { StatusBadge } from '@/components/tracker/StatusBadge';
+import { PaymentProgress } from '@/components/tracker/PaymentProgress';
+import { PaymentLedgerDialog } from '@/components/tracker/PaymentLedgerDialog';
+import { NotesCell } from '@/components/tracker/NotesCell';
+import { AutopaySuggestionActions } from '@/components/tracker/AutopaySuggestionActions';
+
+export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveControls, dragProps }) {
+ const amountRef = useRef(null);
+ const [editPayment, setEditPayment] = useState(null);
+ const [paymentLedgerOpen, setPaymentLedgerOpen] = useState(false);
+ const [showMbs, setShowMbs] = useState(false);
+ const [confirmUnpay, setConfirmUnpay] = useState(false);
+ const [loading, setLoading] = useState(false);
+ const [suggestionLoading, setSuggestionLoading] = useState(false);
+ const [optimisticActual, setOptimisticActual] = useState(undefined);
+ const [showUpdateNudge, setShowUpdateNudge] = useState(false);
+ const [nudgeAmount, setNudgeAmount] = useState(null);
+ const [, startTransition] = useTransition();
+
+ const [editingExpected, setEditingExpected] = useState(false);
+ const [expectedDraft, setExpectedDraft] = useState('');
+ const [editingDue, setEditingDue] = useState(false);
+ const [dueDraft, setDueDraft] = useState('');
+
+ // Effective amount threshold: optimistic override β monthly override β template default.
+ const effectiveActual = optimisticActual !== undefined ? optimisticActual : row.actual_amount;
+ const threshold = effectiveActual != null ? effectiveActual : row.expected_amount;
+ const defaultPaymentDate = paymentDateForTrackerMonth(year, month, row.due_day);
+
+ const isSkipped = !!row.is_skipped;
+ const hasAutopaySuggestion = !!row.autopay_suggestion && !isSkipped;
+
+ // Paid when total payments >= effective threshold
+ const isPaidByThreshold = row.total_paid > 0 && row.total_paid >= threshold;
+ const isPaid = row.status === 'paid' || (row.status === 'autodraft' && !hasAutopaySuggestion) || isPaidByThreshold;
+ const summary = paymentSummary(row, threshold);
+
+ // Effective status to show:
+ // skipped > paid (threshold-based) > backend status
+ const effectiveStatus = isSkipped
+ ? 'skipped'
+ : (isPaidByThreshold && row.status !== 'paid' && row.status !== 'autodraft')
+ ? 'paid'
+ : row.status;
+
+ const rowBg = isSkipped ? '' : (ROW_STATUS_CLS[effectiveStatus] || '');
+
+ async function handleQuickPay() {
+ const val = parseFloat(amountRef.current?.value);
+ if (!val || val <= 0) { toast.error('Enter a payment amount'); return; }
+ try {
+ await api.quickPay({ bill_id: row.id, amount: val, paid_date: defaultPaymentDate });
+ toast.success('Payment added');
+ refresh();
+ } catch (err) {
+ toast.error(err.message);
+ }
+ }
+
+ async function performTogglePaid() {
+ setLoading?.(true);
+ try {
+ const result = await api.togglePaid(row.id, {
+ amount: isPaid ? undefined : threshold,
+ year: year,
+ month: month,
+ });
+ if (isPaid && result.paymentId) {
+ toast.success('Payment moved to recovery', {
+ action: {
+ label: 'Undo',
+ onClick: async () => {
+ try {
+ await api.restorePayment(result.paymentId);
+ toast.success('Payment restored');
+ refresh?.();
+ } catch (err) {
+ toast.error(err.message || 'Failed to restore payment');
+ }
+ },
+ },
+ });
+ } else {
+ toast.success('Payment recorded');
+ }
+ refresh?.();
+ } catch (err) {
+ toast.error(err.message || 'Failed to toggle payment status');
+ } finally {
+ setLoading?.(false);
+ }
+ }
+
+ function handleTogglePaid() {
+ if (isPaid) {
+ setConfirmUnpay(true);
+ return;
+ }
+ performTogglePaid();
+ }
+
+ async function handleMarkFullAmount() {
+ const newActual = summary.paidTowardDue;
+ setOptimisticActual(newActual);
+ try {
+ await api.saveBillMonthlyState(row.id, {
+ year, month,
+ actual_amount: newActual,
+ notes: row.monthly_notes || null,
+ is_skipped: row.is_skipped,
+ });
+ setNudgeAmount(newActual);
+ setShowUpdateNudge(true);
+ refresh?.();
+ } catch (err) {
+ setOptimisticActual(undefined);
+ toast.error(err.message || 'Failed to update amount');
+ }
+ }
+
+ function handleUpdateTemplate() {
+ const amount = nudgeAmount;
+ setShowUpdateNudge(false);
+ startTransition(async () => {
+ try {
+ await api.updateBill(row.id, { name: row.name, due_day: row.due_day, expected_amount: amount });
+ toast.success(`Default updated to ${fmt(amount)}`);
+ refresh?.();
+ } catch (err) {
+ toast.error(err.message || 'Failed to update default');
+ }
+ });
+ }
+
+ async function handleApplySuggestion(amount) {
+ setOptimisticActual(amount);
+ try {
+ await api.saveBillMonthlyState(row.id, {
+ year, month,
+ actual_amount: amount,
+ notes: row.monthly_notes || null,
+ is_skipped: row.is_skipped,
+ });
+ refresh?.();
+ } catch (err) {
+ setOptimisticActual(undefined);
+ toast.error(err.message || 'Failed to apply suggestion');
+ }
+ }
+
+ async function handleSaveExpected() {
+ setEditingExpected(false);
+ const val = parseFloat(expectedDraft);
+ if (!isFinite(val) || val < 0) return;
+ const current = effectiveActual ?? row.expected_amount;
+ if (val === current) return;
+
+ if (effectiveActual != null) {
+ setOptimisticActual(val);
+ try {
+ await api.saveBillMonthlyState(row.id, {
+ year, month,
+ actual_amount: val,
+ notes: row.monthly_notes || null,
+ is_skipped: row.is_skipped,
+ });
+ refresh?.();
+ } catch (err) {
+ setOptimisticActual(undefined);
+ toast.error(err.message || 'Failed to update amount');
+ }
+ } else {
+ try {
+ await api.updateBill(row.id, { name: row.name, due_day: row.due_day, expected_amount: val });
+ refresh?.();
+ } catch (err) {
+ toast.error(err.message || 'Failed to update expected amount');
+ }
+ }
+ }
+
+ async function handleSaveDue() {
+ setEditingDue(false);
+ const day = parseInt(dueDraft, 10);
+ if (!isFinite(day) || day < 1 || day > 31) return;
+ if (day === row.due_day) return;
+ try {
+ await api.updateBill(row.id, { name: row.name, due_day: day, expected_amount: row.expected_amount });
+ refresh?.();
+ } catch (err) {
+ toast.error(err.message || 'Failed to update due date');
+ }
+ }
+
+ async function handleConfirmSuggestion() {
+ setSuggestionLoading(true);
+ try {
+ const result = await api.confirmAutopaySuggestion(row.id, { year, month });
+ toast.success(result.created ? 'Autopay payment confirmed' : 'Autopay already recorded');
+ refresh?.();
+ } catch (err) {
+ toast.error(err.message || 'Failed to confirm autopay suggestion');
+ } finally {
+ setSuggestionLoading(false);
+ }
+ }
+
+ async function handleDismissSuggestion() {
+ setSuggestionLoading(true);
+ try {
+ await api.dismissAutopaySuggestion(row.id, { year, month });
+ toast.success('Autopay suggestion dismissed');
+ refresh?.();
+ } catch (err) {
+ toast.error(err.message || 'Failed to dismiss autopay suggestion');
+ } finally {
+ setSuggestionLoading(false);
+ }
+ }
+
+ return (
+ <>
+
+ {/* Bill name + category + monthly notes (if set) */}
+
+
+
+
+
+ {row.website ? (
+
+ {row.name}
+
+ ) : (
+
+ {row.name}
+
+ )}
+ {row.autopay_enabled && (
+
+ AP
+
+ )}
+ {row.is_subscription && (
+
+ S
+
+ )}
+
onEditBill?.(row)}
+ >
+
+
+
+ {row.category_name && (
+
{row.category_name}
+ )}
+ {/* Monthly notes shown inline under the bill name */}
+ {row.monthly_notes && (
+
+ {row.monthly_notes}
+
+ )}
+
+
+
+
+ {/* Due */}
+
+ {editingDue ? (
+ setDueDraft(e.target.value)}
+ onBlur={handleSaveDue}
+ onKeyDown={e => {
+ if (e.key === 'Enter') e.currentTarget.blur();
+ if (e.key === 'Escape') { setEditingDue(false); }
+ }}
+ className="tracker-number w-12 rounded border border-border bg-transparent px-1 py-0.5 text-sm font-medium text-foreground outline-none focus:ring-[2px] focus:ring-ring/50"
+ title="Day of month (1β31)"
+ />
+ ) : (
+ { setDueDraft(String(row.due_day)); setEditingDue(true); }}
+ className="rounded px-1 py-0.5 transition-colors hover:bg-accent hover:text-foreground"
+ title="Click to edit due day"
+ >
+ {fmtDate(row.due_date)}
+
+ )}
+
+
+ {/* Expected / Actual β shows actual_amount in amber when it overrides the template */}
+
+ {editingExpected ? (
+ setExpectedDraft(e.target.value)}
+ onBlur={handleSaveExpected}
+ onKeyDown={e => {
+ if (e.key === 'Enter') e.currentTarget.blur();
+ if (e.key === 'Escape') { setEditingExpected(false); }
+ }}
+ className="tracker-number w-24 rounded border border-border bg-transparent px-1 py-0.5 text-right text-sm font-semibold text-foreground outline-none focus:ring-[2px] focus:ring-ring/50"
+ />
+ ) : effectiveActual != null ? (
+ { setExpectedDraft(String(effectiveActual)); setEditingExpected(true); }}
+ className="rounded px-1 py-0.5 text-amber-300 transition-colors hover:bg-accent"
+ title={`Monthly override β click to edit. Template default: ${fmt(row.expected_amount)}`}
+ >
+ {fmt(effectiveActual)}
+
+ ) : (
+
+ { setExpectedDraft(String(row.expected_amount)); setEditingExpected(true); }}
+ className="rounded px-1 py-0.5 text-foreground/85 transition-colors hover:bg-accent hover:text-foreground"
+ title="Click to edit expected amount"
+ >
+ {fmt(row.expected_amount)}
+
+ {row.amount_suggestion?.suggestion != null &&
+ Math.abs(row.amount_suggestion.suggestion - row.expected_amount) / row.expected_amount > 0.05 && (
+ handleApplySuggestion(row.amount_suggestion.suggestion)}
+ className="text-[10px] text-muted-foreground/50 transition-colors hover:text-muted-foreground"
+ title={`Based on last ${row.amount_suggestion.months_used} months`}
+ >
+ ~{fmt(row.amount_suggestion.suggestion)}
+
+ )}
+
+ )}
+
+
+ {/* Previous month paid */}
+
+ {row.previous_month_paid > 0 ? fmt(row.previous_month_paid) : 'β'}
+
+
+ {/* Amount paid β mismatch now compares against threshold */}
+
+ setPaymentLedgerOpen(true)}
+ onMarkFullAmount={!isSkipped ? handleMarkFullAmount : undefined}
+ />
+
+
+ {/* Paid date */}
+
+ setPaymentLedgerOpen(true)}
+ className="tracker-number rounded-md px-1.5 py-0.5 font-medium transition-colors hover:bg-accent hover:text-foreground"
+ title="View payment history"
+ >
+ {row.last_paid_date ? fmtDate(row.last_paid_date) : 'β'}
+ {summary.count > 1 && ({summary.count}) }
+
+
+
+ {/* Status β uses effectiveStatus (accounts for skipped + threshold) */}
+
+ {
+ if (effectiveStatus === 'skipped') return;
+ handleTogglePaid();
+ }}
+ loading={loading}
+ />
+
+
+ {/* Actions */}
+
+
+ {showUpdateNudge ? (
+
+ Update default?
+
+ {fmt(nudgeAmount)}
+
+ setShowUpdateNudge(false)}
+ className="text-muted-foreground transition-colors hover:text-foreground"
+ title="Dismiss"
+ >
+
+
+
+ ) : (
+ <>
+ {hasAutopaySuggestion && (
+
+ )}
+ {/* Quick pay β hidden for skipped/paid bills */}
+ {!isPaid && !isSkipped && !hasAutopaySuggestion && (
+
+
+
+ Add
+
+
+ )}
+ >
+ )}
+
+
+
+ {/* Notes cell (monthly state notes) */}
+
+
+
+
+
+ {editPayment && (
+ setEditPayment(null)}
+ onSave={refresh}
+ />
+ )}
+
+ {paymentLedgerOpen && (
+ setPaymentLedgerOpen(false)}
+ onSaved={refresh}
+ />
+ )}
+
+ {showMbs && (
+
+ )}
+
+
+
+
+ Mark this bill unpaid?
+
+ This removes the current payment record for this month and moves it into recovery.
+
+
+
+ Cancel
+
+ {loading ? 'Removing...' : 'Remove Payment'}
+
+
+
+
+ >
+ );
+}
diff --git a/client/hooks/useAuth.jsx b/client/hooks/useAuth.jsx
index b028802..d448b41 100644
--- a/client/hooks/useAuth.jsx
+++ b/client/hooks/useAuth.jsx
@@ -17,7 +17,7 @@ export function AuthProvider({ children }) {
useEffect(() => {
api.authMode().then(d => {
if (d.auth_mode === 'single') setSUM(true);
- }).catch(() => {});
+ }).catch(err => console.error('[useAuth] authMode check failed', err));
api.me().then(applyMeResponse).catch(() => setUser(null));
}, []); // eslint-disable-line
diff --git a/client/lib/trackerUtils.js b/client/lib/trackerUtils.js
new file mode 100644
index 0000000..85761b5
--- /dev/null
+++ b/client/lib/trackerUtils.js
@@ -0,0 +1,106 @@
+import { todayStr } from '@/lib/utils';
+
+export const MONTHS = [
+ 'January','February','March','April','May','June',
+ 'July','August','September','October','November','December',
+];
+export const FILTER_ALL = 'all';
+// Sentinel for the "no method" select option β empty string crashes Radix Select
+export const METHOD_NONE = 'none';
+
+export const ROW_STATUS_CLS = {
+ paid: 'bg-emerald-500/[0.04] dark:bg-emerald-400/[0.02]',
+ autodraft: 'bg-sky-500/[0.04] dark:bg-sky-400/[0.018]',
+ upcoming: '',
+ due_soon: 'bg-amber-400/[0.07] dark:bg-amber-300/[0.016]',
+ late: 'border-l-4 border-l-orange-400 bg-orange-500/[0.16] ring-1 ring-inset ring-orange-400/25 dark:bg-orange-400/[0.11] dark:ring-orange-300/25',
+ missed: 'border-l-4 border-l-rose-400 bg-rose-500/[0.18] ring-1 ring-inset ring-rose-400/30 dark:bg-rose-400/[0.13] dark:ring-rose-300/30',
+};
+
+export const STATUS_META = {
+ paid: { label: 'Paid', cls: 'bg-emerald-500/15 text-emerald-500 border border-emerald-500/30 dark:bg-emerald-300/10 dark:text-emerald-200 dark:border-emerald-300/30' },
+ upcoming: { label: 'Upcoming', cls: 'bg-secondary text-muted-foreground border border-border' },
+ due_soon: { label: 'Due Soon', cls: 'bg-amber-400/15 text-amber-500 border border-amber-400/30 dark:bg-amber-300/10 dark:text-amber-200 dark:border-amber-300/28' },
+ late: { label: 'Late', cls: 'bg-orange-500/30 text-orange-800 border border-orange-500/60 shadow-sm shadow-orange-950/10 dark:bg-orange-400/25 dark:text-orange-100 dark:border-orange-300/60' },
+ missed: { label: 'Missed', cls: 'bg-rose-500/30 text-rose-800 border border-rose-500/70 shadow-sm shadow-rose-950/10 dark:bg-rose-400/25 dark:text-rose-100 dark:border-rose-300/60' },
+ autodraft: { label: 'Autodraft', cls: 'bg-sky-400/15 text-sky-500 border border-sky-400/30 dark:bg-sky-300/10 dark:text-sky-200 dark:border-sky-300/28' },
+ skipped: { label: 'Skipped', cls: 'bg-muted text-muted-foreground border border-border' },
+};
+
+export function paymentDateForTrackerMonth(year, month, dueDay) {
+ const now = new Date();
+ if (year === now.getFullYear() && month === now.getMonth() + 1) {
+ return todayStr();
+ }
+
+ const daysInMonth = new Date(year, month, 0).getDate();
+ const day = Number.isInteger(Number(dueDay))
+ ? Math.min(Math.max(Number(dueDay), 1), daysInMonth)
+ : 1;
+
+ return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
+}
+
+export function amountSearchText(...values) {
+ return values
+ .filter(value => value !== null && value !== undefined && Number.isFinite(Number(value)))
+ .flatMap(value => {
+ const num = Number(value);
+ return [String(num), num.toFixed(2), `$${num.toFixed(2)}`];
+ })
+ .join(' ');
+}
+
+export function rowThreshold(row) {
+ return row.actual_amount != null ? row.actual_amount : row.expected_amount;
+}
+
+export function rowEffectiveStatus(row) {
+ if (row.is_skipped) return 'skipped';
+ const threshold = rowThreshold(row);
+ const isPaidByThreshold = row.total_paid > 0 && row.total_paid >= threshold;
+ return (isPaidByThreshold && row.status !== 'paid' && row.status !== 'autodraft') ? 'paid' : row.status;
+}
+
+export function rowIsPaid(row) {
+ const status = rowEffectiveStatus(row);
+ if (row.autopay_suggestion && status === 'autodraft') return false;
+ return status === 'paid' || status === 'autodraft';
+}
+
+export function rowIsDebt(row) {
+ const category = String(row.category_name || '').toLowerCase();
+ return Number(row.current_balance) > 0
+ || row.minimum_payment != null
+ || ['credit card', 'credit cards', 'loan', 'loans', 'debt', 'mortgage'].some(token => category.includes(token));
+}
+
+export function moveInArray(items, fromIndex, toIndex) {
+ const next = [...items];
+ const [moved] = next.splice(fromIndex, 1);
+ next.splice(toIndex, 0, moved);
+ return next;
+}
+
+export function paymentSummary(row, threshold) {
+ const target = Number(threshold) || 0;
+ const paid = Number(row.total_paid) || 0;
+ const paidTowardDue = Number.isFinite(Number(row.paid_toward_due))
+ ? Number(row.paid_toward_due)
+ : Math.min(paid, target);
+ const overpaid = Number.isFinite(Number(row.overpaid_amount))
+ ? Number(row.overpaid_amount)
+ : Math.max(paid - target, 0);
+ const remaining = Math.max(target - paidTowardDue, 0);
+ const percent = target > 0 ? Math.min(100, Math.round((paidTowardDue / target) * 100)) : 0;
+ return {
+ target,
+ paid,
+ paidTowardDue,
+ overpaid,
+ remaining,
+ percent,
+ count: Array.isArray(row.payments) ? row.payments.length : 0,
+ partial: paid > 0 && remaining > 0,
+ };
+}
diff --git a/client/pages/LoginPage.jsx b/client/pages/LoginPage.jsx
index fa26220..4bc0e08 100644
--- a/client/pages/LoginPage.jsx
+++ b/client/pages/LoginPage.jsx
@@ -40,13 +40,13 @@ export default function LoginPage() {
setAuthMode(d);
if (d.auth_mode === 'single') navigate('/', { replace: true });
})
- .catch(() => {});
+ .catch(err => console.error('[LoginPage] authMode check failed', err));
api.me()
.then(d => {
if (d.user) navigate(destFor(d.user), { replace: true });
})
- .catch(() => {});
+ .catch(err => console.error('[LoginPage] session check failed', err));
}, []); // eslint-disable-line
const handlePostLogin = (user) => {
diff --git a/client/pages/SnowballPage.jsx b/client/pages/SnowballPage.jsx
index e0256cb..52e687d 100644
--- a/client/pages/SnowballPage.jsx
+++ b/client/pages/SnowballPage.jsx
@@ -387,7 +387,7 @@ export default function SnowballPage() {
const loadPlans = useCallback(() => {
api.snowballActivePlan().then(p => setActivePlan(p)).catch(() => setActivePlan(null));
- api.snowballPlans().then(d => setAllPlans(d?.plans ?? [])).catch(() => {});
+ api.snowballPlans().then(d => setAllPlans(d?.plans ?? [])).catch(err => console.error('[SnowballPage] failed to load plan history', err));
}, []);
useEffect(() => { Promise.all([load(), loadProjection()]); loadPlans(); }, [load, loadProjection, loadPlans]);
diff --git a/client/pages/SubscriptionsPage.jsx b/client/pages/SubscriptionsPage.jsx
index b637ed9..e9630d8 100644
--- a/client/pages/SubscriptionsPage.jsx
+++ b/client/pages/SubscriptionsPage.jsx
@@ -412,7 +412,7 @@ export default function SubscriptionsPage() {
useEffect(() => {
load();
loadRecommendations();
- api.allBills().then(b => setBills(Array.isArray(b) ? b : [])).catch(() => {});
+ api.allBills().then(b => setBills(Array.isArray(b) ? b : [])).catch(err => console.error('[SubscriptionsPage] failed to load bills', err));
}, [load, loadRecommendations]);
useEffect(() => {
@@ -545,7 +545,7 @@ export default function SubscriptionsPage() {
await api.reorderBills(reorderPayload(nextBills));
toast.success('Subscription order saved');
await load();
- api.allBills().then(b => setBills(Array.isArray(b) ? b : [])).catch(() => {});
+ api.allBills().then(b => setBills(Array.isArray(b) ? b : [])).catch(err => console.error('[SubscriptionsPage] failed to refresh bills after reorder', err));
} catch (err) {
toast.error(err.message || 'Failed to save subscription order');
await load();
diff --git a/client/pages/TrackerPage.jsx b/client/pages/TrackerPage.jsx
index 84d01f5..87f682b 100644
--- a/client/pages/TrackerPage.jsx
+++ b/client/pages/TrackerPage.jsx
@@ -1,1987 +1,71 @@
-import React, { useState, useEffect, useCallback, useRef, useMemo, useTransition } from 'react';
+import { useState, useEffect, useMemo, useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';
-import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, GripVertical, Pencil, TrendingUp, AlertCircle, Clock, CheckCircle2, Settings2, Loader2, Plus, Search, X, RefreshCw } from 'lucide-react';
+import { ChevronLeft, ChevronRight, AlertCircle, CheckCircle2, Plus, Search, X, RefreshCw } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api.js';
import { useTracker, useDriftReport } from '@/hooks/useQueries';
import BillModal from '@/components/BillModal';
import { makeBillDraft } from '@/lib/billDrafts';
import { scheduleLabel, scheduleValue } from '@/lib/billingSchedule';
-import { cn, fmt, fmtDate, todayStr } from '@/lib/utils';
+import { cn, fmt } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Skeleton } from '@/components/ui/Skeleton';
-import {
- Table, TableHeader, TableBody, TableHead, TableRow, TableCell,
-} from '@/components/ui/table';
-import {
- Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
-} from '@/components/ui/dialog';
-import {
- AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle,
- AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction,
-} from '@/components/ui/alert-dialog';
import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select';
-import { Label } from '@/components/ui/label';
-import MonthlyStateDialog from '@/components/tracker/MonthlyStateDialog';
import StartingAmountsEditDialog from '@/components/tracker/StartingAmountsEditDialog';
-import PaymentModal from '@/components/tracker/PaymentModal';
import OverdueCommandCenter from '@/components/tracker/OverdueCommandCenter';
import DriftInsightPanel from '@/components/tracker/DriftInsightPanel';
-const MONTHS = [
- 'January','February','March','April','May','June',
- 'July','August','September','October','November','December',
-];
-const FILTER_ALL = 'all';
+import {
+ MONTHS, FILTER_ALL,
+ paymentDateForTrackerMonth, amountSearchText, rowEffectiveStatus, rowIsPaid, rowIsDebt,
+} from '@/lib/trackerUtils';
+import { FilterChip } from '@/components/tracker/FilterChip';
+import { SummaryCard, TrendCard } from '@/components/tracker/SummaryCards';
+import { PaymentLedgerDialog } from '@/components/tracker/PaymentLedgerDialog';
+import { TrackerBucket as Bucket } from '@/components/tracker/TrackerBucket';
-// Sentinel for the "no method" select option β empty string crashes Radix Select
-const METHOD_NONE = 'none';
-
-function paymentDateForTrackerMonth(year, month, dueDay) {
- const now = new Date();
- if (year === now.getFullYear() && month === now.getMonth() + 1) {
- return todayStr();
- }
-
- const daysInMonth = new Date(year, month, 0).getDate();
- const day = Number.isInteger(Number(dueDay))
- ? Math.min(Math.max(Number(dueDay), 1), daysInMonth)
- : 1;
-
- return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
-}
-
-const ROW_STATUS_CLS = {
- paid: 'bg-emerald-500/[0.04] dark:bg-emerald-400/[0.02]',
- autodraft: 'bg-sky-500/[0.04] dark:bg-sky-400/[0.018]',
- upcoming: '',
- due_soon: 'bg-amber-400/[0.07] dark:bg-amber-300/[0.016]',
- late: 'border-l-4 border-l-orange-400 bg-orange-500/[0.16] ring-1 ring-inset ring-orange-400/25 dark:bg-orange-400/[0.11] dark:ring-orange-300/25',
- missed: 'border-l-4 border-l-rose-400 bg-rose-500/[0.18] ring-1 ring-inset ring-rose-400/30 dark:bg-rose-400/[0.13] dark:ring-rose-300/30',
-};
-
-const STATUS_META = {
- paid: { label: 'Paid', cls: 'bg-emerald-500/15 text-emerald-500 border border-emerald-500/30 dark:bg-emerald-300/10 dark:text-emerald-200 dark:border-emerald-300/30' },
- upcoming: { label: 'Upcoming', cls: 'bg-secondary text-muted-foreground border border-border' },
- due_soon: { label: 'Due Soon', cls: 'bg-amber-400/15 text-amber-500 border border-amber-400/30 dark:bg-amber-300/10 dark:text-amber-200 dark:border-amber-300/28' },
- late: { label: 'Late', cls: 'bg-orange-500/30 text-orange-800 border border-orange-500/60 shadow-sm shadow-orange-950/10 dark:bg-orange-400/25 dark:text-orange-100 dark:border-orange-300/60' },
- missed: { label: 'Missed', cls: 'bg-rose-500/30 text-rose-800 border border-rose-500/70 shadow-sm shadow-rose-950/10 dark:bg-rose-400/25 dark:text-rose-100 dark:border-rose-300/60' },
- autodraft: { label: 'Autodraft', cls: 'bg-sky-400/15 text-sky-500 border border-sky-400/30 dark:bg-sky-300/10 dark:text-sky-200 dark:border-sky-300/28' },
- skipped: { label: 'Skipped', cls: 'bg-muted text-muted-foreground border border-border' },
-};
-
-function amountSearchText(...values) {
- return values
- .filter(value => value !== null && value !== undefined && Number.isFinite(Number(value)))
- .flatMap(value => {
- const num = Number(value);
- return [String(num), num.toFixed(2), `$${num.toFixed(2)}`];
- })
- .join(' ');
-}
-
-function rowThreshold(row) {
- return row.actual_amount != null ? row.actual_amount : row.expected_amount;
-}
-
-function rowEffectiveStatus(row) {
- if (row.is_skipped) return 'skipped';
- const threshold = rowThreshold(row);
- const isPaidByThreshold = row.total_paid > 0 && row.total_paid >= threshold;
- return (isPaidByThreshold && row.status !== 'paid' && row.status !== 'autodraft') ? 'paid' : row.status;
-}
-
-function rowIsPaid(row) {
- const status = rowEffectiveStatus(row);
- if (row.autopay_suggestion && status === 'autodraft') return false;
- return status === 'paid' || status === 'autodraft';
-}
-
-function rowIsDebt(row) {
- const category = String(row.category_name || '').toLowerCase();
- return Number(row.current_balance) > 0
- || row.minimum_payment != null
- || ['credit card', 'credit cards', 'loan', 'loans', 'debt', 'mortgage'].some(token => category.includes(token));
-}
-
-function moveInArray(items, fromIndex, toIndex) {
- const next = [...items];
- const [moved] = next.splice(fromIndex, 1);
- next.splice(toIndex, 0, moved);
- return next;
-}
-
-function FilterChip({ active, children, onClick }) {
- return (
-
- {children}
-
- );
-}
-
-// ββ Summary cards ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-const CARD_DEFS = {
- starting: {
- label: 'Starting',
- icon: TrendingUp,
- bar: 'from-slate-400 to-slate-300',
- glow: '',
- valueClass: 'text-foreground',
- activateWhen: () => true,
- },
- paid: {
- label: 'Total Paid',
- icon: CheckCircle2,
- bar: 'from-emerald-500 to-emerald-300',
- glow: 'shadow-[0_4px_20px_rgba(16,185,129,0.15)]',
- borderActive: 'border-emerald-400/40',
- valueClass: 'text-emerald-600 dark:text-emerald-200',
- activateWhen: (v) => v > 0,
- },
- remaining: {
- label: 'Remaining',
- icon: Clock,
- bar: 'from-blue-400 to-indigo-300',
- glow: '',
- valueClass: 'text-foreground',
- activateWhen: () => true,
- },
- overdue: {
- label: 'Overdue',
- icon: AlertCircle,
- bar: 'from-rose-400 to-orange-300',
- glow: 'shadow-[0_4px_20px_rgba(251,113,133,0.10)]',
- borderActive: 'border-rose-400/35',
- valueClass: 'text-red-500 dark:text-rose-200',
- activateWhen: (v) => v > 0,
- },
-};
-
-function TrendIndicator({ trend }) {
- if (!trend) return null;
-
- const { direction, percent_change } = trend;
-
- let icon, color, text;
- switch (direction) {
- case 'up':
- icon = 'β';
- color = 'text-emerald-500';
- text = `${icon} ${percent_change}%`;
- break;
- case 'down':
- icon = 'β';
- color = 'text-red-500';
- text = `${icon} ${Math.abs(percent_change)}%`;
- break;
- default:
- icon = 'β';
- color = 'text-muted-foreground';
- text = `${icon} ${percent_change}%`;
- }
-
- return (
-
-
- {text}
-
-
- vs 3-mo avg
-
-
- );
-}
-
-function SummaryCard({ type, value, onEdit, hint, label }) {
- const def = CARD_DEFS[type];
- const isActive = def.activateWhen(value || 0);
- const Icon = def.icon;
- const displayLabel = label || def.label;
-
- return (
-
-
-
-
-
- {displayLabel}
-
- {type === 'starting' && onEdit && (
-
-
-
- )}
-
-
- {fmt(value)}
-
- {hint &&
{hint}
}
-
- );
-}
-
-function TrendCard({ trend }) {
- if (!trend) return null;
-
- return (
-
- );
-}
-
-// ββ Status badge βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-const StatusBadge = React.memo(function StatusBadge({ status, clickable, onClick, loading }) {
- const meta = useMemo(() => STATUS_META[status] || STATUS_META.upcoming, [status]);
-
- const isSkipped = status === 'skipped';
- const isUrgent = status === 'late' || status === 'missed';
- const canClick = clickable && !isSkipped && !loading;
-
- return (
-
- {loading ? (
- <>
-
- {meta.label}
- >
- ) : (
- <>
- {isUrgent && }
- {meta.label}
- >
- )}
-
- );
-});
-
-function AutopaySuggestionActions({ row, loading, onConfirm, onDismiss, compact = false }) {
- const suggestion = row.autopay_suggestion;
- if (!suggestion) return null;
-
- const title = `${fmt(suggestion.amount)} due ${fmtDate(suggestion.paid_date)}`;
-
- return (
-
-
-
- {compact ? `Suggested ${fmt(suggestion.amount)}` : 'Suggested'}
-
-
-
-
-
- {loading ? : }
-
-
- );
-}
-
-// ββ Inline-editable payment cell βββββββββββββββββββββββββββββββββββββββββββ
-// `threshold` = actual_amount ?? expected_amount for this bill/month
-function EditableCell({ row, field, threshold, defaultPaymentDate, refresh }) {
- const [editing, setEditing] = useState(false);
- const [value, setValue] = useState('');
- const inputRef = useRef(null);
-
- const displayVal = field === 'amount'
- ? (row.total_paid > 0 ? fmt(row.total_paid) : 'β')
- : (row.last_paid_date ? fmtDate(row.last_paid_date) : 'β');
-
- const isEmpty = field === 'amount' ? row.total_paid <= 0 : !row.last_paid_date;
- // Mismatch when paid amount differs from the effective threshold for this month
- const mismatch = field === 'amount' && row.total_paid > 0 && row.total_paid !== threshold;
-
- function startEdit() {
- if (editing) return;
- setValue(field === 'amount'
- ? (row.total_paid > 0 ? String(row.total_paid) : '')
- : (row.last_paid_date || ''));
- setEditing(true);
- setTimeout(() => { inputRef.current?.focus(); inputRef.current?.select(); }, 0);
- }
-
- async function commit() {
- setEditing(false);
- const val = value.trim();
- if (!val) return;
- try {
- if (row.payments && row.payments.length > 0) {
- const update = {};
- if (field === 'amount') update.amount = parseFloat(val);
- if (field === 'date') update.paid_date = val;
- await api.updatePayment(row.payments[0].id, update);
- } else {
- await api.createPayment({
- bill_id: row.id,
- amount: field === 'amount' ? parseFloat(val) : threshold,
- paid_date: field === 'date' ? val : defaultPaymentDate,
- });
- }
- toast.success('Saved');
- refresh();
- } catch (err) {
- toast.error(err.message);
- }
- }
-
- function onKeyDown(e) {
- if (e.key === 'Enter') inputRef.current?.blur();
- if (e.key === 'Escape') { setValue(''); setEditing(false); }
- }
-
- if (editing) {
- return (
- setValue(e.target.value)}
- onBlur={commit}
- onKeyDown={onKeyDown}
- className="h-7 w-28 text-right font-mono text-sm bg-background/80 border-border/60"
- />
- );
- }
-
- return (
-
- {displayVal}
-
- );
-}
-
-function paymentSummary(row, threshold) {
- const target = Number(threshold) || 0;
- const paid = Number(row.total_paid) || 0;
- const paidTowardDue = Number.isFinite(Number(row.paid_toward_due))
- ? Number(row.paid_toward_due)
- : Math.min(paid, target);
- const overpaid = Number.isFinite(Number(row.overpaid_amount))
- ? Number(row.overpaid_amount)
- : Math.max(paid - target, 0);
- const remaining = Math.max(target - paidTowardDue, 0);
- const percent = target > 0 ? Math.min(100, Math.round((paidTowardDue / target) * 100)) : 0;
- return {
- target,
- paid,
- paidTowardDue,
- overpaid,
- remaining,
- percent,
- count: Array.isArray(row.payments) ? row.payments.length : 0,
- partial: paid > 0 && remaining > 0,
- };
-}
-
-function PaymentProgress({ row, threshold, onOpen, onMarkFullAmount, compact = false }) {
- const summary = paymentSummary(row, threshold);
- const barTone = summary.remaining === 0
- ? 'bg-emerald-500'
- : summary.paid > 0
- ? 'bg-amber-500'
- : 'bg-muted-foreground/40';
-
- const amountLabel = (() => {
- if (summary.paid === 0) return 'β';
- if (summary.overpaid > 0) return `${fmt(summary.paidTowardDue)} Β· overpaid`;
- if (summary.remaining > 0) return `${fmt(summary.paidTowardDue)} paid`;
- return fmt(summary.paidTowardDue);
- })();
-
- const showQuickFix = onMarkFullAmount && summary.partial && summary.paid > 0;
-
- return (
-
-
-
- 0 ? 'text-emerald-300' : 'text-muted-foreground/85')}>
- {amountLabel}
-
- {summary.count > 1 && (
-
- {summary.count}Γ
-
- )}
-
-
-
- {showQuickFix && (
-
- β {fmt(summary.paidTowardDue)} is the full amount
-
- )}
-
- );
-}
-
-function LowerThisMonthButton({ row, year, month, refresh, compact = false }) {
- const threshold = rowThreshold(row);
- const summary = paymentSummary(row, threshold);
- const [saving, setSaving] = useState(false);
-
- if (row.is_skipped || !summary.partial) return null;
-
- async function handleClick() {
- setSaving(true);
- try {
- await api.saveBillMonthlyState(row.id, {
- year,
- month,
- actual_amount: summary.paid,
- notes: row.monthly_notes || null,
- is_skipped: row.is_skipped,
- });
- toast.success(`${MONTHS[month - 1]} amount set to ${fmt(summary.paid)}`);
- refresh?.();
- } catch (err) {
- toast.error(err.message || 'Failed to update monthly amount');
- } finally {
- setSaving(false);
- }
- }
-
- return (
-
- {saving ? 'Saving...' : 'Bill was lower'}
-
- );
-}
-
-function PaymentLedgerDialog({ row, year, month, threshold, defaultPaymentDate, onClose, onSaved }) {
- const summary = paymentSummary(row, threshold);
- const [amount, setAmount] = useState(String(summary.remaining || summary.target || ''));
- const [date, setDate] = useState(defaultPaymentDate);
- const [method, setMethod] = useState(METHOD_NONE);
- const [notes, setNotes] = useState('');
- const [busy, setBusy] = useState(false);
- const [editPayment, setEditPayment] = useState(null);
- const payments = [...(row.payments || [])].sort((a, b) => String(b.paid_date).localeCompare(String(a.paid_date)));
-
- async function handleAdd(e) {
- e.preventDefault();
- const parsedAmount = parseFloat(amount);
- if (!Number.isFinite(parsedAmount) || parsedAmount <= 0) {
- toast.error('Enter a positive payment amount');
- return;
- }
- if (!date) {
- toast.error('Choose a payment date');
- return;
- }
-
- setBusy(true);
- try {
- await api.createPayment({
- bill_id: row.id,
- amount: parsedAmount,
- paid_date: date,
- method: method === METHOD_NONE ? null : method,
- notes: notes || null,
- });
- toast.success('Partial payment added');
- onSaved?.();
- onClose?.();
- } catch (err) {
- toast.error(err.message || 'Failed to add payment');
- } finally {
- setBusy(false);
- }
- }
-
- return (
- <>
- { if (!value) onClose(); }}>
-
-
- {row.name} Payments
-
-
-
-
-
-
-
-
Payment History
- {payments.length > 0 ? (
-
- {payments.map(payment => (
-
-
-
{fmt(payment.amount)}
-
- {fmtDate(payment.paid_date)}
- {payment.method ? ` Β· ${payment.method}` : ''}
-
- {payment.notes && (
-
{payment.notes}
- )}
-
-
setEditPayment(payment)}>
- Edit
-
-
- ))}
-
- ) : (
-
- No payments recorded for this month.
-
- )}
-
-
-
-
-
-
-
-
- {editPayment && (
- setEditPayment(null)}
- onSave={() => {
- onSaved?.();
- setEditPayment(null);
- }}
- />
- )}
- >
- );
-}
-
-// ββ Notes cell (monthly state notes) βββββββββββββββββββββββββββββββββββββ
-// Shows the monthly state notes for this bill in the current month.
-// Notes are per-month, not per-bill - each month has its own notes field.
-function NotesCell({ row, refresh }) {
- // Monthly notes - the per-month notes stored in monthly_bill_state
- const savedNote = row.monthly_notes || '';
- const [value, setValue] = useState(savedNote);
- const [saving, setSaving] = useState(false);
-
- async function handleBlur() {
- const trimmed = value.trim();
- if (trimmed === savedNote) return;
-
- // Need year and month to save to monthly_bill_state
- // These should be passed via row props from the parent
- const year = row.year;
- const month = row.month;
-
- if (!year || !month) {
- toast.error('Cannot save notes without year/month context');
- setValue(savedNote);
- return;
- }
-
- setSaving(true);
- try {
- await api.saveBillMonthlyState(row.id, {
- year,
- month,
- notes: trimmed || null,
- is_skipped: row.is_skipped,
- actual_amount: row.actual_amount,
- });
- refresh();
- } catch (err) {
- toast.error(err.message);
- setValue(savedNote);
- } finally { setSaving(false); }
- }
-
- return (
- setValue(e.target.value)}
- onBlur={handleBlur}
- onKeyDown={e => { if (e.key === 'Enter') e.currentTarget.blur(); }}
- placeholder='Add monthly notesβ¦'
- disabled={saving}
- className={cn(
- 'w-full bg-transparent text-sm placeholder:text-muted-foreground/40',
- 'border-0 outline-none ring-0',
- 'text-muted-foreground focus:text-foreground',
- 'transition-colors duration-150',
- 'disabled:cursor-not-allowed disabled:opacity-40',
- value && 'text-foreground/80',
- )}
- />
- );
-}
-
-// ββ Table row ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-function Row({ row, year, month, refresh, index, onEditBill, moveControls, dragProps }) {
- const amountRef = useRef(null);
- const [editPayment, setEditPayment] = useState(null);
- const [paymentLedgerOpen, setPaymentLedgerOpen] = useState(false);
- const [showMbs, setShowMbs] = useState(false);
- const [confirmUnpay, setConfirmUnpay] = useState(false);
- const [loading, setLoading] = useState(false);
- const [suggestionLoading, setSuggestionLoading] = useState(false);
- const [optimisticActual, setOptimisticActual] = useState(undefined);
- const [showUpdateNudge, setShowUpdateNudge] = useState(false);
- const [nudgeAmount, setNudgeAmount] = useState(null);
- const [, startTransition] = useTransition();
-
- const [editingExpected, setEditingExpected] = useState(false);
- const [expectedDraft, setExpectedDraft] = useState('');
- const [editingDue, setEditingDue] = useState(false);
- const [dueDraft, setDueDraft] = useState('');
-
- // Effective amount threshold: optimistic override β monthly override β template default.
- const effectiveActual = optimisticActual !== undefined ? optimisticActual : row.actual_amount;
- const threshold = effectiveActual != null ? effectiveActual : row.expected_amount;
- const defaultPaymentDate = paymentDateForTrackerMonth(year, month, row.due_day);
-
- const isSkipped = !!row.is_skipped;
- const hasAutopaySuggestion = !!row.autopay_suggestion && !isSkipped;
-
- // Paid when total payments >= effective threshold
- const isPaidByThreshold = row.total_paid > 0 && row.total_paid >= threshold;
- const isPaid = row.status === 'paid' || (row.status === 'autodraft' && !hasAutopaySuggestion) || isPaidByThreshold;
- const summary = paymentSummary(row, threshold);
-
- // Effective status to show:
- // skipped > paid (threshold-based) > backend status
- const effectiveStatus = isSkipped
- ? 'skipped'
- : (isPaidByThreshold && row.status !== 'paid' && row.status !== 'autodraft')
- ? 'paid'
- : row.status;
-
- const rowBg = isSkipped ? '' : (ROW_STATUS_CLS[effectiveStatus] || '');
-
- async function handleQuickPay() {
- const val = parseFloat(amountRef.current?.value);
- if (!val || val <= 0) { toast.error('Enter a payment amount'); return; }
- try {
- await api.quickPay({ bill_id: row.id, amount: val, paid_date: defaultPaymentDate });
- toast.success('Payment added');
- refresh();
- } catch (err) {
- toast.error(err.message);
- }
- }
-
- async function performTogglePaid() {
- setLoading?.(true);
- try {
- const result = await api.togglePaid(row.id, {
- amount: isPaid ? undefined : threshold,
- year: year,
- month: month,
- });
- if (isPaid && result.paymentId) {
- toast.success('Payment moved to recovery', {
- action: {
- label: 'Undo',
- onClick: async () => {
- try {
- await api.restorePayment(result.paymentId);
- toast.success('Payment restored');
- refresh?.();
- } catch (err) {
- toast.error(err.message || 'Failed to restore payment');
- }
- },
- },
- });
- } else {
- toast.success('Payment recorded');
- }
- refresh?.();
- } catch (err) {
- toast.error(err.message || 'Failed to toggle payment status');
- } finally {
- setLoading?.(false);
- }
- }
-
- function handleTogglePaid() {
- if (isPaid) {
- setConfirmUnpay(true);
- return;
- }
- performTogglePaid();
- }
-
- async function handleMarkFullAmount() {
- const newActual = summary.paidTowardDue;
- setOptimisticActual(newActual);
- try {
- await api.saveBillMonthlyState(row.id, {
- year, month,
- actual_amount: newActual,
- notes: row.monthly_notes || null,
- is_skipped: row.is_skipped,
- });
- setNudgeAmount(newActual);
- setShowUpdateNudge(true);
- refresh?.();
- } catch (err) {
- setOptimisticActual(undefined);
- toast.error(err.message || 'Failed to update amount');
- }
- }
-
- function handleUpdateTemplate() {
- const amount = nudgeAmount;
- setShowUpdateNudge(false);
- startTransition(async () => {
- try {
- await api.updateBill(row.id, { name: row.name, due_day: row.due_day, expected_amount: amount });
- toast.success(`Default updated to ${fmt(amount)}`);
- refresh?.();
- } catch (err) {
- toast.error(err.message || 'Failed to update default');
- }
- });
- }
-
- async function handleApplySuggestion(amount) {
- setOptimisticActual(amount);
- try {
- await api.saveBillMonthlyState(row.id, {
- year, month,
- actual_amount: amount,
- notes: row.monthly_notes || null,
- is_skipped: row.is_skipped,
- });
- refresh?.();
- } catch (err) {
- setOptimisticActual(undefined);
- toast.error(err.message || 'Failed to apply suggestion');
- }
- }
-
- async function handleSaveExpected() {
- setEditingExpected(false);
- const val = parseFloat(expectedDraft);
- if (!isFinite(val) || val < 0) return;
- const current = effectiveActual ?? row.expected_amount;
- if (val === current) return;
-
- if (effectiveActual != null) {
- setOptimisticActual(val);
- try {
- await api.saveBillMonthlyState(row.id, {
- year, month,
- actual_amount: val,
- notes: row.monthly_notes || null,
- is_skipped: row.is_skipped,
- });
- refresh?.();
- } catch (err) {
- setOptimisticActual(undefined);
- toast.error(err.message || 'Failed to update amount');
- }
- } else {
- try {
- await api.updateBill(row.id, { name: row.name, due_day: row.due_day, expected_amount: val });
- refresh?.();
- } catch (err) {
- toast.error(err.message || 'Failed to update expected amount');
- }
- }
- }
-
- async function handleSaveDue() {
- setEditingDue(false);
- const day = parseInt(dueDraft, 10);
- if (!isFinite(day) || day < 1 || day > 31) return;
- if (day === row.due_day) return;
- try {
- await api.updateBill(row.id, { name: row.name, due_day: day, expected_amount: row.expected_amount });
- refresh?.();
- } catch (err) {
- toast.error(err.message || 'Failed to update due date');
- }
- }
-
- async function handleConfirmSuggestion() {
- setSuggestionLoading(true);
- try {
- const result = await api.confirmAutopaySuggestion(row.id, { year, month });
- toast.success(result.created ? 'Autopay payment confirmed' : 'Autopay already recorded');
- refresh?.();
- } catch (err) {
- toast.error(err.message || 'Failed to confirm autopay suggestion');
- } finally {
- setSuggestionLoading(false);
- }
- }
-
- async function handleDismissSuggestion() {
- setSuggestionLoading(true);
- try {
- await api.dismissAutopaySuggestion(row.id, { year, month });
- toast.success('Autopay suggestion dismissed');
- refresh?.();
- } catch (err) {
- toast.error(err.message || 'Failed to dismiss autopay suggestion');
- } finally {
- setSuggestionLoading(false);
- }
- }
-
- return (
- <>
-
- {/* Bill name + category + monthly notes (if set) */}
-
-
-
-
-
- {row.website ? (
-
- {row.name}
-
- ) : (
-
- {row.name}
-
- )}
- {row.autopay_enabled && (
-
- AP
-
- )}
- {row.is_subscription && (
-
- S
-
- )}
-
onEditBill?.(row)}
- >
-
-
-
- {row.category_name && (
-
{row.category_name}
- )}
- {/* Monthly notes shown inline under the bill name */}
- {row.monthly_notes && (
-
- {row.monthly_notes}
-
- )}
-
-
-
-
- {/* Due */}
-
- {editingDue ? (
- setDueDraft(e.target.value)}
- onBlur={handleSaveDue}
- onKeyDown={e => {
- if (e.key === 'Enter') e.currentTarget.blur();
- if (e.key === 'Escape') { setEditingDue(false); }
- }}
- className="tracker-number w-12 rounded border border-border bg-transparent px-1 py-0.5 text-sm font-medium text-foreground outline-none focus:ring-[2px] focus:ring-ring/50"
- title="Day of month (1β31)"
- />
- ) : (
- { setDueDraft(String(row.due_day)); setEditingDue(true); }}
- className="rounded px-1 py-0.5 transition-colors hover:bg-accent hover:text-foreground"
- title="Click to edit due day"
- >
- {fmtDate(row.due_date)}
-
- )}
-
-
- {/* Expected / Actual β shows actual_amount in amber when it overrides the template */}
-
- {editingExpected ? (
- setExpectedDraft(e.target.value)}
- onBlur={handleSaveExpected}
- onKeyDown={e => {
- if (e.key === 'Enter') e.currentTarget.blur();
- if (e.key === 'Escape') { setEditingExpected(false); }
- }}
- className="tracker-number w-24 rounded border border-border bg-transparent px-1 py-0.5 text-right text-sm font-semibold text-foreground outline-none focus:ring-[2px] focus:ring-ring/50"
- />
- ) : effectiveActual != null ? (
- { setExpectedDraft(String(effectiveActual)); setEditingExpected(true); }}
- className="rounded px-1 py-0.5 text-amber-300 transition-colors hover:bg-accent"
- title={`Monthly override β click to edit. Template default: ${fmt(row.expected_amount)}`}
- >
- {fmt(effectiveActual)}
-
- ) : (
-
- { setExpectedDraft(String(row.expected_amount)); setEditingExpected(true); }}
- className="rounded px-1 py-0.5 text-foreground/85 transition-colors hover:bg-accent hover:text-foreground"
- title="Click to edit expected amount"
- >
- {fmt(row.expected_amount)}
-
- {row.amount_suggestion?.suggestion != null &&
- Math.abs(row.amount_suggestion.suggestion - row.expected_amount) / row.expected_amount > 0.05 && (
- handleApplySuggestion(row.amount_suggestion.suggestion)}
- className="text-[10px] text-muted-foreground/50 transition-colors hover:text-muted-foreground"
- title={`Based on last ${row.amount_suggestion.months_used} months`}
- >
- ~{fmt(row.amount_suggestion.suggestion)}
-
- )}
-
- )}
-
-
- {/* Previous month paid */}
-
- {row.previous_month_paid > 0 ? fmt(row.previous_month_paid) : 'β'}
-
-
- {/* Amount paid β mismatch now compares against threshold */}
-
- setPaymentLedgerOpen(true)}
- onMarkFullAmount={!isSkipped ? handleMarkFullAmount : undefined}
- />
-
-
- {/* Paid date */}
-
- setPaymentLedgerOpen(true)}
- className="tracker-number rounded-md px-1.5 py-0.5 font-medium transition-colors hover:bg-accent hover:text-foreground"
- title="View payment history"
- >
- {row.last_paid_date ? fmtDate(row.last_paid_date) : 'β'}
- {summary.count > 1 && ({summary.count}) }
-
-
-
- {/* Status β uses effectiveStatus (accounts for skipped + threshold) */}
-
- {
- if (effectiveStatus === 'skipped') return;
- handleTogglePaid();
- }}
- loading={loading}
- />
-
-
- {/* Actions */}
-
-
- {showUpdateNudge ? (
-
- Update default?
-
- {fmt(nudgeAmount)}
-
- setShowUpdateNudge(false)}
- className="text-muted-foreground transition-colors hover:text-foreground"
- title="Dismiss"
- >
-
-
-
- ) : (
- <>
- {hasAutopaySuggestion && (
-
- )}
- {/* Quick pay β hidden for skipped/paid bills */}
- {!isPaid && !isSkipped && !hasAutopaySuggestion && (
-
-
-
- Add
-
-
- )}
- >
- )}
-
-
-
- {/* Notes cell (monthly state notes) */}
-
-
-
-
-
- {editPayment && (
- setEditPayment(null)}
- onSave={refresh}
- />
- )}
-
- {paymentLedgerOpen && (
- setPaymentLedgerOpen(false)}
- onSaved={refresh}
- />
- )}
-
- {showMbs && (
-
- )}
-
-
-
-
- Mark this bill unpaid?
-
- This removes the current payment record for this month and moves it into recovery.
-
-
-
- Cancel
-
- {loading ? 'Removing...' : 'Remove Payment'}
-
-
-
-
- >
- );
-}
-
-function MobileTrackerRow({ row, year, month, refresh, index, onEditBill, moveControls, dragProps }) {
- const amountRef = useRef(null);
- const [editPayment, setEditPayment] = useState(null);
- const [paymentLedgerOpen, setPaymentLedgerOpen] = useState(false);
- const [showMbs, setShowMbs] = useState(false);
- const [confirmUnpay, setConfirmUnpay] = useState(false);
- const [suggestionLoading, setSuggestionLoading] = useState(false);
-
- const threshold = row.actual_amount != null ? row.actual_amount : row.expected_amount;
- const defaultPaymentDate = paymentDateForTrackerMonth(year, month, row.due_day);
- const isSkipped = !!row.is_skipped;
- const hasAutopaySuggestion = !!row.autopay_suggestion && !isSkipped;
- const isPaidByThreshold = row.total_paid > 0 && row.total_paid >= threshold;
- const isPaid = row.status === 'paid' || (row.status === 'autodraft' && !hasAutopaySuggestion) || isPaidByThreshold;
- const effectiveStatus = isSkipped
- ? 'skipped'
- : (isPaidByThreshold && row.status !== 'paid' && row.status !== 'autodraft')
- ? 'paid'
- : row.status;
- const rowBg = isSkipped ? '' : (ROW_STATUS_CLS[effectiveStatus] || '');
- const remaining = Math.max((threshold || 0) - (row.total_paid || 0), 0);
- const summary = paymentSummary(row, threshold);
-
- async function handleQuickPay() {
- const val = parseFloat(amountRef.current?.value);
- if (!val || val <= 0) { toast.error('Enter a payment amount'); return; }
- try {
- await api.quickPay({ bill_id: row.id, amount: val, paid_date: defaultPaymentDate });
- toast.success('Payment added');
- refresh();
- } catch (err) {
- toast.error(err.message);
- }
- }
-
- async function performTogglePaid() {
- try {
- const result = await api.togglePaid(row.id, {
- amount: isPaid ? undefined : threshold,
- year: year,
- month: month,
- });
- if (isPaid && result.paymentId) {
- toast.success('Payment moved to recovery', {
- action: {
- label: 'Undo',
- onClick: async () => {
- try {
- await api.restorePayment(result.paymentId);
- toast.success('Payment restored');
- refresh();
- } catch (err) {
- toast.error(err.message || 'Failed to restore payment');
- }
- },
- },
- });
- } else {
- toast.success('Payment recorded');
- }
- refresh();
- } catch (err) {
- toast.error(err.message || 'Failed to toggle payment status');
- }
- }
-
- function handleTogglePaid() {
- if (isPaid) {
- setConfirmUnpay(true);
- return;
- }
- performTogglePaid();
- }
-
- async function handleConfirmSuggestion() {
- setSuggestionLoading(true);
- try {
- const result = await api.confirmAutopaySuggestion(row.id, { year, month });
- toast.success(result.created ? 'Autopay payment confirmed' : 'Autopay already recorded');
- refresh();
- } catch (err) {
- toast.error(err.message || 'Failed to confirm autopay suggestion');
- } finally {
- setSuggestionLoading(false);
- }
- }
-
- async function handleDismissSuggestion() {
- setSuggestionLoading(true);
- try {
- await api.dismissAutopaySuggestion(row.id, { year, month });
- toast.success('Autopay suggestion dismissed');
- refresh();
- } catch (err) {
- toast.error(err.message || 'Failed to dismiss autopay suggestion');
- } finally {
- setSuggestionLoading(false);
- }
- }
-
- return (
- <>
-
-
-
-
-
-
- {row.website ? (
-
- {row.name}
-
- ) : (
-
- {row.name}
-
- )}
- {row.autopay_enabled && (
-
- AP
-
- )}
- {row.is_subscription && (
-
- S
-
- )}
-
onEditBill?.(row)}
- >
-
-
-
- {row.monthly_notes && (
-
- {row.monthly_notes}
-
- )}
-
-
-
-
-
-
-
-
Due
-
{fmtDate(row.due_date)}
-
-
-
Category
-
{row.category_name || 'Uncategorized'}
-
-
-
Expected
-
- {fmt(threshold)}
-
-
-
-
Last Month
-
- {fmt(row.previous_month_paid)}
-
-
-
-
Remaining
-
0 ? 'text-foreground' : 'text-emerald-300')}>
- {fmt(remaining)}
-
-
-
-
-
-
setPaymentLedgerOpen(true)} compact />
-
-
-
-
-
- Paid
- {row.total_paid > 0 ? fmt(row.total_paid) : 'β'}
-
-
- Date
- setPaymentLedgerOpen(true)}
- className="tracker-number rounded font-medium text-foreground underline-offset-2 hover:underline"
- >
- {row.last_paid_date ? fmtDate(row.last_paid_date) : 'β'}
- {summary.count > 1 && ({summary.count}) }
-
-
-
-
-
- {hasAutopaySuggestion && (
-
- )}
- {!isPaid && !isSkipped && !hasAutopaySuggestion && (
-
-
-
- Add
-
-
- )}
-
-
-
-
-
-
-
-
-
-
- {editPayment && (
- setEditPayment(null)}
- onSave={refresh}
- />
- )}
-
- {paymentLedgerOpen && (
- setPaymentLedgerOpen(false)}
- onSaved={refresh}
- />
- )}
-
- {showMbs && (
-
- )}
-
-
-
-
- Mark this bill unpaid?
-
- This removes the current payment record for this month and moves it into recovery.
-
-
-
- Cancel
-
- Remove Payment
-
-
-
-
- >
- );
-}
-
-// ββ Bucket βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-function Bucket({ label, rows, year, month, refresh, onEditBill, loading, onReorderRows, reorderEnabled, movingBillId }) {
- const [draggingId, setDraggingId] = useState(null);
- const [dropTargetId, setDropTargetId] = useState(null);
- // Use actual_amount (if set) as the per-row threshold; exclude skipped rows from totals
- const activeRows = rows.filter(r => !r.is_skipped);
- const totalThreshold = activeRows.reduce((s, r) => s + (r.actual_amount ?? r.expected_amount ?? 0), 0);
- const totalPaid = activeRows.reduce((s, r) => s + (r.total_paid || 0), 0);
- const totalPaidTowardDue = activeRows.reduce((s, r) => {
- const threshold = Number(r.actual_amount ?? r.expected_amount ?? 0) || 0;
- const cappedPaid = Number(r.paid_toward_due);
- return s + (Number.isFinite(cappedPaid) ? cappedPaid : Math.min(Number(r.total_paid) || 0, threshold));
- }, 0);
- const totalOverpaid = Math.max(totalPaid - totalPaidTowardDue, 0);
- const totalRemaining = Math.max(totalThreshold - totalPaidTowardDue, 0);
- const skippedCount = rows.length - activeRows.length;
- const pct = totalThreshold > 0 ? Math.min((totalPaidTowardDue / totalThreshold) * 100, 100) : 0;
- const allPaid = pct >= 100;
-
- function reorderByIndex(fromIndex, toIndex) {
- if (!reorderEnabled || fromIndex === toIndex || fromIndex < 0 || toIndex < 0) return;
- onReorderRows?.(moveInArray(rows, fromIndex, toIndex));
- }
-
- function dragPropsFor(row, index) {
- if (!reorderEnabled) return { draggable: false };
- return {
- draggable: true,
- isDragging: draggingId === row.id,
- isDropTarget: dropTargetId === row.id && draggingId !== row.id,
- onDragStart: (event) => {
- setDraggingId(row.id);
- event.dataTransfer.effectAllowed = 'move';
- event.dataTransfer.setData('text/plain', String(row.id));
- },
- onDragEnter: () => {
- if (draggingId && draggingId !== row.id) setDropTargetId(row.id);
- },
- onDragOver: (event) => {
- event.preventDefault();
- event.dataTransfer.dropEffect = 'move';
- if (draggingId && draggingId !== row.id) setDropTargetId(row.id);
- },
- onDrop: (event) => {
- event.preventDefault();
- const sourceId = Number(event.dataTransfer.getData('text/plain') || draggingId);
- const fromIndex = rows.findIndex(item => item.id === sourceId);
- reorderByIndex(fromIndex, index);
- setDraggingId(null);
- setDropTargetId(null);
- },
- onDragEnd: () => {
- setDraggingId(null);
- setDropTargetId(null);
- },
- };
- }
-
- function moveControlsFor(row, index) {
- return {
- enabled: !!reorderEnabled,
- moving: movingBillId === row.id,
- canMoveUp: index > 0,
- canMoveDown: index < rows.length - 1,
- onMoveUp: () => reorderByIndex(index, index - 1),
- onMoveDown: () => reorderByIndex(index, index + 1),
- };
- }
-
- return (
-
-
- {/* Bucket header */}
-
-
-
- {label}
-
- {skippedCount > 0 && (
-
- ({skippedCount} skipped)
-
- )}
-
-
-
- {Math.round(pct)}%
-
-
-
-
-
-
- {fmt(totalPaidTowardDue)}
-
- /
- {fmt(totalThreshold)}
- {totalOverpaid > 0 && (
- +{fmt(totalOverpaid)}
- )}
-
- {!allPaid && totalRemaining > 0 && (
- {fmt(totalRemaining)} left
- )}
- {allPaid && (
- Done
- )}
- {!reorderEnabled && rows.length > 1 && (
- Clear filters to reorder
- )}
-
-
-
-
- {loading ? (
- Array.from({ length: 3 }).map((_, i) => (
-
- ))
- ) : rows.length === 0 ? (
-
- No bills match this bucket and filter set.
-
- ) : (
- rows.map((r, i) => (
-
- ))
- )}
-
-
-
-
-
-
-
- Bill
- Due
- Expected
- Last Month
- Paid
- Paid Date
- Status
-
-
- Notes
-
-
-
-
- {loading ? (
- Array.from({ length: 5 }).map((_, i) => (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ))
- ) : rows.length === 0 ? (
-
-
- No bills match this bucket and filter set.
-
-
- ) : (
- rows.map((r, i) => (
-
- ))
- )}
-
-
-
-
-
- );
-}
// ββ Main page ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
export default function TrackerPage() {
- const [searchParams] = useSearchParams();
+ const [searchParams, setSearchParams] = useSearchParams();
const now = new Date();
- const [year, setYear] = useState(now.getFullYear());
- const [month, setMonth] = useState(now.getMonth() + 1);
+
+ // All navigation + filter state lives in the URL so views are bookmarkable/shareable.
+ const year = Number(searchParams.get('year')) || now.getFullYear();
+ const month = Number(searchParams.get('month')) || (now.getMonth() + 1);
+ const search = searchParams.get('q') || '';
+ const filters = {
+ category: searchParams.get('fc') || FILTER_ALL,
+ cycle: searchParams.get('cy') || FILTER_ALL,
+ autopay: searchParams.get('ap') === '1',
+ firstBucket: searchParams.get('b1') === '1',
+ fifteenthBucket: searchParams.get('b2') === '1',
+ unpaid: searchParams.get('un') === '1',
+ overdue: searchParams.get('ov') === '1',
+ debt: searchParams.get('de') === '1',
+ };
+
+ // replace: true keeps history clean for rapid navigation (e.g. search keystrokes)
+ const updateParams = useCallback((patch) => {
+ setSearchParams(prev => {
+ const next = new URLSearchParams(prev);
+ Object.entries(patch).forEach(([k, v]) => {
+ if (v == null || v === '' || v === false) next.delete(k);
+ else next.set(k, v === true ? '1' : String(v));
+ });
+ return next;
+ }, { replace: true });
+ }, [setSearchParams]);
+
// Edit Bill modal: { bill, categories } when open, null when closed
const [editBillData, setEditBillData] = useState(null);
// Edit Starting Amounts modal: true when open, false when closed
const [editStartingOpen, setEditStartingOpen] = useState(false);
- const [search, setSearch] = useState('');
const [orderedRows, setOrderedRows] = useState(null);
const [movingBillId, setMovingBillId] = useState(null);
- const [filters, setFilters] = useState({
- category: FILTER_ALL,
- cycle: FILTER_ALL,
- autopay: false,
- firstBucket: false,
- fifteenthBucket: false,
- unpaid: false,
- overdue: false,
- debt: false,
- });
// Row to open in PaymentLedgerDialog via the overdue command center
const [commandCenterPayRow, setCommandCenterPayRow] = useState(null);
@@ -1995,18 +79,12 @@ export default function TrackerPage() {
setMovingBillId(null);
}, [dataUpdatedAt, year, month]);
- useEffect(() => {
- const querySearch = searchParams.get('search') || '';
- if (querySearch) setSearch(querySearch);
- }, [searchParams]);
-
function navigate(delta) {
- setMonth(m => {
- const nm = m + delta;
- if (nm > 12) { setYear(y => y + 1); return 1; }
- if (nm < 1) { setYear(y => y - 1); return 12; }
- return nm;
- });
+ let nm = month + delta;
+ let ny = year;
+ if (nm > 12) { ny += 1; nm = 1; }
+ if (nm < 1) { ny -= 1; nm = 12; }
+ updateParams({ year: ny, month: nm });
}
async function handleOpenEditBill(row) {
@@ -2021,17 +99,30 @@ export default function TrackerPage() {
}
}
- function goToday() {
- const n = new Date();
- setYear(n.getFullYear());
- setMonth(n.getMonth() + 1);
+ async function handleOpenAddBill() {
+ try {
+ const categories = await api.categories();
+ setEditBillData({ bill: null, categories });
+ } catch (err) {
+ toast.error(err.message || 'Failed to open bill editor');
+ }
}
+ function goToday() {
+ const n = new Date();
+ updateParams({ year: n.getFullYear(), month: n.getMonth() + 1 });
+ }
const rows = orderedRows || data?.rows || [];
const summary = data?.summary || {};
- const toggleFilter = (key) => setFilters(prev => ({ ...prev, [key]: !prev[key] }));
- const setFilterValue = (key, value) => setFilters(prev => ({ ...prev, [key]: value }));
+ const toggleFilter = (key) => {
+ const paramMap = { autopay: 'ap', firstBucket: 'b1', fifteenthBucket: 'b2', unpaid: 'un', overdue: 'ov', debt: 'de' };
+ updateParams({ [paramMap[key]]: !filters[key] });
+ };
+ const setFilterValue = (key, value) => {
+ const paramMap = { category: 'fc', cycle: 'cy' };
+ updateParams({ [paramMap[key]]: value === FILTER_ALL ? null : value });
+ };
const hasFilters = !!(
search.trim()
|| filters.category !== FILTER_ALL
@@ -2044,17 +135,7 @@ export default function TrackerPage() {
|| filters.debt
);
const resetFilters = () => {
- setSearch('');
- setFilters({
- category: FILTER_ALL,
- cycle: FILTER_ALL,
- autopay: false,
- firstBucket: false,
- fifteenthBucket: false,
- unpaid: false,
- overdue: false,
- debt: false,
- });
+ updateParams({ q: null, fc: null, cy: null, ap: null, b1: null, b2: null, un: null, ov: null, de: null });
};
const categoryOptions = useMemo(() => {
const map = new Map();
@@ -2143,28 +224,41 @@ export default function TrackerPage() {
-
+
navigate(-1)}
- className="h-7 w-7 hover:bg-white/5"
+ size="sm"
+ onClick={handleOpenAddBill}
+ className="h-9 gap-1.5 px-3 shadow-sm"
+ aria-label="Add bill"
+ title="Add bill"
>
-
-
-
- Today
-
-
navigate(1)}
- className="h-7 w-7 hover:bg-white/5"
- >
-
+
+ Add Bill
+
+
+ navigate(-1)}
+ className="h-7 w-7 hover:bg-white/5"
+ >
+
+
+
+ Today
+
+ navigate(1)}
+ className="h-7 w-7 hover:bg-white/5"
+ >
+
+
+
@@ -2174,7 +268,7 @@ export default function TrackerPage() {
setSearch(e.target.value)}
+ onChange={e => updateParams({ q: e.target.value || null })}
placeholder="Search this month by bill, category, notes, or amount"
className="h-10 pl-9"
/>
diff --git a/db/database.js b/db/database.js
index 01919c9..e3be131 100644
--- a/db/database.js
+++ b/db/database.js
@@ -2561,6 +2561,28 @@ function runMigrations() {
END;
`);
}
+ },
+ {
+ version: 'v0.77',
+ description: 'encrypt SMTP password at rest',
+ dependsOn: ['v0.76'],
+ run: function() {
+ try {
+ const { decryptSecret, encryptSecret } = require('../services/encryptionService');
+ const row = db.prepare("SELECT value FROM settings WHERE key = 'notify_smtp_password'").get();
+ if (row?.value) {
+ try {
+ decryptSecret(row.value); // already encrypted β skip
+ } catch {
+ // plaintext β encrypt it
+ db.prepare("UPDATE settings SET value = ?, updated_at = datetime('now') WHERE key = 'notify_smtp_password'")
+ .run(encryptSecret(row.value));
+ }
+ }
+ } catch (err) {
+ console.warn('[v0.77] SMTP password encryption migration failed:', err.message);
+ }
+ }
}
];
diff --git a/package.json b/package.json
index ab64dca..4a8e087 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "bill-tracker",
- "version": "0.34.2",
+ "version": "0.34.3",
"description": "Monthly bill tracking system",
"main": "server.js",
"scripts": {
diff --git a/routes/admin.js b/routes/admin.js
index 773e40a..2052a2a 100644
--- a/routes/admin.js
+++ b/routes/admin.js
@@ -160,23 +160,28 @@ router.post('/users', async (req, res) => {
if (db.prepare('SELECT id FROM users WHERE username = ?').get(username))
return res.status(409).json({ error: 'Username already taken' });
- const hash = await hashPassword(password);
- const result = db.prepare(
- "INSERT INTO users (username, password_hash, role, first_login) VALUES (?, ?, 'user', 1)"
- ).run(username, hash);
+ try {
+ const hash = await hashPassword(password);
+ const result = db.prepare(
+ "INSERT INTO users (username, password_hash, role, first_login) VALUES (?, ?, 'user', 1)"
+ ).run(username, hash);
- const created = db.prepare(
- 'SELECT id, username, role, active, is_default_admin, must_change_password, first_login, created_at FROM users WHERE id = ?'
- ).get(result.lastInsertRowid);
+ const created = db.prepare(
+ 'SELECT id, username, role, active, is_default_admin, must_change_password, first_login, created_at FROM users WHERE id = ?'
+ ).get(result.lastInsertRowid);
- logAudit({
- user_id: req.user.id, action: 'admin.user.create',
- entity_type: 'user', entity_id: created.id,
- details: { created_username: username },
- ip_address: req.ip, user_agent: req.get('user-agent'),
- });
+ logAudit({
+ user_id: req.user.id, action: 'admin.user.create',
+ entity_type: 'user', entity_id: created.id,
+ details: { created_username: username },
+ ip_address: req.ip, user_agent: req.get('user-agent'),
+ });
- res.status(201).json(created);
+ res.status(201).json(created);
+ } catch (err) {
+ console.error('[admin] create-user error:', err.message);
+ res.status(500).json({ error: 'Failed to create user' });
+ }
});
// PUT /api/admin/users/:id/password
@@ -187,21 +192,26 @@ router.put('/users/:id/password', async (req, res) => {
const db = getDb();
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
- if (!user) return res.status(404).json({ error: 'User not found' });
+ if (!user) return res.status(404).json({ error: 'User not found' });
- const hash = await hashPassword(password);
- db.prepare("UPDATE users SET password_hash=?, must_change_password=1, updated_at=datetime('now') WHERE id=?")
- .run(hash, req.params.id);
- db.prepare('DELETE FROM sessions WHERE user_id = ?').run(req.params.id);
+ try {
+ const hash = await hashPassword(password);
+ db.prepare("UPDATE users SET password_hash=?, must_change_password=1, updated_at=datetime('now') WHERE id=?")
+ .run(hash, req.params.id);
+ db.prepare('DELETE FROM sessions WHERE user_id = ?').run(req.params.id);
- logAudit({
- user_id: req.user.id, action: 'admin.password.reset',
- entity_type: 'user', entity_id: Number(req.params.id),
- details: { target_username: user.username },
- ip_address: req.ip, user_agent: req.get('user-agent'),
- });
+ logAudit({
+ user_id: req.user.id, action: 'admin.password.reset',
+ entity_type: 'user', entity_id: Number(req.params.id),
+ details: { target_username: user.username },
+ ip_address: req.ip, user_agent: req.get('user-agent'),
+ });
- res.json({ success: true });
+ res.json({ success: true });
+ } catch (err) {
+ console.error('[admin] reset-password error:', err.message);
+ res.status(500).json({ error: 'Failed to reset password' });
+ }
});
// PUT /api/admin/users/:id/role
@@ -315,10 +325,20 @@ router.delete('/users/:id', (req, res) => {
if (req.user?.id === user.id) return res.status(400).json({ error: 'You cannot delete your own account.' });
const deleteUser = db.transaction(() => {
+ // These three tables have no FK/CASCADE to users β must delete explicitly.
+ // Sessions also has CASCADE but we keep the explicit delete as a safety net
+ // for the rare case where foreign_keys is temporarily OFF during a migration.
db.prepare('DELETE FROM import_sessions WHERE user_id = ?').run(user.id);
db.prepare('DELETE FROM import_history WHERE user_id = ?').run(user.id);
+ db.prepare('DELETE FROM audit_log WHERE user_id = ?').run(user.id);
db.prepare('DELETE FROM sessions WHERE user_id = ?').run(user.id);
db.prepare('DELETE FROM users WHERE id = ?').run(user.id);
+ // ON DELETE CASCADE handles: bills, payments, categories, monthly_bill_state,
+ // bill_history_ranges, notifications, data_sources, financial_accounts,
+ // transactions, user_settings, user_login_history, monthly_income,
+ // monthly_starting_amounts, autopay_suggestion_dismissals, bill_templates,
+ // match_suggestion_rejections, declined_subscription_hints, bill_merchant_rules,
+ // snowball_plans.
});
deleteUser();
res.json({ success: true, deleted_user_id: user.id });
diff --git a/routes/auth.js b/routes/auth.js
index 2eabdd1..28ea070 100644
--- a/routes/auth.js
+++ b/routes/auth.js
@@ -164,33 +164,38 @@ router.post('/change-password', passwordLimiter, requireAuth, async (req, res) =
const db = getDb();
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
- if (!user.must_change_password) {
- const bcrypt = require('bcryptjs');
- const valid = await bcrypt.compare(current_password || '', user.password_hash);
- if (!valid) return res.status(401).json(standardizeError('Current password is incorrect', 'AUTH_ERROR', 'current_password'));
- }
-
- const hash = await hashPassword(new_password);
-
- db.prepare(
- "UPDATE users SET password_hash = ?, must_change_password = 0, last_password_change_at = datetime('now'), updated_at = datetime('now') WHERE id = ?"
- ).run(hash, req.user.id);
-
- // Invalidate all other sessions for this user
- const currentSessionId = req.cookies?.[COOKIE_NAME];
- if (currentSessionId) {
- invalidateOtherSessions(req.user.id, currentSessionId);
-
- // Rotate the current session ID for security
- const newSessionId = rotateSessionId(currentSessionId, req.user.id);
- if (newSessionId) {
- res.cookie(COOKIE_NAME, newSessionId, cookieOpts(req));
+ try {
+ if (!user.must_change_password) {
+ const bcrypt = require('bcryptjs');
+ const valid = await bcrypt.compare(current_password || '', user.password_hash);
+ if (!valid) return res.status(401).json(standardizeError('Current password is incorrect', 'AUTH_ERROR', 'current_password'));
}
+
+ const hash = await hashPassword(new_password);
+
+ db.prepare(
+ "UPDATE users SET password_hash = ?, must_change_password = 0, last_password_change_at = datetime('now'), updated_at = datetime('now') WHERE id = ?"
+ ).run(hash, req.user.id);
+
+ // Invalidate all other sessions for this user
+ const currentSessionId = req.cookies?.[COOKIE_NAME];
+ if (currentSessionId) {
+ invalidateOtherSessions(req.user.id, currentSessionId);
+
+ // Rotate the current session ID for security
+ const newSessionId = rotateSessionId(currentSessionId, req.user.id);
+ if (newSessionId) {
+ res.cookie(COOKIE_NAME, newSessionId, cookieOpts(req));
+ }
+ }
+
+ logAudit({ user_id: req.user.id, action: 'password.change', ip_address: req.ip, user_agent: req.get('user-agent') });
+
+ res.json({ success: true });
+ } catch (err) {
+ console.error('[auth] change-password error:', err.message);
+ res.status(500).json(standardizeError('Password change failed', 'SERVER_ERROR'));
}
-
- logAudit({ user_id: req.user.id, action: 'password.change', ip_address: req.ip, user_agent: req.get('user-agent') });
-
- res.json({ success: true });
});
// βββββββββββββββββββββββββββββββββββββββββ
@@ -232,17 +237,22 @@ router.post('/users', requireAuth, requireAdmin, async (req, res) => {
const existing = db.prepare('SELECT id FROM users WHERE username = ?').get(username);
if (existing) return res.status(409).json(standardizeError('Username already taken', 'CONFLICT', 'username'));
- const hash = await hashPassword(password);
+ try {
+ const hash = await hashPassword(password);
- const result = db.prepare(
- "INSERT INTO users (username, password_hash, role, first_login, last_seen_version) VALUES (?, ?, 'user', 1, ?)"
- ).run(username, hash, getAppVersion());
+ const result = db.prepare(
+ "INSERT INTO users (username, password_hash, role, first_login, last_seen_version) VALUES (?, ?, 'user', 1, ?)"
+ ).run(username, hash, getAppVersion());
- const created = db.prepare(
- 'SELECT id, username, role, must_change_password, first_login, created_at FROM users WHERE id = ?'
- ).get(result.lastInsertRowid);
+ const created = db.prepare(
+ 'SELECT id, username, role, must_change_password, first_login, created_at FROM users WHERE id = ?'
+ ).get(result.lastInsertRowid);
- res.status(201).json(created);
+ res.status(201).json(created);
+ } catch (err) {
+ console.error('[auth] create-user error:', err.message);
+ res.status(500).json(standardizeError('Failed to create user', 'SERVER_ERROR'));
+ }
});
module.exports = router;
diff --git a/routes/monthly-starting-amounts.js b/routes/monthly-starting-amounts.js
index 9938e5a..bf98cfe 100644
--- a/routes/monthly-starting-amounts.js
+++ b/routes/monthly-starting-amounts.js
@@ -20,7 +20,7 @@ function parseYearMonth(source) {
function money(value) {
const n = Number(value);
- return Number.isFinite(n) ? n : 0;
+ return Number.isFinite(n) ? Math.round(n * 100) / 100 : 0;
}
function getStartingAmounts(db, userId, year, month) {
@@ -94,7 +94,7 @@ function buildStartingAmountsResponse(db, userId, year, month) {
const amounts = getStartingAmounts(db, userId, year, month);
const paid = calculatePaidDeductions(db, userId, year, month);
- const combined_amount = amounts.first_amount + amounts.fifteenth_amount + amounts.other_amount;
+ const combined_amount = money(amounts.first_amount + amounts.fifteenth_amount + amounts.other_amount);
const paid_total = paid.paid_total;
return {
@@ -108,10 +108,10 @@ function buildStartingAmountsResponse(db, userId, year, month) {
paid_from_fifteenth: paid.paid_from_fifteenth,
paid_from_other: paid.paid_from_other,
paid_total,
- first_remaining: amounts.first_amount - paid.paid_from_first,
- fifteenth_remaining: amounts.fifteenth_amount - paid.paid_from_fifteenth,
- other_remaining: amounts.other_amount - paid.paid_from_other,
- combined_remaining: combined_amount - paid_total,
+ first_remaining: money(amounts.first_amount - paid.paid_from_first),
+ fifteenth_remaining: money(amounts.fifteenth_amount - paid.paid_from_fifteenth),
+ other_remaining: money(amounts.other_amount - paid.paid_from_other),
+ combined_remaining: money(combined_amount - paid_total),
};
}
diff --git a/routes/notifications.js b/routes/notifications.js
index c1e457f..a515ff0 100644
--- a/routes/notifications.js
+++ b/routes/notifications.js
@@ -3,6 +3,7 @@ const router = express.Router();
const { getDb, getSetting, setSetting } = require('../db/database');
const { requireAuth, requireUser, requireAdmin } = require('../middleware/requireAuth');
const { sendTestEmail } = require('../services/notificationService');
+const { encryptSecret } = require('../services/encryptionService');
// ββ Admin: SMTP configuration βββββββββββββββββββββββββββββββββββββββββββββββββ
@@ -34,7 +35,7 @@ router.put('/admin', requireAuth, requireAdmin, (req, res) => {
}
// Only update password if a real value was sent (not the masked placeholder)
if (req.body.notify_smtp_password && !req.body.notify_smtp_password.startsWith('β’')) {
- setSetting('notify_smtp_password', req.body.notify_smtp_password);
+ setSetting('notify_smtp_password', encryptSecret(req.body.notify_smtp_password));
}
res.json({ success: true });
});
diff --git a/routes/payments.js b/routes/payments.js
index ef7ce3d..0c60ce8 100644
--- a/routes/payments.js
+++ b/routes/payments.js
@@ -401,6 +401,7 @@ router.put('/:id', (req, res) => {
amount = ?, paid_date = ?, method = ?, notes = ?, balance_delta = ?, payment_source = ?,
updated_at = datetime('now')
WHERE id = ?
+ AND bill_id IN (SELECT id FROM bills WHERE user_id = ? AND deleted_at IS NULL)
`).run(
nextAmount,
nextPaidDate,
@@ -409,9 +410,10 @@ router.put('/:id', (req, res) => {
nextBalanceDelta,
nextPaymentSource,
req.params.id,
+ req.user.id,
);
- res.json(db.prepare('SELECT * FROM payments WHERE id = ?').get(req.params.id));
+ res.json(db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${LIVE} AND b.user_id = ? AND b.deleted_at IS NULL`).get(req.params.id, req.user.id));
});
// DELETE /api/payments/:id β soft delete (sets deleted_at)
@@ -423,14 +425,14 @@ router.delete('/:id', (req, res) => {
// Reverse any balance delta that was stored when this payment was created
if (payment.balance_delta != null) {
- const bill = db.prepare('SELECT current_balance FROM bills WHERE id = ?').get(payment.bill_id);
+ const bill = db.prepare('SELECT current_balance FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(payment.bill_id, req.user.id);
if (bill?.current_balance != null) {
const restored = Math.max(0, Math.round((bill.current_balance - payment.balance_delta) * 100) / 100);
db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?").run(restored, payment.bill_id);
}
}
- db.prepare("UPDATE payments SET deleted_at = datetime('now') WHERE id = ?").run(req.params.id);
+ db.prepare("UPDATE payments SET deleted_at = datetime('now') WHERE id = ? AND bill_id IN (SELECT id FROM bills WHERE user_id = ? AND deleted_at IS NULL)").run(req.params.id, req.user.id);
res.json({ success: true });
});
@@ -443,15 +445,15 @@ router.post('/:id/restore', (req, res) => {
// Re-apply the balance delta (undo the reversal done on delete)
if (payment.balance_delta != null) {
- const bill = db.prepare('SELECT current_balance FROM bills WHERE id = ?').get(payment.bill_id);
+ const bill = db.prepare('SELECT current_balance FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(payment.bill_id, req.user.id);
if (bill?.current_balance != null) {
const reapplied = Math.max(0, Math.round((bill.current_balance + payment.balance_delta) * 100) / 100);
db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?").run(reapplied, payment.bill_id);
}
}
- db.prepare('UPDATE payments SET deleted_at = NULL WHERE id = ?').run(req.params.id);
- res.json(db.prepare('SELECT * FROM payments WHERE id = ?').get(req.params.id));
+ db.prepare('UPDATE payments SET deleted_at = NULL WHERE id = ? AND bill_id IN (SELECT id FROM bills WHERE user_id = ? AND deleted_at IS NULL)').run(req.params.id, req.user.id);
+ res.json(db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${LIVE} AND b.user_id = ? AND b.deleted_at IS NULL`).get(req.params.id, req.user.id));
});
module.exports = router;
diff --git a/routes/profile.js b/routes/profile.js
index e3a1785..7d8bca0 100644
--- a/routes/profile.js
+++ b/routes/profile.js
@@ -237,36 +237,41 @@ router.post('/change-password', passwordLimiter, async (req, res) => {
const user = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(req.user.id);
if (!user) return res.status(404).json({ error: 'User not found' });
- const valid = await bcrypt.compare(current_password, user.password_hash);
- if (!valid) {
- return res.status(401).json({ error: 'current password is incorrect' });
- }
-
- const hash = await hashPassword(new_password);
-
- db.prepare(`
- UPDATE users
- SET password_hash = ?, must_change_password = 0,
- last_password_change_at = datetime('now'),
- updated_at = datetime('now')
- WHERE id = ?
- `).run(hash, req.user.id);
-
- // Invalidate all other sessions for this user
- const currentSessionId = req.cookies?.[COOKIE_NAME];
- if (currentSessionId) {
- invalidateOtherSessions(req.user.id, currentSessionId);
-
- // Rotate the current session ID for security
- const newSessionId = rotateSessionId(currentSessionId, req.user.id);
- if (newSessionId) {
- res.cookie(COOKIE_NAME, newSessionId, cookieOpts(req));
+ try {
+ const valid = await bcrypt.compare(current_password, user.password_hash);
+ if (!valid) {
+ return res.status(401).json({ error: 'current password is incorrect' });
}
+
+ const hash = await hashPassword(new_password);
+
+ db.prepare(`
+ UPDATE users
+ SET password_hash = ?, must_change_password = 0,
+ last_password_change_at = datetime('now'),
+ updated_at = datetime('now')
+ WHERE id = ?
+ `).run(hash, req.user.id);
+
+ // Invalidate all other sessions for this user
+ const currentSessionId = req.cookies?.[COOKIE_NAME];
+ if (currentSessionId) {
+ invalidateOtherSessions(req.user.id, currentSessionId);
+
+ // Rotate the current session ID for security
+ const newSessionId = rotateSessionId(currentSessionId, req.user.id);
+ if (newSessionId) {
+ res.cookie(COOKIE_NAME, newSessionId, cookieOpts(req));
+ }
+ }
+
+ logAudit({ user_id: req.user.id, action: 'password.change', ip_address: req.ip, user_agent: req.get('user-agent') });
+
+ res.json({ success: true });
+ } catch (err) {
+ console.error('[profile] change-password error:', err.message);
+ res.status(500).json({ error: 'Password change failed' });
}
-
- logAudit({ user_id: req.user.id, action: 'password.change', ip_address: req.ip, user_agent: req.get('user-agent') });
-
- res.json({ success: true });
});
// ββ GET /api/profile/exports ββββββββββββββββββββββββββββββββββββββββββββββββββ
diff --git a/routes/summary.js b/routes/summary.js
index b43d560..edb8489 100644
--- a/routes/summary.js
+++ b/routes/summary.js
@@ -22,7 +22,7 @@ function parseYearMonth(source) {
function money(value) {
const n = Number(value);
- return Number.isFinite(n) ? n : 0;
+ return Number.isFinite(n) ? Math.round(n * 100) / 100 : 0;
}
function getStartingAmounts(db, userId, year, month) {
@@ -100,7 +100,7 @@ function buildStartingAmountsSummary(db, userId, year, month) {
const amounts = getStartingAmounts(db, userId, year, month);
const paid = calculatePaidDeductions(db, userId, year, month);
- const combined_amount = amounts.first_amount + amounts.fifteenth_amount + amounts.other_amount;
+ const combined_amount = money(amounts.first_amount + amounts.fifteenth_amount + amounts.other_amount);
const paid_total = paid.paid_total;
return {
@@ -114,10 +114,10 @@ function buildStartingAmountsSummary(db, userId, year, month) {
paid_from_fifteenth: paid.paid_from_fifteenth,
paid_from_other: paid.paid_from_other,
paid_total,
- first_remaining: amounts.first_amount - paid.paid_from_first,
- fifteenth_remaining: amounts.fifteenth_amount - paid.paid_from_fifteenth,
- other_remaining: amounts.other_amount - paid.paid_from_other,
- combined_remaining: combined_amount - paid_total,
+ first_remaining: money(amounts.first_amount - paid.paid_from_first),
+ fifteenth_remaining: money(amounts.fifteenth_amount - paid.paid_from_fifteenth),
+ other_remaining: money(amounts.other_amount - paid.paid_from_other),
+ combined_remaining: money(combined_amount - paid_total),
};
}
@@ -207,12 +207,12 @@ function buildSummary(db, userId, year, month) {
const countedExpenses = expenses.filter(expense => !expense.is_skipped);
const incomeTotal = money(income.amount);
- const expenseTotal = countedExpenses.reduce((sum, expense) => sum + money(expense.display_amount), 0);
- const paidTotal = countedExpenses.reduce((sum, expense) => sum + money(expense.paid_amount), 0);
+ const expenseTotal = money(countedExpenses.reduce((sum, expense) => sum + expense.display_amount, 0));
+ const paidTotal = money(countedExpenses.reduce((sum, expense) => sum + expense.paid_amount, 0));
const paidExpenseCount = countedExpenses.filter(expense => expense.is_paid).length;
const starting_amounts = buildStartingAmountsSummary(db, userId, year, month);
const planBaseTotal = money(starting_amounts.combined_amount);
- const result = planBaseTotal - expenseTotal;
+ const result = money(planBaseTotal - expenseTotal);
// Previous month context
let previous_month = null;
@@ -254,7 +254,7 @@ function buildSummary(db, userId, year, month) {
paid_expense_count: paidExpenseCount,
expense_count: countedExpenses.length,
paid_total: starting_amounts.paid_total,
- remaining_expense_total: Math.max(0, expenseTotal - paidTotal),
+ remaining_expense_total: money(Math.max(0, expenseTotal - paidTotal)),
result,
},
chart: [
diff --git a/server.js b/server.js
index 8d25dbd..109a887 100644
--- a/server.js
+++ b/server.js
@@ -29,7 +29,7 @@ if (process.env.CORS_ORIGIN) {
app.use(cors({ credentials: true, origin: allowed }));
}
-app.use(express.json());
+app.use(express.json({ limit: '100kb' })); // import routes override this per-endpoint
app.use(cookieParser());
// ββ CSRF token provider - sets CSRF cookie on every response ββββββββββββββββ
@@ -151,6 +151,11 @@ app.use((err, req, res, next) => {
});
});
+// ββ Safety net: log unhandled promise rejections instead of crashing βββββββββ
+process.on('unhandledRejection', (reason) => {
+ console.error('[server] Unhandled promise rejection:', reason?.message || reason);
+});
+
// ββ Bootstrap βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
async function main() {
const db = getDb();
diff --git a/services/notificationService.js b/services/notificationService.js
index 266f82a..6cd336c 100644
--- a/services/notificationService.js
+++ b/services/notificationService.js
@@ -1,5 +1,6 @@
const nodemailer = require('nodemailer');
const { getDb, getSetting } = require('../db/database');
+const { decryptSecret } = require('./encryptionService');
const {
markNotificationError,
markNotificationSuccess,
@@ -8,12 +9,22 @@ const {
// ββ SMTP transport ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+function getSmtpPassword() {
+ const stored = getSetting('notify_smtp_password');
+ if (!stored) return '';
+ try {
+ return decryptSecret(stored);
+ } catch {
+ return stored; // legacy plaintext β works until re-saved via admin UI
+ }
+}
+
function createTransport() {
const host = getSetting('notify_smtp_host');
const port = parseInt(getSetting('notify_smtp_port') || '587', 10);
const encryption = getSetting('notify_smtp_encryption') || 'starttls';
const username = getSetting('notify_smtp_username');
- const password = getSetting('notify_smtp_password');
+ const password = getSmtpPassword();
const selfSigned = getSetting('notify_smtp_self_signed') === 'true';
if (!host) throw new Error('SMTP host is not configured');
diff --git a/services/statusService.js b/services/statusService.js
index 57ef79a..8d6855a 100644
--- a/services/statusService.js
+++ b/services/statusService.js
@@ -277,4 +277,5 @@ module.exports = {
resolveBucket,
resolveDueDate,
resolveGracePeriodDays,
+ roundMoney,
};
diff --git a/services/trackerService.js b/services/trackerService.js
index aff97dc..58ba82d 100644
--- a/services/trackerService.js
+++ b/services/trackerService.js
@@ -1,7 +1,7 @@
'use strict';
const { getDb } = require('../db/database');
-const { buildTrackerRow, getCycleRange, resolveDueDate } = require('./statusService');
+const { buildTrackerRow, getCycleRange, resolveDueDate, roundMoney } = require('./statusService');
const { getUserSettings } = require('./userSettings');
const { computeBalanceDelta } = require('./billsService');
const { computeAmountSuggestion } = require('./amountSuggestionService');
@@ -288,8 +288,8 @@ function getTracker(userId, query = {}, now = new Date()) {
const dayOfMonth = now.getDate();
const activeRemainingPeriod = dayOfMonth < 15 ? '1st' : '15th';
const periodRows = activeRows.filter(r => r.bucket === activeRemainingPeriod);
- const periodPaidTowardDue = periodRows.reduce((s, r) => s + rowPaidTowardDue(r), 0);
- const periodOutstandingBalance = periodRows.reduce((s, r) => s + Math.max(r.balance || 0, 0), 0);
+ const periodPaidTowardDue = roundMoney(periodRows.reduce((s, r) => s + rowPaidTowardDue(r), 0));
+ const periodOutstandingBalance = roundMoney(periodRows.reduce((s, r) => s + Math.max(r.balance || 0, 0), 0));
const periodStartingAmount = activeRemainingPeriod === '1st'
? (startingAmounts?.first_amount || 0)
: (startingAmounts?.fifteenth_amount || 0);
@@ -297,14 +297,14 @@ function getTracker(userId, query = {}, now = new Date()) {
const totalStarting = startingAmounts?.combined_amount || 0;
const hasStartingAmounts = !!startingAmounts;
- const activeTotalPaid = activeRows.reduce((s, r) => s + r.total_paid, 0);
- const activePaidTowardDue = activeRows.reduce((s, r) => s + rowPaidTowardDue(r), 0);
- const activeTotalExpected = activeRows.reduce((s, r) => s + rowDueAmount(r), 0);
- const activeOutstandingBalance = activeRows.reduce((s, r) => s + Math.max(r.balance || 0, 0), 0);
- const totalOverdue = rows
+ const activeTotalPaid = roundMoney(activeRows.reduce((s, r) => s + r.total_paid, 0));
+ const activePaidTowardDue = roundMoney(activeRows.reduce((s, r) => s + rowPaidTowardDue(r), 0));
+ const activeTotalExpected = roundMoney(activeRows.reduce((s, r) => s + rowDueAmount(r), 0));
+ const activeOutstandingBalance = roundMoney(activeRows.reduce((s, r) => s + Math.max(r.balance || 0, 0), 0));
+ const totalOverdue = roundMoney(rows
.filter(r => !r.is_skipped && (r.status === 'late' || r.status === 'missed'))
- .reduce((s, r) => s + r.balance, 0);
- const previousMonthTotal = activeRows.reduce((s, r) => s + r.previous_month_paid, 0);
+ .reduce((s, r) => s + r.balance, 0));
+ const previousMonthTotal = roundMoney(activeRows.reduce((s, r) => s + r.previous_month_paid, 0));
return {
year,
@@ -316,8 +316,8 @@ function getTracker(userId, query = {}, now = new Date()) {
has_starting_amounts: hasStartingAmounts,
total_paid: activeTotalPaid,
paid_toward_due: activePaidTowardDue,
- remaining: hasStartingAmounts ? periodStartingAmount - periodPaidTowardDue : periodOutstandingBalance,
- total_remaining: hasStartingAmounts ? totalStarting - activePaidTowardDue : activeOutstandingBalance,
+ remaining: roundMoney(hasStartingAmounts ? periodStartingAmount - periodPaidTowardDue : periodOutstandingBalance),
+ total_remaining: roundMoney(hasStartingAmounts ? totalStarting - activePaidTowardDue : activeOutstandingBalance),
remaining_period: activeRemainingPeriod,
remaining_label: periodLabel,
remaining_hint: hasStartingAmounts