chore: version bump to 0.28.01 and update HISTORY format

This commit is contained in:
null 2026-05-16 21:36:04 -05:00
parent 9d933f70cc
commit 060c8dc2f4
21 changed files with 2240 additions and 182 deletions

1
.gitignore vendored
View File

@ -20,3 +20,4 @@ backups/
*.log *.log
simplefin-bank-sync-issue.md simplefin-bank-sync-issue.md
project-wide-data-input-and-sync-issue.md project-wide-data-input-and-sync-issue.md
docs/SIMPLEFIN_CONSUMER_GUARDRAILS.md

View File

@ -1,19 +1,27 @@
# Bill Tracker — Changelog # Bill Tracker — Changelog
## v0.28.1 ## v0.28.01
### Added ### 🏆 Major Features
- **Transaction foundation and CSV import** — New `data_sources`, `financial_accounts`, and `transactions` tables provide the shared data layer for manual, file-import, and provider-sync workflows. CSV transaction import on the Data page offers upload → preview → column mapping → commit with SHA-256 dedupe and import-history logging.
### 🚀 Features
- **Manual bill payment history** — The Bills edit/detail modal now shows a payment history ledger with paid date, amount, method, notes, and payment source. - **Manual bill payment history** — The Bills edit/detail modal now shows a payment history ledger with paid date, amount, method, notes, and payment source.
- **Bills-side payment management** — Users can add, edit, soft-delete, and restore manual payments from the bill modal using the existing payment APIs. - **Bills-side payment management** — Users can add, edit, soft-delete, and restore manual payments from the bill modal using the existing payment APIs.
- **Payment source metadata** — The existing `payments` table now carries `payment_source` and `transaction_id` metadata so manual records can become the canonical base for later import and sync work without adding a new payment table. - **Payment source metadata** — The existing `payments` table now carries `payment_source` and `transaction_id` metadata so manual records can become the canonical base for later import and sync work without adding a new payment table.
- **Transaction CSV import** — The Data page now includes a transaction CSV importer with preview, column mapping, commit counts, duplicate skipping, and import-history logging into the shared `transactions` table. - **Transaction CSV import** — The Data page now includes a transaction CSV importer with preview, column mapping, commit counts, duplicate skipping, and import-history logging into the shared `transactions` table.
### Changed ### 🌟 Enhancements
- **Canonical payment ledger** — Payment responses now include source metadata while preserving existing REAL-dollar payment amounts, partial-payment status derivation, and Tracker payment behavior. - **Canonical payment ledger** — Payment responses now include source metadata while preserving existing REAL-dollar payment amounts, partial-payment status derivation, and Tracker payment behavior.
- **Import controls**`DATA_IMPORT_ENABLED=false` now disables import preview/apply/commit endpoints, and CSV import is available through both `/api/import/csv/*` and `/api/imports/csv/*`. - **Import controls**`DATA_IMPORT_ENABLED=false` now disables import preview/apply/commit endpoints, and CSV import is available through both `/api/import/csv/*` and `/api/imports/csv/*`.
### Release Image
![Doing my part](/img/doingmypart.jpg)
## v0.28.0 ## v0.28.0
### 🏆 Major Features ### 🏆 Major Features

View File

@ -168,6 +168,7 @@ export const api = {
duplicateBill: (id, data) => post(`/bills/${id}/duplicate`, data), duplicateBill: (id, data) => post(`/bills/${id}/duplicate`, data),
togglePaid: (id, data) => post(`/bills/${id}/toggle-paid`, data), togglePaid: (id, data) => post(`/bills/${id}/toggle-paid`, data),
billPayments: (id, p, l) => get(`/bills/${id}/payments?page=${p||1}&limit=${l||20}`), billPayments: (id, p, l) => get(`/bills/${id}/payments?page=${p||1}&limit=${l||20}`),
billTransactions: (id) => get(`/bills/${id}/transactions`),
billMonthlyState: (id, y, m) => get(`/bills/${id}/monthly-state?year=${y}&month=${m}`), billMonthlyState: (id, y, m) => get(`/bills/${id}/monthly-state?year=${y}&month=${m}`),
saveBillMonthlyState: (id, data) => put(`/bills/${id}/monthly-state`, data), saveBillMonthlyState: (id, data) => put(`/bills/${id}/monthly-state`, data),
billHistoryRanges: (id) => get(`/bills/${id}/history-ranges`), billHistoryRanges: (id) => get(`/bills/${id}/history-ranges`),
@ -287,6 +288,20 @@ export const api = {
commitCsvTransactionImport: (data) => post('/import/csv/commit', data), commitCsvTransactionImport: (data) => post('/import/csv/commit', data),
importHistory: () => get('/import/history'), importHistory: () => get('/import/history'),
// Transactions
transactions: (params = {}) => get(`/transactions${queryString(params)}`),
createManualTransaction: (data) => post('/transactions/manual', data),
updateTransaction: (id, data) => put(`/transactions/${id}`, data),
deleteTransaction: (id) => del(`/transactions/${id}`),
matchTransaction: (id, billId) => post(`/transactions/${id}/match`, { billId }),
unmatchTransaction: (id) => post(`/transactions/${id}/unmatch`),
ignoreTransaction: (id) => post(`/transactions/${id}/ignore`),
unignoreTransaction: (id) => post(`/transactions/${id}/unignore`),
// Match suggestions
matchSuggestions: (params = {}) => get(`/matches/suggestions${queryString(params)}`),
rejectMatchSuggestion: (id) => post(`/matches/${encodeURIComponent(id)}/reject`),
// User SQLite import // User SQLite import
previewUserDbImport: async (file) => { previewUserDbImport: async (file) => {
const res = await fetch('/api/import/user-db/preview', { const res = await fetch('/api/import/user-db/preview', {

View File

@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { ChevronDown, Copy, Pencil, Plus, Trash2 } from 'lucide-react'; import { ChevronDown, Copy, Link2, Link2Off, Pencil, Plus, Trash2 } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@ -45,6 +45,28 @@ const PAYMENT_METHODS = [
const DEBT_KEYWORDS = ['credit', 'loan', 'mortgage', 'housing', 'debt']; const DEBT_KEYWORDS = ['credit', 'loan', 'mortgage', 'housing', 'debt'];
const SNOWBALL_KEYWORDS = ['credit', 'loan', 'debt']; const SNOWBALL_KEYWORDS = ['credit', 'loan', 'debt'];
function fmtTransactionAmount(amount, currency = 'USD') {
const cents = Number(amount || 0);
const value = Math.abs(cents) / 100;
const sign = cents < 0 ? '-' : '+';
return `${sign}${new Intl.NumberFormat(undefined, {
style: 'currency',
currency: currency || 'USD',
}).format(value)}`;
}
function transactionDate(tx) {
return tx?.posted_date || String(tx?.transacted_at || '').slice(0, 10) || null;
}
function transactionTitle(tx) {
return tx?.payee || tx?.description || tx?.memo || 'Transaction';
}
function isTransactionLinkedPayment(payment) {
return payment?.payment_source === 'transaction_match' || payment?.transaction_id != null;
}
function isDebtCat(categories, catId) { function isDebtCat(categories, catId) {
if (!catId || catId === CAT_NONE) return false; if (!catId || catId === CAT_NONE) return false;
const cat = categories.find(c => String(c.id) === catId); const cat = categories.find(c => String(c.id) === catId);
@ -93,6 +115,9 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
const [errors, setErrors] = useState({}); const [errors, setErrors] = useState({});
const [payments, setPayments] = useState([]); const [payments, setPayments] = useState([]);
const [paymentsLoading, setPaymentsLoading] = useState(false); const [paymentsLoading, setPaymentsLoading] = useState(false);
const [linkedTransactions, setLinkedTransactions] = useState([]);
const [linkedTransactionsLoading, setLinkedTransactionsLoading] = useState(false);
const [transactionBusyId, setTransactionBusyId] = useState(null);
const [paymentBusy, setPaymentBusy] = useState(false); const [paymentBusy, setPaymentBusy] = useState(false);
const [paymentFormOpen, setPaymentFormOpen] = useState(false); const [paymentFormOpen, setPaymentFormOpen] = useState(false);
const [editingPayment, setEditingPayment] = useState(null); const [editingPayment, setEditingPayment] = useState(null);
@ -120,8 +145,22 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
} }
} }
async function loadLinkedTransactions() {
if (isNew || !bill?.id) return;
setLinkedTransactionsLoading(true);
try {
const data = await api.billTransactions(bill.id);
setLinkedTransactions(data.transactions || []);
} catch (err) {
toast.error(err.message || 'Failed to load linked transactions.');
} finally {
setLinkedTransactionsLoading(false);
}
}
useEffect(() => { useEffect(() => {
loadPayments(); loadPayments();
loadLinkedTransactions();
}, [bill?.id]); }, [bill?.id]);
const validateName = (val) => { const validateName = (val) => {
@ -318,6 +357,21 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
} }
} }
async function handleUnmatchTransaction(transaction) {
if (!transaction?.id) return;
setTransactionBusyId(transaction.id);
try {
await api.unmatchTransaction(transaction.id);
toast.success('Transaction unmatched');
await Promise.all([loadPayments(), loadLinkedTransactions()]);
onSave?.();
} catch (err) {
toast.error(err.message || 'Transaction could not be unmatched.');
} finally {
setTransactionBusyId(null);
}
}
async function handleSubmit(e) { async function handleSubmit(e) {
e.preventDefault(); e.preventDefault();
@ -825,7 +879,9 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
</div> </div>
) : ( ) : (
<div className="max-h-52 space-y-2 overflow-y-auto pr-1"> <div className="max-h-52 space-y-2 overflow-y-auto pr-1">
{payments.map(payment => ( {payments.map(payment => {
const linkedPayment = isTransactionLinkedPayment(payment);
return (
<div key={payment.id} className="flex items-start justify-between gap-3 rounded-lg border border-border/60 bg-background/35 px-3 py-2.5"> <div key={payment.id} className="flex items-start justify-between gap-3 rounded-lg border border-border/60 bg-background/35 px-3 py-2.5">
<div className="min-w-0"> <div className="min-w-0">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
@ -842,17 +898,91 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
)} )}
</div> </div>
<div className="flex shrink-0 gap-1"> <div className="flex shrink-0 gap-1">
{linkedPayment ? (
<span className="inline-flex h-8 items-center gap-1.5 rounded-md border border-primary/20 bg-primary/5 px-2 text-[11px] font-medium text-primary">
<Link2 className="h-3.5 w-3.5" />
Matched
</span>
) : (
<>
<Button type="button" size="icon" variant="ghost" disabled={paymentBusy} onClick={() => startEditPayment(payment)} className="h-8 w-8" aria-label="Edit payment"> <Button type="button" size="icon" variant="ghost" disabled={paymentBusy} onClick={() => startEditPayment(payment)} className="h-8 w-8" aria-label="Edit payment">
<Pencil className="h-3.5 w-3.5" /> <Pencil className="h-3.5 w-3.5" />
</Button> </Button>
<Button type="button" size="icon" variant="ghost" disabled={paymentBusy} onClick={() => setDeletePaymentTarget(payment)} className="h-8 w-8 text-destructive hover:bg-destructive/10 hover:text-destructive" aria-label="Remove payment"> <Button type="button" size="icon" variant="ghost" disabled={paymentBusy} onClick={() => setDeletePaymentTarget(payment)} className="h-8 w-8 text-destructive hover:bg-destructive/10 hover:text-destructive" aria-label="Remove payment">
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
</Button> </Button>
</>
)}
</div>
</div>
);
})}
</div>
)}
<div className="mt-4 rounded-lg border border-border/60 bg-background/35">
<div className="flex items-center justify-between gap-3 border-b border-border/50 px-3 py-2.5">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Linked transactions</p>
<p className="text-[11px] text-muted-foreground/75">{linkedTransactions.length} confirmed matches</p>
</div>
<span className="inline-flex h-7 items-center gap-1.5 rounded-md border border-primary/20 bg-primary/5 px-2 text-[11px] font-medium text-primary">
<Link2 className="h-3.5 w-3.5" />
Matched
</span>
</div>
{linkedTransactionsLoading ? (
<div className="px-3 py-8 text-center text-sm text-muted-foreground">
Loading linked transactions...
</div>
) : linkedTransactions.length === 0 ? (
<div className="flex items-center justify-center gap-2 px-3 py-8 text-sm text-muted-foreground">
<Link2Off className="h-4 w-4" />
No transactions linked to this bill yet.
</div>
) : (
<div className="max-h-56 divide-y divide-border/40 overflow-y-auto">
{linkedTransactions.map(transaction => (
<div key={transaction.id} className="flex items-start justify-between gap-3 px-3 py-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<p className="truncate text-sm font-medium text-foreground">{transactionTitle(transaction)}</p>
<span className="rounded-md border border-border/60 bg-muted/40 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
{transaction.source_label || transaction.source_type_label || 'Transaction'}
</span>
</div>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{transactionDate(transaction) ? fmtDate(transactionDate(transaction)) : 'No date'} · {transaction.description || transaction.memo || 'No description'}
</p>
{transaction.account_name && (
<p className="mt-0.5 truncate text-[11px] text-muted-foreground/75">{transaction.account_name}</p>
)}
</div>
<div className="flex shrink-0 flex-col items-end gap-2 sm:flex-row sm:items-center">
<p className={cn(
'font-mono text-sm font-semibold tabular-nums',
Number(transaction.amount) < 0 ? 'text-destructive' : 'text-emerald-600',
)}>
{fmtTransactionAmount(transaction.amount, transaction.currency)}
</p>
<Button
type="button"
size="sm"
variant="outline"
disabled={transactionBusyId === transaction.id}
onClick={() => handleUnmatchTransaction(transaction)}
className="h-8 gap-1.5 text-xs"
>
<Link2Off className="h-3.5 w-3.5" />
{transactionBusyId === transaction.id ? 'Unmatching...' : 'Unmatch'}
</Button>
</div> </div>
</div> </div>
))} ))}
</div> </div>
)} )}
</div>
{paymentFormOpen && ( {paymentFormOpen && (
<form onSubmit={handlePaymentSubmit} className="mt-3 rounded-lg border border-border/60 bg-muted/20 p-3"> <form onSubmit={handlePaymentSubmit} className="mt-3 rounded-lg border border-border/60 bg-muted/20 p-3">

View File

@ -4,7 +4,8 @@ import {
Upload, FileSpreadsheet, Database, Download, CheckCircle2, XCircle, Upload, FileSpreadsheet, Database, Download, CheckCircle2, XCircle,
AlertTriangle, Loader2, RefreshCw, Clock, ChevronDown, AlertTriangle, Loader2, RefreshCw, Clock, ChevronDown,
ChevronUp, SkipForward, Plus, CheckCheck, Sparkles, ChevronUp, SkipForward, Plus, CheckCheck, Sparkles,
List, Building2, ChevronLeft, FileText, List, Building2, ChevronLeft, FileText, Link2, Link2Off,
EyeOff, Eye, Search,
} from 'lucide-react'; } from 'lucide-react';
import { api } from '@/api'; import { api } from '@/api';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@ -22,6 +23,14 @@ import {
AlertDialogTitle, AlertDialogTitle,
AlertDialogTrigger, AlertDialogTrigger,
} from '@/components/ui/alert-dialog'; } from '@/components/ui/alert-dialog';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
// User export availability flag // User export availability flag
// Flip to true when GET /api/export/user-db and GET /api/export/user-excel exist. // Flip to true when GET /api/export/user-db and GET /api/export/user-excel exist.
@ -328,6 +337,384 @@ function CountPill({ label, value }) {
); );
} }
// Section 2: Transaction Matching
const TRANSACTION_FILTERS = [
{ id: 'open', label: 'Open', params: { ignored: 'false' } },
{ id: 'unmatched', label: 'Unmatched', params: { match_status: 'unmatched', ignored: 'false' } },
{ id: 'matched', label: 'Matched', params: { match_status: 'matched', ignored: 'false' } },
{ id: 'ignored', label: 'Ignored', params: { match_status: 'ignored', ignored: 'true' } },
{ id: 'all', label: 'All', params: { ignored: 'all' } },
];
function transactionStatus(tx) {
if (tx?.ignored) return 'ignored';
return tx?.match_status || 'unmatched';
}
function TransactionStatusBadge({ tx }) {
const status = transactionStatus(tx);
const styles = {
matched: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600',
ignored: 'border-muted-foreground/30 bg-muted/40 text-muted-foreground',
unmatched: 'border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400',
};
return (
<span className={cn(
'inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-semibold uppercase',
styles[status] || styles.unmatched,
)}>
{status}
</span>
);
}
function formatTransactionAmount(amount, currency = 'USD') {
const value = Math.abs(Number(amount || 0)) / 100;
const sign = Number(amount || 0) < 0 ? '-' : '+';
return `${sign}${new Intl.NumberFormat(undefined, {
style: 'currency',
currency: currency || 'USD',
}).format(value)}`;
}
function transactionDate(tx) {
return tx?.posted_date || String(tx?.transacted_at || '').slice(0, 10) || '—';
}
function transactionTitle(tx) {
return tx?.payee || tx?.description || tx?.memo || 'Transaction';
}
function MatchBillDialog({ open, onOpenChange, transaction, bills, onConfirm, loading }) {
const [query, setQuery] = useState('');
const [selectedBillId, setSelectedBillId] = useState('');
useEffect(() => {
if (open) {
setQuery('');
setSelectedBillId(transaction?.matched_bill_id ? String(transaction.matched_bill_id) : '');
}
}, [open, transaction?.id, transaction?.matched_bill_id]);
const filteredBills = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return bills.slice(0, 40);
return bills
.filter(bill => String(bill.name || '').toLowerCase().includes(q))
.slice(0, 40);
}, [bills, query]);
const selectedBill = bills.find(bill => String(bill.id) === String(selectedBillId));
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Match Transaction</DialogTitle>
<DialogDescription>
Choose the bill this transaction paid. Nothing changes until you confirm.
</DialogDescription>
</DialogHeader>
{transaction && (
<div className="rounded-lg border border-border/60 bg-muted/25 p-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="min-w-0">
<p className="truncate text-sm font-medium">{transactionTitle(transaction)}</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{transactionDate(transaction)} · {transaction.source_label || transaction.source_type_label || 'Transaction'}
</p>
</div>
<p className={cn(
'text-sm font-semibold tabular-nums',
Number(transaction.amount) < 0 ? 'text-destructive' : 'text-emerald-600',
)}>
{formatTransactionAmount(transaction.amount, transaction.currency)}
</p>
</div>
{transaction.description && transaction.description !== transactionTitle(transaction) && (
<p className="mt-2 text-xs text-muted-foreground">{transaction.description}</p>
)}
</div>
)}
<div className="space-y-3">
<label className="space-y-1.5">
<span className="text-xs font-medium text-muted-foreground">Find bill</span>
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search bills"
className="pl-8"
/>
</div>
</label>
<div className="max-h-72 overflow-y-auto rounded-lg border border-border/60">
{filteredBills.length === 0 ? (
<p className="px-4 py-8 text-center text-sm text-muted-foreground">No bills found.</p>
) : (
<div className="divide-y divide-border/40">
{filteredBills.map(bill => (
<button
key={bill.id}
type="button"
onClick={() => setSelectedBillId(String(bill.id))}
className={cn(
'flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition-colors hover:bg-muted/30',
String(selectedBillId) === String(bill.id) && 'bg-primary/5',
)}
>
<div className="min-w-0">
<p className="truncate text-sm font-medium">{bill.name}</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Due day {bill.due_day ?? '—'} · expected ${Number(bill.expected_amount || 0).toFixed(2)}
</p>
</div>
{String(selectedBillId) === String(bill.id) && (
<CheckCircle2 className="h-4 w-4 shrink-0 text-primary" />
)}
</button>
))}
</div>
)}
</div>
</div>
<DialogFooter className="gap-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
type="button"
disabled={!selectedBill || loading}
onClick={() => selectedBill && onConfirm(selectedBill.id)}
>
{loading ? <><Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />Matching</> : <><Link2 className="h-3.5 w-3.5 mr-1.5" />Confirm Match</>}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function TransactionMatchingSection({ refreshKey }) {
const [transactions, setTransactions] = useState([]);
const [bills, setBills] = useState([]);
const [filter, setFilter] = useState('open');
const [loading, setLoading] = useState(true);
const [billsLoading, setBillsLoading] = useState(true);
const [actionId, setActionId] = useState(null);
const [matchOpen, setMatchOpen] = useState(false);
const [matchTransaction, setMatchTransaction] = useState(null);
const currentFilter = TRANSACTION_FILTERS.find(item => item.id === filter) || TRANSACTION_FILTERS[0];
const loadTransactions = async () => {
setLoading(true);
try {
const data = await api.transactions({ limit: 100, ...currentFilter.params });
setTransactions(data || []);
} catch (err) {
toast.error(err.message || 'Failed to load transactions.');
setTransactions([]);
} finally {
setLoading(false);
}
};
const loadBills = async () => {
setBillsLoading(true);
try {
const data = await api.bills();
setBills(data || []);
} catch {
setBills([]);
} finally {
setBillsLoading(false);
}
};
useEffect(() => { loadBills(); }, []);
useEffect(() => { loadTransactions(); }, [filter, refreshKey]);
const openMatchDialog = (tx) => {
setMatchTransaction(tx);
setMatchOpen(true);
if (!bills.length && !billsLoading) loadBills();
};
const runTransactionAction = async (tx, action) => {
setActionId(`${action}:${tx.id}`);
try {
if (action === 'unmatch') {
await api.unmatchTransaction(tx.id);
toast.success('Transaction unmatched.');
} else if (action === 'ignore') {
await api.ignoreTransaction(tx.id);
toast.success('Transaction ignored.');
} else if (action === 'unignore') {
await api.unignoreTransaction(tx.id);
toast.success('Transaction restored.');
}
await loadTransactions();
} catch (err) {
toast.error(err.message || 'Transaction action failed.');
} finally {
setActionId(null);
}
};
const confirmMatch = async (billId) => {
if (!matchTransaction) return;
setActionId(`match:${matchTransaction.id}`);
try {
await api.matchTransaction(matchTransaction.id, billId);
toast.success('Transaction matched to bill.');
setMatchOpen(false);
setMatchTransaction(null);
await loadTransactions();
} catch (err) {
toast.error(err.message || 'Transaction match failed.');
} finally {
setActionId(null);
}
};
return (
<SectionCard
title="Transactions"
subtitle="Review imported or manual transactions and confirm matches to bills."
>
<div className="px-6 py-5 space-y-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="flex flex-wrap gap-2">
{TRANSACTION_FILTERS.map(item => (
<button
key={item.id}
type="button"
onClick={() => setFilter(item.id)}
className={cn(
'rounded-full border px-3 py-1.5 text-xs font-medium transition-colors',
filter === item.id
? 'border-primary/40 bg-primary/10 text-primary'
: 'border-border/60 bg-muted/20 text-muted-foreground hover:bg-muted hover:text-foreground',
)}
>
{item.label}
</button>
))}
</div>
<Button size="sm" variant="outline" type="button" onClick={loadTransactions} disabled={loading}>
{loading ? <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5 mr-1.5" />}
Refresh
</Button>
</div>
<div className="overflow-x-auto rounded-lg border border-border/60">
{loading ? (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">Loading transactions</div>
) : transactions.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
No transactions found for this filter.
</div>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border/50 bg-muted/30 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
<th className="px-4 py-2 text-left">Date</th>
<th className="px-4 py-2 text-left">Transaction</th>
<th className="px-4 py-2 text-left">Match</th>
<th className="px-4 py-2 text-right">Amount</th>
<th className="px-4 py-2 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-border/30">
{transactions.map(tx => {
const status = transactionStatus(tx);
const busy = actionId?.endsWith(`:${tx.id}`);
return (
<tr key={tx.id} className="hover:bg-muted/20">
<td className="px-4 py-3 text-xs text-muted-foreground whitespace-nowrap">
{transactionDate(tx)}
</td>
<td className="px-4 py-3 min-w-[240px]">
<div className="min-w-0">
<p className="truncate text-sm font-medium">{transactionTitle(tx)}</p>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{[tx.description, tx.account_name, tx.source_label].filter(Boolean).join(' · ') || '—'}
</p>
</div>
</td>
<td className="px-4 py-3 min-w-[180px]">
<div className="flex flex-col gap-1.5">
<TransactionStatusBadge tx={tx} />
{tx.matched_bill_name ? (
<span className="text-xs text-foreground">{tx.matched_bill_name}</span>
) : (
<span className="text-xs text-muted-foreground">No bill linked</span>
)}
</div>
</td>
<td className={cn(
'px-4 py-3 text-right font-semibold tabular-nums whitespace-nowrap',
Number(tx.amount) < 0 ? 'text-destructive' : 'text-emerald-600',
)}>
{formatTransactionAmount(tx.amount, tx.currency)}
</td>
<td className="px-4 py-3">
<div className="flex justify-end gap-1.5">
{status === 'ignored' ? (
<Button size="sm" variant="outline" type="button" disabled={busy} onClick={() => runTransactionAction(tx, 'unignore')}>
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Eye className="h-3.5 w-3.5" />}
<span className="ml-1.5 hidden xl:inline">Unignore</span>
</Button>
) : (
<>
{status === 'matched' ? (
<Button size="sm" variant="outline" type="button" disabled={busy} onClick={() => runTransactionAction(tx, 'unmatch')}>
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Link2Off className="h-3.5 w-3.5" />}
<span className="ml-1.5 hidden xl:inline">Unmatch</span>
</Button>
) : (
<Button size="sm" type="button" disabled={busy || billsLoading} onClick={() => openMatchDialog(tx)}>
<Link2 className="h-3.5 w-3.5" />
<span className="ml-1.5 hidden xl:inline">Match</span>
</Button>
)}
<Button size="sm" variant="ghost" type="button" disabled={busy} onClick={() => runTransactionAction(tx, 'ignore')}>
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <EyeOff className="h-3.5 w-3.5" />}
<span className="ml-1.5 hidden xl:inline">Ignore</span>
</Button>
</>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
</div>
<MatchBillDialog
open={matchOpen}
onOpenChange={setMatchOpen}
transaction={matchTransaction}
bills={bills}
loading={actionId === `match:${matchTransaction?.id}`}
onConfirm={confirmMatch}
/>
</SectionCard>
);
}
// Section 1: Import Transaction CSV // Section 1: Import Transaction CSV
const CSV_MAPPING_FIELDS = [ const CSV_MAPPING_FIELDS = [
@ -356,34 +743,207 @@ function canCommitCsvMapping(mapping) {
return !!mapping?.posted_date && !!(mapping.amount || mapping.debit_amount || mapping.credit_amount); return !!mapping?.posted_date && !!(mapping.amount || mapping.debit_amount || mapping.credit_amount);
} }
function CsvMappingSelect({ field, label, headers, mapping, onChange }) { const CSV_IMPORT_STEPS = ['Upload', 'Preview', 'Map', 'Commit', 'Results'];
function csvImportStepIndex(preview, mapping, commitState) {
if (commitState.status === 'done') return 4;
if (commitState.status === 'loading') return 3;
if (preview.status === 'ready') return canCommitCsvMapping(mapping) ? 3 : 2;
if (preview.status === 'loading' || preview.status === 'error') return 1;
return 0;
}
function CsvImportStepper({ activeIndex }) {
return (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-5">
{CSV_IMPORT_STEPS.map((step, index) => {
const complete = index < activeIndex;
const active = index === activeIndex;
return (
<div
key={step}
className={cn(
'flex items-center gap-2 rounded-lg border px-3 py-2 text-xs transition-colors',
complete && 'border-emerald-500/30 bg-emerald-500/5 text-emerald-600',
active && 'border-primary/40 bg-primary/5 text-foreground',
!complete && !active && 'border-border/60 bg-muted/20 text-muted-foreground',
)}
>
<span className={cn(
'flex h-5 w-5 shrink-0 items-center justify-center rounded-full border text-[10px] font-semibold tabular-nums',
complete && 'border-emerald-500/40 bg-emerald-500/10 text-emerald-600',
active && 'border-primary/50 bg-primary/10 text-primary',
!complete && !active && 'border-border text-muted-foreground',
)}>
{complete ? <CheckCircle2 className="h-3 w-3" /> : index + 1}
</span>
<span className="truncate font-medium">{step}</span>
</div>
);
})}
</div>
);
}
function csvFieldRequirement(field, mapping) {
if (field === 'posted_date') return 'Required';
if (['amount', 'debit_amount', 'credit_amount'].includes(field)) {
return canCommitCsvMapping({ ...mapping, posted_date: mapping?.posted_date || '__date__' })
? 'Amount source'
: 'One required';
}
return 'Optional';
}
function csvFieldSamples(preview, header) {
if (!header) return [];
const values = [];
for (const row of preview?.sampleRows || []) {
const value = String(row?.[header] || '').trim();
if (value && !values.includes(value)) values.push(value);
if (values.length >= 3) break;
}
return values;
}
function CsvMappingRow({ field, label, preview, mapping, onChange, disabled = false }) {
const headers = preview?.headers || [];
const suggested = preview?.suggestedMapping?.[field] || '';
const current = mapping[field] || ''; const current = mapping[field] || '';
const used = new Set(Object.entries(mapping) const used = new Set(Object.entries(mapping)
.filter(([key, value]) => key !== field && value) .filter(([key, value]) => key !== field && value)
.map(([, value]) => value)); .map(([, value]) => value));
const requirement = csvFieldRequirement(field, mapping);
const missingRequired = (requirement === 'Required' || requirement === 'One required') && !current;
const samples = csvFieldSamples(preview, current);
const suggestedAvailable = suggested && suggested !== current && !used.has(suggested);
return ( return (
<label className="space-y-1.5"> <div className={cn(
<span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground"> 'grid gap-3 border-b border-border/40 px-4 py-3 last:border-b-0 lg:grid-cols-[minmax(150px,0.8fr)_minmax(220px,1fr)_minmax(180px,1fr)]',
{label} missingRequired && 'bg-destructive/5',
{field === 'posted_date' && <span className="text-destructive">*</span>} )}>
{field === 'amount' && !mapping.debit_amount && !mapping.credit_amount && ( <div className="min-w-0">
<span className="text-destructive">*</span> <div className="flex items-center gap-2">
)} <p className="truncate text-sm font-medium">{label}</p>
<span className={cn(
'rounded-full border px-2 py-0.5 text-[10px] font-semibold uppercase',
requirement === 'Required' || requirement === 'One required'
? 'border-destructive/30 bg-destructive/10 text-destructive'
: requirement === 'Amount source'
? 'border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400'
: 'border-border/60 bg-muted/30 text-muted-foreground',
)}>
{requirement}
</span> </span>
</div>
<p className="mt-1 font-mono text-[11px] text-muted-foreground">{field}</p>
</div>
<div className="space-y-1.5">
<select <select
value={current} value={current}
onChange={e => onChange(field, e.target.value)} onChange={e => onChange(field, e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-2 text-sm focus:outline-none focus:ring-1 focus:ring-ring" disabled={disabled}
className={cn(
'h-9 w-full rounded-md border bg-background px-2 text-sm focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-60',
missingRequired ? 'border-destructive/50' : 'border-input',
)}
> >
<option value="">Not mapped</option> <option value="">Not mapped</option>
{headers.map(header => ( {headers.map(header => (
<option key={header} value={header} disabled={used.has(header)}> <option key={header} value={header} disabled={used.has(header)}>
{header} {header}{used.has(header) ? ' (assigned)' : ''}
</option> </option>
))} ))}
</select> </select>
</label> <div className="flex min-h-5 flex-wrap items-center gap-1.5">
{current && current === suggested && (
<span className="text-[11px] font-medium text-emerald-600">Suggested match</span>
)}
{suggestedAvailable && !disabled && (
<button
type="button"
onClick={() => onChange(field, suggested)}
className="rounded-full border border-border/70 px-2 py-0.5 text-[11px] text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
Use {suggested}
</button>
)}
{missingRequired && (
<span className="text-[11px] text-destructive">Needs a column</span>
)}
</div>
</div>
<div className="min-w-0">
{samples.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{samples.map(value => (
<span key={value} className="max-w-full truncate rounded border border-border/50 bg-muted/25 px-2 py-1 text-[11px] text-muted-foreground">
{value}
</span>
))}
</div>
) : (
<p className="text-xs text-muted-foreground">
{current ? 'No sample values' : 'Map a column to preview values'}
</p>
)}
</div>
</div>
);
}
function CsvMappingReview({ preview, fields, mapping, onMappingChange, onUseSuggested, onClearMapping, disabled = false }) {
const mappingFields = CSV_MAPPING_FIELDS.filter(field => fields[field]);
const mappedCount = mappingFields.filter(field => mapping[field]).length;
const hasSuggestedMapping = Object.values(preview?.suggestedMapping || {}).some(Boolean);
const missingRequired = [
!mapping.posted_date ? 'Posted date' : null,
!(mapping.amount || mapping.debit_amount || mapping.credit_amount) ? 'Amount' : null,
].filter(Boolean);
return (
<div className="overflow-hidden rounded-lg border border-border/60">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border/50 bg-muted/25 px-4 py-3">
<div>
<p className="text-sm font-medium">Column mapping</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{mappedCount} of {mappingFields.length} fields mapped
{missingRequired.length > 0 && ` · missing ${missingRequired.join(' and ')}`}
</p>
</div>
<div className="flex gap-2">
<Button size="sm" variant="outline" type="button" onClick={onUseSuggested} disabled={disabled || !hasSuggestedMapping}>
Use Suggested
</Button>
<Button size="sm" variant="ghost" type="button" onClick={onClearMapping} disabled={disabled}>
Clear Mapping
</Button>
</div>
</div>
<div className="hidden border-b border-border/50 bg-muted/10 px-4 py-2 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground lg:grid lg:grid-cols-[minmax(150px,0.8fr)_minmax(220px,1fr)_minmax(180px,1fr)]">
<span>Field</span>
<span>CSV Column</span>
<span>Sample Values</span>
</div>
<div>
{mappingFields.map(field => (
<CsvMappingRow
key={field}
field={field}
label={fields[field]}
preview={preview}
mapping={mapping}
onChange={onMappingChange}
disabled={disabled}
/>
))}
</div>
</div>
); );
} }
@ -429,6 +989,12 @@ function CsvSampleTable({ preview }) {
); );
} }
function formatCsvRowDetail(detail) {
if (!detail) return '';
const field = detail.field ? `${detail.field}: ` : '';
return `${field}${detail.message || detail.value || JSON.stringify(detail)}`;
}
export function ImportTransactionCsvSection({ onHistoryRefresh }) { export function ImportTransactionCsvSection({ onHistoryRefresh }) {
const fileRef = useRef(null); const fileRef = useRef(null);
const [file, setFile] = useState(null); const [file, setFile] = useState(null);
@ -445,6 +1011,7 @@ export function ImportTransactionCsvSection({ onHistoryRefresh }) {
}; };
const handleMappingChange = (field, header) => { const handleMappingChange = (field, header) => {
if (commitState.status === 'done') return;
setMapping(prev => { setMapping(prev => {
const next = { ...prev }; const next = { ...prev };
if (header) next[field] = header; if (header) next[field] = header;
@ -492,15 +1059,27 @@ export function ImportTransactionCsvSection({ onHistoryRefresh }) {
} }
}; };
const applySuggestedMapping = () => {
if (commitState.status === 'done') return;
setMapping(compactMapping(preview.data?.suggestedMapping || {}));
setCommitState({ status: 'idle', result: null, error: null });
};
const clearMapping = () => {
if (commitState.status === 'done') return;
setMapping({});
setCommitState({ status: 'idle', result: null, error: null });
};
const fields = preview.data?.fields || {}; const fields = preview.data?.fields || {};
const mappingFields = CSV_MAPPING_FIELDS.filter(field => fields[field]);
const canCommit = preview.status === 'ready' const canCommit = preview.status === 'ready'
&& preview.data?.import_session_id && preview.data?.import_session_id
&& canCommitCsvMapping(mapping) && canCommitCsvMapping(mapping)
&& commitState.status !== 'loading' && commitState.status !== 'loading'
&& commitState.status !== 'done'; && commitState.status !== 'done';
const failedRows = (commitState.result?.details || []).filter(d => d.result === 'failed').slice(0, 5); const activeStep = csvImportStepIndex(preview, mapping, commitState);
const skippedRows = (commitState.result?.details || []).filter(d => d.result === 'skipped_duplicate').slice(0, 5); const failedRows = (commitState.result?.details || []).filter(d => d.result === 'failed');
const skippedRows = (commitState.result?.details || []).filter(d => d.result === 'skipped_duplicate');
return ( return (
<SectionCard <SectionCard
@ -520,6 +1099,9 @@ export function ImportTransactionCsvSection({ onHistoryRefresh }) {
</div> </div>
</div> </div>
<CsvImportStepper activeIndex={activeStep} />
<div className="rounded-lg border border-border/60 bg-background/50 p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end"> <div className="flex flex-col gap-3 sm:flex-row sm:items-end">
<label className="flex-1 space-y-1.5"> <label className="flex-1 space-y-1.5">
<span className="text-xs font-medium text-muted-foreground">CSV file</span> <span className="text-xs font-medium text-muted-foreground">CSV file</span>
@ -546,6 +1128,7 @@ export function ImportTransactionCsvSection({ onHistoryRefresh }) {
</Button> </Button>
</div> </div>
</div> </div>
</div>
{preview.status === 'error' && ( {preview.status === 'error' && (
<div className="rounded-lg border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive"> <div className="rounded-lg border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
@ -563,11 +1146,18 @@ export function ImportTransactionCsvSection({ onHistoryRefresh }) {
{preview.status === 'ready' && preview.data && ( {preview.status === 'ready' && preview.data && (
<div className="space-y-5"> <div className="space-y-5">
<div className="rounded-lg border border-border bg-muted/30 p-4 space-y-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">CSV Preview</p>
<p className="mt-1 text-sm font-medium">{file?.name || 'Transaction CSV'}</p>
</div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<CountPill label="Rows" value={preview.data.rowCount} /> <CountPill label="Rows" value={preview.data.rowCount} />
<CountPill label="Columns" value={preview.data.headers?.length || 0} /> <CountPill label="Columns" value={preview.data.headers?.length || 0} />
<CountPill label="Issues" value={preview.data.errors?.length || 0} /> <CountPill label="Issues" value={preview.data.errors?.length || 0} />
</div> </div>
</div>
{preview.data.errors?.length > 0 && ( {preview.data.errors?.length > 0 && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-3"> <div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-3">
@ -583,41 +1173,28 @@ export function ImportTransactionCsvSection({ onHistoryRefresh }) {
</div> </div>
)} )}
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<p className="text-sm font-medium">Column mapping</p>
<span className="text-xs text-muted-foreground">
Posted date and one amount mapping are required.
</span>
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{mappingFields.map(field => (
<CsvMappingSelect
key={field}
field={field}
label={fields[field]}
headers={preview.data.headers || []}
mapping={mapping}
onChange={handleMappingChange}
/>
))}
</div>
{!canCommitCsvMapping(mapping) && (
<p className="text-xs text-destructive">
Map a posted date column and either amount, debit amount, or credit amount before importing.
</p>
)}
</div>
<div className="space-y-3">
<p className="text-sm font-medium">Sample rows</p>
<CsvSampleTable preview={preview.data} /> <CsvSampleTable preview={preview.data} />
</div> </div>
<CsvMappingReview
preview={preview.data}
fields={fields}
mapping={mapping}
onMappingChange={handleMappingChange}
onUseSuggested={applySuggestedMapping}
onClearMapping={clearMapping}
disabled={commitState.status === 'done'}
/>
<div className="rounded-lg border border-border/60 bg-muted/25 px-4 py-3 flex items-center justify-between gap-3 flex-wrap"> <div className="rounded-lg border border-border/60 bg-muted/25 px-4 py-3 flex items-center justify-between gap-3 flex-wrap">
<p className="text-xs text-muted-foreground"> <div>
Duplicate rows are skipped using a CSV transaction ID when available, otherwise a stable row hash. <p className="text-sm font-medium">
{canCommitCsvMapping(mapping) ? 'Ready to commit' : 'Mapping incomplete'}
</p> </p>
<p className="mt-0.5 text-xs text-muted-foreground">
Duplicates are skipped using a CSV transaction ID when available, otherwise a stable row hash.
</p>
</div>
{commitState.status === 'done' ? ( {commitState.status === 'done' ? (
<Button size="sm" variant="outline" type="button" onClick={reset}> <Button size="sm" variant="outline" type="button" onClick={reset}>
<Plus className="h-3.5 w-3.5 mr-1.5" />New CSV import <Plus className="h-3.5 w-3.5 mr-1.5" />New CSV import
@ -646,20 +1223,31 @@ export function ImportTransactionCsvSection({ onHistoryRefresh }) {
</div> </div>
{skippedRows.length > 0 && ( {skippedRows.length > 0 && (
<div className="mt-3 text-xs text-muted-foreground"> <div className="mt-3 text-xs text-muted-foreground">
<p className="font-medium text-foreground">Skipped duplicates</p> <p className="font-medium text-foreground">Skipped duplicates ({skippedRows.length})</p>
<ul className="mt-1 space-y-0.5"> <ul className="mt-1 max-h-44 space-y-1 overflow-y-auto rounded-md border border-border/50 bg-background/40 p-2">
{skippedRows.map(row => ( {skippedRows.map(row => (
<li key={row.row}>Row {row.row}: {row.provider_transaction_id}</li> <li key={row.row} className="break-all">
Row {row.row}: {row.provider_transaction_id}
</li>
))} ))}
</ul> </ul>
</div> </div>
)} )}
{failedRows.length > 0 && ( {failedRows.length > 0 && (
<div className="mt-3 text-xs text-destructive"> <div className="mt-3 text-xs text-destructive">
<p className="font-medium">Failed rows</p> <p className="font-medium">Failed rows ({failedRows.length})</p>
<ul className="mt-1 space-y-0.5"> <ul className="mt-1 max-h-64 space-y-2 overflow-y-auto rounded-md border border-destructive/20 bg-background/40 p-2">
{failedRows.map(row => ( {failedRows.map((row, index) => (
<li key={row.row}>Row {row.row}: {row.message}</li> <li key={`${row.row}-${index}`} className="rounded border border-destructive/10 bg-destructive/5 px-2 py-1.5">
<p>Row {row.row}: {row.message}</p>
{row.details?.length > 0 && (
<ul className="mt-1 space-y-0.5 pl-3 text-destructive/90">
{row.details.map((detail, detailIndex) => (
<li key={detailIndex}>{formatCsvRowDetail(detail)}</li>
))}
</ul>
)}
</li>
))} ))}
</ul> </ul>
</div> </div>
@ -2324,6 +2912,7 @@ function SeedDemoDataSection({ onSeeded }) {
export default function DataPage() { export default function DataPage() {
const [history, setHistory] = useState(null); const [history, setHistory] = useState(null);
const [historyLoading, setHistoryLoading] = useState(true); const [historyLoading, setHistoryLoading] = useState(true);
const [transactionRefreshKey, setTransactionRefreshKey] = useState(0);
const loadHistory = async () => { const loadHistory = async () => {
setHistoryLoading(true); setHistoryLoading(true);
@ -2339,6 +2928,11 @@ export default function DataPage() {
useEffect(() => { loadHistory(); }, []); useEffect(() => { loadHistory(); }, []);
const handleTransactionImportComplete = () => {
loadHistory();
setTransactionRefreshKey(key => key + 1);
};
return ( return (
<div className="mx-auto w-full max-w-6xl space-y-5"> <div className="mx-auto w-full max-w-6xl space-y-5">
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between"> <div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
@ -2354,7 +2948,8 @@ export default function DataPage() {
</div> </div>
<div className="space-y-5"> <div className="space-y-5">
<ImportTransactionCsvSection onHistoryRefresh={loadHistory} /> <ImportTransactionCsvSection onHistoryRefresh={handleTransactionImportComplete} />
<TransactionMatchingSection refreshKey={transactionRefreshKey} />
<ImportSpreadsheetSection onHistoryRefresh={loadHistory} /> <ImportSpreadsheetSection onHistoryRefresh={loadHistory} />
<ImportMyDataSection onHistoryRefresh={loadHistory} /> <ImportMyDataSection onHistoryRefresh={loadHistory} />
</div> </div>

View File

@ -984,6 +984,43 @@ function reconcileLegacyMigrations() {
ensureTransactionFoundationSchema(db); ensureTransactionFoundationSchema(db);
console.log('[migration] transaction foundation tables ensured'); console.log('[migration] transaction foundation tables ensured');
} }
},
{
version: 'v0.61',
description: 'payments: one active payment per linked transaction',
check: function() {
return !!db.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_payments_transaction_active'").get();
},
run: function() {
db.exec(`
CREATE UNIQUE INDEX IF NOT EXISTS idx_payments_transaction_active
ON payments(transaction_id)
WHERE transaction_id IS NOT NULL AND deleted_at IS NULL
`);
console.log('[migration] payments: transaction active unique index ensured');
}
},
{
version: 'v0.62',
description: 'matches: rejected transaction match suggestions',
check: function() {
return !!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='match_suggestion_rejections'").get();
},
run: function() {
db.exec(`
CREATE TABLE IF NOT EXISTS match_suggestion_rejections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
transaction_id INTEGER NOT NULL REFERENCES transactions(id) ON DELETE CASCADE,
bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE,
rejected_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(user_id, transaction_id, bill_id)
);
CREATE INDEX IF NOT EXISTS idx_match_suggestion_rejections_user
ON match_suggestion_rejections(user_id, transaction_id, bill_id);
`);
console.log('[migration] match suggestion rejections table ensured');
}
} }
]; ];
@ -1699,6 +1736,39 @@ function runMigrations() {
ensureTransactionFoundationSchema(db); ensureTransactionFoundationSchema(db);
console.log('[migration] transaction foundation tables ensured'); console.log('[migration] transaction foundation tables ensured');
} }
},
{
version: 'v0.61',
description: 'payments: one active payment per linked transaction',
dependsOn: ['v0.60'],
run: function() {
db.exec(`
CREATE UNIQUE INDEX IF NOT EXISTS idx_payments_transaction_active
ON payments(transaction_id)
WHERE transaction_id IS NOT NULL AND deleted_at IS NULL
`);
console.log('[migration] payments: transaction active unique index ensured');
}
},
{
version: 'v0.62',
description: 'matches: rejected transaction match suggestions',
dependsOn: ['v0.61'],
run: function() {
db.exec(`
CREATE TABLE IF NOT EXISTS match_suggestion_rejections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
transaction_id INTEGER NOT NULL REFERENCES transactions(id) ON DELETE CASCADE,
bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE,
rejected_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(user_id, transaction_id, bill_id)
);
CREATE INDEX IF NOT EXISTS idx_match_suggestion_rejections_user
ON match_suggestion_rejections(user_id, transaction_id, bill_id);
`);
console.log('[migration] match suggestion rejections table ensured');
}
} }
]; ];
@ -2111,6 +2181,17 @@ const ROLLBACK_SQL_MAP = {
'DROP TABLE IF EXISTS data_sources', 'DROP TABLE IF EXISTS data_sources',
] ]
}, },
'v0.61': {
description: 'payments: one active payment per linked transaction',
sql: ['DROP INDEX IF EXISTS idx_payments_transaction_active']
},
'v0.62': {
description: 'matches: rejected transaction match suggestions',
sql: [
'DROP INDEX IF EXISTS idx_match_suggestion_rejections_user',
'DROP TABLE IF EXISTS match_suggestion_rejections',
]
},
'v0.51': { 'v0.51': {
description: 'bills: snowball_exempt column', description: 'bills: snowball_exempt column',
sql: ['ALTER TABLE bills DROP COLUMN snowball_exempt'] sql: ['ALTER TABLE bills DROP COLUMN snowball_exempt']

View File

@ -50,6 +50,7 @@ CREATE TABLE IF NOT EXISTS payments (
balance_delta REAL, balance_delta REAL,
payment_source TEXT NOT NULL DEFAULT 'manual', payment_source TEXT NOT NULL DEFAULT 'manual',
transaction_id INTEGER, transaction_id INTEGER,
deleted_at TEXT,
created_at TEXT DEFAULT (datetime('now')), created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')) updated_at TEXT DEFAULT (datetime('now'))
); );
@ -178,6 +179,9 @@ CREATE INDEX IF NOT EXISTS idx_notifications_lookup ON notifications(bill_id, us
CREATE INDEX IF NOT EXISTS idx_bills_active ON bills(active); CREATE INDEX IF NOT EXISTS idx_bills_active ON bills(active);
CREATE INDEX IF NOT EXISTS idx_payments_bill_id ON payments(bill_id); CREATE INDEX IF NOT EXISTS idx_payments_bill_id ON payments(bill_id);
CREATE INDEX IF NOT EXISTS idx_payments_paid_date ON payments(paid_date); CREATE INDEX IF NOT EXISTS idx_payments_paid_date ON payments(paid_date);
CREATE UNIQUE INDEX IF NOT EXISTS idx_payments_transaction_active
ON payments(transaction_id)
WHERE transaction_id IS NOT NULL AND deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id); CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at); CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
CREATE INDEX IF NOT EXISTS idx_data_sources_user_type ON data_sources(user_id, type, status); CREATE INDEX IF NOT EXISTS idx_data_sources_user_type ON data_sources(user_id, type, status);
@ -193,6 +197,18 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_transactions_provider_dedupe
ON transactions (data_source_id, provider_transaction_id) ON transactions (data_source_id, provider_transaction_id)
WHERE provider_transaction_id IS NOT NULL; WHERE provider_transaction_id IS NOT NULL;
CREATE TABLE IF NOT EXISTS match_suggestion_rejections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
transaction_id INTEGER NOT NULL REFERENCES transactions(id) ON DELETE CASCADE,
bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE,
rejected_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(user_id, transaction_id, bill_id)
);
CREATE INDEX IF NOT EXISTS idx_match_suggestion_rejections_user
ON match_suggestion_rejections(user_id, transaction_id, bill_id);
CREATE TABLE IF NOT EXISTS monthly_bill_state ( CREATE TABLE IF NOT EXISTS monthly_bill_state (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE, bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE,

View File

@ -1,8 +1,8 @@
# Engineering Reference Manual — Bill Tracker # Engineering Reference Manual — Bill Tracker
**Status:** Current code reference **Status:** Current code reference
**Last Updated:** 2026-05-10 **Last Updated:** 2026-05-16
**Version:** 0.23.2 **Version:** 0.28.1
**Primary stack:** Node.js + Express, React + Vite, Tailwind CSS + shadcn/ui, Sonner, SQLite via `better-sqlite3` **Primary stack:** Node.js + Express, React + Vite, Tailwind CSS + shadcn/ui, Sonner, SQLite via `better-sqlite3`
This manual reflects the current application code in `server.js`, `routes/`, `services/`, `middleware/`, `db/`, `client/`, `package.json`, `Dockerfile`, and `docker-compose.yml`. It is written as a current-state reference, not a changelog. This manual reflects the current application code in `server.js`, `routes/`, `services/`, `middleware/`, `db/`, `client/`, `package.json`, `Dockerfile`, and `docker-compose.yml`. It is written as a current-state reference, not a changelog.
@ -34,7 +34,7 @@ Runtime flow:
- `server.js` — Express entry point and route mounting. - `server.js` — Express entry point and route mounting.
- `routes/` — HTTP API handlers. - `routes/` — HTTP API handlers.
- `services/` — auth, OIDC, backup, cleanup, notification, import, status, audit business logic. - `services/` — auth, OIDC, backup, cleanup, notification, import, status, audit, transaction, CSV import business logic.
- `middleware/` — auth guards, CSRF, rate limits, security headers, error formatting. - `middleware/` — auth guards, CSRF, rate limits, security headers, error formatting.
- `db/schema.sql` — base SQLite schema. - `db/schema.sql` — base SQLite schema.
- `db/database.js` — DB connection, migrations, defaults, settings, rollback support. - `db/database.js` — DB connection, migrations, defaults, settings, rollback support.
@ -197,15 +197,34 @@ Settings are stored in `settings`; run results are stored as JSON.
- Sanitizes categories, bills, payments, monthly state, and starting amounts. - Sanitizes categories, bills, payments, monthly state, and starting amounts.
- Preview stores an import session; apply maps export IDs to current user-owned IDs. - Preview stores an import session; apply maps export IDs to current user-owned IDs.
### `services/statusService.js` ### `services/transactionService.js`
Shared tracker/calendar logic: Transaction data source and transaction row helpers:
- `resolveDueDate(bill, year, month)` clamps due day to month length. - `ensureManualDataSource(db, userId)` creates/retrieves a user-specific manual data source (`type='manual', provider='manual', name='Manual Entry'`).
- `resolveBucket(bill)` uses bucket or due-day threshold. - `decorateDataSource(row)` removes `encrypted_secret`, adds `source_label` and `source_type_label`.
- `getCycleRange(year, month)` returns first/last day of month. - `decorateTransaction(row)` adds `source_label`, `source_type_label`, and embedded `data_source` object with safe fields.
- `calculateStatus(...)` returns paid/autodraft/upcoming/due/overdue-style status. - `getSourceTypeLabel(type)` returns labels: `manual` → Manual, `file_import` → File import, `provider_sync` → Provider sync.
- `buildTrackerRow(...)` returns row data for the monthly tracker. - `sourceLabel(source)` constructs human-readable source labels for manual entries or provider names.
### `services/csvTransactionImportService.js`
CSV import workflow for transactions:
- Parses CSV with quoted-field support and quote doubling.
- `previewCsvTransactions(userId, buffer, options)` returns headers, sample rows, suggested field mapping, errors, and creates a 24-hour TTL import session (max 25k rows).
- `suggestMapping(headers)` auto-detects field mappings from header names against `posted_date`, `transacted_at`, `amount`, `description`, `payee`, `memo`, `category`, `account`, `transaction_type`, `currency`, etc.
- `commitCsvTransactions(userId, importSessionId, mapping)` imports rows into the `transactions` table with `source_type='file_import'`, auto-creates a CSV data source and financial accounts per unique account name.
- Stable deduplication via SHA-256 hash: `csv:id:` prefix for explicit transaction IDs, `csv:hash:` prefix from date+amount+description+payee+account.
- Imports record to `import_history` with counts and details.
- `FIELD_LABELS` maps field keys to user-friendly labels for validation messages.
### `services/paymentValidation.js`
Payment validation helpers for source tracking and matching:
- Validates `payment_source` values (`manual`, `file_import`, `provider_sync`).
- Supports transaction linking via `transaction_id` when available.
### `services/auditService.js` ### `services/auditService.js`
@ -797,6 +816,13 @@ SQLite uses WAL mode and foreign keys. Base schema is in `db/schema.sql`; `db/da
- `is_seeded INTEGER NOT NULL DEFAULT 0` - `is_seeded INTEGER NOT NULL DEFAULT 0`
- `cycle_type TEXT NOT NULL DEFAULT 'monthly'` - `cycle_type TEXT NOT NULL DEFAULT 'monthly'`
- `cycle_day TEXT` - `cycle_day TEXT`
- `current_balance REAL`
- `minimum_payment REAL`
- `snowball_order INTEGER`
- `snowball_include INTEGER NOT NULL DEFAULT 0`
- `snowball_exempt INTEGER NOT NULL DEFAULT 0`
- `auto_mark_paid INTEGER NOT NULL DEFAULT 0`
- `deleted_at TEXT`
#### `payments` #### `payments`
@ -806,6 +832,9 @@ SQLite uses WAL mode and foreign keys. Base schema is in `db/schema.sql`; `db/da
- `paid_date TEXT NOT NULL` - `paid_date TEXT NOT NULL`
- `method TEXT` - `method TEXT`
- `notes TEXT` - `notes TEXT`
- `balance_delta REAL`
- `payment_source TEXT NOT NULL DEFAULT 'manual'`
- `transaction_id INTEGER`
- `created_at TEXT DEFAULT datetime('now')` - `created_at TEXT DEFAULT datetime('now')`
- `updated_at TEXT DEFAULT datetime('now')` - `updated_at TEXT DEFAULT datetime('now')`
- `deleted_at TEXT` - `deleted_at TEXT`

View File

@ -1,6 +1,6 @@
{ {
"name": "bill-tracker", "name": "bill-tracker",
"version": "0.28.1", "version": "0.28.01",
"description": "Monthly bill tracking system", "description": "Monthly bill tracking system",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {

View File

@ -13,6 +13,7 @@ const {
const { amortizationSchedule, debtAprSnapshot } = require('../services/aprService'); const { amortizationSchedule, debtAprSnapshot } = require('../services/aprService');
const { standardizeError } = require('../middleware/errorFormatter'); const { standardizeError } = require('../middleware/errorFormatter');
const { validatePaymentInput } = require('../services/paymentValidation'); const { validatePaymentInput } = require('../services/paymentValidation');
const { decorateTransaction } = require('../services/transactionService');
// ── GET /api/bills ──────────────────────────────────────────────────────────── // ── GET /api/bills ────────────────────────────────────────────────────────────
router.get('/', (req, res) => { router.get('/', (req, res) => {
@ -395,6 +396,64 @@ router.get('/:id/payments', (req, res) => {
}); });
}); });
// ── GET /api/bills/:id/transactions ──────────────────────────────────────────
router.get('/:id/transactions', (req, res) => {
const db = getDb();
const billId = parseInt(req.params.id, 10);
if (!Number.isInteger(billId)) {
return res.status(400).json(standardizeError('bill_id must be an integer', 'VALIDATION_ERROR', 'bill_id'));
}
const bill = db.prepare('SELECT id, name 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 rows = db.prepare(`
SELECT
t.id, t.user_id, t.data_source_id, t.account_id, t.provider_transaction_id,
t.source_type, t.transaction_type, t.posted_date, t.transacted_at, t.amount,
t.currency, t.description, t.payee, t.memo, t.category, t.matched_bill_id,
t.match_status, t.ignored, t.created_at, t.updated_at,
ds.type AS data_source_type, ds.provider AS data_source_provider,
ds.name AS data_source_name, ds.status AS data_source_status,
fa.name AS account_name, fa.org_name AS account_org_name,
fa.account_type AS account_type,
b.name AS matched_bill_name,
p.id AS linked_payment_id,
p.amount AS linked_payment_amount,
p.paid_date AS linked_payment_date,
p.payment_source AS linked_payment_source,
p.method AS linked_payment_method
FROM transactions t
LEFT JOIN data_sources ds ON ds.id = t.data_source_id AND ds.user_id = t.user_id
LEFT JOIN financial_accounts fa ON fa.id = t.account_id AND fa.user_id = t.user_id
LEFT JOIN bills b ON b.id = t.matched_bill_id AND b.user_id = t.user_id AND b.deleted_at IS NULL
LEFT JOIN payments p ON p.transaction_id = t.id AND p.bill_id = ? AND p.deleted_at IS NULL
WHERE t.user_id = ?
AND t.matched_bill_id = ?
AND t.match_status = 'matched'
AND t.ignored = 0
ORDER BY COALESCE(t.posted_date, substr(t.transacted_at, 1, 10), t.created_at) DESC, t.id DESC
`).all(billId, req.user.id, billId);
const transactions = rows.map(row => decorateTransaction({
...row,
linked_payment: row.linked_payment_id ? {
id: row.linked_payment_id,
amount: row.linked_payment_amount,
paid_date: row.linked_payment_date,
payment_source: row.linked_payment_source,
method: row.linked_payment_method,
} : null,
}));
res.json({
bill_id: billId,
bill_name: bill.name,
total: transactions.length,
transactions,
});
});
// ── POST /api/bills/:id/toggle-paid — toggle Paid/Unpaid status ────────────── // ── POST /api/bills/:id/toggle-paid — toggle Paid/Unpaid status ──────────────
router.post('/:id/toggle-paid', (req, res) => { router.post('/:id/toggle-paid', (req, res) => {
const db = getDb(); const db = getDb();

View File

@ -240,7 +240,7 @@ router.post('/csv/commit', requireDataImportEnabled, express.json({ limit: '1mb'
// ─── GET /api/import/history ────────────────────────────────────────────────── // ─── GET /api/import/history ──────────────────────────────────────────────────
// Returns the authenticated user's import history (last 100 imports). // Returns the authenticated user's import history (last 100 imports).
router.get('/history', (req, res) => { router.get('/history', requireDataImportEnabled, (req, res) => {
try { try {
const history = getImportHistory(req.user.id); const history = getImportHistory(req.user.id);
res.json({ history }); res.json({ history });

34
routes/matches.js Normal file
View File

@ -0,0 +1,34 @@
const router = require('express').Router();
const { standardizeError } = require('../middleware/errorFormatter');
const {
listMatchSuggestions,
rejectMatchSuggestion,
} = require('../services/matchSuggestionService');
function sendMatchError(res, err, fallbackMessage = 'Match operation failed') {
if (err.status) {
return res.status(err.status).json(standardizeError(err.message, err.code || 'MATCH_ERROR', err.field));
}
console.error('[matches] service error:', err.stack || err.message);
return res.status(500).json(standardizeError(fallbackMessage, 'MATCH_ERROR'));
}
// GET /api/matches/suggestions
router.get('/suggestions', (req, res) => {
try {
res.json(listMatchSuggestions(req.user.id, req.query));
} catch (err) {
return sendMatchError(res, err, 'Match suggestions failed');
}
});
// POST /api/matches/:id/reject
router.post('/:id/reject', (req, res) => {
try {
res.json(rejectMatchSuggestion(req.user.id, req.params.id));
} catch (err) {
return sendMatchError(res, err, 'Rejecting match suggestion failed');
}
});
module.exports = router;

View File

@ -7,6 +7,19 @@ const { validatePaymentInput } = require('../services/paymentValidation');
const { getCycleRange, resolveDueDate } = require('../services/statusService'); const { getCycleRange, resolveDueDate } = require('../services/statusService');
const LIVE = 'deleted_at IS NULL'; // filter for non-deleted payments const LIVE = 'deleted_at IS NULL'; // filter for non-deleted payments
const TRANSACTION_MATCH_SOURCE = 'transaction_match';
function isTransactionLinkedPayment(payment) {
return payment?.payment_source === TRANSACTION_MATCH_SOURCE || payment?.transaction_id != null;
}
function rejectTransactionLinkedPayment(res) {
return res.status(409).json(standardizeError(
'Transaction-linked payments must be changed through transaction match controls',
'TRANSACTION_PAYMENT_LOCKED',
'transaction_id',
));
}
function parseYearMonth(body) { function parseYearMonth(body) {
const year = parseInt(body.year, 10); const year = parseInt(body.year, 10);
@ -349,6 +362,7 @@ router.put('/:id', (req, res) => {
const db = getDb(); const db = getDb();
const existing = db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${LIVE} AND b.user_id = ? AND b.deleted_at IS NULL`).get(req.params.id, req.user.id); const existing = db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${LIVE} AND b.user_id = ? AND b.deleted_at IS NULL`).get(req.params.id, req.user.id);
if (!existing) return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id')); if (!existing) return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id'));
if (isTransactionLinkedPayment(existing)) return rejectTransactionLinkedPayment(res);
const { amount, paid_date, method, notes, payment_source } = req.body; const { amount, paid_date, method, notes, payment_source } = req.body;
const validation = validatePaymentInput( const validation = validatePaymentInput(
@ -405,6 +419,7 @@ router.delete('/:id', (req, res) => {
const db = getDb(); 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.${LIVE} AND b.user_id = ? AND b.deleted_at IS NULL`).get(req.params.id, req.user.id); const payment = db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${LIVE} 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) return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id'));
if (isTransactionLinkedPayment(payment)) return rejectTransactionLinkedPayment(res);
// Reverse any balance delta that was stored when this payment was created // Reverse any balance delta that was stored when this payment was created
if (payment.balance_delta != null) { if (payment.balance_delta != null) {
@ -424,6 +439,7 @@ router.post('/:id/restore', (req, res) => {
const db = getDb(); 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.deleted_at IS NOT NULL AND b.user_id = ? AND b.deleted_at IS NULL').get(req.params.id, req.user.id); 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 NOT NULL 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('Deleted payment not found', 'NOT_FOUND', 'id')); if (!payment) return res.status(404).json(standardizeError('Deleted payment not found', 'NOT_FOUND', 'id'));
if (isTransactionLinkedPayment(payment)) return rejectTransactionLinkedPayment(res);
// Re-apply the balance delta (undo the reversal done on delete) // Re-apply the balance delta (undo the reversal done on delete)
if (payment.balance_delta != null) { if (payment.balance_delta != null) {

View File

@ -9,10 +9,22 @@ const { getDb, getSetting } = require('../db/database');
const { hashPassword, invalidateOtherSessions, rotateSessionId, COOKIE_NAME, cookieOpts } = require('../services/authService'); const { hashPassword, invalidateOtherSessions, rotateSessionId, COOKIE_NAME, cookieOpts } = require('../services/authService');
const { getImportHistory } = require('../services/spreadsheetImportService'); const { getImportHistory } = require('../services/spreadsheetImportService');
const { logAudit } = require('../services/auditService'); const { logAudit } = require('../services/auditService');
const { standardizeError } = require('../middleware/errorFormatter');
// All profile routes require authentication — enforced in server.js. // All profile routes require authentication — enforced in server.js.
// req.user is always the signed-in user; user_id is never accepted from the body. // req.user is always the signed-in user; user_id is never accepted from the body.
function dataImportEnabled() {
return String(process.env.DATA_IMPORT_ENABLED ?? 'true').toLowerCase() !== 'false';
}
function requireDataImportEnabled(req, res, next) {
if (!dataImportEnabled()) {
return res.status(403).json(standardizeError('Data import is disabled by DATA_IMPORT_ENABLED=false', 'FORBIDDEN'));
}
next();
}
// ── GET /api/profile ────────────────────────────────────────────────────────── // ── GET /api/profile ──────────────────────────────────────────────────────────
// Returns safe profile data for the signed-in user. // Returns safe profile data for the signed-in user.
// Never returns password_hash, session tokens, or secrets. // Never returns password_hash, session tokens, or secrets.
@ -281,7 +293,7 @@ router.get('/exports', (req, res) => {
// ── GET /api/profile/import-history ────────────────────────────────────────── // ── GET /api/profile/import-history ──────────────────────────────────────────
// Returns the signed-in user's import history. // Returns the signed-in user's import history.
// Delegates to the same service as GET /api/import/history. // Delegates to the same service as GET /api/import/history.
router.get('/import-history', (req, res) => { router.get('/import-history', requireDataImportEnabled, (req, res) => {
try { try {
const history = getImportHistory(req.user.id); const history = getImportHistory(req.user.id);
res.json({ history }); res.json({ history });

View File

@ -6,9 +6,16 @@ const {
ensureManualDataSource, ensureManualDataSource,
getTransactionForUser, getTransactionForUser,
} = require('../services/transactionService'); } = require('../services/transactionService');
const {
ignoreTransaction,
matchTransactionToBill,
unignoreTransaction,
unmatchTransaction,
} = require('../services/transactionMatchService');
const MATCH_STATUSES = new Set(['unmatched', 'matched', 'ignored']); const MATCH_STATUSES = new Set(['unmatched', 'matched', 'ignored']);
const SOURCE_TYPES = new Set(['manual', 'file_import', 'provider_sync']); const SOURCE_TYPES = new Set(['manual', 'file_import', 'provider_sync']);
const MATCH_CONTROL_FIELDS = ['matched_bill_id', 'match_status', 'ignored'];
const TEXT_FIELDS = { const TEXT_FIELDS = {
transaction_type: 64, transaction_type: 64,
currency: 16, currency: 16,
@ -243,6 +250,24 @@ function selectedTransaction(db, userId, id) {
return decorateTransaction(getTransactionForUser(db, userId, id)); return decorateTransaction(getTransactionForUser(db, userId, id));
} }
function rejectDirectMatchState(body = {}) {
const field = MATCH_CONTROL_FIELDS.find(name => hasOwn(body, name));
if (!field) return null;
return standardizeError(
'Use the transaction match, unmatch, ignore, or unignore endpoint to change match state',
'VALIDATION_ERROR',
field,
);
}
function sendTransactionServiceError(res, err, fallbackMessage = 'Transaction operation failed') {
if (err.status) {
return res.status(err.status).json(standardizeError(err.message, err.code || 'TRANSACTION_ERROR', err.field));
}
console.error('[transactions] service error:', err.stack || err.message);
return res.status(500).json(standardizeError(fallbackMessage, 'TRANSACTION_ERROR'));
}
// GET /api/transactions // GET /api/transactions
router.get('/', (req, res) => { router.get('/', (req, res) => {
const db = getDb(); const db = getDb();
@ -346,6 +371,9 @@ router.get('/', (req, res) => {
// POST /api/transactions/manual // POST /api/transactions/manual
router.post('/manual', (req, res) => { router.post('/manual', (req, res) => {
const db = getDb(); const db = getDb();
const directMatchState = rejectDirectMatchState(req.body);
if (directMatchState) return res.status(400).json(directMatchState);
const validation = normalizeTransactionFields(db, req.user.id, req.body); const validation = normalizeTransactionFields(db, req.user.id, req.body);
if (validation.error) return res.status(validation.status || 400).json(validation.error); if (validation.error) return res.status(validation.status || 400).json(validation.error);
const tx = validation.normalized; const tx = validation.normalized;
@ -384,6 +412,9 @@ router.post('/manual', (req, res) => {
// PUT /api/transactions/:id // PUT /api/transactions/:id
router.put('/:id', (req, res) => { router.put('/:id', (req, res) => {
const db = getDb(); const db = getDb();
const directMatchState = rejectDirectMatchState(req.body);
if (directMatchState) return res.status(400).json(directMatchState);
const id = parseInteger(req.params.id, 'id'); const id = parseInteger(req.params.id, 'id');
if (id.error) return res.status(400).json(id.error); if (id.error) return res.status(400).json(id.error);
@ -442,55 +473,55 @@ router.delete('/:id', (req, res) => {
if (!existing) return res.status(404).json(standardizeError('Transaction not found', 'NOT_FOUND', 'id')); if (!existing) return res.status(404).json(standardizeError('Transaction not found', 'NOT_FOUND', 'id'));
db.transaction(() => { db.transaction(() => {
db.prepare(` unmatchTransaction(req.user.id, id.value);
UPDATE payments
SET transaction_id = NULL, updated_at = datetime('now')
WHERE transaction_id = ?
AND bill_id IN (SELECT id FROM bills WHERE user_id = ?)
`).run(id.value, req.user.id);
db.prepare('DELETE FROM transactions WHERE id = ? AND user_id = ?').run(id.value, req.user.id); db.prepare('DELETE FROM transactions WHERE id = ? AND user_id = ?').run(id.value, req.user.id);
})(); })();
res.json({ success: true, deleted: true, id: id.value }); res.json({ success: true, deleted: true, id: id.value });
}); });
// POST /api/transactions/:id/match
router.post('/:id/match', (req, res) => {
try {
const result = matchTransactionToBill(
req.user.id,
req.params.id,
req.body?.billId ?? req.body?.bill_id,
);
res.json(result);
} catch (err) {
return sendTransactionServiceError(res, err, 'Transaction match failed');
}
});
// POST /api/transactions/:id/unmatch
router.post('/:id/unmatch', (req, res) => {
try {
const result = unmatchTransaction(req.user.id, req.params.id);
res.json(result);
} catch (err) {
return sendTransactionServiceError(res, err, 'Transaction unmatch failed');
}
});
// POST /api/transactions/:id/ignore // POST /api/transactions/:id/ignore
router.post('/:id/ignore', (req, res) => { router.post('/:id/ignore', (req, res) => {
const db = getDb(); try {
const id = parseInteger(req.params.id, 'id'); const result = ignoreTransaction(req.user.id, req.params.id);
if (id.error) return res.status(400).json(id.error); res.json(result.transaction);
if (!getTransactionForUser(db, req.user.id, id.value)) { } catch (err) {
return res.status(404).json(standardizeError('Transaction not found', 'NOT_FOUND', 'id')); return sendTransactionServiceError(res, err, 'Transaction ignore failed');
} }
db.prepare(`
UPDATE transactions
SET ignored = 1, match_status = 'ignored', matched_bill_id = NULL, updated_at = datetime('now')
WHERE id = ? AND user_id = ?
`).run(id.value, req.user.id);
res.json(selectedTransaction(db, req.user.id, id.value));
}); });
// POST /api/transactions/:id/unignore // POST /api/transactions/:id/unignore
router.post('/:id/unignore', (req, res) => { router.post('/:id/unignore', (req, res) => {
const db = getDb(); try {
const id = parseInteger(req.params.id, 'id'); const result = unignoreTransaction(req.user.id, req.params.id);
if (id.error) return res.status(400).json(id.error); res.json(result.transaction);
if (!getTransactionForUser(db, req.user.id, id.value)) { } catch (err) {
return res.status(404).json(standardizeError('Transaction not found', 'NOT_FOUND', 'id')); return sendTransactionServiceError(res, err, 'Transaction unignore failed');
} }
db.prepare(`
UPDATE transactions
SET ignored = 0,
match_status = 'unmatched',
matched_bill_id = NULL,
updated_at = datetime('now')
WHERE id = ? AND user_id = ?
`).run(id.value, req.user.id);
res.json(selectedTransaction(db, req.user.id, id.value));
}); });
module.exports = router; module.exports = router;

View File

@ -85,6 +85,7 @@ app.use('/api/bills', csrfMiddleware, requireAuth, requireUser, require(
app.use('/api/payments', csrfMiddleware, requireAuth, requireUser, require('./routes/payments')); app.use('/api/payments', csrfMiddleware, requireAuth, requireUser, require('./routes/payments'));
app.use('/api/data-sources', csrfMiddleware, requireAuth, requireUser, require('./routes/dataSources')); app.use('/api/data-sources', csrfMiddleware, requireAuth, requireUser, require('./routes/dataSources'));
app.use('/api/transactions', csrfMiddleware, requireAuth, requireUser, require('./routes/transactions')); app.use('/api/transactions', csrfMiddleware, requireAuth, requireUser, require('./routes/transactions'));
app.use('/api/matches', csrfMiddleware, requireAuth, requireUser, require('./routes/matches'));
app.use('/api/categories', csrfMiddleware, requireAuth, requireUser, require('./routes/categories')); app.use('/api/categories', csrfMiddleware, requireAuth, requireUser, require('./routes/categories'));
app.use('/api/settings', csrfMiddleware, requireAuth, requireUser, require('./routes/settings')); app.use('/api/settings', csrfMiddleware, requireAuth, requireUser, require('./routes/settings'));
app.use('/api/user', csrfMiddleware, requireAuth, requireUser, require('./routes/user')); app.use('/api/user', csrfMiddleware, requireAuth, requireUser, require('./routes/user'));

View File

@ -0,0 +1,334 @@
'use strict';
const { getDb } = require('../db/database');
const { getCycleRange, resolveDueDate } = require('./statusService');
const { decorateTransaction } = require('./transactionService');
function suggestionError(status, message, code, field = null) {
const err = new Error(message);
err.status = status;
err.code = code;
err.field = field;
return err;
}
function normalizeId(value, field) {
const id = typeof value === 'number' ? value : Number(value);
if (!Number.isSafeInteger(id) || id <= 0) {
throw suggestionError(400, `${field} must be a positive integer`, 'VALIDATION_ERROR', field);
}
return id;
}
function suggestionId(transactionId, billId) {
return `${transactionId}:${billId}`;
}
function parseSuggestionId(id) {
const match = /^(\d+):(\d+)$/.exec(String(id || '').trim());
if (!match) {
throw suggestionError(400, 'Suggestion id must be transactionId:billId', 'VALIDATION_ERROR', 'id');
}
return {
transactionId: normalizeId(match[1], 'transaction_id'),
billId: normalizeId(match[2], 'bill_id'),
};
}
function textKey(value) {
return String(value || '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, ' ')
.trim();
}
function transactionDate(transaction) {
const date = transaction.posted_date || String(transaction.transacted_at || '').slice(0, 10);
return /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : null;
}
function dateParts(date) {
const [year, month] = String(date).split('-').map(Number);
return { year, month };
}
function diffDays(a, b) {
const left = new Date(`${a}T00:00:00Z`).getTime();
const right = new Date(`${b}T00:00:00Z`).getTime();
if (!Number.isFinite(left) || !Number.isFinite(right)) return null;
return Math.abs(Math.round((left - right) / 86400000));
}
function amountDollars(transaction) {
const cents = Number(transaction.amount);
return Number.isFinite(cents) ? Math.abs(cents) / 100 : 0;
}
function addAmountScore(score, reasons, transaction, bill) {
const txAmount = amountDollars(transaction);
const expected = Number(bill.expected_amount) || 0;
if (txAmount <= 0 || expected <= 0) return score;
const delta = Math.abs(txAmount - expected);
const pct = delta / expected;
if (delta <= 0.01) {
reasons.push('amount matches');
return score + 40;
}
if (delta <= 1) {
reasons.push('amount within $1');
return score + 32;
}
if (delta <= 5 || pct <= 0.05) {
reasons.push('amount close to bill');
return score + 22;
}
if (pct <= 0.15) {
reasons.push('amount within 15%');
return score + 12;
}
return score;
}
function addDateScore(score, reasons, transaction, bill) {
const postedDate = transactionDate(transaction);
if (!postedDate) return score;
const { year, month } = dateParts(postedDate);
const dueDate = resolveDueDate(bill, year, month);
if (!dueDate) return score;
const distance = diffDays(postedDate, dueDate);
if (distance === null) return score;
if (distance <= 1) {
reasons.push('date within 1 day');
return score + 25;
}
if (distance <= 3) {
reasons.push(`date within ${distance} days`);
return score + 20;
}
if (distance <= 7) {
reasons.push('date within 7 days');
return score + 12;
}
return score;
}
function addNameScore(score, reasons, transaction, bill) {
const billName = textKey(bill.name);
if (!billName) return score;
const payee = textKey(transaction.payee);
const description = textKey(transaction.description);
const memo = textKey(transaction.memo);
if (payee && (payee.includes(billName) || billName.includes(payee))) {
reasons.push('payee contains bill name');
score += 22;
}
if (description && (description.includes(billName) || billName.includes(description))) {
reasons.push('description contains bill name');
score += 18;
}
if (memo && (memo.includes(billName) || billName.includes(memo))) {
reasons.push('memo contains bill name');
score += 8;
}
return score;
}
function addPriorMatchScore(score, reasons, transaction, bill, priorMatchKeys) {
const payee = textKey(transaction.payee);
const description = textKey(transaction.description);
if (
(payee && priorMatchKeys.has(`${bill.id}:payee:${payee}`)) ||
(description && priorMatchKeys.has(`${bill.id}:description:${description}`))
) {
reasons.push('prior match for this bill');
return score + 12;
}
return score;
}
function hasPaymentInTransactionCycle(db, bill, transaction) {
const postedDate = transactionDate(transaction);
if (!postedDate) return false;
const { year, month } = dateParts(postedDate);
const range = getCycleRange(year, month, bill);
if (!range) return false;
return !!db.prepare(`
SELECT 1
FROM payments
WHERE bill_id = ?
AND paid_date BETWEEN ? AND ?
AND deleted_at IS NULL
LIMIT 1
`).get(bill.id, range.start, range.end);
}
function loadCandidateTransactions(db, userId, transactionId = null) {
const params = [userId];
const where = [
't.user_id = ?',
't.ignored = 0',
"t.match_status = 'unmatched'",
];
if (transactionId) {
where.push('t.id = ?');
params.push(transactionId);
}
return db.prepare(`
SELECT
t.id, t.user_id, t.data_source_id, t.account_id, t.provider_transaction_id,
t.source_type, t.transaction_type, t.posted_date, t.transacted_at, t.amount,
t.currency, t.description, t.payee, t.memo, t.category, t.matched_bill_id,
t.match_status, t.ignored, t.created_at, t.updated_at,
ds.type AS data_source_type, ds.provider AS data_source_provider,
ds.name AS data_source_name, ds.status AS data_source_status,
fa.name AS account_name, fa.org_name AS account_org_name,
fa.account_type AS account_type,
b.name AS matched_bill_name
FROM transactions t
LEFT JOIN data_sources ds ON ds.id = t.data_source_id AND ds.user_id = t.user_id
LEFT JOIN financial_accounts fa ON fa.id = t.account_id AND fa.user_id = t.user_id
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 ${where.join(' AND ')}
ORDER BY COALESCE(t.posted_date, substr(t.transacted_at, 1, 10), t.created_at) DESC, t.id DESC
LIMIT 100
`).all(...params).map(decorateTransaction);
}
function loadBills(db, userId) {
return db.prepare(`
SELECT b.*, c.name AS category_name
FROM bills b
LEFT JOIN categories c ON c.id = b.category_id AND c.deleted_at IS NULL
WHERE b.user_id = ?
AND b.deleted_at IS NULL
AND b.active = 1
ORDER BY b.name COLLATE NOCASE ASC
`).all(userId);
}
function loadRejections(db, userId) {
const rows = db.prepare(`
SELECT transaction_id, bill_id
FROM match_suggestion_rejections
WHERE user_id = ?
`).all(userId);
return new Set(rows.map(row => suggestionId(row.transaction_id, row.bill_id)));
}
function loadPriorMatchKeys(db, userId) {
const rows = db.prepare(`
SELECT matched_bill_id, payee, description
FROM transactions
WHERE user_id = ?
AND matched_bill_id IS NOT NULL
AND match_status = 'matched'
AND ignored = 0
`).all(userId);
const keys = new Set();
for (const row of rows) {
const payee = textKey(row.payee);
const description = textKey(row.description);
if (payee) keys.add(`${row.matched_bill_id}:payee:${payee}`);
if (description) keys.add(`${row.matched_bill_id}:description:${description}`);
}
return keys;
}
function scoreSuggestion(transaction, bill, priorMatchKeys) {
const reasons = [];
let score = 0;
score = addAmountScore(score, reasons, transaction, bill);
score = addDateScore(score, reasons, transaction, bill);
score = addNameScore(score, reasons, transaction, bill);
score = addPriorMatchScore(score, reasons, transaction, bill, priorMatchKeys);
return { score: Math.min(score, 100), reasons };
}
function listMatchSuggestions(userId, options = {}) {
const db = getDb();
const rawTransactionId = options.transactionId ?? options.transaction_id;
const transactionId = rawTransactionId
? normalizeId(rawTransactionId, 'transaction_id')
: null;
const limit = Math.max(1, Math.min(Number.parseInt(options.limit || '50', 10) || 50, 100));
const transactions = loadCandidateTransactions(db, userId, transactionId);
const bills = loadBills(db, userId);
const rejections = loadRejections(db, userId);
const priorMatchKeys = loadPriorMatchKeys(db, userId);
const suggestions = [];
for (const transaction of transactions) {
for (const bill of bills) {
const id = suggestionId(transaction.id, bill.id);
if (rejections.has(id)) continue;
if (hasPaymentInTransactionCycle(db, bill, transaction)) continue;
const scored = scoreSuggestion(transaction, bill, priorMatchKeys);
if (scored.score < 20) continue;
suggestions.push({
id,
transactionId: transaction.id,
billId: bill.id,
score: scored.score,
reasons: scored.reasons,
transaction,
bill: {
id: bill.id,
name: bill.name,
expected_amount: bill.expected_amount,
due_day: bill.due_day,
category_name: bill.category_name || null,
},
});
}
}
return suggestions
.sort((a, b) => b.score - a.score || a.bill.name.localeCompare(b.bill.name))
.slice(0, limit);
}
function rejectMatchSuggestion(userId, id) {
const db = getDb();
const parsed = parseSuggestionId(id);
const transaction = db.prepare('SELECT id FROM transactions WHERE id = ? AND user_id = ?').get(parsed.transactionId, userId);
if (!transaction) {
throw suggestionError(404, 'Transaction not found', 'NOT_FOUND', 'transaction_id');
}
const bill = db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(parsed.billId, userId);
if (!bill) {
throw suggestionError(404, 'Bill not found', 'NOT_FOUND', 'bill_id');
}
db.prepare(`
INSERT INTO match_suggestion_rejections (user_id, transaction_id, bill_id, rejected_at)
VALUES (?, ?, ?, datetime('now'))
ON CONFLICT(user_id, transaction_id, bill_id) DO UPDATE SET
rejected_at = excluded.rejected_at
`).run(userId, parsed.transactionId, parsed.billId);
return {
success: true,
id: suggestionId(parsed.transactionId, parsed.billId),
transactionId: parsed.transactionId,
billId: parsed.billId,
rejected: true,
};
}
module.exports = {
listMatchSuggestions,
parseSuggestionId,
rejectMatchSuggestion,
suggestionId,
};

View File

@ -39,7 +39,7 @@ function validatePositiveAmount(value, field = 'amount') {
return { value: amount }; return { value: amount };
} }
const PAYMENT_SOURCES = ['manual', 'file_import', 'provider_sync']; const PAYMENT_SOURCES = ['manual', 'file_import', 'provider_sync', 'transaction_match'];
function validatePaymentSource(value, field = 'payment_source') { function validatePaymentSource(value, field = 'payment_source') {
if (typeof value !== 'string') { if (typeof value !== 'string') {

View File

@ -0,0 +1,313 @@
'use strict';
const { getDb } = require('../db/database');
const { computeBalanceDelta } = require('./billsService');
const {
decorateTransaction,
getTransactionForUser,
} = require('./transactionService');
const MATCH_PAYMENT_SOURCE = 'transaction_match';
const MATCH_PAYMENT_METHOD = 'transaction_match';
function matchError(status, message, code, field = null) {
const err = new Error(message);
err.status = status;
err.code = code;
err.field = field;
return err;
}
function normalizeId(value, field) {
const id = typeof value === 'number' ? value : Number(value);
if (!Number.isSafeInteger(id) || id <= 0) {
throw matchError(400, `${field} must be a positive integer`, 'VALIDATION_ERROR', field);
}
return id;
}
function getOwnedTransaction(db, userId, transactionId) {
const id = normalizeId(transactionId, 'transaction_id');
const transaction = getTransactionForUser(db, userId, id);
if (!transaction) {
throw matchError(404, 'Transaction not found', 'NOT_FOUND', 'transaction_id');
}
return transaction;
}
function getOwnedBill(db, userId, billId) {
const id = normalizeId(billId, 'bill_id');
const bill = db.prepare(`
SELECT *
FROM bills
WHERE id = ? AND user_id = ? AND deleted_at IS NULL
`).get(id, userId);
if (!bill) {
throw matchError(404, 'Bill not found', 'NOT_FOUND', 'bill_id');
}
return bill;
}
function paymentDateForTransaction(transaction) {
const date = transaction.posted_date || String(transaction.transacted_at || '').slice(0, 10);
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
throw matchError(
400,
'Transaction must have a posted date before it can be matched to a bill',
'VALIDATION_ERROR',
'posted_date',
);
}
return date;
}
function paymentAmountForTransaction(transaction) {
const cents = Number(transaction.amount);
if (!Number.isSafeInteger(cents) || cents === 0) {
throw matchError(
400,
'Transaction amount must be a non-zero integer number of cents',
'VALIDATION_ERROR',
'amount',
);
}
return Math.round(Math.abs(cents)) / 100;
}
function getActivePaymentForTransaction(db, userId, transactionId) {
return db.prepare(`
SELECT p.*
FROM payments p
JOIN bills b ON b.id = p.bill_id
WHERE p.transaction_id = ?
AND p.deleted_at IS NULL
AND b.user_id = ?
AND b.deleted_at IS NULL
ORDER BY p.id ASC
LIMIT 1
`).get(transactionId, userId);
}
function getPaymentForResponse(db, userId, paymentId) {
if (!paymentId) return null;
return db.prepare(`
SELECT p.*
FROM payments p
JOIN bills b ON b.id = p.bill_id
WHERE p.id = ?
AND b.user_id = ?
`).get(paymentId, userId) || null;
}
function restorePaymentBalance(db, payment) {
if (!payment || payment.balance_delta == null) return;
const bill = db.prepare('SELECT id, current_balance FROM bills WHERE id = ?').get(payment.bill_id);
if (bill?.current_balance == null) return;
const restored = Math.max(0, Math.round((Number(bill.current_balance) - Number(payment.balance_delta)) * 100) / 100);
db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?")
.run(restored, bill.id);
}
function applyPaymentBalance(db, bill, amount) {
const freshBill = db.prepare('SELECT * FROM bills WHERE id = ?').get(bill.id) || bill;
const balCalc = computeBalanceDelta(freshBill, amount);
if (balCalc) {
db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?")
.run(balCalc.new_balance, bill.id);
}
return balCalc?.balance_delta ?? null;
}
function buildMatchPaymentNotes(transaction, bill) {
const label = transaction.payee || transaction.description || `transaction ${transaction.id}`;
return `Matched transaction to ${bill.name}: ${label}`.slice(0, 500);
}
function createOrUpdateMatchPayment(db, userId, transaction, bill) {
const amount = paymentAmountForTransaction(transaction);
const paidDate = paymentDateForTransaction(transaction);
const notes = buildMatchPaymentNotes(transaction, bill);
const existingPayment = getActivePaymentForTransaction(db, userId, transaction.id);
if (existingPayment && existingPayment.payment_source !== MATCH_PAYMENT_SOURCE) {
throw matchError(
409,
'Transaction is already linked to a non-matching payment. Unlink that payment before matching this transaction.',
'TRANSACTION_PAYMENT_ALREADY_LINKED',
'transaction_id',
);
}
if (existingPayment) {
restorePaymentBalance(db, existingPayment);
const balanceDelta = applyPaymentBalance(db, bill, amount);
db.prepare(`
UPDATE payments
SET bill_id = ?,
amount = ?,
paid_date = ?,
method = ?,
notes = ?,
balance_delta = ?,
payment_source = ?,
updated_at = datetime('now')
WHERE id = ?
`).run(
bill.id,
amount,
paidDate,
MATCH_PAYMENT_METHOD,
notes,
balanceDelta,
MATCH_PAYMENT_SOURCE,
existingPayment.id,
);
return existingPayment.id;
}
const balanceDelta = applyPaymentBalance(db, bill, amount);
const result = db.prepare(`
INSERT INTO payments
(bill_id, amount, paid_date, method, notes, balance_delta, payment_source, transaction_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(
bill.id,
amount,
paidDate,
MATCH_PAYMENT_METHOD,
notes,
balanceDelta,
MATCH_PAYMENT_SOURCE,
transaction.id,
);
return result.lastInsertRowid;
}
function unlinkPaymentForTransaction(db, userId, transactionId) {
const existingPayment = getActivePaymentForTransaction(db, userId, transactionId);
if (!existingPayment) return null;
if (existingPayment.payment_source === MATCH_PAYMENT_SOURCE) {
restorePaymentBalance(db, existingPayment);
db.prepare(`
UPDATE payments
SET deleted_at = datetime('now'), updated_at = datetime('now')
WHERE id = ?
`).run(existingPayment.id);
return { ...existingPayment, deleted: true };
}
db.prepare(`
UPDATE payments
SET transaction_id = NULL, updated_at = datetime('now')
WHERE id = ?
`).run(existingPayment.id);
return { ...existingPayment, unlinked: true };
}
function responseForTransaction(db, userId, transactionId, paymentId = null, extra = {}) {
return {
success: true,
transaction: decorateTransaction(getTransactionForUser(db, userId, transactionId)),
payment: getPaymentForResponse(db, userId, paymentId),
...extra,
};
}
function matchTransactionToBill(userId, transactionId, billId) {
const db = getDb();
const tx = db.transaction(() => {
const transaction = getOwnedTransaction(db, userId, transactionId);
if (transaction.ignored || transaction.match_status === 'ignored') {
throw matchError(400, 'Ignored transactions must be unignored before matching', 'TRANSACTION_IGNORED', 'transaction_id');
}
const bill = getOwnedBill(db, userId, billId);
const paymentId = createOrUpdateMatchPayment(db, userId, transaction, bill);
db.prepare(`
UPDATE transactions
SET matched_bill_id = ?,
match_status = 'matched',
ignored = 0,
updated_at = datetime('now')
WHERE id = ? AND user_id = ?
`).run(bill.id, transaction.id, userId);
return responseForTransaction(db, userId, transaction.id, paymentId);
});
return tx();
}
function unmatchTransaction(userId, transactionId) {
const db = getDb();
const tx = db.transaction(() => {
const transaction = getOwnedTransaction(db, userId, transactionId);
const removedPayment = unlinkPaymentForTransaction(db, userId, transaction.id);
db.prepare(`
UPDATE transactions
SET matched_bill_id = NULL,
match_status = 'unmatched',
ignored = 0,
updated_at = datetime('now')
WHERE id = ? AND user_id = ?
`).run(transaction.id, userId);
return responseForTransaction(db, userId, transaction.id, null, { removed_payment: removedPayment });
});
return tx();
}
function ignoreTransaction(userId, transactionId) {
const db = getDb();
const tx = db.transaction(() => {
const transaction = getOwnedTransaction(db, userId, transactionId);
const removedPayment = unlinkPaymentForTransaction(db, userId, transaction.id);
db.prepare(`
UPDATE transactions
SET ignored = 1,
match_status = 'ignored',
matched_bill_id = NULL,
updated_at = datetime('now')
WHERE id = ? AND user_id = ?
`).run(transaction.id, userId);
return responseForTransaction(db, userId, transaction.id, null, { removed_payment: removedPayment });
});
return tx();
}
function unignoreTransaction(userId, transactionId) {
const db = getDb();
const tx = db.transaction(() => {
const transaction = getOwnedTransaction(db, userId, transactionId);
db.prepare(`
UPDATE transactions
SET ignored = 0,
match_status = 'unmatched',
matched_bill_id = NULL,
updated_at = datetime('now')
WHERE id = ? AND user_id = ?
`).run(transaction.id, userId);
return responseForTransaction(db, userId, transaction.id);
});
return tx();
}
module.exports = {
MATCH_PAYMENT_METHOD,
MATCH_PAYMENT_SOURCE,
ignoreTransaction,
matchTransactionToBill,
unignoreTransaction,
unmatchTransaction,
};

View File

@ -12,7 +12,7 @@ const SESSION_TTL_HOURS = 24;
const REQUIRED_TABLES = ['export_metadata', 'categories', 'bills', 'payments', 'monthly_bill_state']; const REQUIRED_TABLES = ['export_metadata', 'categories', 'bills', 'payments', 'monthly_bill_state'];
const VALID_BILLING_CYCLES = new Set(['monthly', 'quarterly', 'annually', 'irregular']); const VALID_BILLING_CYCLES = new Set(['monthly', 'quarterly', 'annually', 'irregular']);
const VALID_AUTODRAFT = new Set(['none', 'pending', 'assumed_paid', 'confirmed']); const VALID_AUTODRAFT = new Set(['none', 'pending', 'assumed_paid', 'confirmed']);
const VALID_PAYMENT_SOURCES = new Set(['manual', 'file_import', 'provider_sync']); const VALID_PAYMENT_SOURCES = new Set(['manual', 'file_import', 'provider_sync', 'transaction_match']);
function importError(status, message, code, details = []) { function importError(status, message, code, details = []) {
const err = new Error(message); const err = new Error(message);

View File

@ -0,0 +1,383 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const dbPath = path.join(os.tmpdir(), `bill-tracker-transaction-match-test-${process.pid}.sqlite`);
process.env.DB_PATH = dbPath;
const { getDb, closeDb } = require('../db/database');
const { ensureManualDataSource } = require('../services/transactionService');
const { getTracker } = require('../services/trackerService');
const {
listMatchSuggestions,
rejectMatchSuggestion,
suggestionId,
} = require('../services/matchSuggestionService');
const {
ignoreTransaction,
matchTransactionToBill,
unignoreTransaction,
unmatchTransaction,
} = require('../services/transactionMatchService');
function createUser(db, suffix) {
return db.prepare(`
INSERT INTO users (username, password_hash, role, active, email, created_at, updated_at)
VALUES (?, 'x', 'user', 1, ?, datetime('now'), datetime('now'))
`).run(`match-user-${suffix}`, `match-user-${suffix}@local`).lastInsertRowid;
}
function createBill(db, userId, name = 'City Water') {
return db.prepare(`
INSERT INTO bills (user_id, name, due_day, expected_amount)
VALUES (?, ?, 16, 85)
`).run(userId, name).lastInsertRowid;
}
function createTransaction(db, userId, overrides = {}) {
const source = ensureManualDataSource(db, userId);
return db.prepare(`
INSERT INTO transactions
(user_id, data_source_id, source_type, posted_date, amount, currency,
description, payee, match_status, ignored)
VALUES (?, ?, 'manual', ?, ?, 'USD', ?, ?, 'unmatched', 0)
`).run(
userId,
source.id,
overrides.posted_date || '2026-05-16',
overrides.amount ?? -8500,
overrides.description || 'Water bill payment',
overrides.payee || 'City Water',
).lastInsertRowid;
}
function activePaymentsForTransaction(db, transactionId) {
return db.prepare(`
SELECT *
FROM payments
WHERE transaction_id = ? AND deleted_at IS NULL
ORDER BY id
`).all(transactionId);
}
function createManualPayment(db, billId, overrides = {}) {
return db.prepare(`
INSERT INTO payments (bill_id, amount, paid_date, method, payment_source, notes)
VALUES (?, ?, ?, ?, ?, ?)
`).run(
billId,
overrides.amount ?? 85,
overrides.paid_date || '2026-05-16',
overrides.method || 'manual',
overrides.payment_source || 'manual',
overrides.notes || 'Manual payment',
).lastInsertRowid;
}
function trackerRow(userId, billId, today = '2026-05-20') {
const tracker = getTracker(userId, { year: 2026, month: 5 }, new Date(`${today}T12:00:00Z`));
assert.equal(tracker.error, undefined);
const row = tracker.rows.find(item => item.id === billId);
assert.ok(row, 'tracker row should exist');
return row;
}
function callBillsRoute(routePath, { userId, params = {}, query = {} }) {
const billsRouter = require('../routes/bills');
const layer = billsRouter.stack.find(item => item.route?.path === routePath && item.route.methods.get);
assert.ok(layer, `route ${routePath} should exist`);
const handler = layer.route.stack[0].handle;
return new Promise((resolve, reject) => {
const req = {
params,
query,
user: { id: userId, role: 'user' },
};
const res = {
statusCode: 200,
status(code) {
this.statusCode = code;
return this;
},
json(data) {
resolve({ status: this.statusCode, data });
},
};
try {
handler(req, res);
} catch (err) {
reject(err);
}
});
}
function callPaymentsRoute(routePath, method, { userId, params = {}, query = {}, body = {} }) {
const paymentsRouter = require('../routes/payments');
const layer = paymentsRouter.stack.find(item => item.route?.path === routePath && item.route.methods[method]);
assert.ok(layer, `route ${method.toUpperCase()} ${routePath} should exist`);
const handler = layer.route.stack[0].handle;
return new Promise((resolve, reject) => {
const req = {
body,
params,
query,
user: { id: userId, role: 'user' },
};
const res = {
statusCode: 200,
status(code) {
this.statusCode = code;
return this;
},
json(data) {
resolve({ status: this.statusCode, data });
},
};
try {
handler(req, res);
} catch (err) {
reject(err);
}
});
}
test.after(() => {
closeDb();
for (const suffix of ['', '-wal', '-shm']) {
fs.rmSync(`${dbPath}${suffix}`, { force: true });
}
});
test('matching a transaction creates one active transaction_match payment and unmatch removes it', () => {
const db = getDb();
const userId = createUser(db, 'basic');
const billId = createBill(db, userId);
const transactionId = createTransaction(db, userId);
const matched = matchTransactionToBill(userId, transactionId, billId);
assert.equal(matched.transaction.id, transactionId);
assert.equal(matched.transaction.matched_bill_id, billId);
assert.equal(matched.transaction.match_status, 'matched');
assert.equal(matched.transaction.ignored, 0);
assert.equal(matched.payment.bill_id, billId);
assert.equal(matched.payment.amount, 85);
assert.equal(matched.payment.paid_date, '2026-05-16');
assert.equal(matched.payment.method, 'transaction_match');
assert.equal(matched.payment.payment_source, 'transaction_match');
assert.equal(matched.payment.transaction_id, transactionId);
const matchedAgain = matchTransactionToBill(userId, transactionId, billId);
assert.equal(matchedAgain.payment.id, matched.payment.id);
assert.equal(activePaymentsForTransaction(db, transactionId).length, 1);
const unmatched = unmatchTransaction(userId, transactionId);
assert.equal(unmatched.transaction.matched_bill_id, null);
assert.equal(unmatched.transaction.match_status, 'unmatched');
assert.equal(unmatched.transaction.ignored, 0);
assert.equal(activePaymentsForTransaction(db, transactionId).length, 0);
const deletedPayment = db.prepare('SELECT deleted_at FROM payments WHERE id = ?').get(matched.payment.id);
assert.ok(deletedPayment.deleted_at);
});
test('ignoring a matched transaction removes the match payment and blocks rematching until unignored', () => {
const db = getDb();
const userId = createUser(db, 'ignore');
const billId = createBill(db, userId, 'Internet');
const transactionId = createTransaction(db, userId, {
description: 'Internet payment',
payee: 'Fiber Co',
amount: -6500,
});
matchTransactionToBill(userId, transactionId, billId);
const ignored = ignoreTransaction(userId, transactionId);
assert.equal(ignored.transaction.match_status, 'ignored');
assert.equal(ignored.transaction.ignored, 1);
assert.equal(ignored.transaction.matched_bill_id, null);
assert.equal(activePaymentsForTransaction(db, transactionId).length, 0);
assert.throws(
() => matchTransactionToBill(userId, transactionId, billId),
/Ignored transactions must be unignored before matching/,
);
const unignored = unignoreTransaction(userId, transactionId);
assert.equal(unignored.transaction.match_status, 'unmatched');
assert.equal(unignored.transaction.ignored, 0);
const rematched = matchTransactionToBill(userId, transactionId, billId);
assert.equal(rematched.transaction.match_status, 'matched');
assert.equal(activePaymentsForTransaction(db, transactionId).length, 1);
});
test('transaction match payments cannot be edited, deleted, or restored through payment routes', async () => {
const db = getDb();
const userId = createUser(db, 'payment-route-lock');
const billId = createBill(db, userId, 'Water');
const transactionId = createTransaction(db, userId);
const matched = matchTransactionToBill(userId, transactionId, billId);
const updateRes = await callPaymentsRoute('/:id', 'put', {
userId,
params: { id: String(matched.payment.id) },
body: {
amount: 1,
paid_date: '2026-05-17',
method: 'manual',
payment_source: 'manual',
},
});
assert.equal(updateRes.status, 409);
let payment = db.prepare('SELECT amount, paid_date, method, payment_source, transaction_id, deleted_at FROM payments WHERE id = ?').get(matched.payment.id);
assert.equal(payment.amount, 85);
assert.equal(payment.paid_date, '2026-05-16');
assert.equal(payment.method, 'transaction_match');
assert.equal(payment.payment_source, 'transaction_match');
assert.equal(payment.transaction_id, transactionId);
assert.equal(payment.deleted_at, null);
const deleteRes = await callPaymentsRoute('/:id', 'delete', {
userId,
params: { id: String(matched.payment.id) },
});
assert.equal(deleteRes.status, 409);
assert.equal(activePaymentsForTransaction(db, transactionId).length, 1);
assert.equal(db.prepare('SELECT match_status FROM transactions WHERE id = ?').get(transactionId).match_status, 'matched');
unmatchTransaction(userId, transactionId);
payment = db.prepare('SELECT deleted_at FROM payments WHERE id = ?').get(matched.payment.id);
assert.ok(payment.deleted_at);
const restoreRes = await callPaymentsRoute('/:id/restore', 'post', {
userId,
params: { id: String(matched.payment.id) },
});
assert.equal(restoreRes.status, 409);
assert.equal(activePaymentsForTransaction(db, transactionId).length, 0);
assert.equal(db.prepare('SELECT match_status, matched_bill_id FROM transactions WHERE id = ?').get(transactionId).match_status, 'unmatched');
});
test('matching marks the tracker row paid and unmatching recalculates it as unpaid', () => {
const db = getDb();
const userId = createUser(db, 'tracker');
const billId = createBill(db, userId, 'Electric');
const transactionId = createTransaction(db, userId, {
description: 'Electric bill payment',
payee: 'Electric Utility',
});
assert.notEqual(trackerRow(userId, billId).status, 'paid');
matchTransactionToBill(userId, transactionId, billId);
const paidRow = trackerRow(userId, billId);
assert.equal(paidRow.status, 'paid');
assert.equal(paidRow.has_payment, true);
assert.equal(paidRow.payments[0].transaction_id, transactionId);
unmatchTransaction(userId, transactionId);
const unpaidRow = trackerRow(userId, billId);
assert.notEqual(unpaidRow.status, 'paid');
assert.equal(unpaidRow.has_payment, false);
assert.equal(unpaidRow.total_paid, 0);
});
test('ignoring a transaction does not change bill status or manual payments', () => {
const db = getDb();
const userId = createUser(db, 'ignore-status');
const billId = createBill(db, userId, 'Phone');
const transactionId = createTransaction(db, userId, {
description: 'Phone store charge',
payee: 'Phone Store',
amount: -8500,
});
const manualPaymentId = createManualPayment(db, billId);
const before = trackerRow(userId, billId);
assert.equal(before.status, 'paid');
assert.equal(before.payments.some(payment => payment.id === manualPaymentId), true);
ignoreTransaction(userId, transactionId);
const after = trackerRow(userId, billId);
assert.equal(after.status, 'paid');
assert.equal(after.total_paid, before.total_paid);
assert.deepEqual(activePaymentsForTransaction(db, transactionId), []);
assert.equal(after.payments.some(payment => payment.id === manualPaymentId), true);
});
test('match suggestions are read-only and rejections do not touch payments or transactions', () => {
const db = getDb();
const userId = createUser(db, 'suggestions');
const billId = createBill(db, userId);
const transactionId = createTransaction(db, userId);
const beforeTransaction = db.prepare('SELECT matched_bill_id, match_status, ignored FROM transactions WHERE id = ?').get(transactionId);
const beforePaymentCount = db.prepare('SELECT COUNT(*) AS n FROM payments WHERE bill_id = ? AND deleted_at IS NULL').get(billId).n;
const suggestions = listMatchSuggestions(userId, { transactionId });
const match = suggestions.find(item => item.transactionId === transactionId && item.billId === billId);
assert.ok(match, 'expected a suggestion for the matching bill');
assert.equal(match.score > 0, true);
assert.ok(match.reasons.length > 0);
const afterSuggestTransaction = db.prepare('SELECT matched_bill_id, match_status, ignored FROM transactions WHERE id = ?').get(transactionId);
const afterSuggestPaymentCount = db.prepare('SELECT COUNT(*) AS n FROM payments WHERE bill_id = ? AND deleted_at IS NULL').get(billId).n;
assert.deepEqual(afterSuggestTransaction, beforeTransaction);
assert.equal(afterSuggestPaymentCount, beforePaymentCount);
const rejected = rejectMatchSuggestion(userId, suggestionId(transactionId, billId));
assert.equal(rejected.rejected, true);
const afterRejectTransaction = db.prepare('SELECT matched_bill_id, match_status, ignored FROM transactions WHERE id = ?').get(transactionId);
const afterRejectPaymentCount = db.prepare('SELECT COUNT(*) AS n FROM payments WHERE bill_id = ? AND deleted_at IS NULL').get(billId).n;
assert.deepEqual(afterRejectTransaction, beforeTransaction);
assert.equal(afterRejectPaymentCount, beforePaymentCount);
assert.equal(listMatchSuggestions(userId, { transactionId }).some(item => item.id === rejected.id), false);
});
test('manual payment history remains visible and suppresses duplicate suggestions for the same cycle', async () => {
const db = getDb();
const userId = createUser(db, 'manual-history');
const billId = createBill(db, userId, 'Internet');
const manualPaymentId = createManualPayment(db, billId, {
amount: 65,
notes: 'Paid from checking',
});
const transactionId = createTransaction(db, userId, {
amount: -6500,
description: 'Internet bill',
payee: 'Internet',
});
assert.equal(
listMatchSuggestions(userId, { transactionId }).some(item => item.billId === billId),
false,
);
const matched = matchTransactionToBill(userId, transactionId, billId);
const paymentsRes = await callBillsRoute('/:id/payments', {
userId,
params: { id: String(billId) },
query: { limit: '100' },
});
assert.equal(paymentsRes.status, 200);
assert.equal(paymentsRes.data.payments.some(payment => payment.id === manualPaymentId && payment.payment_source === 'manual'), true);
assert.equal(paymentsRes.data.payments.some(payment => payment.id === matched.payment.id && payment.payment_source === 'transaction_match'), true);
const transactionsRes = await callBillsRoute('/:id/transactions', {
userId,
params: { id: String(billId) },
});
assert.equal(transactionsRes.status, 200);
assert.equal(transactionsRes.data.transactions.length, 1);
assert.equal(transactionsRes.data.transactions[0].id, transactionId);
assert.equal(transactionsRes.data.transactions[0].linked_payment.id, matched.payment.id);
});