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();