From eeb26ccab1bb8be659c96363fe48d425a9f64749 Mon Sep 17 00:00:00 2001 From: null Date: Fri, 29 May 2026 03:02:36 -0500 Subject: [PATCH] feat: manual match/unmatch transactions to bills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - POST /api/matches/confirm — atomic payment creation + transaction match - POST /api/matches/:transactionId/unmatch — soft-delete payment, reset transaction - Account transactions include matched_bill_id and matched_bill_name Frontend: - Unmatched transactions show + match pill button - BillPickerDialog with transaction details + searchable bill list - Confirm creates payment and updates row immediately - Matched transactions show Unlink icon to remove match - Toast on success with bill name and date --- client/api.js | 2 + client/components/data/BankSyncSection.jsx | 185 ++++++++++++++++++++- package.json | 2 +- routes/dataSources.js | 11 +- routes/matches.js | 85 ++++++++++ 5 files changed, 273 insertions(+), 12 deletions(-) diff --git a/client/api.js b/client/api.js index ac89ae8..95ab5c9 100644 --- a/client/api.js +++ b/client/api.js @@ -181,6 +181,8 @@ export const api = { // Subscriptions subscriptions: () => get('/subscriptions'), + confirmTransactionMatch: (transactionId, billId) => post('/matches/confirm', { transaction_id: transactionId, bill_id: billId }), + unmatchTransaction: (transactionId) => post(`/matches/${transactionId}/unmatch`), subscriptionRecommendations: () => get('/subscriptions/recommendations'), updateSubscription: (id, data) => _fetch('PATCH', `/subscriptions/${id}`, data), createSubscriptionFromRecommendation: (data) => post('/subscriptions/recommendations/create', data), diff --git a/client/components/data/BankSyncSection.jsx b/client/components/data/BankSyncSection.jsx index a798470..e55afe7 100644 --- a/client/components/data/BankSyncSection.jsx +++ b/client/components/data/BankSyncSection.jsx @@ -1,8 +1,8 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { toast } from 'sonner'; import { AlertTriangle, Building2, ChevronDown, ChevronRight, - Eye, EyeOff, ExternalLink, Link2Off, Loader2, RefreshCw, + Eye, EyeOff, ExternalLink, Link2Off, Loader2, RefreshCw, Unlink, } from 'lucide-react'; import { api } from '@/api'; import { cn } from '@/lib/utils'; @@ -13,6 +13,9 @@ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, +} from '@/components/ui/dialog'; import { SectionCard } from './dataShared'; function TokenInput({ value, onChange, disabled }) { @@ -71,9 +74,13 @@ function fmtDollars(cents) { return `${sign}$${abs.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; } -function MatchBadge({ status }) { +function MatchBadge({ status, billName }) { if (status === 'matched') { - return matched; + return ( + + {billName || 'matched'} + + ); } if (status === 'ignored') { return ignored; @@ -81,7 +88,77 @@ function MatchBadge({ status }) { return unmatched; } -function AccountRow({ account, sourceId, expanded, onToggleExpand, onToggleMonitored, toggling }) { +function BillPickerDialog({ open, onClose, transaction, bills, onConfirm, busy }) { + const [search, setSearch] = useState(''); + const [selectedId, setSelectedId] = useState(null); + + useEffect(() => { if (open) { setSearch(''); setSelectedId(null); } }, [open]); + + const filtered = useMemo(() => { + const q = search.toLowerCase(); + return (bills || []).filter(b => !q || b.name.toLowerCase().includes(q)); + }, [bills, search]); + + const txDate = transaction?.posted_date || transaction?.transacted_at?.slice(0, 10); + const txLabel = transaction?.payee || transaction?.description || '—'; + const txAmt = transaction ? fmtDollars(transaction.amount) : ''; + + return ( + { if (!v) onClose(); }}> + + + Match to bill +

+ {txLabel} + {txDate && {fmtShortDate(txDate)}} + {txAmt} +

+

+ A payment record will be created for the selected bill using this transaction's amount and date. +

+
+ + setSearch(e.target.value)} + className="text-sm" + /> + +
+ {filtered.length === 0 ? ( +

No bills found.

+ ) : filtered.map(bill => ( + + ))} +
+ + + + + +
+
+ ); +} + +function AccountRow({ account, sourceId, expanded, onToggleExpand, onToggleMonitored, toggling, bills, onMatch, onUnmatch, matchingTxId }) { const txDate = account.transactions?.[0]?.posted_date || account.transactions?.[0]?.transacted_at?.slice(0, 10); return ( @@ -149,7 +226,7 @@ function AccountRow({ account, sourceId, expanded, onToggleExpand, onToggleMonit Date Payee / Description Amount - Status + Bill @@ -168,7 +245,33 @@ function AccountRow({ account, sourceId, expanded, onToggleExpand, onToggleMonit {fmtDollars(tx.amount)} - + {tx.match_status === 'matched' ? ( +
+ + +
+ ) : tx.match_status === 'ignored' ? ( + + ) : ( + + )} ))} @@ -197,6 +300,9 @@ export default function BankSyncSection({ onConnectionChange }) { const [disconnecting, setDisconnecting] = useState(false); const [expandedAccount, setExpandedAccount] = useState(null); const [togglingAccount, setTogglingAccount] = useState(null); + const [bills, setBills] = useState([]); + const [matchTarget, setMatchTarget] = useState(null); // { sourceId, tx } + const [matchingTxId, setMatchingTxId] = useState(null); const loadAccounts = useCallback(async (conns) => { for (const conn of conns) { @@ -234,6 +340,58 @@ export default function BankSyncSection({ onConnectionChange }) { useEffect(() => { load(); }, [load]); + // Load bills once when connections become available (for the match picker) + useEffect(() => { + if (connections.length > 0 && bills.length === 0) { + api.allBills().then(b => setBills(Array.isArray(b) ? b : [])).catch(() => {}); + } + }, [connections, bills.length]); + + function updateTxInState(sourceId, txId, updates) { + setAccountsBySource(prev => ({ + ...prev, + [sourceId]: (prev[sourceId] || []).map(acc => ({ + ...acc, + transactions: acc.transactions.map(tx => tx.id === txId ? { ...tx, ...updates } : tx), + })), + })); + } + + const handleMatch = (sourceId, tx) => setMatchTarget({ sourceId, tx }); + + const handleConfirmMatch = async (billId) => { + if (!matchTarget || !billId) return; + const { sourceId, tx } = matchTarget; + setMatchingTxId(tx.id); + try { + const { transaction } = await api.confirmTransactionMatch(tx.id, billId); + updateTxInState(sourceId, tx.id, { + match_status: transaction.match_status, + matched_bill_id: transaction.matched_bill_id, + matched_bill_name: transaction.matched_bill_name, + }); + setMatchTarget(null); + toast.success(`Matched to "${transaction.matched_bill_name}" — payment recorded for ${fmtShortDate(tx.posted_date || tx.transacted_at?.slice(0, 10))}.`); + } catch (err) { + toast.error(err.message || 'Failed to match transaction.'); + } finally { + setMatchingTxId(null); + } + }; + + const handleUnmatch = async (sourceId, tx) => { + setMatchingTxId(tx.id); + try { + await api.unmatchTransaction(tx.id); + updateTxInState(sourceId, tx.id, { match_status: 'unmatched', matched_bill_id: null, matched_bill_name: null }); + toast.success('Match removed.'); + } catch (err) { + toast.error(err.message || 'Failed to remove match.'); + } finally { + setMatchingTxId(null); + } + }; + const handleConnect = async () => { const token = setupToken.trim(); if (!token) { toast.error('Paste your SimpleFIN setup token first.'); return; } @@ -462,6 +620,10 @@ export default function BankSyncSection({ onConnectionChange }) { onToggleExpand={() => setExpandedAccount(prev => prev === account.id ? null : account.id)} onToggleMonitored={(accountId, monitored) => handleToggleMonitored(conn.id, accountId, monitored)} toggling={togglingAccount === account.id} + bills={bills} + onMatch={tx => handleMatch(conn.id, tx)} + onUnmatch={tx => handleUnmatch(conn.id, tx)} + matchingTxId={matchingTxId} /> )) )} @@ -549,6 +711,15 @@ export default function BankSyncSection({ onConnectionChange }) { + + setMatchTarget(null)} + transaction={matchTarget?.tx} + bills={bills} + onConfirm={handleConfirmMatch} + busy={!!matchingTxId} + /> ); } diff --git a/package.json b/package.json index 15ae147..3fa31ab 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bill-tracker", - "version": "0.33.4", + "version": "0.33.5", "description": "Monthly bill tracking system", "main": "server.js", "scripts": { diff --git a/routes/dataSources.js b/routes/dataSources.js index 23fec47..ba075fc 100644 --- a/routes/dataSources.js +++ b/routes/dataSources.js @@ -116,10 +116,13 @@ router.get('/:sourceId/accounts', (req, res) => { `).all(sourceId, req.user.id); const txStmt = db.prepare(` - SELECT id, posted_date, transacted_at, amount, currency, payee, description, memo, match_status, ignored - FROM transactions - WHERE account_id = ? AND user_id = ? - ORDER BY COALESCE(posted_date, substr(transacted_at, 1, 10), created_at) DESC, id DESC + SELECT t.id, t.posted_date, t.transacted_at, t.amount, t.currency, + t.payee, t.description, t.memo, t.match_status, t.ignored, + t.matched_bill_id, b.name AS matched_bill_name + FROM transactions t + LEFT JOIN bills b ON b.id = t.matched_bill_id AND b.user_id = t.user_id AND b.deleted_at IS NULL + WHERE t.account_id = ? AND t.user_id = ? + ORDER BY COALESCE(t.posted_date, substr(t.transacted_at, 1, 10), t.created_at) DESC, t.id DESC LIMIT 50 `); diff --git a/routes/matches.js b/routes/matches.js index 180a8c6..0427377 100644 --- a/routes/matches.js +++ b/routes/matches.js @@ -1,5 +1,6 @@ const router = require('express').Router(); const { standardizeError } = require('../middleware/errorFormatter'); +const { getDb } = require('../db/database'); const { listMatchSuggestions, rejectMatchSuggestion, @@ -31,4 +32,88 @@ router.post('/:id/reject', (req, res) => { } }); +// POST /api/matches/confirm — link a transaction to a bill and record a payment +router.post('/confirm', (req, res) => { + const txId = parseInt(req.body?.transaction_id, 10); + const billId = parseInt(req.body?.bill_id, 10); + if (!Number.isInteger(txId) || !Number.isInteger(billId)) { + return res.status(400).json(standardizeError('transaction_id and bill_id are required integers', 'VALIDATION_ERROR')); + } + + const db = getDb(); + const tx = db.prepare('SELECT * FROM transactions WHERE id = ? AND user_id = ?').get(txId, req.user.id); + if (!tx) return res.status(404).json(standardizeError('Transaction not found', 'NOT_FOUND', 'transaction_id')); + if (tx.match_status === 'matched') { + return res.status(409).json(standardizeError('Transaction is already matched to a bill', 'ALREADY_MATCHED', 'transaction_id')); + } + + const bill = db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(billId, req.user.id); + if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id')); + + const existing = db.prepare('SELECT id FROM payments WHERE transaction_id = ? AND deleted_at IS NULL').get(txId); + if (existing) return res.status(409).json(standardizeError('A payment is already linked to this transaction', 'DUPLICATE_MATCH')); + + const paidDate = tx.posted_date || (tx.transacted_at ? tx.transacted_at.slice(0, 10) : new Date().toISOString().slice(0, 10)); + const amount = Math.round(Math.abs(tx.amount)) / 100; // cents → dollars + + try { + db.exec('BEGIN'); + const payResult = db.prepare( + "INSERT INTO payments (bill_id, amount, paid_date, payment_source, transaction_id) VALUES (?, ?, ?, 'transaction_match', ?)" + ).run(billId, amount, paidDate, txId); + + db.prepare(` + UPDATE transactions + SET matched_bill_id = ?, match_status = 'matched', updated_at = datetime('now') + WHERE id = ? AND user_id = ? + `).run(billId, txId, req.user.id); + db.exec('COMMIT'); + + const payment = db.prepare('SELECT * FROM payments WHERE id = ?').get(payResult.lastInsertRowid); + const updated = db.prepare(` + SELECT t.*, b.name AS matched_bill_name + FROM transactions t + LEFT JOIN bills b ON b.id = t.matched_bill_id AND b.deleted_at IS NULL + WHERE t.id = ? + `).get(txId); + res.json({ transaction: updated, payment }); + } catch (err) { + try { db.exec('ROLLBACK'); } catch {} + return sendMatchError(res, err, 'Failed to confirm match'); + } +}); + +// POST /api/matches/:transactionId/unmatch — remove a manual match +router.post('/:transactionId/unmatch', (req, res) => { + const txId = parseInt(req.params.transactionId, 10); + if (!Number.isInteger(txId)) { + return res.status(400).json(standardizeError('transactionId must be an integer', 'VALIDATION_ERROR')); + } + + const db = getDb(); + const tx = db.prepare('SELECT * FROM transactions WHERE id = ? AND user_id = ?').get(txId, req.user.id); + if (!tx) return res.status(404).json(standardizeError('Transaction not found', 'NOT_FOUND')); + if (tx.match_status !== 'matched') { + return res.status(409).json(standardizeError('Transaction is not matched', 'NOT_MATCHED')); + } + + try { + db.exec('BEGIN'); + db.prepare(` + UPDATE payments SET deleted_at = datetime('now'), updated_at = datetime('now') + WHERE transaction_id = ? AND payment_source = 'transaction_match' AND deleted_at IS NULL + `).run(txId); + db.prepare(` + UPDATE transactions + SET matched_bill_id = NULL, match_status = 'unmatched', updated_at = datetime('now') + WHERE id = ? AND user_id = ? + `).run(txId, req.user.id); + db.exec('COMMIT'); + res.json({ ok: true }); + } catch (err) { + try { db.exec('ROLLBACK'); } catch {} + return sendMatchError(res, err, 'Failed to unmatch transaction'); + } +}); + module.exports = router;