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