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'} + + + +
+ ); +} 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 ( + + ); +} 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 ( + + ); +} 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 + + )} + +
+ {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 + +
+
+ +
+ {hasAutopaySuggestion && ( + + )} + {!isPaid && !isSkipped && !hasAutopaySuggestion && ( +
+ + +
+ )} + + +
+
+ +
+ +
+
+ + {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}

+ )} +
+ +
+ ))} +
+ ) : ( +

+ No payments recorded for this month. +

+ )} +
+ +
+

Add Partial Payment

+
+
+ + setAmount(e.target.value)} + className="font-mono bg-background/70 border-border/60" + /> +
+
+ + setDate(e.target.value)} + className="font-mono bg-background/70 border-border/60" + /> +
+
+ + +
+
+ + setNotes(e.target.value)} + className="bg-background/70 border-border/60" + /> +
+ +
+
+
+
+
+
+ + {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 ( +
+ + {showQuickFix && ( + + )} +
+ ); +} 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 ( + + ); +}); 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 ( +
+
+
+ +

+ 3-Month Trend +

+
+
+ +
+
+ ); +} 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) => ( +
+
+
+
+
+
+
+
+
+
+
+
+

Expected

+
+
+
+

Remaining

+
+
+
+
+ )) + ) : 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 + + )} + +
+ {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)" + /> + ) : ( + + )} + + + {/* 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 ? ( + + ) : ( +
+ + {row.amount_suggestion?.suggestion != null && + Math.abs(row.amount_suggestion.suggestion - row.expected_amount) / row.expected_amount > 0.05 && ( + + )} +
+ )} +
+ + {/* 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 */} + + + + + {/* Status β€” uses effectiveStatus (accounts for skipped + threshold) */} + + { + if (effectiveStatus === 'skipped') return; + handleTogglePaid(); + }} + loading={loading} + /> + + + {/* Actions */} + +
+ {showUpdateNudge ? ( +
+ Update default? + + +
+ ) : ( + <> + {hasAutopaySuggestion && ( + + )} + {/* Quick pay β€” hidden for skipped/paid bills */} + {!isPaid && !isSkipped && !hasAutopaySuggestion && ( +
+ + +
+ )} + + )} +
+
+ + {/* 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 ( - - ); -} - -// ── 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 ( -
-
-
- -

- 3-Month Trend -

-
-
- -
-
- ); -} - -// ── 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 ( - - ); -}); - -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'} - - - -
- ); -} - -// ── 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 ( -
- - {showQuickFix && ( - - )} -
- ); -} - -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 ( - - ); -} - -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}

- )} -
- -
- ))} -
- ) : ( -

- No payments recorded for this month. -

- )} -
- -
-

Add Partial Payment

-
-
- - setAmount(e.target.value)} - className="font-mono bg-background/70 border-border/60" - /> -
-
- - setDate(e.target.value)} - className="font-mono bg-background/70 border-border/60" - /> -
-
- - -
-
- - setNotes(e.target.value)} - className="bg-background/70 border-border/60" - /> -
- -
-
-
-
-
-
- - {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 - - )} - -
- {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)" - /> - ) : ( - - )} - - - {/* 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 ? ( - - ) : ( -
- - {row.amount_suggestion?.suggestion != null && - Math.abs(row.amount_suggestion.suggestion - row.expected_amount) / row.expected_amount > 0.05 && ( - - )} -
- )} -
- - {/* 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 */} - - - - - {/* Status β€” uses effectiveStatus (accounts for skipped + threshold) */} - - { - if (effectiveStatus === 'skipped') return; - handleTogglePaid(); - }} - loading={loading} - /> - - - {/* Actions */} - -
- {showUpdateNudge ? ( -
- Update default? - - -
- ) : ( - <> - {hasAutopaySuggestion && ( - - )} - {/* Quick pay β€” hidden for skipped/paid bills */} - {!isPaid && !isSkipped && !hasAutopaySuggestion && ( -
- - -
- )} - - )} -
-
- - {/* 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 - - )} - -
- {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 - -
-
- -
- {hasAutopaySuggestion && ( - - )} - {!isPaid && !isSkipped && !hasAutopaySuggestion && ( -
- - -
- )} - - -
-
- -
- -
-
- - {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) => ( -
-
-
-
-
-
-
-
-
-
-
-
-

Expected

-
-
-
-

Remaining

-
-
-
-
- )) - ) : 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() {

-
+
- - + +
+ + + +
@@ -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