diff --git a/client/api.js b/client/api.js index 7f1382c..6954bf8 100644 --- a/client/api.js +++ b/client/api.js @@ -366,6 +366,7 @@ export const api = { deleteTransaction: (id) => del(`/transactions/${id}`), matchTransaction: (id, billId) => post(`/transactions/${id}/match`, { billId }), unmatchTransaction: (id) => post(`/transactions/${id}/unmatch`), + unmatchTransactionBulk: (matches) => post('/transactions/unmatch-bulk', { matches }), ignoreTransaction: (id) => post(`/transactions/${id}/ignore`), unignoreTransaction: (id) => post(`/transactions/${id}/unignore`), diff --git a/client/components/BillModal.jsx b/client/components/BillModal.jsx index 53e684b..3bb88a9 100644 --- a/client/components/BillModal.jsx +++ b/client/components/BillModal.jsx @@ -1,12 +1,13 @@ import { useEffect, useState } from 'react'; -import { ChevronDown, Copy, Link2, Link2Off, Loader2, Pencil, Plus, RefreshCw, Trash2 } from 'lucide-react'; +import { ChevronDown, Copy, Layers, Link2, Link2Off, Loader2, Pencil, Plus, RefreshCw, Trash2 } from 'lucide-react'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { - Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, + Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter, } from '@/components/ui/dialog'; +import { Checkbox } from '@/components/ui/checkbox'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, @@ -115,6 +116,18 @@ function isSnowballCat(categories, catId) { return cat ? SNOWBALL_KEYWORDS.some(kw => cat.name.toLowerCase().includes(kw)) : false; } +function normalizePayee(s) { + return (s || '').toLowerCase().replace(/[^a-z0-9]/g, ''); +} + +function isSimilarPayee(a, b) { + const na = normalizePayee(a); + const nb = normalizePayee(b); + const minLen = Math.min(na.length, nb.length); + if (minLen < 3) return false; + return na.startsWith(nb) || nb.startsWith(na); +} + export default function BillModal({ bill, initialBill, categories, onClose, onSave, onDuplicate }) { const isNew = !bill; const sourceBill = bill || initialBill || null; @@ -171,6 +184,12 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa const [paymentMethod, setPaymentMethod] = useState('manual'); const [paymentNotes, setPaymentNotes] = useState(''); + // Unmatch dialog state + const [unmatchTarget, setUnmatchTarget] = useState(null); + const [unmatchConfirmOpen, setUnmatchConfirmOpen] = useState(false); + const [bulkUnmatch, setBulkUnmatch] = useState(null); + const [bulkBusy, setBulkBusy] = useState(false); + const isDebtCategory = isDebtCat(categories, categoryId); const isSnowballCategory = isSnowballCat(categories, categoryId); const showOnSnowball = snowballInclude || (isSnowballCategory && !snowballExempt); @@ -401,12 +420,25 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa } } - async function handleUnmatchTransaction(transaction) { - if (!transaction?.id) return; - setTransactionBusyId(transaction.id); + function openUnmatch(transaction) { + setUnmatchTarget(transaction); + setBulkUnmatch(null); + setUnmatchConfirmOpen(false); + } + + function closeUnmatch() { + setUnmatchTarget(null); + setBulkUnmatch(null); + setUnmatchConfirmOpen(false); + } + + async function handleSingleUnmatch() { + if (!unmatchTarget?.id) return; + setTransactionBusyId(unmatchTarget.id); try { - await api.unmatchTransaction(transaction.id); + await api.unmatchTransaction(unmatchTarget.id); toast.success('Transaction unmatched'); + closeUnmatch(); await Promise.all([loadPayments(), loadLinkedTransactions()]); onSave?.(); } catch (err) { @@ -416,6 +448,62 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa } } + async function handleOpenBulkUnmatch() { + if (!unmatchTarget || !bill?.id) return; + const targetPayee = transactionTitle(unmatchTarget); + const similar = linkedTransactions.filter(tx => + isSimilarPayee(transactionTitle(tx), targetPayee) + ); + if (!similar.find(tx => tx.id === unmatchTarget.id)) { + similar.unshift(unmatchTarget); + } + let rules = []; + try { + const ruleData = await api.billMerchantRules(bill.id); + rules = (ruleData || []).filter(r => isSimilarPayee(r.merchant, targetPayee)); + } catch { + // ignore — rules are optional + } + setBulkUnmatch({ + similar, + rules, + checkedIds: new Set(similar.map(tx => tx.id)), + removeRuleId: null, + }); + } + + async function handleBulkConfirm() { + if (!bulkUnmatch) return; + const { similar, checkedIds, removeRuleId } = bulkUnmatch; + const matches = similar + .filter(tx => checkedIds.has(tx.id) && tx.linked_payment) + .map(tx => ({ + transaction_id: tx.id, + payment_id: tx.linked_payment.id, + payment_source: tx.linked_payment.payment_source, + })); + if (matches.length === 0) { closeUnmatch(); return; } + setBulkBusy(true); + try { + await api.unmatchTransactionBulk(matches); + if (removeRuleId) { + try { + await api.deleteMerchantRule(bill.id, removeRuleId); + } catch { + toast.error('Transactions unmatched, but could not remove merchant rule.'); + } + } + toast.success(`${matches.length} transaction${matches.length !== 1 ? 's' : ''} unmatched`); + closeUnmatch(); + await Promise.all([loadPayments(), loadLinkedTransactions()]); + onSave?.(); + } catch (err) { + toast.error(err.message || 'Could not unmatch transactions.'); + } finally { + setBulkBusy(false); + } + } + async function handleSubmit(e) { e.preventDefault(); @@ -1122,7 +1210,7 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa size="sm" variant="outline" disabled={transactionBusyId === transaction.id} - onClick={() => handleUnmatchTransaction(transaction)} + onClick={() => openUnmatch(transaction)} className="h-8 gap-1.5 text-xs" > @@ -1261,6 +1349,235 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa + + {/* ── Unmatch choice dialog ─────────────────────────────────── */} + { if (!open) closeUnmatch(); }} + > + + + Unmatch transaction + + How would you like to proceed? + + + + {unmatchTarget && ( + + {transactionTitle(unmatchTarget)} + + {transactionDate(unmatchTarget) ? fmtDate(transactionDate(unmatchTarget)) : 'No date'} + {' · '} + {fmtTransactionAmount(unmatchTarget.amount, unmatchTarget.currency)} + + + )} + + + setUnmatchConfirmOpen(true)} + className="flex items-start gap-3 rounded-lg border border-border/60 bg-background/50 p-3 text-left transition-colors hover:border-primary/40 hover:bg-primary/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + > + + + Unmatch this payment only + Remove just this one transaction from the bill. + + + + + + Review all similar matches + + See all transactions with a similar payee name and manage them together. Optionally remove a merchant rule too. + + + + + + + + Cancel + + + + + + {/* ── Single unmatch confirm ────────────────────────────────── */} + { if (!open) setUnmatchConfirmOpen(false); }} + > + + + Unmatch this transaction? + + {unmatchTarget && ( + <> + {transactionTitle(unmatchTarget)} + {' '}will be unlinked from this bill. + {unmatchTarget.linked_payment?.payment_source === 'provider_sync' + ? ' The payment record will be removed and the balance restored.' + : ' The payment record will be removed.'} + > + )} + + + + setUnmatchConfirmOpen(false)}> + Cancel + + + {transactionBusyId ? 'Unmatching…' : 'Confirm Unmatch'} + + + + + + {/* ── Bulk unmatch dialog ───────────────────────────────────── */} + { if (!open) closeUnmatch(); }} + > + + + Review similar matches + {unmatchTarget && ( + + Transactions similar to {transactionTitle(unmatchTarget)}. + Uncheck any you want to keep matched. + + )} + + + {bulkUnmatch && ( + <> + + Quick select: + setBulkUnmatch(p => ({ ...p, checkedIds: new Set(p.similar.map(t => t.id)) }))} + > + All + + setBulkUnmatch(p => ({ ...p, checkedIds: new Set() }))} + > + None + + + {bulkUnmatch.checkedIds.size} of {bulkUnmatch.similar.length} selected + + + + + {bulkUnmatch.similar.map(tx => ( + + { + setBulkUnmatch(p => { + const next = new Set(p.checkedIds); + checked ? next.add(tx.id) : next.delete(tx.id); + return { ...p, checkedIds: next }; + }); + }} + /> + + {transactionTitle(tx)} + + {transactionDate(tx) ? fmtDate(transactionDate(tx)) : 'No date'} + {tx.account_name && ` · ${tx.account_name}`} + + + + {fmtTransactionAmount(tx.amount, tx.currency)} + + + ))} + + + {bulkUnmatch.rules.length > 0 && ( + + + Merchant rules + + {bulkUnmatch.rules.map(rule => ( + + + setBulkUnmatch(p => ({ ...p, removeRuleId: checked ? rule.id : null })) + } + /> + + + Remove rule "{rule.merchant}" + + + Future syncs won't auto-match this pattern to this bill. + + + + ))} + + )} + > + )} + + + + Cancel + + setBulkUnmatch(null)} + className="text-xs" + > + Back + + + {bulkBusy + ? 'Unmatching…' + : `Unmatch ${bulkUnmatch?.checkedIds?.size ?? 0} transaction${bulkUnmatch?.checkedIds?.size !== 1 ? 's' : ''}`} + + + + ); diff --git a/routes/transactions.js b/routes/transactions.js index c0254a3..38345c8 100644 --- a/routes/transactions.js +++ b/routes/transactions.js @@ -514,6 +514,79 @@ router.post('/:id/unmatch', (req, res) => { } }); +// POST /api/transactions/unmatch-bulk +// Body: { matches: [{ transaction_id, payment_id, payment_source }] } +// Handles both provider_sync (full undo: balance restore + delete payment) and +// transaction_match (standard service unmatch) in one call. +router.post('/unmatch-bulk', (req, res) => { + const matches = req.body?.matches; + if (!Array.isArray(matches) || matches.length === 0) { + return res.status(400).json(standardizeError('matches array required', 'VALIDATION_ERROR')); + } + if (matches.length > 50) { + return res.status(400).json(standardizeError('Cannot unmatch more than 50 at once', 'VALIDATION_ERROR')); + } + + const db = getDb(); + const userId = req.user.id; + const results = []; + + db.transaction(() => { + for (const m of matches) { + const txId = parseInt(m.transaction_id, 10); + if (!Number.isInteger(txId) || txId < 1) { + results.push({ transaction_id: m.transaction_id, ok: false, error: 'Invalid transaction_id' }); + continue; + } + try { + if (m.payment_source === 'provider_sync' && m.payment_id) { + // Full reversal: restore balance, soft-delete payment, unlink tx + const payment = db.prepare(` + SELECT p.* FROM payments p + JOIN bills b ON b.id = p.bill_id + WHERE p.id = ? AND p.deleted_at IS NULL AND b.user_id = ? + `).get(m.payment_id, userId); + + if (payment) { + 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(txId, userId); + results.push({ transaction_id: txId, ok: true }); + } else { + // Standard service unmatch (restores balance for transaction_match payments) + unmatchTransaction(userId, String(txId)); + results.push({ transaction_id: txId, ok: true }); + } + } catch (err) { + results.push({ transaction_id: txId, ok: false, error: err.message }); + } + } + })(); + + const failed = results.filter(r => !r.ok); + if (failed.length > 0 && failed.length === results.length) { + return res.status(500).json({ error: 'All unmatches failed', results }); + } + res.json({ results, unmatched: results.filter(r => r.ok).length }); +}); + // POST /api/transactions/:id/ignore router.post('/:id/ignore', (req, res) => { try {
{transactionTitle(unmatchTarget)}
+ {transactionDate(unmatchTarget) ? fmtDate(transactionDate(unmatchTarget)) : 'No date'} + {' · '} + {fmtTransactionAmount(unmatchTarget.amount, unmatchTarget.currency)} +
Unmatch this payment only
Remove just this one transaction from the bill.
Review all similar matches
+ See all transactions with a similar payee name and manage them together. Optionally remove a merchant rule too. +
{transactionTitle(tx)}
+ {transactionDate(tx) ? fmtDate(transactionDate(tx)) : 'No date'} + {tx.account_name && ` · ${tx.account_name}`} +
+ {fmtTransactionAmount(tx.amount, tx.currency)} +
+ Merchant rules +
+ Remove rule "{rule.merchant}" +
+ Future syncs won't auto-match this pattern to this bill. +