feat: tracker payment flow and mobile row improvements
This commit is contained in:
parent
3b555e4d8e
commit
e1082145ab
|
|
@ -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`),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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?.();
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ───────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue