feat: manual match/unmatch transactions to bills
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
This commit is contained in:
parent
c43c476ae9
commit
eeb26ccab1
|
|
@ -181,6 +181,8 @@ export const api = {
|
||||||
|
|
||||||
// Subscriptions
|
// Subscriptions
|
||||||
subscriptions: () => get('/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'),
|
subscriptionRecommendations: () => get('/subscriptions/recommendations'),
|
||||||
updateSubscription: (id, data) => _fetch('PATCH', `/subscriptions/${id}`, data),
|
updateSubscription: (id, data) => _fetch('PATCH', `/subscriptions/${id}`, data),
|
||||||
createSubscriptionFromRecommendation: (data) => post('/subscriptions/recommendations/create', data),
|
createSubscriptionFromRecommendation: (data) => post('/subscriptions/recommendations/create', data),
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import {
|
import {
|
||||||
AlertTriangle, Building2, ChevronDown, ChevronRight,
|
AlertTriangle, Building2, ChevronDown, ChevronRight,
|
||||||
Eye, EyeOff, ExternalLink, Link2Off, Loader2, RefreshCw,
|
Eye, EyeOff, ExternalLink, Link2Off, Loader2, RefreshCw, Unlink,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { api } from '@/api';
|
import { api } from '@/api';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
@ -13,6 +13,9 @@ import {
|
||||||
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
||||||
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
|
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
|
||||||
} from '@/components/ui/alert-dialog';
|
} from '@/components/ui/alert-dialog';
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
import { SectionCard } from './dataShared';
|
import { SectionCard } from './dataShared';
|
||||||
|
|
||||||
function TokenInput({ value, onChange, disabled }) {
|
function TokenInput({ value, onChange, disabled }) {
|
||||||
|
|
@ -71,9 +74,13 @@ function fmtDollars(cents) {
|
||||||
return `${sign}$${abs.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
return `${sign}$${abs.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function MatchBadge({ status }) {
|
function MatchBadge({ status, billName }) {
|
||||||
if (status === 'matched') {
|
if (status === 'matched') {
|
||||||
return <span className="text-[10px] font-medium text-emerald-600 dark:text-emerald-400 bg-emerald-500/10 px-1.5 py-0.5 rounded-full">matched</span>;
|
return (
|
||||||
|
<span className="text-[10px] font-medium text-emerald-600 dark:text-emerald-400 bg-emerald-500/10 px-1.5 py-0.5 rounded-full max-w-[120px] truncate inline-block" title={billName || 'matched'}>
|
||||||
|
{billName || 'matched'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (status === 'ignored') {
|
if (status === 'ignored') {
|
||||||
return <span className="text-[10px] font-medium text-amber-600 dark:text-amber-400 bg-amber-500/10 px-1.5 py-0.5 rounded-full">ignored</span>;
|
return <span className="text-[10px] font-medium text-amber-600 dark:text-amber-400 bg-amber-500/10 px-1.5 py-0.5 rounded-full">ignored</span>;
|
||||||
|
|
@ -81,7 +88,77 @@ function MatchBadge({ status }) {
|
||||||
return <span className="text-[10px] font-medium text-muted-foreground bg-muted/60 px-1.5 py-0.5 rounded-full">unmatched</span>;
|
return <span className="text-[10px] font-medium text-muted-foreground bg-muted/60 px-1.5 py-0.5 rounded-full">unmatched</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 (
|
||||||
|
<Dialog open={open} onOpenChange={v => { if (!v) onClose(); }}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Match to bill</DialogTitle>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
<span className="font-medium text-foreground">{txLabel}</span>
|
||||||
|
{txDate && <span className="ml-2 text-xs">{fmtShortDate(txDate)}</span>}
|
||||||
|
<span className="ml-2 font-medium text-destructive/80">{txAmt}</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
A payment record will be created for the selected bill using this transaction's amount and date.
|
||||||
|
</p>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
placeholder="Search bills…"
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
className="text-sm"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="max-h-56 overflow-y-auto rounded-lg border border-border/60 divide-y divide-border/40">
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<p className="px-3 py-4 text-sm text-muted-foreground text-center">No bills found.</p>
|
||||||
|
) : filtered.map(bill => (
|
||||||
|
<button
|
||||||
|
key={bill.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedId(bill.id)}
|
||||||
|
className={cn(
|
||||||
|
'w-full flex items-center justify-between px-3 py-2.5 text-left text-sm transition-colors hover:bg-muted/40',
|
||||||
|
selectedId === bill.id && 'bg-primary/10 text-primary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="truncate font-medium">{bill.name}</span>
|
||||||
|
<span className="shrink-0 ml-3 text-xs text-muted-foreground tabular-nums">
|
||||||
|
${(bill.expected_amount ?? 0).toFixed(2)}/mo
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={onClose} disabled={busy}>Cancel</Button>
|
||||||
|
<Button onClick={() => onConfirm(selectedId)} disabled={!selectedId || busy}>
|
||||||
|
{busy ? <><Loader2 className="h-3.5 w-3.5 animate-spin mr-1.5" />Matching…</> : 'Confirm match'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
const txDate = account.transactions?.[0]?.posted_date || account.transactions?.[0]?.transacted_at?.slice(0, 10);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -149,7 +226,7 @@ function AccountRow({ account, sourceId, expanded, onToggleExpand, onToggleMonit
|
||||||
<th className="px-4 py-2 text-left font-medium text-muted-foreground whitespace-nowrap">Date</th>
|
<th className="px-4 py-2 text-left font-medium text-muted-foreground whitespace-nowrap">Date</th>
|
||||||
<th className="px-4 py-2 text-left font-medium text-muted-foreground">Payee / Description</th>
|
<th className="px-4 py-2 text-left font-medium text-muted-foreground">Payee / Description</th>
|
||||||
<th className="px-4 py-2 text-right font-medium text-muted-foreground whitespace-nowrap">Amount</th>
|
<th className="px-4 py-2 text-right font-medium text-muted-foreground whitespace-nowrap">Amount</th>
|
||||||
<th className="px-4 py-2 text-right font-medium text-muted-foreground whitespace-nowrap">Status</th>
|
<th className="px-4 py-2 text-right font-medium text-muted-foreground whitespace-nowrap">Bill</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|
@ -168,7 +245,33 @@ function AccountRow({ account, sourceId, expanded, onToggleExpand, onToggleMonit
|
||||||
{fmtDollars(tx.amount)}
|
{fmtDollars(tx.amount)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-1.5 text-right whitespace-nowrap">
|
<td className="px-4 py-1.5 text-right whitespace-nowrap">
|
||||||
<MatchBadge status={tx.match_status} />
|
{tx.match_status === 'matched' ? (
|
||||||
|
<div className="flex items-center justify-end gap-1.5">
|
||||||
|
<MatchBadge status="matched" billName={tx.matched_bill_name} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Remove match"
|
||||||
|
disabled={matchingTxId === tx.id}
|
||||||
|
onClick={() => onUnmatch(tx)}
|
||||||
|
className="text-muted-foreground hover:text-destructive disabled:opacity-40 transition-colors"
|
||||||
|
>
|
||||||
|
{matchingTxId === tx.id
|
||||||
|
? <Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
: <Unlink className="h-3 w-3" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : tx.match_status === 'ignored' ? (
|
||||||
|
<MatchBadge status="ignored" />
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={matchingTxId === tx.id}
|
||||||
|
onClick={() => onMatch(tx)}
|
||||||
|
className="text-[10px] font-medium text-primary/70 hover:text-primary bg-primary/5 hover:bg-primary/10 px-1.5 py-0.5 rounded-full transition-colors disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{matchingTxId === tx.id ? <Loader2 className="h-3 w-3 animate-spin inline" /> : '+ match'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|
@ -197,6 +300,9 @@ export default function BankSyncSection({ onConnectionChange }) {
|
||||||
const [disconnecting, setDisconnecting] = useState(false);
|
const [disconnecting, setDisconnecting] = useState(false);
|
||||||
const [expandedAccount, setExpandedAccount] = useState(null);
|
const [expandedAccount, setExpandedAccount] = useState(null);
|
||||||
const [togglingAccount, setTogglingAccount] = 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) => {
|
const loadAccounts = useCallback(async (conns) => {
|
||||||
for (const conn of conns) {
|
for (const conn of conns) {
|
||||||
|
|
@ -234,6 +340,58 @@ export default function BankSyncSection({ onConnectionChange }) {
|
||||||
|
|
||||||
useEffect(() => { load(); }, [load]);
|
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 handleConnect = async () => {
|
||||||
const token = setupToken.trim();
|
const token = setupToken.trim();
|
||||||
if (!token) { toast.error('Paste your SimpleFIN setup token first.'); return; }
|
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)}
|
onToggleExpand={() => setExpandedAccount(prev => prev === account.id ? null : account.id)}
|
||||||
onToggleMonitored={(accountId, monitored) => handleToggleMonitored(conn.id, accountId, monitored)}
|
onToggleMonitored={(accountId, monitored) => handleToggleMonitored(conn.id, accountId, monitored)}
|
||||||
toggling={togglingAccount === account.id}
|
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 }) {
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
|
<BillPickerDialog
|
||||||
|
open={!!matchTarget}
|
||||||
|
onClose={() => setMatchTarget(null)}
|
||||||
|
transaction={matchTarget?.tx}
|
||||||
|
bills={bills}
|
||||||
|
onConfirm={handleConfirmMatch}
|
||||||
|
busy={!!matchingTxId}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "bill-tracker",
|
"name": "bill-tracker",
|
||||||
"version": "0.33.4",
|
"version": "0.33.5",
|
||||||
"description": "Monthly bill tracking system",
|
"description": "Monthly bill tracking system",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -116,10 +116,13 @@ router.get('/:sourceId/accounts', (req, res) => {
|
||||||
`).all(sourceId, req.user.id);
|
`).all(sourceId, req.user.id);
|
||||||
|
|
||||||
const txStmt = db.prepare(`
|
const txStmt = db.prepare(`
|
||||||
SELECT id, posted_date, transacted_at, amount, currency, payee, description, memo, match_status, ignored
|
SELECT t.id, t.posted_date, t.transacted_at, t.amount, t.currency,
|
||||||
FROM transactions
|
t.payee, t.description, t.memo, t.match_status, t.ignored,
|
||||||
WHERE account_id = ? AND user_id = ?
|
t.matched_bill_id, b.name AS matched_bill_name
|
||||||
ORDER BY COALESCE(posted_date, substr(transacted_at, 1, 10), created_at) DESC, id DESC
|
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
|
LIMIT 50
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
const router = require('express').Router();
|
const router = require('express').Router();
|
||||||
const { standardizeError } = require('../middleware/errorFormatter');
|
const { standardizeError } = require('../middleware/errorFormatter');
|
||||||
|
const { getDb } = require('../db/database');
|
||||||
const {
|
const {
|
||||||
listMatchSuggestions,
|
listMatchSuggestions,
|
||||||
rejectMatchSuggestion,
|
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;
|
module.exports = router;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue