feat: auto-match review panel with undo — last 7d provider_sync payments
This commit is contained in:
parent
6e211c8366
commit
b4779c9eda
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="rounded-lg border border-border/60 overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(v => !v)}
|
||||
className="w-full flex items-center justify-between px-4 py-2.5 bg-muted/20 border-b border-border/40 hover:bg-muted/30 transition-colors text-left"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
Auto-matched — review
|
||||
</span>
|
||||
{items.length > 0 && (
|
||||
<span className="text-[10px] font-medium bg-primary/15 text-primary px-1.5 py-0.5 rounded-full">
|
||||
{items.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{open
|
||||
? <ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
: <ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="divide-y divide-border/40">
|
||||
{loading && items.length === 0 ? (
|
||||
<p className="px-4 py-3 text-xs text-muted-foreground italic">Loading…</p>
|
||||
) : items.map(item => (
|
||||
<div key={item.id} className="flex items-center justify-between gap-3 px-4 py-2.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-medium truncate">{payeeLabel(item)}</span>
|
||||
{item.paid_date && (
|
||||
<span className="text-[10px] text-muted-foreground shrink-0">{fmtDate(item.paid_date)}</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground mt-0.5">
|
||||
<span className="font-medium text-foreground">{fmtAmt(item.amount)}</span>
|
||||
{' → '}
|
||||
<span className="truncate">{item.bill_name}</span>
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={undoing === item.id}
|
||||
onClick={() => handleUndo(item)}
|
||||
className={cn(
|
||||
'shrink-0 h-7 text-xs gap-1 text-muted-foreground hover:text-destructive hover:bg-destructive/8',
|
||||
undoing === item.id && 'opacity-50',
|
||||
)}
|
||||
>
|
||||
<Undo2 className="h-3 w-3" />
|
||||
{undoing === item.id ? 'Undoing…' : 'Undo'}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<div className="px-4 py-2 bg-muted/10">
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
Payments auto-created from merchant rules in the last 7 days. Undo removes the payment and restores the bill balance.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 = {} })
|
|||
)}
|
||||
</SectionCard>
|
||||
|
||||
{/* ── Auto-match review panel ── */}
|
||||
{enabled && connections.length > 0 && (
|
||||
<AutoMatchReview refreshKey={autoMatchRefreshKey} />
|
||||
)}
|
||||
|
||||
{/* ── Bank Budget Tracking ── */}
|
||||
{enabled && connections.length > 0 && (
|
||||
<SectionCard
|
||||
|
|
|
|||
|
|
@ -105,6 +105,29 @@ router.get('/', (req, res) => {
|
|||
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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue