feat: tracker payment flow and mobile row improvements

This commit is contained in:
null 2026-06-07 14:49:39 -05:00
parent 3b555e4d8e
commit e1082145ab
14 changed files with 317 additions and 30 deletions

View File

@ -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`),

View File

@ -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
</Select>
</div>
{/* 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 (
<div className="rounded-md border border-border/50 bg-muted/20 px-3 py-2.5 space-y-1.5">
<div className="flex items-center justify-between gap-2">
<span className={cn('text-xs font-medium', failures > 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'}
</span>
<button
type="button"
onClick={handleVerifyAutopay}
className="text-[11px] text-sky-500 hover:text-sky-400 underline underline-offset-2 transition-colors"
>
Mark verified
</button>
</div>
{needsVerify && (
<p className="text-[11px] text-amber-500/80">
{daysSince === null
? "Autopay never confirmed — verify it's still active."
: `Last verified ${daysSince}d ago — confirm autopay is still on.`}
</p>
)}
{!needsVerify && (
<p className="text-[11px] text-muted-foreground/60">Verified {daysSince}d ago</p>
)}
{failures > 0 && stats?.last_failure_date && (
<p className="text-[11px] text-amber-500/80">
Last failure: {stats.last_failure_date}{stats?.last_failure_notes ? `${stats.last_failure_notes}` : ''}
</p>
)}
</div>
);
})()}
{/* Notes */}
<div className="col-span-2 space-y-1.5">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Notes</Label>

View File

@ -332,6 +332,7 @@ export function MobileTrackerRow({ row, year, month, refresh, index, onEditBill,
{editPayment && (
<PaymentModal
payment={editPayment}
autopayEnabled={!!row.autopay_enabled}
onClose={() => setEditPayment(null)}
onSave={refresh}
/>

View File

@ -173,6 +173,7 @@ export function PaymentLedgerDialog({ row, year, month, threshold, defaultPaymen
{editPayment && (
<PaymentModal
payment={editPayment}
autopayEnabled={!!row.autopay_enabled}
onClose={() => setEditPayment(null)}
onSave={() => {
onSaved?.();

View File

@ -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 }) {
<Input id="pm-notes" value={notes} onChange={e => setNotes(e.target.value)}
className="bg-background/50 border-border/60" />
</div>
{showAutopayFailure && (
<label className="flex items-center gap-2.5 cursor-pointer group">
<input
type="checkbox"
checked={autopayFailure}
onChange={e => setAutopayFailure(e.target.checked)}
className="h-4 w-4 rounded border-border accent-amber-500"
/>
<span className="text-sm text-muted-foreground group-hover:text-foreground transition-colors">
Autopay failed paid manually
</span>
</label>
)}
</form>
<DialogFooter className="flex-row gap-2 sm:justify-between mt-2">

View File

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

View File

@ -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
</span>
)}
<TooltipProvider delayDuration={300}>
{row.autopay_enabled && (
<Tooltip>
<TooltipTrigger asChild>
<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 cursor-default">
AP
</span>
</TooltipTrigger>
<TooltipContent>Autopay enabled</TooltipContent>
</Tooltip>
)}
{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 (
<Tooltip>
<TooltipTrigger asChild>
<span className={cn('inline-flex shrink-0 items-center gap-0.5 rounded border px-1.5 py-0.5 text-[10px] font-bold leading-none cursor-default', badgeCls)}>
{hasFailed ? '⚠' : null}AP
{needsVerify && <Clock className="h-2.5 w-2.5 ml-0.5 opacity-70" />}
</span>
</TooltipTrigger>
<TooltipContent className="max-w-[220px] space-y-1">
{stats && stats.total > 0 ? (
<p className={cn('font-semibold', hasFailed ? 'text-amber-300' : 'text-emerald-300')}>
{hasFailed ? '⚠' : '✓'} {stats.total - stats.failures}/{stats.total} on time (12mo)
</p>
) : (
<p className="font-semibold">Autopay enabled</p>
)}
{hasFailed && stats.last_failure_date && (
<p className="text-xs opacity-80">Last failed: {stats.last_failure_date}</p>
)}
{hasFailed && stats.last_failure_notes && (
<p className="text-xs opacity-70 italic">{stats.last_failure_notes}</p>
)}
{needsVerify ? (
<p className="text-xs opacity-70">
{daysSinceVerify === null ? 'Never verified — confirm it\'s still active' : `Verified ${daysSinceVerify}d ago — check soon`}
</p>
) : (
<p className="text-xs opacity-70">Verified {daysSinceVerify}d ago</p>
)}
</TooltipContent>
</Tooltip>
);
})()}
{(row.has_merchant_rule || row.has_linked_transactions) && (
<Tooltip>
<TooltipTrigger asChild>
@ -476,6 +510,28 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
>
{fmt(row.expected_amount)}
</button>
{isDrifted && (
<span className="inline-flex items-center gap-0.5 text-[10px] font-semibold text-amber-500">
<TrendingUp className="h-2.5 w-2.5" />Changed
</span>
)}
{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 (
<svg width={W} height={H} viewBox={`0 0 ${W} ${H}`} className="opacity-50">
<polyline points={pts} fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round" />
</svg>
);
})()}
{row.amount_suggestion?.suggestion != null &&
Math.abs(row.amount_suggestion.suggestion - row.expected_amount) / row.expected_amount > 0.05 && (
<button
@ -607,6 +663,7 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
{editPayment && (
<PaymentModal
payment={editPayment}
autopayEnabled={!!row.autopay_enabled}
onClose={() => setEditPayment(null)}
onSave={refresh}
/>

View File

@ -621,6 +621,7 @@ export default function BillsPage() {
const [modal, setModal] = useState(null);
// Bill pending deactivation confirmation (AlertDialog replaces window.confirm)
const [deactivate, setDeactivate] = useState(null);
const [deactivateReason, setDeactivateReason] = useState('');
const [deleteTarget, setDeleteTarget] = useState(null);
const [deleteConfirmed, setDeleteConfirmed] = useState(false);
const [deleteBusy, setDeleteBusy] = useState(false);
@ -732,10 +733,12 @@ export default function BillsPage() {
}
}
async function doToggle(bill) {
async function doToggle(bill, reason) {
if (!bill) return;
try {
await api.updateBill(bill.id, { active: bill.active ? 0 : 1 });
const payload = { active: bill.active ? 0 : 1 };
if (bill.active && reason) payload.inactive_reason = reason;
await api.updateBill(bill.id, payload);
toast.success(bill.active ? 'Bill deactivated' : 'Bill activated');
load();
} catch (err) {
@ -1140,7 +1143,7 @@ export default function BillsPage() {
)}
{/* ── Deactivate confirmation (replaces window.confirm) ── */}
<AlertDialog open={!!deactivate} onOpenChange={open => { if (!open) setDeactivate(null); }}>
<AlertDialog open={!!deactivate} onOpenChange={open => { if (!open) { setDeactivate(null); setDeactivateReason(''); } }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Deactivate "{deactivate?.name}"?</AlertDialogTitle>
@ -1148,11 +1151,28 @@ export default function BillsPage() {
This bill will be hidden from the tracker. You can reactivate it at any time.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="space-y-1.5 py-1">
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Reason (optional)
</label>
<select
value={deactivateReason}
onChange={e => setDeactivateReason(e.target.value)}
className="w-full rounded-md border border-border/60 bg-background px-3 py-2 text-sm text-foreground outline-none focus:ring-1 focus:ring-ring"
>
<option value="">Select a reason</option>
<option value="Moved to spouse">Moved to spouse</option>
<option value="Switched providers">Switched providers</option>
<option value="Paid off">Paid off</option>
<option value="Cancelled">Cancelled</option>
<option value="Other">Other</option>
</select>
</div>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setDeactivate(null)}>Cancel</AlertDialogCancel>
<AlertDialogCancel onClick={() => { setDeactivate(null); setDeactivateReason(''); }}>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => { const b = deactivate; setDeactivate(null); doToggle(b); }}
onClick={() => { const b = deactivate; const r = deactivateReason; setDeactivate(null); setDeactivateReason(''); doToggle(b, r); }}
>
Deactivate
</AlertDialogAction>

View File

@ -134,6 +134,7 @@ export default function TrackerPage() {
// Use React Query for data fetching
const { data, isLoading: loading, isError, error, refetch, dataUpdatedAt } = useTracker(year, month);
const { data: driftData, refetch: refetchDrift } = useDriftReport();
const driftedIds = useMemo(() => new Set((driftData?.bills ?? []).map(b => b.id)), [driftData]);
useEffect(() => {
setOrderedRows(null);
@ -741,8 +742,8 @@ export default function TrackerPage() {
)}
{!isError && (first.length > 0 || second.length > 0) && (
<div className="space-y-5">
{first.length > 0 && <Bucket label="1st 14th" rows={first} year={year} month={month} refresh={refetch} onEditBill={handleOpenEditBill} loading={loading} reorderEnabled={reorderEnabled} movingBillId={movingBillId} sortKey={sortKey} sortDir={sortDir} onSort={handleSortHeader} onReorderRows={(next) => handleReorderBucket('1st', next)} />}
{second.length > 0 && <Bucket label="15th 31st" rows={second} year={year} month={month} refresh={refetch} onEditBill={handleOpenEditBill} loading={loading} reorderEnabled={reorderEnabled} movingBillId={movingBillId} sortKey={sortKey} sortDir={sortDir} onSort={handleSortHeader} onReorderRows={(next) => handleReorderBucket('15th', next)} />}
{first.length > 0 && <Bucket label="1st 14th" rows={first} year={year} month={month} refresh={refetch} onEditBill={handleOpenEditBill} loading={loading} reorderEnabled={reorderEnabled} movingBillId={movingBillId} sortKey={sortKey} sortDir={sortDir} onSort={handleSortHeader} onReorderRows={(next) => handleReorderBucket('1st', next)} driftedIds={driftedIds} />}
{second.length > 0 && <Bucket label="15th 31st" rows={second} year={year} month={month} refresh={refetch} onEditBill={handleOpenEditBill} loading={loading} reorderEnabled={reorderEnabled} movingBillId={movingBillId} sortKey={sortKey} sortDir={sortDir} onSort={handleSortHeader} onReorderRows={(next) => handleReorderBucket('15th', next)} driftedIds={driftedIds} />}
</div>
)}

View File

@ -3298,6 +3298,31 @@ function runMigrations() {
console.log('[v0.98] payment accounting override columns ensured');
}
},
{
version: 'v0.99',
description: 'bills: autopay trust indicators + lifecycle fields; payments: autopay failure flag',
check() {
const billCols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
return billCols.includes('autopay_verified_at') && billCols.includes('inactive_reason');
},
run() {
const billCols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
const paymentCols = db.prepare('PRAGMA table_info(payments)').all().map(c => c.name);
if (!billCols.includes('autopay_verified_at')) {
db.exec('ALTER TABLE bills ADD COLUMN autopay_verified_at TEXT');
}
if (!billCols.includes('inactive_reason')) {
db.exec('ALTER TABLE bills ADD COLUMN inactive_reason TEXT');
}
if (!billCols.includes('inactivated_at')) {
db.exec('ALTER TABLE bills ADD COLUMN inactivated_at TEXT');
}
if (!paymentCols.includes('autopay_failure')) {
db.exec('ALTER TABLE payments ADD COLUMN autopay_failure INTEGER NOT NULL DEFAULT 0');
}
console.log('[v0.99] autopay trust indicators + lifecycle fields added');
}
},
];
// ── users: notification columns ───────────────────────────────────────────

View File

@ -350,7 +350,43 @@ router.get('/:id', (req, res) => {
WHERE b.id = ? AND b.user_id = ? AND b.deleted_at IS NULL
`).get(req.params.id, req.user.id);
if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
res.json(bill);
let autopay_stats = null;
if (bill.autopay_enabled) {
autopay_stats = db.prepare(`
SELECT COUNT(*) AS total,
SUM(autopay_failure) AS failures,
MAX(CASE WHEN autopay_failure = 1 THEN paid_date END) AS last_failure_date,
MAX(CASE WHEN autopay_failure = 1 THEN notes END) AS last_failure_notes
FROM payments
WHERE bill_id = ? AND deleted_at IS NULL AND paid_date >= date('now', '-12 months')
`).get(bill.id);
autopay_stats = {
total: autopay_stats.total || 0,
failures: autopay_stats.failures || 0,
last_failure_date: autopay_stats.last_failure_date || null,
last_failure_notes: autopay_stats.last_failure_notes || null,
};
}
res.json({ ...bill, autopay_stats });
});
// ── POST /api/bills/:id/verify-autopay ───────────────────────────────────────
router.post('/:id/verify-autopay', (req, res) => {
const db = getDb();
const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json(standardizeError('Invalid id', 'VALIDATION_ERROR', 'bill_id'));
}
const bill = db.prepare('SELECT id, autopay_enabled FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(id, req.user.id);
if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
if (!bill.autopay_enabled) {
return res.status(400).json(standardizeError('Bill does not have autopay enabled', 'VALIDATION_ERROR', 'autopay_enabled'));
}
const now = new Date().toISOString();
db.prepare("UPDATE bills SET autopay_verified_at = ?, updated_at = ? WHERE id = ? AND user_id = ?").run(now, now, id, req.user.id);
res.json({ ok: true, autopay_verified_at: now });
});
// ── POST /api/bills ───────────────────────────────────────────────────────────
@ -410,6 +446,11 @@ router.put('/:id', (req, res) => {
return res.status(400).json(standardizeError('category_id is invalid for this user', 'VALIDATION_ERROR', 'category_id'));
}
const inactiveReason = typeof req.body.inactive_reason === 'string' ? req.body.inactive_reason.trim() || null : null;
const wasActive = existing.active === 1;
const nowActive = normalized.active === 1;
const inactivatedAt = (!wasActive || nowActive) ? existing.inactivated_at : (inactiveReason ? new Date().toISOString().slice(0, 10) : existing.inactivated_at);
db.prepare(`
UPDATE bills SET
name = ?, category_id = ?, due_day = ?, override_due_date = ?, bucket = ?,
@ -418,6 +459,7 @@ router.put('/:id', (req, res) => {
history_visibility = ?, cycle_type = ?, cycle_day = ?,
current_balance = ?, minimum_payment = ?, snowball_order = ?, snowball_include = ?, snowball_exempt = ?,
is_subscription = ?, subscription_type = ?, reminder_days_before = ?, subscription_source = ?, subscription_detected_at = ?,
inactive_reason = ?, inactivated_at = ?,
updated_at = datetime('now')
WHERE id = ? AND user_id = ?
`).run(
@ -451,6 +493,8 @@ router.put('/:id', (req, res) => {
normalized.reminder_days_before,
normalized.subscription_source,
normalized.subscription_detected_at,
inactiveReason,
inactivatedAt,
req.params.id,
req.user.id,
);

View File

@ -191,7 +191,7 @@ router.post('/:id/undo-auto', (req, res) => {
// POST /api/payments — create single payment
router.post('/', (req, res) => {
const db = getDb();
const { bill_id, amount, paid_date, method, notes, payment_source } = req.body;
const { bill_id, amount, paid_date, method, notes, payment_source, autopay_failure } = req.body;
const validation = validatePaymentInput({ bill_id, amount, paid_date, payment_source: payment_source ?? 'manual' });
if (validation.error) {
@ -204,9 +204,10 @@ router.post('/', (req, res) => {
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
const balCalc = computeBalanceDelta(bill, payment.amount);
const failureFlag = autopay_failure ? 1 : 0;
const result = db.prepare(
'INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, interest_delta, payment_source) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
).run(payment.bill_id, payment.amount, payment.paid_date, method || null, notes || null, balCalc?.balance_delta ?? null, balCalc?.interest_delta ?? null, payment.payment_source);
'INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, interest_delta, payment_source, autopay_failure) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
).run(payment.bill_id, payment.amount, payment.paid_date, method || null, notes || null, balCalc?.balance_delta ?? null, balCalc?.interest_delta ?? null, payment.payment_source, failureFlag);
applyBalanceDelta(db, bill.id, balCalc);
@ -475,10 +476,13 @@ router.put('/:id', (req, res) => {
}
}
const { autopay_failure } = req.body;
const nextAutopayFailure = autopay_failure !== undefined ? (autopay_failure ? 1 : 0) : existing.autopay_failure;
db.prepare(`
UPDATE payments SET
amount = ?, paid_date = ?, method = ?, notes = ?, balance_delta = ?, interest_delta = ?,
payment_source = ?, updated_at = datetime('now')
payment_source = ?, autopay_failure = ?, updated_at = datetime('now')
WHERE id = ?
AND bill_id IN (SELECT id FROM bills WHERE user_id = ? AND deleted_at IS NULL)
`).run(
@ -489,6 +493,7 @@ router.put('/:id', (req, res) => {
nextBalanceDelta,
nextInterestDelta,
nextPaymentSource,
nextAutopayFailure,
req.params.id,
req.user.id,
);

View File

@ -269,6 +269,11 @@ function buildTrackerRow(bill, payments, year, month, todayStr, options = {}) {
has_merchant_rule: !!bill.has_merchant_rule,
has_linked_transactions: !!bill.has_linked_transactions,
website: bill.website || null,
autopay_verified_at: bill.autopay_verified_at ?? null,
inactive_reason: bill.inactive_reason ?? null,
inactivated_at: bill.inactivated_at ?? null,
sparkline: bill.sparkline ?? null,
autopay_stats: bill.autopay_stats ?? null,
payments: safePayments,
};
}

View File

@ -180,6 +180,52 @@ function fetchDismissedSuggestions(db, userId, billIds, year, month) {
return new Set(rows.map(row => row.bill_id));
}
function fetchSparklines(db, billIds) {
if (billIds.length === 0) return {};
const ph = billIds.map(() => '?').join(',');
const rows = db.prepare(`
SELECT bill_id, substr(paid_date, 1, 7) AS month_str, SUM(amount) AS total
FROM payments
WHERE bill_id IN (${ph})
AND paid_date >= date('now', '-6 months')
AND deleted_at IS NULL AND accounting_excluded = 0
GROUP BY bill_id, month_str
ORDER BY bill_id, month_str
`).all(...billIds);
const out = {};
for (const r of rows) {
if (!out[r.bill_id]) out[r.bill_id] = [];
out[r.bill_id].push(r.total);
}
return out;
}
function fetchAutopayStats(db, billIds) {
if (billIds.length === 0) return {};
const ph = billIds.map(() => '?').join(',');
const rows = db.prepare(`
SELECT
p.bill_id,
COUNT(*) AS total,
SUM(p.autopay_failure) AS failures,
MAX(CASE WHEN p.autopay_failure = 1 THEN p.paid_date END) AS last_failure_date,
MAX(CASE WHEN p.autopay_failure = 1 THEN p.notes END) AS last_failure_notes
FROM payments p
JOIN bills b ON b.id = p.bill_id
WHERE p.bill_id IN (${ph})
AND b.autopay_enabled = 1
AND p.deleted_at IS NULL
AND p.paid_date >= date('now', '-12 months')
GROUP BY p.bill_id
`).all(...billIds);
return Object.fromEntries(rows.map(r => [r.bill_id, {
total: r.total,
failures: r.failures || 0,
last_failure_date: r.last_failure_date || null,
last_failure_notes: r.last_failure_notes || null,
}]));
}
function rowDueAmount(row) {
const amount = Number(row.actual_amount ?? row.expected_amount);
return Number.isFinite(amount) ? amount : 0;
@ -327,8 +373,12 @@ function getTracker(userId, query = {}, now = new Date()) {
const monthlyStates = fetchMonthlyStates(db, billIds, year, month);
const prevMonthPayments = fetchPreviousMonthPaid(db, billIds, prevMonthRange);
const dismissedSuggestions = fetchDismissedSuggestions(db, userId, billIds, year, month);
const sparklines = fetchSparklines(db, billIds);
const autopayStatsMap = fetchAutopayStats(db, billIds);
const rows = bills.map(bill => {
bill.sparkline = sparklines[bill.id] ?? null;
bill.autopay_stats = autopayStatsMap[bill.id] ?? null;
if (!resolveDueDate(bill, year, month)) return null;
const payments = fetchPaymentsForBillCycle(db, bill, year, month);