diff --git a/client/api.js b/client/api.js index 7995916..781cf17 100644 --- a/client/api.js +++ b/client/api.js @@ -202,6 +202,7 @@ export const api = { deleteBill: (id) => del(`/bills/${id}`), restoreBill: (id) => post(`/bills/${id}/restore`), duplicateBill: (id, data) => post(`/bills/${id}/duplicate`, data), + verifyAutopay: (id) => post(`/bills/${id}/verify-autopay`, {}), togglePaid: (id, data) => post(`/bills/${id}/toggle-paid`, data), billPayments: (id, p, l) => get(`/bills/${id}/payments?page=${p||1}&limit=${l||20}`), billTransactions: (id) => get(`/bills/${id}/transactions`), diff --git a/client/components/BillModal.jsx b/client/components/BillModal.jsx index f52b7dc..7361865 100644 --- a/client/components/BillModal.jsx +++ b/client/components/BillModal.jsx @@ -186,6 +186,9 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa const [paymentDate, setPaymentDate] = useState(todayStr()); const [paymentMethod, setPaymentMethod] = useState('manual'); const [paymentNotes, setPaymentNotes] = useState(''); + const [localVerifiedAt, setLocalVerifiedAt] = useState( + bill?.autopay_verified_at ? new Date(bill.autopay_verified_at) : null + ); // Unmatch dialog state const [unmatchTarget, setUnmatchTarget] = useState(null); @@ -312,6 +315,17 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa } }; + async function handleVerifyAutopay() { + if (!bill?.id) return; + try { + const res = await api.verifyAutopay(bill.id); + setLocalVerifiedAt(new Date(res.autopay_verified_at)); + toast.success('Autopay marked as verified.'); + } catch (err) { + toast.error(err.message); + } + } + const handleAutopayChange = (checked) => { setAutopay(checked); if (checked) { @@ -981,6 +995,50 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa + {/* Autopay trust indicator — edit mode only */} + {!isNew && autopay && (() => { + const stats = bill?.autopay_stats; + const total = stats?.total ?? 0; + const failures = stats?.failures ?? 0; + const daysSince = localVerifiedAt + ? Math.floor((Date.now() - localVerifiedAt.getTime()) / 86400000) + : null; + const needsVerify = daysSince === null || daysSince > 90; + return ( +
+
+ 0 ? 'text-amber-500' : total > 0 ? 'text-emerald-500' : 'text-muted-foreground/60')}> + {total > 0 + ? `${failures > 0 ? '⚠' : '✓'} ${total - failures}/${total} successful (12 mo)` + : 'No payment history yet'} + + +
+ {needsVerify && ( +

+ {daysSince === null + ? "Autopay never confirmed — verify it's still active." + : `Last verified ${daysSince}d ago — confirm autopay is still on.`} +

+ )} + {!needsVerify && ( +

Verified {daysSince}d ago

+ )} + {failures > 0 && stats?.last_failure_date && ( +

+ Last failure: {stats.last_failure_date}{stats?.last_failure_notes ? ` — ${stats.last_failure_notes}` : ''} +

+ )} +
+ ); + })()} + {/* Notes */}
diff --git a/client/components/tracker/MobileTrackerRow.jsx b/client/components/tracker/MobileTrackerRow.jsx index b4b939c..7f159ad 100644 --- a/client/components/tracker/MobileTrackerRow.jsx +++ b/client/components/tracker/MobileTrackerRow.jsx @@ -332,6 +332,7 @@ export function MobileTrackerRow({ row, year, month, refresh, index, onEditBill, {editPayment && ( setEditPayment(null)} onSave={refresh} /> diff --git a/client/components/tracker/PaymentLedgerDialog.jsx b/client/components/tracker/PaymentLedgerDialog.jsx index 103775a..e1b1bbf 100644 --- a/client/components/tracker/PaymentLedgerDialog.jsx +++ b/client/components/tracker/PaymentLedgerDialog.jsx @@ -173,6 +173,7 @@ export function PaymentLedgerDialog({ row, year, month, threshold, defaultPaymen {editPayment && ( setEditPayment(null)} onSave={() => { onSaved?.(); diff --git a/client/components/tracker/PaymentModal.jsx b/client/components/tracker/PaymentModal.jsx index 7dc2619..ed50311 100644 --- a/client/components/tracker/PaymentModal.jsx +++ b/client/components/tracker/PaymentModal.jsx @@ -17,24 +17,28 @@ import { const METHOD_NONE = 'none'; -function PaymentModal({ payment, onClose, onSave }) { +function PaymentModal({ payment, autopayEnabled, onClose, onSave }) { const [amount, setAmount] = useState(String(payment.amount)); const [date, setDate] = useState(payment.paid_date); // Use METHOD_NONE sentinel — empty string value crashes Radix Select const [method, setMethod] = useState(payment.method || METHOD_NONE); const [notes, setNotes] = useState(payment.notes || ''); + const [autopayFailure, setAutopayFailure] = useState(!!payment.autopay_failure); const [busy, setBusy] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false); + const showAutopayFailure = autopayEnabled || method === 'autopay'; + async function handleSave(e) { e.preventDefault(); setBusy(true); try { await api.updatePayment(payment.id, { - amount: parseFloat(amount), - paid_date: date, - method: method === METHOD_NONE ? null : method, - notes: notes || null, + amount: parseFloat(amount), + paid_date: date, + method: method === METHOD_NONE ? null : method, + notes: notes || null, + autopay_failure: showAutopayFailure ? (autopayFailure ? 1 : 0) : undefined, }); toast.success('Payment saved'); onSave(); onClose(); @@ -109,6 +113,19 @@ function PaymentModal({ payment, onClose, onSave }) { setNotes(e.target.value)} className="bg-background/50 border-border/60" />
+ {showAutopayFailure && ( + + )} diff --git a/client/components/tracker/TrackerBucket.jsx b/client/components/tracker/TrackerBucket.jsx index bb8e4c9..29c85ed 100644 --- a/client/components/tracker/TrackerBucket.jsx +++ b/client/components/tracker/TrackerBucket.jsx @@ -38,7 +38,7 @@ function SortableHead({ sortKey, activeSortKey, sortDir, onSort, children, class ); } -export function TrackerBucket({ label, rows, year, month, refresh, onEditBill, loading, onReorderRows, reorderEnabled, movingBillId, sortKey = TRACKER_SORT_DEFAULT, sortDir = TRACKER_SORT_ASC, onSort }) { +export function TrackerBucket({ label, rows, year, month, refresh, onEditBill, loading, onReorderRows, reorderEnabled, movingBillId, sortKey = TRACKER_SORT_DEFAULT, sortDir = TRACKER_SORT_ASC, onSort, driftedIds = new Set() }) { 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 @@ -199,6 +199,7 @@ export function TrackerBucket({ label, rows, year, month, refresh, onEditBill, l onEditBill={onEditBill} moveControls={moveControlsFor(r, i)} dragProps={dragPropsFor(r, i)} + isDrifted={driftedIds.has(r.id)} /> )) )} @@ -267,6 +268,7 @@ export function TrackerBucket({ label, rows, year, month, refresh, onEditBill, l onEditBill={onEditBill} moveControls={moveControlsFor(r, i)} dragProps={dragPropsFor(r, i)} + isDrifted={driftedIds.has(r.id)} /> )) )} diff --git a/client/components/tracker/TrackerRow.jsx b/client/components/tracker/TrackerRow.jsx index 94332d4..dd31ab5 100644 --- a/client/components/tracker/TrackerRow.jsx +++ b/client/components/tracker/TrackerRow.jsx @@ -1,5 +1,5 @@ import React, { useState, useRef, useTransition } from 'react'; -import { ArrowDown, ArrowUp, GripVertical, Pencil, X } from 'lucide-react'; +import { ArrowDown, ArrowUp, CheckCircle2, Clock, GripVertical, Pencil, TrendingUp, X } from 'lucide-react'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { toast } from 'sonner'; import { api } from '@/api.js'; @@ -22,7 +22,7 @@ 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 }) { +export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveControls, dragProps, isDrifted }) { const amountRef = useRef(null); const [editPayment, setEditPayment] = useState(null); const [paymentLedgerOpen, setPaymentLedgerOpen] = useState(false); @@ -358,16 +358,50 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC )} - {row.autopay_enabled && ( - - - - AP - - - Autopay enabled - - )} + {row.autopay_enabled && (() => { + const stats = row.autopay_stats; + const hasFailed = stats && stats.failures > 0; + const verifiedAt = row.autopay_verified_at ? new Date(row.autopay_verified_at) : null; + const daysSinceVerify = verifiedAt + ? Math.floor((Date.now() - verifiedAt) / 86400000) + : null; + const needsVerify = daysSinceVerify === null || daysSinceVerify > 90; + const badgeCls = hasFailed + ? 'border-amber-500/40 bg-amber-500/15 text-amber-600 dark:text-amber-300' + : 'border-sky-500/25 bg-sky-500/10 text-sky-600 dark:text-sky-300'; + return ( + + + + {hasFailed ? '⚠' : null}AP + {needsVerify && } + + + + {stats && stats.total > 0 ? ( +

+ {hasFailed ? '⚠' : '✓'} {stats.total - stats.failures}/{stats.total} on time (12mo) +

+ ) : ( +

Autopay enabled

+ )} + {hasFailed && stats.last_failure_date && ( +

Last failed: {stats.last_failure_date}

+ )} + {hasFailed && stats.last_failure_notes && ( +

{stats.last_failure_notes}

+ )} + {needsVerify ? ( +

+ {daysSinceVerify === null ? 'Never verified — confirm it\'s still active' : `Verified ${daysSinceVerify}d ago — check soon`} +

+ ) : ( +

Verified {daysSinceVerify}d ago

+ )} +
+
+ ); + })()} {(row.has_merchant_rule || row.has_linked_transactions) && ( @@ -476,6 +510,28 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC > {fmt(row.expected_amount)} + {isDrifted && ( + + Changed + + )} + {row.sparkline && row.sparkline.length >= 2 && (() => { + const vals = row.sparkline; + const min = Math.min(...vals); + const max = Math.max(...vals); + const range = max - min || 1; + const W = 44, H = 16; + const pts = vals.map((v, i) => { + const x = (i / (vals.length - 1)) * W; + const y = H - ((v - min) / range) * H; + return `${x.toFixed(1)},${y.toFixed(1)}`; + }).join(' '); + return ( + + + + ); + })()} {row.amount_suggestion?.suggestion != null && Math.abs(row.amount_suggestion.suggestion - row.expected_amount) / row.expected_amount > 0.05 && (