This commit is contained in:
null 2026-05-31 15:06:10 -05:00
parent 0fda211e37
commit 31bafb0e55
37 changed files with 2373 additions and 2142 deletions

View File

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

View File

@ -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);
};

View File

@ -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]);

View File

@ -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 }));

View File

@ -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) => {

View File

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

View File

@ -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 (
<div className={cn(
'flex items-center gap-1.5',
compact ? 'w-full flex-wrap justify-end sm:w-auto' : 'justify-end',
)}>
<span
className={cn(
'inline-flex min-w-0 items-center gap-1.5 rounded-md border border-sky-500/20 bg-sky-500/10',
'px-2 py-1 text-xs font-medium text-sky-600 dark:text-sky-300',
compact ? 'mr-auto' : 'max-w-32',
)}
title={`Autopay suggested: ${title}`}
>
<Clock className="h-3 w-3 shrink-0" />
<span className="truncate">{compact ? `Suggested ${fmt(suggestion.amount)}` : 'Suggested'}</span>
</span>
<Button
size="icon"
variant="ghost"
disabled={loading}
onClick={onDismiss}
className="h-8 w-8 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
title={`Dismiss suggested autopay payment for ${title}`}
aria-label={`Dismiss suggested autopay payment for ${row.name}`}
>
<X className="h-3.5 w-3.5" />
</Button>
<Button
size="icon"
variant="default"
disabled={loading}
onClick={onConfirm}
className="h-8 w-8"
title={`Confirm suggested autopay payment for ${title}`}
aria-label={`Confirm suggested autopay payment for ${row.name}`}
>
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <CheckCircle2 className="h-3.5 w-3.5" />}
</Button>
</div>
);
}

View File

@ -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 (
<Input
ref={inputRef}
type={field === 'date' ? 'date' : 'number'}
step={field === 'amount' ? '0.01' : undefined}
min={field === 'amount' ? '0' : undefined}
value={value}
onChange={e => 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 (
<span
onClick={startEdit}
title={`Click to edit ${field === 'amount' ? 'payment amount' : 'paid date'}`}
className={cn(
'cursor-pointer rounded-md px-1.5 py-0.5 text-sm font-mono',
'transition-all duration-150 hover:bg-accent hover:ring-1 hover:ring-border',
isEmpty && 'text-muted-foreground',
mismatch && 'text-amber-500',
!isEmpty && !mismatch && 'text-emerald-500',
)}
>
{displayVal}
</span>
);
}

View File

@ -0,0 +1,18 @@
import { cn } from '@/lib/utils';
export function FilterChip({ active, children, onClick }) {
return (
<button
type="button"
onClick={onClick}
className={cn(
'h-8 rounded-full border px-3 text-xs font-medium transition-colors',
active
? 'border-primary/50 bg-primary/15 text-primary'
: 'border-border/70 bg-card/70 text-muted-foreground hover:bg-accent hover:text-foreground',
)}
>
{children}
</button>
);
}

View File

@ -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 (
<Button
type="button"
size="sm"
variant="ghost"
disabled={saving}
onClick={handleClick}
className={cn(
'h-7 px-2.5 text-xs font-semibold text-amber-600 hover:bg-amber-500/10 hover:text-amber-700',
'dark:text-amber-400 dark:hover:text-amber-300',
compact && 'h-8',
)}
title={`Set ${MONTHS[month - 1]} amount to ${fmt(summary.paid)} because this bill was lower this month`}
>
{saving ? 'Saving...' : 'Bill was lower'}
</Button>
);
}

View File

@ -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 (
<>
<div
draggable={dragProps?.draggable}
onDragStart={dragProps?.onDragStart}
onDragEnter={dragProps?.onDragEnter}
onDragOver={dragProps?.onDragOver}
onDragEnd={dragProps?.onDragEnd}
onDrop={dragProps?.onDrop}
className={cn(
'rounded-lg border border-border/60 bg-background/60 p-3 shadow-sm',
'space-y-3 transition-colors',
isSkipped ? 'opacity-55' : rowBg,
dragProps?.isDragging && 'opacity-45',
dragProps?.isDropTarget && 'ring-2 ring-primary/40',
)}
style={{ animationDelay: `${index * 40}ms` }}
>
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="flex min-w-0 gap-2">
<div className="flex shrink-0 items-center gap-0.5 pt-0.5">
<GripVertical
className={cn(
'h-4 w-4 text-muted-foreground/55',
moveControls?.enabled && 'cursor-grab',
!moveControls?.enabled && 'opacity-30',
)}
aria-hidden="true"
/>
<button
type="button"
onClick={moveControls?.onMoveUp}
disabled={!moveControls?.enabled || !moveControls?.canMoveUp || moveControls?.moving}
className="rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-25"
title="Move bill up"
aria-label={`Move ${row.name} up`}
>
<ArrowUp className="h-3 w-3" />
</button>
<button
type="button"
onClick={moveControls?.onMoveDown}
disabled={!moveControls?.enabled || !moveControls?.canMoveDown || moveControls?.moving}
className="rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-25"
title="Move bill down"
aria-label={`Move ${row.name} down`}
>
<ArrowDown className="h-3 w-3" />
</button>
</div>
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
{row.website ? (
<a
href={row.website}
target="_blank"
rel="noreferrer"
className={cn(
'min-w-0 truncate text-[15px] font-semibold leading-tight text-foreground',
'underline-offset-2 transition-colors hover:text-primary hover:underline',
isSkipped && 'line-through',
)}
>
{row.name}
</a>
) : (
<span className={cn('min-w-0 truncate text-[15px] font-semibold leading-tight text-foreground', isSkipped && 'line-through')}>
{row.name}
</span>
)}
{row.autopay_enabled && (
<span
className="inline-flex shrink-0 rounded border border-sky-500/25 bg-sky-500/10 px-1.5 py-0.5 text-[10px] font-bold leading-none text-sky-600 dark:text-sky-300"
title="Autopay"
>
AP
</span>
)}
{row.is_subscription && (
<span
className="inline-flex shrink-0 rounded border border-indigo-500/25 bg-indigo-500/10 px-1.5 py-0.5 text-[10px] font-bold leading-none text-indigo-600 dark:text-indigo-300"
title="Subscription"
>
S
</span>
)}
<Button
size="icon" variant="ghost"
className="h-7 w-7 opacity-100 transition-opacity text-muted-foreground hover:text-foreground hover:bg-accent lg:opacity-0 lg:group-hover:opacity-100"
title="Edit bill"
onClick={() => onEditBill?.(row)}
>
<Pencil className="h-3 w-3" />
</Button>
</div>
{row.monthly_notes && (
<p className="mt-1 line-clamp-2 text-xs italic text-amber-500/80" title={row.monthly_notes}>
{row.monthly_notes}
</p>
)}
</div>
</div>
<StatusBadge status={effectiveStatus} clickable={!isSkipped} onClick={handleTogglePaid} />
</div>
<div className="grid grid-cols-2 gap-x-3 gap-y-2 text-xs text-muted-foreground sm:grid-cols-4">
<div>
<p className="uppercase tracking-wide text-muted-foreground/60">Due</p>
<p className="tracker-number mt-0.5 text-sm font-medium text-foreground/90">{fmtDate(row.due_date)}</p>
</div>
<div>
<p className="uppercase tracking-wide text-muted-foreground/60">Category</p>
<p className="mt-0.5 truncate text-sm text-foreground">{row.category_name || 'Uncategorized'}</p>
</div>
<div>
<p className="uppercase tracking-wide text-muted-foreground/60">Expected</p>
<p className={cn('tracker-number mt-0.5 text-sm font-semibold', row.actual_amount != null ? 'text-amber-300' : 'text-foreground')}>
{fmt(threshold)}
</p>
</div>
<div>
<p className="uppercase tracking-wide text-muted-foreground/60">Last Month</p>
<p className="tracker-number mt-0.5 text-sm font-medium text-muted-foreground/80">
{fmt(row.previous_month_paid)}
</p>
</div>
<div>
<p className="uppercase tracking-wide text-muted-foreground/60">Remaining</p>
<p className={cn('tracker-number mt-0.5 text-sm font-semibold', remaining > 0 ? 'text-foreground' : 'text-emerald-300')}>
{fmt(remaining)}
</p>
</div>
</div>
<div className="rounded-md border border-border/50 bg-muted/20">
<PaymentProgress row={row} threshold={threshold} onOpen={() => setPaymentLedgerOpen(true)} compact />
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="grid grid-cols-2 gap-2 text-xs sm:flex sm:items-center">
<div className="rounded-md bg-muted/45 px-2 py-1.5">
<span className="text-muted-foreground">Paid </span>
<span className="tracker-number font-semibold text-emerald-300">{row.total_paid > 0 ? fmt(row.total_paid) : '—'}</span>
</div>
<div className="rounded-md bg-muted/45 px-2 py-1.5">
<span className="text-muted-foreground">Date </span>
<button
type="button"
onClick={() => setPaymentLedgerOpen(true)}
className="tracker-number rounded font-medium text-foreground underline-offset-2 hover:underline"
>
{row.last_paid_date ? fmtDate(row.last_paid_date) : '—'}
{summary.count > 1 && <span className="ml-1 text-[10px] text-muted-foreground">({summary.count})</span>}
</button>
</div>
</div>
<div className="flex flex-wrap items-center justify-end gap-1.5">
{hasAutopaySuggestion && (
<AutopaySuggestionActions
row={row}
loading={suggestionLoading}
onConfirm={handleConfirmSuggestion}
onDismiss={handleDismissSuggestion}
compact
/>
)}
{!isPaid && !isSkipped && !hasAutopaySuggestion && (
<div className="flex items-center gap-1.5">
<Input
ref={amountRef}
type="number" min="0" step="0.01"
defaultValue={summary.remaining || threshold}
className="tracker-number h-8 w-24 text-right text-sm font-medium bg-background/70 border-border/60"
title="Payment amount"
aria-label={`${row.name} payment amount`}
/>
<Button
size="sm" variant="default"
onClick={handleQuickPay}
className="h-8 px-3 text-xs font-semibold"
>
Add
</Button>
</div>
)}
<LowerThisMonthButton
row={row}
year={year}
month={month}
refresh={refresh}
compact
/>
</div>
</div>
<div className="rounded-md border border-border/50 bg-muted/25 px-2 py-1.5">
<NotesCell row={{ ...row, year, month }} refresh={refresh} />
</div>
</div>
{editPayment && (
<PaymentModal
payment={editPayment}
onClose={() => setEditPayment(null)}
onSave={refresh}
/>
)}
{paymentLedgerOpen && (
<PaymentLedgerDialog
row={row}
year={year}
month={month}
threshold={threshold}
defaultPaymentDate={defaultPaymentDate}
onClose={() => setPaymentLedgerOpen(false)}
onSaved={refresh}
/>
)}
{showMbs && (
<MonthlyStateDialog
row={row}
year={year}
month={month}
open={showMbs}
onOpenChange={setShowMbs}
onSaved={refresh}
/>
)}
<AlertDialog open={confirmUnpay} onOpenChange={setConfirmUnpay}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Mark this bill unpaid?</AlertDialogTitle>
<AlertDialogDescription>
This removes the current payment record for this month and moves it into recovery.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={performTogglePaid}
>
Remove Payment
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@ -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 (
<input
type="text"
value={value}
onChange={e => 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',
)}
/>
);
}

View File

@ -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 (
<>
<Dialog open onOpenChange={value => { if (!value) onClose(); }}>
<DialogContent className="max-h-[92svh] overflow-y-auto border-border/60 bg-card/95 backdrop-blur-xl sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="text-base font-semibold tracking-tight">{row.name} Payments</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="rounded-lg border border-border/60 bg-muted/25 p-3">
<PaymentProgress row={row} threshold={threshold} onOpen={() => {}} />
<div className="mt-2 flex justify-end">
<LowerThisMonthButton
row={row}
year={year}
month={month}
refresh={onSaved}
/>
</div>
</div>
<div className="grid gap-3 md:grid-cols-[1fr_1fr]">
<div className="rounded-lg border border-border/60 bg-background/45 p-3">
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">Payment History</p>
{payments.length > 0 ? (
<div className="space-y-2">
{payments.map(payment => (
<div key={payment.id} className="flex items-center justify-between gap-3 rounded-md border border-border/50 bg-card/60 px-3 py-2">
<div className="min-w-0">
<p className="font-mono text-sm font-semibold text-foreground">{fmt(payment.amount)}</p>
<p className="truncate text-xs text-muted-foreground">
{fmtDate(payment.paid_date)}
{payment.method ? ` · ${payment.method}` : ''}
</p>
{payment.notes && (
<p className="mt-0.5 truncate text-xs text-muted-foreground/80">{payment.notes}</p>
)}
</div>
<Button type="button" size="sm" variant="ghost" className="h-8 px-2.5 text-xs" onClick={() => setEditPayment(payment)}>
Edit
</Button>
</div>
))}
</div>
) : (
<p className="rounded-md border border-dashed border-border/70 px-3 py-8 text-center text-sm text-muted-foreground">
No payments recorded for this month.
</p>
)}
</div>
<form onSubmit={handleAdd} className="rounded-lg border border-border/60 bg-background/45 p-3">
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">Add Partial Payment</p>
<div className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor={`partial-amount-${row.id}`} className="text-xs uppercase tracking-wider text-muted-foreground">Amount</Label>
<Input
id={`partial-amount-${row.id}`}
type="number"
min="0"
step="0.01"
value={amount}
onChange={e => setAmount(e.target.value)}
className="font-mono bg-background/70 border-border/60"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor={`partial-date-${row.id}`} className="text-xs uppercase tracking-wider text-muted-foreground">Paid Date</Label>
<Input
id={`partial-date-${row.id}`}
type="date"
value={date}
onChange={e => setDate(e.target.value)}
className="font-mono bg-background/70 border-border/60"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Method</Label>
<Select value={method} onValueChange={setMethod}>
<SelectTrigger className="bg-background/70 border-border/60">
<SelectValue placeholder="—" />
</SelectTrigger>
<SelectContent>
<SelectItem value={METHOD_NONE}></SelectItem>
<SelectItem value="bank">Bank Transfer</SelectItem>
<SelectItem value="card">Card</SelectItem>
<SelectItem value="autopay">Autopay</SelectItem>
<SelectItem value="check">Check</SelectItem>
<SelectItem value="cash">Cash</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor={`partial-notes-${row.id}`} className="text-xs uppercase tracking-wider text-muted-foreground">Notes</Label>
<Input
id={`partial-notes-${row.id}`}
value={notes}
onChange={e => setNotes(e.target.value)}
className="bg-background/70 border-border/60"
/>
</div>
<Button type="submit" disabled={busy} className="w-full gap-2">
<Plus className="h-4 w-4" />
{busy ? 'Adding...' : 'Add Payment'}
</Button>
</div>
</form>
</div>
</div>
</DialogContent>
</Dialog>
{editPayment && (
<PaymentModal
payment={editPayment}
onClose={() => setEditPayment(null)}
onSave={() => {
onSaved?.();
setEditPayment(null);
}}
/>
)}
</>
);
}

View File

@ -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 (
<div>
<button
type="button"
onClick={onOpen}
className={cn(
'w-full rounded-md text-left transition-colors hover:bg-accent/60 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50',
compact ? 'p-2' : 'px-2 py-1.5',
)}
title="View payment history"
>
<div className="flex items-center justify-between gap-2 text-xs">
<span className={cn('tracker-number text-[13px] font-semibold', summary.paid > 0 ? 'text-emerald-300' : 'text-muted-foreground/85')}>
{amountLabel}
</span>
{summary.count > 1 && (
<span className="shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
{summary.count}×
</span>
)}
</div>
<div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-muted">
<div
className={cn('h-full rounded-full transition-all', barTone)}
style={{ width: `${summary.percent}%` }}
/>
</div>
</button>
{showQuickFix && (
<button
type="button"
onClick={onMarkFullAmount}
className="mt-0.5 w-full rounded px-2 py-0.5 text-left text-[10px] text-muted-foreground/60 transition-colors hover:bg-accent/50 hover:text-foreground"
title={`Set ${fmt(summary.paidTowardDue)} as the full amount due this month`}
>
{fmt(summary.paidTowardDue)} is the full amount
</button>
)}
</div>
);
}

View File

@ -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 (
<button
type="button"
disabled={!canClick || loading}
onClick={onClick}
className={cn(
'inline-flex items-center px-2.5 py-0.5 text-[11px] rounded-md font-semibold',
'uppercase tracking-wide whitespace-nowrap',
'transition-all duration-150',
isUrgent && 'gap-1.5 px-2.5 py-1 text-xs',
canClick && 'cursor-pointer hover:scale-105 hover:shadow-sm',
canClick && status === 'paid' && 'hover:bg-red-500/20 hover:text-red-600 hover:border-red-500/40',
canClick && status !== 'paid' && 'hover:bg-emerald-500/20 hover:text-emerald-600 hover:border-emerald-500/40',
loading && 'opacity-60 cursor-wait',
meta.cls,
)}
title={canClick ? (status === 'paid' || status === 'autodraft' ? 'Click to mark unpaid' : 'Click to mark paid') : undefined}
>
{loading ? (
<>
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
{meta.label}
</>
) : (
<>
{isUrgent && <AlertCircle className="h-3.5 w-3.5" />}
{meta.label}
</>
)}
</button>
);
});

View File

@ -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 (
<div className="flex items-center gap-1.5">
<span className={`text-lg font-bold ${color}`}>
{text}
</span>
<span className="text-[10px] text-muted-foreground whitespace-nowrap">
vs 3-mo avg
</span>
</div>
);
}
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 (
<div className={cn(
'flex-1 min-w-0 relative overflow-hidden rounded-xl border border-border/80',
'bg-card/95 px-5 py-4 shadow-sm shadow-black/15 transition-all duration-300',
isActive && def.glow,
isActive && def.borderActive,
)}>
<div className={cn(
'absolute top-0 left-0 right-0 h-[3px] bg-gradient-to-r',
def.bar,
!isActive && (type === 'paid' || type === 'overdue') && 'opacity-30',
)} />
<div className="flex items-center gap-2 mb-3">
<Icon className={cn('h-4 w-4', isActive ? def.valueClass : 'text-muted-foreground')} />
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{displayLabel}
</p>
{type === 'starting' && onEdit && (
<button
onClick={onEdit}
className="ml-auto h-4 w-4 text-muted-foreground hover:text-foreground transition-colors"
title="Edit monthly starting amounts"
aria-label="Edit monthly starting amounts"
>
<Settings2 className="h-4 w-4" />
</button>
)}
</div>
<p className={cn(
'text-[1.75rem] font-bold tracking-tight font-mono leading-none',
isActive ? def.valueClass : 'text-foreground',
)}>
{fmt(value)}
</p>
{hint && <p className="mt-2 text-[11px] text-muted-foreground">{hint}</p>}
</div>
);
}
export function TrendCard({ trend }) {
if (!trend) return null;
return (
<div className="flex-1 min-w-0 relative overflow-hidden rounded-xl border border-border/80 bg-card/95 px-5 py-4 shadow-sm shadow-black/15 transition-all duration-300">
<div className="absolute top-0 left-0 right-0 h-[3px] bg-gradient-to-r from-purple-500 to-indigo-400" />
<div className="flex items-center gap-2 mb-3">
<TrendingUp className="h-4 w-4 text-foreground" />
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
3-Month Trend
</p>
</div>
<div className="flex items-center justify-center h-10">
<TrendIndicator trend={trend} />
</div>
</div>
);
}

View File

@ -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 (
<div className="rounded-xl border border-border/80 overflow-hidden bg-card/95 shadow-sm shadow-black/15">
{/* Bucket header */}
<div className="flex items-center justify-between px-5 py-3 bg-muted/35 border-b border-border/80">
<div className="flex items-center gap-3">
<span className="text-[11px] font-bold uppercase tracking-[0.12em] text-muted-foreground">
{label}
</span>
{skippedCount > 0 && (
<span className="text-[10px] text-muted-foreground/60">
({skippedCount} skipped)
</span>
)}
<div className="flex items-center gap-2">
<div className="h-1.5 w-24 rounded-full bg-border overflow-hidden">
<div
className={cn(
'h-full rounded-full transition-all duration-700',
allPaid ? 'bg-emerald-500' : 'bg-emerald-400/70',
)}
style={{ width: `${pct}%` }}
/>
</div>
<span className="text-[11px] font-mono text-muted-foreground/70">
{Math.round(pct)}%
</span>
</div>
</div>
<div className="flex items-center gap-3 text-xs font-mono text-muted-foreground">
<span>
<span className={cn(allPaid ? 'text-emerald-500' : 'text-foreground')}>
{fmt(totalPaidTowardDue)}
</span>
<span className="text-muted-foreground/50 mx-1">/</span>
{fmt(totalThreshold)}
{totalOverpaid > 0 && (
<span className="ml-1 text-emerald-500">+{fmt(totalOverpaid)}</span>
)}
</span>
{!allPaid && totalRemaining > 0 && (
<span className="text-[11px] text-muted-foreground/70">{fmt(totalRemaining)} left</span>
)}
{allPaid && (
<span className="text-[11px] text-emerald-500">Done</span>
)}
{!reorderEnabled && rows.length > 1 && (
<span className="hidden text-[11px] text-muted-foreground/60 xl:inline">Clear filters to reorder</span>
)}
</div>
</div>
<div className="grid gap-3 p-3 lg:hidden" aria-busy={loading ? 'true' : 'false'}>
{loading ? (
Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="rounded-lg border border-border/60 bg-background/60 p-3 animate-pulse">
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<div className="h-1.5 w-1.5 shrink-0 rounded-full bg-muted" />
<div className="h-4 w-32 rounded-md bg-muted" />
</div>
</div>
<div className="h-5 w-20 rounded-md bg-muted" />
</div>
<div className="grid grid-cols-2 gap-x-3 gap-y-2 text-xs text-muted-foreground mt-2">
<div>
<p className="uppercase tracking-wide text-muted-foreground/60">Expected</p>
<div className="h-3 w-20 rounded-md bg-muted mt-0.5" />
</div>
<div>
<p className="uppercase tracking-wide text-muted-foreground/60">Remaining</p>
<div className="h-3 w-20 rounded-md bg-muted mt-0.5" />
</div>
</div>
</div>
))
) : rows.length === 0 ? (
<div className="rounded-lg border border-dashed border-border/70 bg-background/40 px-4 py-8 text-center text-sm text-muted-foreground">
No bills match this bucket and filter set.
</div>
) : (
rows.map((r, i) => (
<MobileTrackerRow
key={r.id}
row={r}
year={year}
month={month}
refresh={refresh}
index={i}
onEditBill={onEditBill}
moveControls={moveControlsFor(r, i)}
dragProps={dragPropsFor(r, i)}
/>
))
)}
</div>
<div className="hidden lg:block" aria-busy={loading ? 'true' : 'false'}>
<div className="overflow-x-auto">
<Table className="min-w-[1120px]">
<TableHeader>
<TableRow className="border-border/80 bg-background/30 hover:bg-background/30">
<TableHead className="w-[18%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/90">Bill</TableHead>
<TableHead className="w-[10%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/90">Due</TableHead>
<TableHead className="w-[10%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/90 text-right">Expected</TableHead>
<TableHead className="w-[10%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/80 text-right">Last Month</TableHead>
<TableHead className="w-[10%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/90 text-right">Paid</TableHead>
<TableHead className="w-[10%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/90">Paid Date</TableHead>
<TableHead className="w-[9%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/90">Status</TableHead>
<TableHead className="w-[10%] py-2.5" />
<TableHead className="w-[23%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/90 border-l border-border/80 pl-4">
Notes
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
Array.from({ length: 5 }).map((_, i) => (
<TableRow key={i} className="border-border/50">
<TableCell className="w-[18%] py-3">
<div className="flex items-center gap-2.5">
<div className="h-1.5 w-1.5 rounded-full bg-muted" />
<div className="h-4 w-48 rounded-md bg-muted" />
</div>
</TableCell>
<TableCell className="w-[10%] py-3"><div className="h-3 w-20 rounded-md bg-muted" /></TableCell>
<TableCell className="w-[10%] py-3 text-right"><div className="h-3 w-20 ml-auto rounded-md bg-muted" /></TableCell>
<TableCell className="w-[10%] py-3 text-right"><div className="h-3 w-20 ml-auto rounded-md bg-muted" /></TableCell>
<TableCell className="w-[10%] py-3"><div className="h-7 w-24 ml-auto rounded-md bg-muted" /></TableCell>
<TableCell className="w-[10%] py-3"><div className="h-7 w-24 rounded-md bg-muted" /></TableCell>
<TableCell className="w-[9%] py-3"><div className="h-5 w-20 rounded-md bg-muted" /></TableCell>
<TableCell className="w-[10%] py-3 text-right">
<div className="flex items-center justify-end gap-1">
<div className="h-7 w-20 rounded-md bg-muted" />
<div className="h-7 w-7 rounded-md bg-muted" />
</div>
</TableCell>
<TableCell className="w-[23%] py-3 border-l border-border pl-4">
<div className="h-4 w-full rounded-md bg-muted" />
</TableCell>
</TableRow>
))
) : rows.length === 0 ? (
<TableRow className="border-border/50">
<TableCell colSpan={9} className="py-10 text-center text-sm text-muted-foreground">
No bills match this bucket and filter set.
</TableCell>
</TableRow>
) : (
rows.map((r, i) => (
<Row
key={r.id}
row={r}
year={year}
month={month}
refresh={refresh}
index={i}
onEditBill={onEditBill}
moveControls={moveControlsFor(r, i)}
dragProps={dragPropsFor(r, i)}
/>
))
)}
</TableBody>
</Table>
</div>
</div>
</div>
);
}

View File

@ -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 (
<>
<TableRow
draggable={dragProps?.draggable}
onDragStart={dragProps?.onDragStart}
onDragEnter={dragProps?.onDragEnter}
onDragOver={dragProps?.onDragOver}
onDragEnd={dragProps?.onDragEnd}
onDrop={dragProps?.onDrop}
className={cn(
'group border-border/65 transition-colors duration-150',
isSkipped ? 'opacity-40' : 'hover:bg-accent/50',
rowBg,
dragProps?.isDragging && 'opacity-45',
dragProps?.isDropTarget && 'ring-2 ring-inset ring-primary/35',
)}
style={{ animationDelay: `${index * 40}ms` }}
>
{/* Bill name + category + monthly notes (if set) */}
<TableCell className="w-[18%] py-3">
<div className="flex items-center gap-2.5">
<div className="flex shrink-0 items-center gap-0.5">
<GripVertical
className={cn(
'h-4 w-4 text-muted-foreground/55',
moveControls?.enabled && 'cursor-grab group-active:cursor-grabbing',
!moveControls?.enabled && 'opacity-30',
)}
aria-hidden="true"
/>
<div className="hidden flex-col sm:flex">
<button
type="button"
onClick={moveControls?.onMoveUp}
disabled={!moveControls?.enabled || !moveControls?.canMoveUp || moveControls?.moving}
className="rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-25"
title="Move bill up"
aria-label={`Move ${row.name} up`}
>
<ArrowUp className="h-3 w-3" />
</button>
<button
type="button"
onClick={moveControls?.onMoveDown}
disabled={!moveControls?.enabled || !moveControls?.canMoveDown || moveControls?.moving}
className="rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-25"
title="Move bill down"
aria-label={`Move ${row.name} down`}
>
<ArrowDown className="h-3 w-3" />
</button>
</div>
</div>
<div>
<div className="flex items-center gap-1">
{row.website ? (
<a
href={row.website}
target="_blank"
rel="noreferrer"
className={cn(
'text-[15px] font-semibold leading-tight text-foreground transition-colors',
'hover:underline decoration-muted-foreground/50 underline-offset-2',
isSkipped && 'line-through',
)}
>
{row.name}
</a>
) : (
<span className={cn('text-[15px] font-semibold leading-tight text-foreground', isSkipped && 'line-through')}>
{row.name}
</span>
)}
{row.autopay_enabled && (
<span
className="inline-flex shrink-0 rounded border border-sky-500/25 bg-sky-500/10 px-1.5 py-0.5 text-[10px] font-bold leading-none text-sky-600 dark:text-sky-300"
title="Autopay"
>
AP
</span>
)}
{row.is_subscription && (
<span
className="inline-flex shrink-0 rounded border border-indigo-500/25 bg-indigo-500/10 px-1.5 py-0.5 text-[10px] font-bold leading-none text-indigo-600 dark:text-indigo-300"
title="Subscription"
>
S
</span>
)}
<Button
size="icon" variant="ghost"
className="h-7 w-7 opacity-100 transition-opacity text-muted-foreground hover:text-foreground hover:bg-accent lg:opacity-0 lg:group-hover:opacity-100"
title="Edit bill"
onClick={() => onEditBill?.(row)}
>
<Pencil className="h-3 w-3" />
</Button>
</div>
{row.category_name && (
<p className="mt-0.5 text-xs text-muted-foreground/85">{row.category_name}</p>
)}
{/* Monthly notes shown inline under the bill name */}
{row.monthly_notes && (
<p className="text-[11px] text-amber-500/80 mt-0.5 italic truncate max-w-[140px]"
title={row.monthly_notes}>
{row.monthly_notes}
</p>
)}
</div>
</div>
</TableCell>
{/* Due */}
<TableCell className="tracker-number w-[10%] py-3 text-[13px] font-medium text-foreground/75">
{editingDue ? (
<input
type="number"
min="1" max="31"
value={dueDraft}
autoFocus
onChange={e => 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 (131)"
/>
) : (
<button
type="button"
onClick={() => { setDueDraft(String(row.due_day)); setEditingDue(true); }}
className="rounded px-1 py-0.5 transition-colors hover:bg-accent hover:text-foreground"
title="Click to edit due day"
>
{fmtDate(row.due_date)}
</button>
)}
</TableCell>
{/* Expected / Actual — shows actual_amount in amber when it overrides the template */}
<TableCell className="tracker-number w-[10%] py-3 text-right text-[13px] font-semibold">
{editingExpected ? (
<input
type="number"
min="0" step="0.01"
value={expectedDraft}
autoFocus
onChange={e => 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 ? (
<button
type="button"
onClick={() => { setExpectedDraft(String(effectiveActual)); setEditingExpected(true); }}
className="rounded px-1 py-0.5 text-amber-300 transition-colors hover:bg-accent"
title={`Monthly override — click to edit. Template default: ${fmt(row.expected_amount)}`}
>
{fmt(effectiveActual)}
</button>
) : (
<div className="flex flex-col items-end gap-0.5">
<button
type="button"
onClick={() => { setExpectedDraft(String(row.expected_amount)); setEditingExpected(true); }}
className="rounded px-1 py-0.5 text-foreground/85 transition-colors hover:bg-accent hover:text-foreground"
title="Click to edit expected amount"
>
{fmt(row.expected_amount)}
</button>
{row.amount_suggestion?.suggestion != null &&
Math.abs(row.amount_suggestion.suggestion - row.expected_amount) / row.expected_amount > 0.05 && (
<button
type="button"
onClick={() => handleApplySuggestion(row.amount_suggestion.suggestion)}
className="text-[10px] text-muted-foreground/50 transition-colors hover:text-muted-foreground"
title={`Based on last ${row.amount_suggestion.months_used} months`}
>
~{fmt(row.amount_suggestion.suggestion)}
</button>
)}
</div>
)}
</TableCell>
{/* Previous month paid */}
<TableCell className="tracker-number w-[10%] py-3 text-right text-[13px] font-medium text-muted-foreground/80">
{row.previous_month_paid > 0 ? fmt(row.previous_month_paid) : '—'}
</TableCell>
{/* Amount paid — mismatch now compares against threshold */}
<TableCell className="w-[10%] py-3 text-right">
<PaymentProgress
row={row}
threshold={threshold}
onOpen={() => setPaymentLedgerOpen(true)}
onMarkFullAmount={!isSkipped ? handleMarkFullAmount : undefined}
/>
</TableCell>
{/* Paid date */}
<TableCell className="w-[10%] py-3 text-[13px] text-foreground/75">
<button
type="button"
onClick={() => setPaymentLedgerOpen(true)}
className="tracker-number rounded-md px-1.5 py-0.5 font-medium transition-colors hover:bg-accent hover:text-foreground"
title="View payment history"
>
{row.last_paid_date ? fmtDate(row.last_paid_date) : '—'}
{summary.count > 1 && <span className="ml-1 text-[10px] text-muted-foreground">({summary.count})</span>}
</button>
</TableCell>
{/* Status — uses effectiveStatus (accounts for skipped + threshold) */}
<TableCell className="w-[9%] py-3">
<StatusBadge
status={effectiveStatus}
clickable
onClick={() => {
if (effectiveStatus === 'skipped') return;
handleTogglePaid();
}}
loading={loading}
/>
</TableCell>
{/* Actions */}
<TableCell className="w-[10%] py-3 text-right">
<div className="flex items-center justify-end gap-1">
{showUpdateNudge ? (
<div className="flex items-center gap-1 animate-in fade-in slide-in-from-right-1 duration-200">
<span className="text-[10px] text-muted-foreground">Update default?</span>
<Button
size="sm" variant="ghost"
onClick={handleUpdateTemplate}
className="h-6 px-2 text-[10px] font-semibold text-emerald-600 hover:bg-emerald-500/10 hover:text-emerald-700 dark:text-emerald-400 dark:hover:text-emerald-300"
>
{fmt(nudgeAmount)}
</Button>
<button
type="button"
onClick={() => setShowUpdateNudge(false)}
className="text-muted-foreground transition-colors hover:text-foreground"
title="Dismiss"
>
<X className="h-3 w-3" />
</button>
</div>
) : (
<>
{hasAutopaySuggestion && (
<AutopaySuggestionActions
row={row}
loading={suggestionLoading}
onConfirm={handleConfirmSuggestion}
onDismiss={handleDismissSuggestion}
/>
)}
{/* Quick pay — hidden for skipped/paid bills */}
{!isPaid && !isSkipped && !hasAutopaySuggestion && (
<div className="flex items-center gap-1">
<Input
ref={amountRef}
type="number" min="0" step="0.01"
defaultValue={summary.remaining || threshold}
className="tracker-number h-7 w-20 text-right text-sm font-medium bg-background/50 border-border/50"
title="Payment amount"
/>
<Button
size="sm" variant="ghost"
onClick={handleQuickPay}
className="h-7 px-2.5 text-xs font-semibold text-emerald-600 hover:text-emerald-700 hover:bg-emerald-50 dark:text-emerald-400 dark:hover:text-emerald-300 dark:hover:bg-emerald-500/10"
>
Add
</Button>
</div>
)}
</>
)}
</div>
</TableCell>
{/* Notes cell (monthly state notes) */}
<TableCell className="w-[23%] py-3 border-l border-border pl-4">
<NotesCell row={{ ...row, year, month }} refresh={refresh} />
</TableCell>
</TableRow>
{editPayment && (
<PaymentModal
payment={editPayment}
onClose={() => setEditPayment(null)}
onSave={refresh}
/>
)}
{paymentLedgerOpen && (
<PaymentLedgerDialog
row={row}
year={year}
month={month}
threshold={threshold}
defaultPaymentDate={defaultPaymentDate}
onClose={() => setPaymentLedgerOpen(false)}
onSaved={refresh}
/>
)}
{showMbs && (
<MonthlyStateDialog
row={row}
year={year}
month={month}
open={showMbs}
onOpenChange={setShowMbs}
onSaved={refresh}
/>
)}
<AlertDialog open={confirmUnpay} onOpenChange={setConfirmUnpay}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Mark this bill unpaid?</AlertDialogTitle>
<AlertDialogDescription>
This removes the current payment record for this month and moves it into recovery.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={loading}>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={loading}
onClick={performTogglePaid}
>
{loading ? 'Removing...' : 'Remove Payment'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

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

106
client/lib/trackerUtils.js Normal file
View File

@ -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,
};
}

View File

@ -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) => {

View File

@ -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]);

View File

@ -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();

File diff suppressed because it is too large Load Diff

View File

@ -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);
}
}
}
];

View File

@ -1,6 +1,6 @@
{
"name": "bill-tracker",
"version": "0.34.2",
"version": "0.34.3",
"description": "Monthly bill tracking system",
"main": "server.js",
"scripts": {

View File

@ -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 });

View File

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

View File

@ -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),
};
}

View File

@ -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 });
});

View File

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

View File

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

View File

@ -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: [

View File

@ -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();

View File

@ -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');

View File

@ -277,4 +277,5 @@ module.exports = {
resolveBucket,
resolveDueDate,
resolveGracePeriodDays,
roundMoney,
};

View File

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