diff --git a/client/api.js b/client/api.js index 1441b7e..7f1382c 100644 --- a/client/api.js +++ b/client/api.js @@ -245,6 +245,8 @@ export const api = { updatePayment: (id, data) => put(`/payments/${id}`, data), deletePayment: (id) => del(`/payments/${id}`), restorePayment: (id) => post(`/payments/${id}/restore`), + recentAutoMatched: () => get('/payments/recent-auto'), + undoAutoMatch: (id) => post(`/payments/${id}/undo-auto`), // Snowball snowball: () => get('/snowball'), diff --git a/client/components/data/AutoMatchReview.jsx b/client/components/data/AutoMatchReview.jsx new file mode 100644 index 0000000..b66b56e --- /dev/null +++ b/client/components/data/AutoMatchReview.jsx @@ -0,0 +1,123 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { Undo2, ChevronDown, ChevronRight } from 'lucide-react'; +import { toast } from 'sonner'; +import { api } from '@/api'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; + +function fmtAmt(dollars) { + if (dollars == null) return '—'; + return `$${Number(dollars).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; +} + +function fmtDate(iso) { + if (!iso) return ''; + const d = new Date(`${iso}T00:00:00`); + return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); +} + +function payeeLabel(row) { + return row.payee || row.description || `Transaction #${row.transaction_id}`; +} + +export default function AutoMatchReview({ refreshKey }) { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(false); + const [open, setOpen] = useState(true); + const [undoing, setUndoing] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + try { + const rows = await api.recentAutoMatched(); + setItems(Array.isArray(rows) ? rows : []); + } catch { + // Non-blocking — don't surface errors for a review panel + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { load(); }, [load, refreshKey]); + + async function handleUndo(item) { + setUndoing(item.id); + try { + await api.undoAutoMatch(item.id); + setItems(prev => prev.filter(p => p.id !== item.id)); + toast.success(`Undone — ${payeeLabel(item)} unlinked from "${item.bill_name}"`); + } catch (err) { + toast.error(err.message || 'Failed to undo auto-match'); + } finally { + setUndoing(null); + } + } + + if (!loading && items.length === 0) return null; + + return ( +
+ + + {open && ( +
+ {loading && items.length === 0 ? ( +

Loading…

+ ) : items.map(item => ( +
+
+
+ {payeeLabel(item)} + {item.paid_date && ( + {fmtDate(item.paid_date)} + )} +
+

+ {fmtAmt(item.amount)} + {' → '} + {item.bill_name} +

+
+ +
+ ))} +
+

+ Payments auto-created from merchant rules in the last 7 days. Undo removes the payment and restores the bill balance. +

+
+
+ )} +
+ ); +} diff --git a/client/components/data/BankSyncSection.jsx b/client/components/data/BankSyncSection.jsx index f751433..79b161a 100644 --- a/client/components/data/BankSyncSection.jsx +++ b/client/components/data/BankSyncSection.jsx @@ -19,6 +19,7 @@ import { import { Label } from '@/components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { SectionCard } from './dataShared'; +import AutoMatchReview from './AutoMatchReview'; function TokenInput({ value, onChange, disabled }) { const [show, setShow] = useState(false); @@ -307,6 +308,7 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} }) const [bills, setBills] = useState([]); const [matchTarget, setMatchTarget] = useState(null); // { sourceId, tx } const [matchingTxId, setMatchingTxId] = useState(null); + const [autoMatchRefreshKey, setAutoMatchRefreshKey] = useState(0); // Bank tracking state const [btEnabled, setBtEnabled] = useState(false); @@ -470,6 +472,7 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} }) } else { toast.success(`Synced — ${result.transactionsNew} new transaction(s).`); } + setAutoMatchRefreshKey(k => k + 1); await load(); } catch (err) { toast.error(err.message || 'Sync failed'); @@ -488,6 +491,7 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} }) } else { toast.success(`Backfill complete — ${result.transactionsNew} new transaction(s) pulled from the last ${seedDays} days.`); } + setAutoMatchRefreshKey(k => k + 1); await load(); } catch (err) { toast.error(err.message || 'Backfill failed'); @@ -775,6 +779,11 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} }) )} + {/* ── Auto-match review panel ── */} + {enabled && connections.length > 0 && ( + + )} + {/* ── Bank Budget Tracking ── */} {enabled && connections.length > 0 && ( { res.json(db.prepare(query).all(...params)); }); +// GET /api/payments/recent-auto — provider_sync payments with a linked tx, last 7 days +router.get('/recent-auto', (req, res) => { + const db = getDb(); + const rows = db.prepare(` + SELECT p.id, p.bill_id, p.amount, p.paid_date, p.payment_source, + p.transaction_id, p.balance_delta, p.interest_delta, p.created_at, + b.name AS bill_name, + t.payee, t.description, t.amount AS tx_cents, t.posted_date + FROM payments p + JOIN bills b ON b.id = p.bill_id + LEFT JOIN transactions t ON t.id = p.transaction_id + WHERE b.user_id = ? + AND p.payment_source = 'provider_sync' + AND p.transaction_id IS NOT NULL + AND p.deleted_at IS NULL + AND b.deleted_at IS NULL + AND p.created_at >= datetime('now', '-7 days') + ORDER BY p.created_at DESC + LIMIT 50 + `).all(req.user.id); + res.json(rows); +}); + // GET /api/payments/:id router.get('/:id', (req, res) => { const db = getDb(); @@ -113,6 +136,53 @@ router.get('/:id', (req, res) => { res.json(payment); }); +// POST /api/payments/:id/undo-auto — reverse a provider_sync auto-match +router.post('/:id/undo-auto', (req, res) => { + const db = getDb(); + const payment = db.prepare(` + SELECT p.* FROM payments p + JOIN bills b ON b.id = p.bill_id + WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL + `).get(req.params.id, req.user.id); + + if (!payment) return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id')); + if (payment.payment_source !== 'provider_sync') { + return res.status(409).json(standardizeError('Only provider_sync payments can be undone here', 'NOT_AUTO_MATCH')); + } + if (!payment.transaction_id) { + return res.status(409).json(standardizeError('Payment has no linked transaction', 'NO_TRANSACTION')); + } + + try { + db.transaction(() => { + // Restore balance (same logic as DELETE /:id) + if (payment.balance_delta != null) { + const bill = db.prepare('SELECT current_balance FROM bills WHERE id = ?').get(payment.bill_id); + if (bill?.current_balance != null) { + const restored = Math.max(0, Math.round((Number(bill.current_balance) - Number(payment.balance_delta)) * 100) / 100); + db.prepare(` + UPDATE bills + SET current_balance = ?, + interest_accrued_month = CASE WHEN ? THEN NULL ELSE interest_accrued_month END, + updated_at = datetime('now') + WHERE id = ? + `).run(restored, payment.interest_delta != null ? 1 : 0, payment.bill_id); + } + } + db.prepare("UPDATE payments SET deleted_at = datetime('now') WHERE id = ?").run(payment.id); + db.prepare(` + UPDATE transactions + SET match_status = 'unmatched', matched_bill_id = NULL, updated_at = datetime('now') + WHERE id = ? AND user_id = ? + `).run(payment.transaction_id, req.user.id); + })(); + res.json({ success: true }); + } catch (err) { + console.error('[payments] undo-auto error:', err.message); + res.status(500).json(standardizeError('Failed to undo auto-match', 'SERVER_ERROR')); + } +}); + // POST /api/payments — create single payment router.post('/', (req, res) => { const db = getDb();