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