feat(banking): bank transactions page with merchant/store matching, transaction matching refactor, bank sync improvements (batch 0.40.0)
This commit is contained in:
parent
dc951ab59c
commit
81ddcb5fc1
|
|
@ -412,6 +412,9 @@ export const api = {
|
||||||
unmatchTransactionBulk: (matches) => post('/transactions/unmatch-bulk', { matches }),
|
unmatchTransactionBulk: (matches) => post('/transactions/unmatch-bulk', { matches }),
|
||||||
ignoreTransaction: (id) => post(`/transactions/${id}/ignore`),
|
ignoreTransaction: (id) => post(`/transactions/${id}/ignore`),
|
||||||
unignoreTransaction: (id) => post(`/transactions/${id}/unignore`),
|
unignoreTransaction: (id) => post(`/transactions/${id}/unignore`),
|
||||||
|
transactionMerchantMatch: (id) => get(`/transactions/${id}/merchant-match`),
|
||||||
|
applyTransactionMerchantMatch: (id) => post(`/transactions/${id}/apply-merchant-match`),
|
||||||
|
autoCategorizeTransactions: (opts = {}) => post('/transactions/auto-categorize', opts),
|
||||||
|
|
||||||
// Match suggestions
|
// Match suggestions
|
||||||
matchSuggestions: (params = {}) => get(`/matches/suggestions${queryString(params)}`),
|
matchSuggestions: (params = {}) => get(`/matches/suggestions${queryString(params)}`),
|
||||||
|
|
|
||||||
|
|
@ -325,6 +325,10 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} })
|
||||||
const [btAccounts, setBtAccounts] = useState([]);
|
const [btAccounts, setBtAccounts] = useState([]);
|
||||||
const [btSaving, setBtSaving] = useState(false);
|
const [btSaving, setBtSaving] = useState(false);
|
||||||
|
|
||||||
|
// Auto-categorization state
|
||||||
|
const [acmEnabled, setAcmEnabled] = useState(true);
|
||||||
|
const [acmSaving, setAcmSaving] = useState(false);
|
||||||
|
|
||||||
const loadAccounts = useCallback(async (conns) => {
|
const loadAccounts = useCallback(async (conns) => {
|
||||||
for (const conn of conns) {
|
for (const conn of conns) {
|
||||||
setAccountsLoading(prev => ({ ...prev, [conn.id]: true }));
|
setAccountsLoading(prev => ({ ...prev, [conn.id]: true }));
|
||||||
|
|
@ -352,6 +356,7 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} })
|
||||||
setBtPendingDays(parseInt(settings.bank_tracking_pending_days, 10) || 3);
|
setBtPendingDays(parseInt(settings.bank_tracking_pending_days, 10) || 3);
|
||||||
setBtLateGraceDays(parseInt(settings.bank_late_attribution_days, 10) || 0);
|
setBtLateGraceDays(parseInt(settings.bank_late_attribution_days, 10) || 0);
|
||||||
setBtAccounts(Array.isArray(accounts) ? accounts : []);
|
setBtAccounts(Array.isArray(accounts) ? accounts : []);
|
||||||
|
setAcmEnabled(settings.bank_auto_categorize_merchants !== 'false');
|
||||||
} catch {
|
} catch {
|
||||||
// non-fatal — bank tracking section just won't populate
|
// non-fatal — bank tracking section just won't populate
|
||||||
}
|
}
|
||||||
|
|
@ -379,6 +384,19 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} })
|
||||||
}
|
}
|
||||||
}, [btEnabled, btAccountId, btPendingDays]);
|
}, [btEnabled, btAccountId, btPendingDays]);
|
||||||
|
|
||||||
|
const handleAcmSave = useCallback(async (next) => {
|
||||||
|
setAcmSaving(true);
|
||||||
|
try {
|
||||||
|
await api.saveSettings({ bank_auto_categorize_merchants: String(next) });
|
||||||
|
setAcmEnabled(next);
|
||||||
|
toast.success('Auto-categorization setting saved');
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.message || 'Failed to save auto-categorization setting');
|
||||||
|
} finally {
|
||||||
|
setAcmSaving(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoadError('');
|
setLoadError('');
|
||||||
try {
|
try {
|
||||||
|
|
@ -931,6 +949,33 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} })
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{enabled && connections.length > 0 && (
|
||||||
|
<SectionCard
|
||||||
|
title="Auto-categorize Transactions"
|
||||||
|
subtitle="Match merchant names and categories from a 5,000-entry reference list during sync."
|
||||||
|
{...cardProps}
|
||||||
|
>
|
||||||
|
<div className="px-6 py-5">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label htmlFor="acm-toggle" className="text-sm font-medium">
|
||||||
|
Auto-categorize transactions
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
When new transactions sync, recognized merchants and stores are automatically assigned a spending category.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="acm-toggle"
|
||||||
|
checked={acmEnabled}
|
||||||
|
disabled={acmSaving}
|
||||||
|
onCheckedChange={handleAcmSave}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
)}
|
||||||
|
|
||||||
<AlertDialog open={!!disconnectTarget} onOpenChange={open => { if (!open) setDisconnectTarget(null); }}>
|
<AlertDialog open={!!disconnectTarget} onOpenChange={open => { if (!open) setDisconnectTarget(null); }}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
|
|
|
||||||
|
|
@ -2,19 +2,21 @@ import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import {
|
import {
|
||||||
Sparkles, CheckCircle2, Loader2, RefreshCw, Link2, Link2Off,
|
Sparkles, CheckCircle2, Loader2, RefreshCw, Link2, Link2Off,
|
||||||
XCircle, Eye, EyeOff, Search, Plus, Clock, ChevronLeft, ChevronRight,
|
XCircle, Eye, EyeOff, Search, Clock, ChevronLeft, ChevronRight,
|
||||||
ArrowUp, ArrowDown, ArrowUpDown,
|
ArrowUp, ArrowDown, ArrowUpDown,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { api } from '@/api';
|
import { api } from '@/api';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import {
|
|
||||||
Dialog, DialogContent, DialogDescription, DialogFooter,
|
|
||||||
DialogHeader, DialogTitle,
|
|
||||||
} from '@/components/ui/dialog';
|
|
||||||
import { SectionCard } from './dataShared';
|
import { SectionCard } from './dataShared';
|
||||||
import BillModal from '@/components/BillModal';
|
import BillModal from '@/components/BillModal';
|
||||||
|
import {
|
||||||
|
MatchBillDialog,
|
||||||
|
transactionTitle,
|
||||||
|
transactionDate,
|
||||||
|
formatTransactionAmount,
|
||||||
|
} from '@/components/transactions/MatchBillDialog';
|
||||||
|
|
||||||
const TRANSACTION_FILTERS = [
|
const TRANSACTION_FILTERS = [
|
||||||
{ id: 'open', label: 'Open', params: { ignored: 'false' } },
|
{ id: 'open', label: 'Open', params: { ignored: 'false' } },
|
||||||
|
|
@ -47,23 +49,6 @@ function TransactionStatusBadge({ tx }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 matchScoreTone(score) {
|
function matchScoreTone(score) {
|
||||||
const value = Number(score) || 0;
|
const value = Number(score) || 0;
|
||||||
if (value >= 80) return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400';
|
if (value >= 80) return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400';
|
||||||
|
|
@ -181,189 +166,6 @@ function SuggestedMatchesPanel({ suggestions, loading, actionId, onAccept, onRej
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MatchBillDialog({ open, onOpenChange, transaction, bills, onConfirm, loading, onCreateBill }) {
|
|
||||||
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 ? (
|
|
||||||
<div className="flex flex-col items-center gap-3 px-4 py-8 text-center">
|
|
||||||
<p className="text-sm text-muted-foreground">No bills found.</p>
|
|
||||||
{onCreateBill && (() => {
|
|
||||||
const af = transaction?.advisory_filter;
|
|
||||||
const label = query.trim()
|
|
||||||
? `Create "${query.trim()}" as a new bill`
|
|
||||||
: 'Create a new bill';
|
|
||||||
if (af?.confidence === 'high') {
|
|
||||||
return (
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
Probably not a bill ·{' '}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="underline hover:text-foreground transition-colors"
|
|
||||||
onClick={() => { onOpenChange(false); onCreateBill(transaction, query.trim() || undefined); }}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => { onOpenChange(false); onCreateBill(transaction, query.trim() || undefined); }}
|
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-1.5 text-xs hover:underline',
|
|
||||||
af?.confidence === 'medium' ? 'text-muted-foreground' : 'text-primary',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Plus className="h-3.5 w-3.5" />
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<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">
|
|
||||||
{onCreateBill && (() => {
|
|
||||||
const af = transaction?.advisory_filter;
|
|
||||||
if (af?.confidence === 'high') {
|
|
||||||
return (
|
|
||||||
<span className="mr-auto flex items-center gap-2 text-xs text-muted-foreground">
|
|
||||||
Probably not a bill
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="underline hover:text-foreground transition-colors"
|
|
||||||
onClick={() => { onOpenChange(false); onCreateBill(transaction); }}
|
|
||||||
>
|
|
||||||
create anyway
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
className={cn(
|
|
||||||
'mr-auto text-xs',
|
|
||||||
af?.confidence === 'medium'
|
|
||||||
? 'text-muted-foreground/60 hover:text-muted-foreground'
|
|
||||||
: 'text-muted-foreground hover:text-foreground',
|
|
||||||
)}
|
|
||||||
onClick={() => { onOpenChange(false); onCreateBill(transaction); }}
|
|
||||||
>
|
|
||||||
<Plus className="h-3.5 w-3.5 mr-1.5" />
|
|
||||||
Create Bill
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
<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 parseUtc(str) {
|
function parseUtc(str) {
|
||||||
if (!str) return null;
|
if (!str) return null;
|
||||||
const normalized = str.includes('T') ? str : str.replace(' ', 'T') + 'Z';
|
const normalized = str.includes('T') ? str : str.replace(' ', 'T') + 'Z';
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import { Tag } from 'lucide-react';
|
||||||
|
|
||||||
|
// ── Category picker dropdown ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function CategoryPicker({ categories, current, onSelect }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const close = e => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
|
||||||
|
document.addEventListener('mousedown', close);
|
||||||
|
return () => document.removeEventListener('mousedown', close);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const currentCat = categories.find(c => c.id === current);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(v => !v)}
|
||||||
|
className="flex items-center gap-1.5 rounded-md border border-border/60 bg-background px-2 py-1 text-xs text-muted-foreground hover:border-primary/40 hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<Tag className="h-3 w-3 shrink-0" />
|
||||||
|
<span className="max-w-[100px] truncate">{currentCat?.name ?? 'Uncategorized'}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="absolute right-0 top-full z-50 mt-1 w-48 rounded-lg border border-border/60 bg-popover shadow-lg overflow-hidden">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onMouseDown={e => { e.preventDefault(); onSelect(null, false); setOpen(false); }}
|
||||||
|
className="w-full px-3 py-2 text-left text-xs text-muted-foreground hover:bg-muted/50 transition-colors"
|
||||||
|
>
|
||||||
|
— Uncategorized
|
||||||
|
</button>
|
||||||
|
{categories.length === 0 ? (
|
||||||
|
<p className="px-3 py-2 text-xs text-muted-foreground italic">
|
||||||
|
No spending categories. Enable some in Categories.
|
||||||
|
</p>
|
||||||
|
) : categories.map(cat => (
|
||||||
|
<button
|
||||||
|
key={cat.id}
|
||||||
|
type="button"
|
||||||
|
onMouseDown={e => { e.preventDefault(); onSelect(cat.id, false); setOpen(false); }}
|
||||||
|
className={`w-full px-3 py-2 text-left text-xs hover:bg-muted/50 transition-colors ${cat.id === current ? 'text-primary font-medium' : ''}`}
|
||||||
|
>
|
||||||
|
{cat.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,211 @@
|
||||||
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
CheckCircle2, Loader2, Link2, Search, Plus,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogDescription, DialogFooter,
|
||||||
|
DialogHeader, DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
|
||||||
|
export function transactionDate(tx) {
|
||||||
|
return tx?.posted_date || String(tx?.transacted_at || '').slice(0, 10) || '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function transactionTitle(tx) {
|
||||||
|
return tx?.payee || tx?.description || tx?.memo || 'Transaction';
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MatchBillDialog({ open, onOpenChange, transaction, bills, onConfirm, loading, onCreateBill }) {
|
||||||
|
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 ? (
|
||||||
|
<div className="flex flex-col items-center gap-3 px-4 py-8 text-center">
|
||||||
|
<p className="text-sm text-muted-foreground">No bills found.</p>
|
||||||
|
{onCreateBill && (() => {
|
||||||
|
const af = transaction?.advisory_filter;
|
||||||
|
const label = query.trim()
|
||||||
|
? `Create "${query.trim()}" as a new bill`
|
||||||
|
: 'Create a new bill';
|
||||||
|
if (af?.confidence === 'high') {
|
||||||
|
return (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Probably not a bill ·{' '}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="underline hover:text-foreground transition-colors"
|
||||||
|
onClick={() => { onOpenChange(false); onCreateBill(transaction, query.trim() || undefined); }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { onOpenChange(false); onCreateBill(transaction, query.trim() || undefined); }}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1.5 text-xs hover:underline',
|
||||||
|
af?.confidence === 'medium' ? 'text-muted-foreground' : 'text-primary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<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">
|
||||||
|
{onCreateBill && (() => {
|
||||||
|
const af = transaction?.advisory_filter;
|
||||||
|
if (af?.confidence === 'high') {
|
||||||
|
return (
|
||||||
|
<span className="mr-auto flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
Probably not a bill
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="underline hover:text-foreground transition-colors"
|
||||||
|
onClick={() => { onOpenChange(false); onCreateBill(transaction); }}
|
||||||
|
>
|
||||||
|
create anyway
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
className={cn(
|
||||||
|
'mr-auto text-xs',
|
||||||
|
af?.confidence === 'medium'
|
||||||
|
? 'text-muted-foreground/60 hover:text-muted-foreground'
|
||||||
|
: 'text-muted-foreground hover:text-foreground',
|
||||||
|
)}
|
||||||
|
onClick={() => { onOpenChange(false); onCreateBill(transaction); }}
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5 mr-1.5" />
|
||||||
|
Create Bill
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -43,3 +43,26 @@ export function fmtBytes(bytes) {
|
||||||
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
|
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
return `${(bytes / 1048576).toFixed(2)} MB`;
|
return `${(bytes / 1048576).toFixed(2)} MB`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CATEGORY_COLOR_TONES = [
|
||||||
|
{ border: 'border-emerald-300/50', bg: 'bg-emerald-400/15', text: 'text-emerald-700 dark:text-emerald-200', bar: 'bg-emerald-500' },
|
||||||
|
{ border: 'border-sky-300/50', bg: 'bg-sky-400/15', text: 'text-sky-700 dark:text-sky-200', bar: 'bg-sky-500' },
|
||||||
|
{ border: 'border-amber-300/50', bg: 'bg-amber-400/15', text: 'text-amber-700 dark:text-amber-200', bar: 'bg-amber-500' },
|
||||||
|
{ border: 'border-rose-300/50', bg: 'bg-rose-400/15', text: 'text-rose-700 dark:text-rose-200', bar: 'bg-rose-500' },
|
||||||
|
{ border: 'border-indigo-300/50', bg: 'bg-indigo-400/15', text: 'text-indigo-700 dark:text-indigo-200', bar: 'bg-indigo-500' },
|
||||||
|
{ border: 'border-violet-300/50', bg: 'bg-violet-400/15', text: 'text-violet-700 dark:text-violet-200', bar: 'bg-violet-500' },
|
||||||
|
{ border: 'border-teal-300/50', bg: 'bg-teal-400/15', text: 'text-teal-700 dark:text-teal-200', bar: 'bg-teal-500' },
|
||||||
|
{ border: 'border-orange-300/50', bg: 'bg-orange-400/15', text: 'text-orange-700 dark:text-orange-200', bar: 'bg-orange-500' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Deterministic color tone for a category (or merchant) name, used for
|
||||||
|
// badges and avatars so the same name always renders the same color.
|
||||||
|
export function categoryColor(name) {
|
||||||
|
const key = String(name || 'Uncategorized');
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < key.length; i++) {
|
||||||
|
hash = (hash * 31 + key.charCodeAt(i)) | 0;
|
||||||
|
}
|
||||||
|
const index = Math.abs(hash) % CATEGORY_COLOR_TONES.length;
|
||||||
|
return CATEGORY_COLOR_TONES[index];
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,25 @@
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import {
|
import {
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
ArrowDownUp,
|
ArrowDownUp,
|
||||||
ArrowUp,
|
ArrowUp,
|
||||||
Building2,
|
Building2,
|
||||||
|
Check,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Clock3,
|
Clock3,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
Landmark,
|
Landmark,
|
||||||
Link2,
|
Link2,
|
||||||
|
Link2Off,
|
||||||
|
MoreVertical,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Search,
|
Search,
|
||||||
|
Sparkles,
|
||||||
TrendingDown,
|
TrendingDown,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
WalletCards,
|
WalletCards,
|
||||||
|
|
@ -21,6 +28,7 @@ import { api } from '@/api';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Skeleton } from '@/components/ui/Skeleton';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
|
|
@ -36,9 +44,29 @@ import {
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table';
|
} from '@/components/ui/table';
|
||||||
import { cn, fmt, fmtDate } from '@/lib/utils';
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { CategoryPicker } from '@/components/transactions/CategoryPicker';
|
||||||
|
import { MatchBillDialog } from '@/components/transactions/MatchBillDialog';
|
||||||
|
import BillModal from '@/components/BillModal';
|
||||||
|
import { cn, fmt, fmtDate, categoryColor, localDateString } from '@/lib/utils';
|
||||||
|
|
||||||
const PAGE_SIZE = 50;
|
const PAGE_SIZE = 50;
|
||||||
|
const TABLE_COLUMN_COUNT = 8;
|
||||||
|
|
||||||
const flowOptions = [
|
const flowOptions = [
|
||||||
{ value: 'all', label: 'All' },
|
{ value: 'all', label: 'All' },
|
||||||
|
|
@ -46,6 +74,7 @@ const flowOptions = [
|
||||||
{ value: 'out', label: 'Money out' },
|
{ value: 'out', label: 'Money out' },
|
||||||
{ value: 'pending', label: 'Pending' },
|
{ value: 'pending', label: 'Pending' },
|
||||||
{ value: 'unmatched', label: 'Needs review' },
|
{ value: 'unmatched', label: 'Needs review' },
|
||||||
|
{ value: 'uncategorized', label: 'Needs category' },
|
||||||
{ value: 'matched', label: 'Matched' },
|
{ value: 'matched', label: 'Matched' },
|
||||||
{ value: 'ignored', label: 'Ignored' },
|
{ value: 'ignored', label: 'Ignored' },
|
||||||
];
|
];
|
||||||
|
|
@ -86,6 +115,34 @@ function transactionDate(tx) {
|
||||||
return tx.posted_date || (tx.transacted_at ? tx.transacted_at.slice(0, 10) : null);
|
return tx.posted_date || (tx.transacted_at ? tx.transacted_at.slice(0, 10) : null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function dateGroupLabel(dateStr) {
|
||||||
|
if (!dateStr) return 'Unknown date';
|
||||||
|
const today = localDateString();
|
||||||
|
const yesterdayDate = new Date();
|
||||||
|
yesterdayDate.setDate(yesterdayDate.getDate() - 1);
|
||||||
|
const yesterday = localDateString(yesterdayDate);
|
||||||
|
if (dateStr === today) return 'Today';
|
||||||
|
if (dateStr === yesterday) return 'Yesterday';
|
||||||
|
return fmtDate(dateStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Groups already-sorted transactions into date sections for display.
|
||||||
|
// Returns null when the list isn't sorted by date (grouping wouldn't make sense).
|
||||||
|
function groupByDate(transactions, sortBy) {
|
||||||
|
if (sortBy !== 'date') return null;
|
||||||
|
const groups = [];
|
||||||
|
let current = null;
|
||||||
|
for (const tx of transactions) {
|
||||||
|
const date = transactionDate(tx);
|
||||||
|
if (!current || current.date !== date) {
|
||||||
|
current = { date, label: dateGroupLabel(date), items: [] };
|
||||||
|
groups.push(current);
|
||||||
|
}
|
||||||
|
current.items.push(tx);
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
function accountLabel(account) {
|
function accountLabel(account) {
|
||||||
if (!account) return 'Unknown account';
|
if (!account) return 'Unknown account';
|
||||||
return [account.org_name, account.name].filter(Boolean).join(' - ') || account.name || 'Account';
|
return [account.org_name, account.name].filter(Boolean).join(' - ') || account.name || 'Account';
|
||||||
|
|
@ -136,19 +193,94 @@ function SummaryTile({ icon: Icon, label, value, tone, detail }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TransactionMobileCard({ tx }) {
|
// Small circular initial badge, colored by the transaction's category — gives
|
||||||
|
// the table/cards a quick visual anchor for scanning by category.
|
||||||
|
function MerchantAvatar({ tx }) {
|
||||||
|
const tone = categoryColor(tx.spending_category_name || 'Uncategorized');
|
||||||
|
const initial = transactionTitle(tx).trim().charAt(0).toUpperCase() || '?';
|
||||||
|
return (
|
||||||
|
<span className={cn('flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-bold', tone.bg, tone.text)}>
|
||||||
|
{initial}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SuggestionBadge({ tx, onApply, applying }) {
|
||||||
|
if (!tx.suggested_match) return null;
|
||||||
|
return (
|
||||||
|
<div className="mt-1 flex items-center gap-1.5">
|
||||||
|
<Badge variant="outline" className="max-w-[160px] truncate border-dashed text-[10px] text-muted-foreground">
|
||||||
|
{tx.suggested_match.display_name} · {tx.suggested_match.category}
|
||||||
|
</Badge>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={applying}
|
||||||
|
onClick={() => onApply(tx)}
|
||||||
|
title="Apply suggested category"
|
||||||
|
className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full border border-primary/30 text-primary hover:bg-primary/10 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Check className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RowActionsMenu({ tx, onMatch, onUnmatch, onIgnore, onUnignore }) {
|
||||||
|
const isMatched = tx.match_status === 'matched';
|
||||||
|
const isIgnored = tx.ignored || tx.match_status === 'ignored';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button type="button" variant="ghost" size="icon" className="h-8 w-8">
|
||||||
|
<MoreVertical className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => onMatch(tx)}>
|
||||||
|
<Link2 className="h-3.5 w-3.5" />
|
||||||
|
{isMatched ? 'Change bill match…' : 'Match to bill…'}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{isMatched && (
|
||||||
|
<DropdownMenuItem onClick={() => onUnmatch(tx)}>
|
||||||
|
<Link2Off className="h-3.5 w-3.5" />
|
||||||
|
Unmatch
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{isIgnored ? (
|
||||||
|
<DropdownMenuItem onClick={() => onUnignore(tx)}>
|
||||||
|
<Eye className="h-3.5 w-3.5" />
|
||||||
|
Unignore
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : (
|
||||||
|
<DropdownMenuItem onClick={() => onIgnore(tx)}>
|
||||||
|
<EyeOff className="h-3.5 w-3.5" />
|
||||||
|
Ignore
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TransactionMobileCard({ tx, categories, onCategorize, onApplySuggestion, applyingSuggestionId, onMatch, onUnmatch, onIgnore, onUnignore, selected, onToggleSelected }) {
|
||||||
const cents = Number(tx.amount || 0);
|
const cents = Number(tx.amount || 0);
|
||||||
const isCredit = cents > 0;
|
const isCredit = cents > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-border/70 bg-card/85 p-4 shadow-sm">
|
<div className={cn('rounded-lg border border-border/70 bg-card/85 p-4 shadow-sm', selected && 'ring-2 ring-primary/50')}>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex min-w-0 items-start gap-2.5">
|
||||||
|
<Checkbox className="mt-1" checked={selected} onCheckedChange={() => onToggleSelected(tx.id)} aria-label="Select transaction" />
|
||||||
|
<MerchantAvatar tx={tx} />
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate text-sm font-semibold text-foreground">{transactionTitle(tx)}</p>
|
<p className="truncate text-sm font-semibold text-foreground">{transactionTitle(tx)}</p>
|
||||||
<p className="mt-1 truncate text-xs font-medium text-muted-foreground">
|
<p className="mt-1 truncate text-xs font-medium text-muted-foreground">
|
||||||
{[tx.account_org_name, tx.account_name].filter(Boolean).join(' - ') || 'Account'}
|
{[tx.account_org_name, tx.account_name].filter(Boolean).join(' - ') || 'Account'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<p className={cn('tracker-number shrink-0 text-sm font-bold', isCredit ? 'text-emerald-600 dark:text-emerald-300' : 'text-rose-600 dark:text-rose-300')}>
|
<p className={cn('tracker-number shrink-0 text-sm font-bold', isCredit ? 'text-emerald-600 dark:text-emerald-300' : 'text-rose-600 dark:text-rose-300')}>
|
||||||
{formatCents(cents, { signed: true })}
|
{formatCents(cents, { signed: true })}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -163,6 +295,13 @@ function TransactionMobileCard({ tx }) {
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-3 flex items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<CategoryPicker categories={categories} current={tx.spending_category_id} onSelect={categoryId => onCategorize(tx, categoryId)} />
|
||||||
|
<SuggestionBadge tx={tx} onApply={onApplySuggestion} applying={applyingSuggestionId === tx.id} />
|
||||||
|
</div>
|
||||||
|
<RowActionsMenu tx={tx} onMatch={onMatch} onUnmatch={onUnmatch} onIgnore={onIgnore} onUnignore={onUnignore} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -178,6 +317,16 @@ export default function BankTransactionsPage() {
|
||||||
const [sortBy, setSortBy] = useState('date');
|
const [sortBy, setSortBy] = useState('date');
|
||||||
const [sortDir, setSortDir] = useState('desc');
|
const [sortDir, setSortDir] = useState('desc');
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
|
const [categories, setCategories] = useState([]);
|
||||||
|
const [bills, setBills] = useState([]);
|
||||||
|
const [matchTarget, setMatchTarget] = useState(null);
|
||||||
|
const [matchSubmitting, setMatchSubmitting] = useState(false);
|
||||||
|
const [applyingSuggestionId, setApplyingSuggestionId] = useState(null);
|
||||||
|
const [autoCategorizing, setAutoCategorizing] = useState(false);
|
||||||
|
const [autoCategorizePreview, setAutoCategorizePreview] = useState(null);
|
||||||
|
const [createBillSourceTx, setCreateBillSourceTx] = useState(null);
|
||||||
|
const [selectedIds, setSelectedIds] = useState(() => new Set());
|
||||||
|
const [bulkActing, setBulkActing] = useState(false);
|
||||||
// Monotonic request id: a response only lands if it belongs to the newest
|
// Monotonic request id: a response only lands if it belongs to the newest
|
||||||
// request, so a slow Refresh can never overwrite fresher filter results.
|
// request, so a slow Refresh can never overwrite fresher filter results.
|
||||||
const requestSeq = useRef(0);
|
const requestSeq = useRef(0);
|
||||||
|
|
@ -194,6 +343,15 @@ export default function BankTransactionsPage() {
|
||||||
setPage(1);
|
setPage(1);
|
||||||
}, [accountId, flow, sortBy, sortDir]);
|
}, [accountId, flow, sortBy, sortDir]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedIds(new Set());
|
||||||
|
}, [accountId, flow, sortBy, sortDir, query, page]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.categories().then(data => setCategories(data || [])).catch(() => setCategories([]));
|
||||||
|
api.bills().then(data => setBills(data || [])).catch(() => setBills([]));
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Single fetch path — used by both the load effect and the Refresh button.
|
// Single fetch path — used by both the load effect and the Refresh button.
|
||||||
const loadLedger = useCallback(async () => {
|
const loadLedger = useCallback(async () => {
|
||||||
const seq = ++requestSeq.current;
|
const seq = ++requestSeq.current;
|
||||||
|
|
@ -219,11 +377,181 @@ export default function BankTransactionsPage() {
|
||||||
|
|
||||||
useEffect(() => { loadLedger(); }, [loadLedger]);
|
useEffect(() => { loadLedger(); }, [loadLedger]);
|
||||||
|
|
||||||
|
const updateTransaction = useCallback((id, patch) => {
|
||||||
|
setLedger(prev => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
transactions: prev.transactions.map(tx => (tx.id === id ? { ...tx, ...patch } : tx)),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCategorize = useCallback(async (tx, categoryId) => {
|
||||||
|
try {
|
||||||
|
await api.categorizeTransaction(tx.id, { category_id: categoryId, save_rule: false });
|
||||||
|
const categoryName = categories.find(c => c.id === categoryId)?.name ?? null;
|
||||||
|
updateTransaction(tx.id, { spending_category_id: categoryId, spending_category_name: categoryName, suggested_match: null });
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.message || 'Failed to categorize transaction');
|
||||||
|
}
|
||||||
|
}, [categories, updateTransaction]);
|
||||||
|
|
||||||
|
const handleApplySuggestion = useCallback(async (tx) => {
|
||||||
|
setApplyingSuggestionId(tx.id);
|
||||||
|
try {
|
||||||
|
const result = await api.applyTransactionMerchantMatch(tx.id);
|
||||||
|
if (result.matched) {
|
||||||
|
updateTransaction(tx.id, {
|
||||||
|
spending_category_id: result.category.id,
|
||||||
|
spending_category_name: result.category.name,
|
||||||
|
suggested_match: null,
|
||||||
|
});
|
||||||
|
toast.success(`Categorized as ${result.category.name}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.message || 'Failed to apply suggestion');
|
||||||
|
} finally {
|
||||||
|
setApplyingSuggestionId(null);
|
||||||
|
}
|
||||||
|
}, [updateTransaction]);
|
||||||
|
|
||||||
|
const handleAutoCategorize = useCallback(async () => {
|
||||||
|
setAutoCategorizing(true);
|
||||||
|
try {
|
||||||
|
const preview = await api.autoCategorizeTransactions({ dry_run: true });
|
||||||
|
if (!preview?.changes?.length) {
|
||||||
|
toast.success('No new matches found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAutoCategorizePreview(preview);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.message || 'Failed to preview auto-categorize');
|
||||||
|
} finally {
|
||||||
|
setAutoCategorizing(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleUndoAutoCategorize = useCallback(async (changes) => {
|
||||||
|
try {
|
||||||
|
await Promise.all(changes.map(c => api.categorizeTransaction(c.transaction_id, { category_id: null, save_rule: false })));
|
||||||
|
toast.success('Auto-categorize undone');
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.message || 'Failed to undo auto-categorize');
|
||||||
|
} finally {
|
||||||
|
loadLedger();
|
||||||
|
}
|
||||||
|
}, [loadLedger]);
|
||||||
|
|
||||||
|
const handleConfirmAutoCategorize = useCallback(async () => {
|
||||||
|
setAutoCategorizing(true);
|
||||||
|
try {
|
||||||
|
const result = await api.autoCategorizeTransactions();
|
||||||
|
const changes = result?.changes || [];
|
||||||
|
setAutoCategorizePreview(null);
|
||||||
|
await Promise.all([
|
||||||
|
loadLedger(),
|
||||||
|
api.categories().then(data => setCategories(data || [])).catch(() => {}),
|
||||||
|
]);
|
||||||
|
const count = changes.length;
|
||||||
|
toast.success(`Categorized ${count} transaction${count === 1 ? '' : 's'}`, {
|
||||||
|
action: { label: 'Undo', onClick: () => handleUndoAutoCategorize(changes) },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.message || 'Failed to auto-categorize transactions');
|
||||||
|
} finally {
|
||||||
|
setAutoCategorizing(false);
|
||||||
|
}
|
||||||
|
}, [loadLedger, handleUndoAutoCategorize]);
|
||||||
|
|
||||||
|
const handleConfirmMatch = useCallback(async (billId) => {
|
||||||
|
if (!matchTarget) return;
|
||||||
|
setMatchSubmitting(true);
|
||||||
|
try {
|
||||||
|
const result = await api.matchTransaction(matchTarget.id, billId);
|
||||||
|
updateTransaction(matchTarget.id, result.transaction);
|
||||||
|
setMatchTarget(null);
|
||||||
|
toast.success('Transaction matched to bill');
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.message || 'Failed to match transaction');
|
||||||
|
} finally {
|
||||||
|
setMatchSubmitting(false);
|
||||||
|
}
|
||||||
|
}, [matchTarget, updateTransaction]);
|
||||||
|
|
||||||
|
const openCreateBill = useCallback((tx, nameOverride) => {
|
||||||
|
const amount = Math.abs(Number(tx.amount || 0)) / 100;
|
||||||
|
const dateStr = transactionDate(tx);
|
||||||
|
const day = dateStr ? parseInt(dateStr.slice(8, 10), 10) : 1;
|
||||||
|
setCreateBillSourceTx({
|
||||||
|
tx,
|
||||||
|
initialBill: {
|
||||||
|
name: nameOverride || transactionTitle(tx),
|
||||||
|
expected_amount: amount || 0,
|
||||||
|
due_day: day >= 1 && day <= 31 ? day : 1,
|
||||||
|
billing_cycle: 'monthly',
|
||||||
|
cycle_type: 'monthly',
|
||||||
|
cycle_day: '1',
|
||||||
|
active: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleBillCreated = useCallback(async (newBill) => {
|
||||||
|
const tx = createBillSourceTx?.tx;
|
||||||
|
setCreateBillSourceTx(null);
|
||||||
|
setMatchTarget(null);
|
||||||
|
if (tx && newBill?.id) {
|
||||||
|
try {
|
||||||
|
const result = await api.matchTransaction(tx.id, newBill.id);
|
||||||
|
updateTransaction(tx.id, result.transaction);
|
||||||
|
toast.success('Bill created and matched to transaction');
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.message || 'Bill created but match failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
api.bills().then(data => setBills(data || [])).catch(() => {});
|
||||||
|
}, [createBillSourceTx, updateTransaction]);
|
||||||
|
|
||||||
|
const handleUnmatch = useCallback(async (tx) => {
|
||||||
|
try {
|
||||||
|
const result = await api.unmatchTransaction(tx.id);
|
||||||
|
updateTransaction(tx.id, result.transaction);
|
||||||
|
toast.success('Transaction unmatched');
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.message || 'Failed to unmatch transaction');
|
||||||
|
loadLedger();
|
||||||
|
}
|
||||||
|
}, [updateTransaction, loadLedger]);
|
||||||
|
|
||||||
|
const handleIgnore = useCallback(async (tx) => {
|
||||||
|
try {
|
||||||
|
const transaction = await api.ignoreTransaction(tx.id);
|
||||||
|
updateTransaction(tx.id, transaction);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.message || 'Failed to ignore transaction');
|
||||||
|
loadLedger();
|
||||||
|
}
|
||||||
|
}, [updateTransaction, loadLedger]);
|
||||||
|
|
||||||
|
const handleUnignore = useCallback(async (tx) => {
|
||||||
|
try {
|
||||||
|
const transaction = await api.unignoreTransaction(tx.id);
|
||||||
|
updateTransaction(tx.id, transaction);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.message || 'Failed to unignore transaction');
|
||||||
|
loadLedger();
|
||||||
|
}
|
||||||
|
}, [updateTransaction, loadLedger]);
|
||||||
|
|
||||||
const accounts = ledger?.accounts || [];
|
const accounts = ledger?.accounts || [];
|
||||||
const transactions = ledger?.transactions || [];
|
const transactions = ledger?.transactions || [];
|
||||||
const summary = ledger?.summary || {};
|
const summary = ledger?.summary || {};
|
||||||
const total = Number(ledger?.total || 0);
|
const total = Number(ledger?.total || 0);
|
||||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||||
|
const categoryBreakdown = summary.category_breakdown || [];
|
||||||
|
const maxCategoryTotal = categoryBreakdown.reduce((max, c) => Math.max(max, Number(c.total || 0)), 0) || 1;
|
||||||
|
const dateGroups = useMemo(() => groupByDate(transactions, sortBy), [transactions, sortBy]);
|
||||||
|
|
||||||
// If a filter shrinks the result set below the current page, snap back to
|
// If a filter shrinks the result set below the current page, snap back to
|
||||||
// the last real page instead of stranding the user on an empty one.
|
// the last real page instead of stranding the user on an empty one.
|
||||||
|
|
@ -236,6 +564,99 @@ export default function BankTransactionsPage() {
|
||||||
}, [ledger?.sources]);
|
}, [ledger?.sources]);
|
||||||
const connected = Boolean(ledger?.enabled && ledger?.has_connections);
|
const connected = Boolean(ledger?.enabled && ledger?.has_connections);
|
||||||
|
|
||||||
|
const selectedTransactions = useMemo(
|
||||||
|
() => transactions.filter(tx => selectedIds.has(tx.id)),
|
||||||
|
[transactions, selectedIds],
|
||||||
|
);
|
||||||
|
const allOnPageSelected = transactions.length > 0 && transactions.every(tx => selectedIds.has(tx.id));
|
||||||
|
|
||||||
|
const toggleSelected = useCallback((id) => {
|
||||||
|
setSelectedIds(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id); else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleSelectAll = useCallback(() => {
|
||||||
|
setSelectedIds(prev => {
|
||||||
|
if (transactions.length > 0 && transactions.every(tx => prev.has(tx.id))) return new Set();
|
||||||
|
return new Set(transactions.map(tx => tx.id));
|
||||||
|
});
|
||||||
|
}, [transactions]);
|
||||||
|
|
||||||
|
const handleBulkApplySuggestions = useCallback(async () => {
|
||||||
|
const targets = selectedTransactions.filter(tx => tx.suggested_match && !tx.spending_category_id);
|
||||||
|
if (targets.length === 0) return;
|
||||||
|
setBulkActing(true);
|
||||||
|
try {
|
||||||
|
const results = await Promise.allSettled(targets.map(tx => api.applyTransactionMerchantMatch(tx.id)));
|
||||||
|
let applied = 0;
|
||||||
|
results.forEach((r, i) => {
|
||||||
|
if (r.status === 'fulfilled' && r.value?.matched) {
|
||||||
|
updateTransaction(targets[i].id, {
|
||||||
|
spending_category_id: r.value.category.id,
|
||||||
|
spending_category_name: r.value.category.name,
|
||||||
|
suggested_match: null,
|
||||||
|
});
|
||||||
|
applied++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
toast.success(`Applied suggestions to ${applied} of ${targets.length}`);
|
||||||
|
if (applied < targets.length) loadLedger();
|
||||||
|
} finally {
|
||||||
|
setBulkActing(false);
|
||||||
|
setSelectedIds(new Set());
|
||||||
|
}
|
||||||
|
}, [selectedTransactions, updateTransaction, loadLedger]);
|
||||||
|
|
||||||
|
const handleBulkCategorize = useCallback(async (categoryId) => {
|
||||||
|
const targets = selectedTransactions;
|
||||||
|
if (targets.length === 0) return;
|
||||||
|
const categoryName = categories.find(c => c.id === categoryId)?.name ?? null;
|
||||||
|
setBulkActing(true);
|
||||||
|
try {
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
targets.map(tx => api.categorizeTransaction(tx.id, { category_id: categoryId, save_rule: false })),
|
||||||
|
);
|
||||||
|
let applied = 0;
|
||||||
|
results.forEach((r, i) => {
|
||||||
|
if (r.status === 'fulfilled') {
|
||||||
|
updateTransaction(targets[i].id, { spending_category_id: categoryId, spending_category_name: categoryName, suggested_match: null });
|
||||||
|
applied++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
toast.success(`Categorized ${applied} of ${targets.length}`);
|
||||||
|
if (applied < targets.length) loadLedger();
|
||||||
|
} finally {
|
||||||
|
setBulkActing(false);
|
||||||
|
setSelectedIds(new Set());
|
||||||
|
}
|
||||||
|
}, [selectedTransactions, categories, updateTransaction, loadLedger]);
|
||||||
|
|
||||||
|
const handleBulkIgnoreToggle = useCallback(async (ignore) => {
|
||||||
|
const targets = selectedTransactions;
|
||||||
|
if (targets.length === 0) return;
|
||||||
|
setBulkActing(true);
|
||||||
|
try {
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
targets.map(tx => (ignore ? api.ignoreTransaction(tx.id) : api.unignoreTransaction(tx.id))),
|
||||||
|
);
|
||||||
|
let applied = 0;
|
||||||
|
results.forEach((r, i) => {
|
||||||
|
if (r.status === 'fulfilled') {
|
||||||
|
updateTransaction(targets[i].id, r.value);
|
||||||
|
applied++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
toast.success(`${ignore ? 'Ignored' : 'Unignored'} ${applied} of ${targets.length}`);
|
||||||
|
if (applied < targets.length) loadLedger();
|
||||||
|
} finally {
|
||||||
|
setBulkActing(false);
|
||||||
|
setSelectedIds(new Set());
|
||||||
|
}
|
||||||
|
}, [selectedTransactions, updateTransaction, loadLedger]);
|
||||||
|
|
||||||
// A failed request must never masquerade as "not connected" — without this,
|
// A failed request must never masquerade as "not connected" — without this,
|
||||||
// a transient API error told users with working bank sync to go connect it.
|
// a transient API error told users with working bank sync to go connect it.
|
||||||
if (!loading && error && !ledger) {
|
if (!loading && error && !ledger) {
|
||||||
|
|
@ -314,6 +735,10 @@ export default function BankTransactionsPage() {
|
||||||
<span className="font-medium text-muted-foreground">Last sync </span>
|
<span className="font-medium text-muted-foreground">Last sync </span>
|
||||||
<span className="font-semibold text-foreground">{formatSyncTime(latestSync)}</span>
|
<span className="font-semibold text-foreground">{formatSyncTime(latestSync)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<Button type="button" variant="outline" onClick={handleAutoCategorize} disabled={autoCategorizing}>
|
||||||
|
<Sparkles className={cn('h-4 w-4', autoCategorizing && 'animate-pulse')} />
|
||||||
|
Auto-categorize
|
||||||
|
</Button>
|
||||||
<Button type="button" variant="outline" onClick={loadLedger} disabled={loading}>
|
<Button type="button" variant="outline" onClick={loadLedger} disabled={loading}>
|
||||||
<RefreshCw className={cn('h-4 w-4', loading && 'animate-spin')} />
|
<RefreshCw className={cn('h-4 w-4', loading && 'animate-spin')} />
|
||||||
Refresh
|
Refresh
|
||||||
|
|
@ -359,6 +784,47 @@ export default function BankTransactionsPage() {
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{categoryBreakdown.length > 0 && (
|
||||||
|
<section className="flex gap-3 overflow-x-auto pb-1">
|
||||||
|
{categoryBreakdown.map(cat => {
|
||||||
|
const tone = categoryColor(cat.name);
|
||||||
|
const pct = Math.max(4, Math.round((Number(cat.total || 0) / maxCategoryTotal) * 100));
|
||||||
|
const isUncategorized = cat.name === 'Uncategorized';
|
||||||
|
const active = isUncategorized
|
||||||
|
? flow === 'uncategorized'
|
||||||
|
: search.trim().toLowerCase() === cat.name.toLowerCase();
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={cat.name}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (isUncategorized) {
|
||||||
|
setSearch('');
|
||||||
|
setFlow(prev => (prev === 'uncategorized' ? 'all' : 'uncategorized'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSearch(prev => (prev.trim().toLowerCase() === cat.name.toLowerCase() ? '' : cat.name));
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'flex min-w-[160px] shrink-0 flex-col gap-1.5 rounded-lg border p-3 text-left transition-shadow',
|
||||||
|
tone.border, tone.bg,
|
||||||
|
active && 'ring-2 ring-primary/50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className={cn('truncate text-sm font-semibold', tone.text)}>{cat.name}</span>
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">{cat.count}</span>
|
||||||
|
</div>
|
||||||
|
<span className="tracker-number text-sm font-bold text-foreground">{formatCents(cat.total)}</span>
|
||||||
|
<div className="h-1.5 w-full overflow-hidden rounded-full bg-background/60">
|
||||||
|
<div className={cn('h-full rounded-full', tone.bar)} style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{accounts.length > 0 && (
|
{accounts.length > 0 && (
|
||||||
<section className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
<section className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||||
{accounts.map(account => (
|
{accounts.map(account => (
|
||||||
|
|
@ -452,41 +918,87 @@ export default function BankTransactionsPage() {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{selectedIds.size > 0 && (
|
||||||
|
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-primary/30 bg-primary/5 p-3 shadow-sm">
|
||||||
|
<span className="text-sm font-medium text-foreground">{selectedIds.size} selected</span>
|
||||||
|
<Button type="button" variant="outline" size="sm" disabled={bulkActing} onClick={handleBulkApplySuggestions}>
|
||||||
|
<Sparkles className="h-3.5 w-3.5" />
|
||||||
|
Apply suggestions
|
||||||
|
</Button>
|
||||||
|
<CategoryPicker categories={categories} current={null} onSelect={categoryId => handleBulkCategorize(categoryId)} />
|
||||||
|
<Button type="button" variant="outline" size="sm" disabled={bulkActing} onClick={() => handleBulkIgnoreToggle(true)}>
|
||||||
|
<EyeOff className="h-3.5 w-3.5" />
|
||||||
|
Ignore
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" size="sm" disabled={bulkActing} onClick={() => handleBulkIgnoreToggle(false)}>
|
||||||
|
<Eye className="h-3.5 w-3.5" />
|
||||||
|
Unignore
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="ghost" size="sm" className="ml-auto" disabled={bulkActing} onClick={() => setSelectedIds(new Set())}>
|
||||||
|
Clear selection
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="hidden overflow-hidden rounded-lg border border-border/70 bg-card/85 shadow-sm lg:block">
|
<div className="hidden overflow-hidden rounded-lg border border-border/70 bg-card/85 shadow-sm lg:block">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="bg-muted/35 hover:bg-muted/35">
|
<TableRow className="bg-muted/35 hover:bg-muted/35">
|
||||||
|
<TableHead className="w-10">
|
||||||
|
<Checkbox checked={allOnPageSelected} onCheckedChange={toggleSelectAll} aria-label="Select all transactions on this page" />
|
||||||
|
</TableHead>
|
||||||
<TableHead className="w-32">Date</TableHead>
|
<TableHead className="w-32">Date</TableHead>
|
||||||
<TableHead>Merchant</TableHead>
|
<TableHead>Merchant</TableHead>
|
||||||
<TableHead>Account</TableHead>
|
<TableHead>Account</TableHead>
|
||||||
|
<TableHead>Category</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>Status</TableHead>
|
||||||
<TableHead className="text-right">Amount</TableHead>
|
<TableHead className="text-right">Amount</TableHead>
|
||||||
|
<TableHead className="w-12"></TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{loading && transactions.length === 0 && (
|
{loading && transactions.length === 0 && (
|
||||||
<TableRow>
|
Array.from({ length: 6 }).map((_, i) => (
|
||||||
<TableCell colSpan={5} className="py-10 text-center text-muted-foreground">Loading transactions...</TableCell>
|
<TableRow key={`skeleton-${i}`}>
|
||||||
|
{Array.from({ length: TABLE_COLUMN_COUNT }).map((__, j) => (
|
||||||
|
<TableCell key={j}><Skeleton variant="line" /></TableCell>
|
||||||
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
))
|
||||||
)}
|
)}
|
||||||
{!loading && transactions.length === 0 && (
|
{!loading && transactions.length === 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={5} className="py-10 text-center text-muted-foreground">No transactions found.</TableCell>
|
<TableCell colSpan={TABLE_COLUMN_COUNT} className="py-10 text-center text-muted-foreground">No transactions found.</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
{transactions.map(tx => {
|
{(dateGroups ?? [{ label: null, items: transactions }]).map((group, gi) => (
|
||||||
|
<Fragment key={group.label ?? gi}>
|
||||||
|
{group.label && (
|
||||||
|
<TableRow className="bg-muted/20 hover:bg-muted/20">
|
||||||
|
<TableCell colSpan={TABLE_COLUMN_COUNT} className="py-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
{group.label}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{group.items.map(tx => {
|
||||||
const cents = Number(tx.amount || 0);
|
const cents = Number(tx.amount || 0);
|
||||||
const isCredit = cents > 0;
|
const isCredit = cents > 0;
|
||||||
return (
|
return (
|
||||||
<TableRow key={tx.id}>
|
<TableRow key={tx.id} data-state={selectedIds.has(tx.id) ? 'selected' : undefined}>
|
||||||
|
<TableCell>
|
||||||
|
<Checkbox checked={selectedIds.has(tx.id)} onCheckedChange={() => toggleSelected(tx.id)} aria-label="Select transaction" />
|
||||||
|
</TableCell>
|
||||||
<TableCell className="text-muted-foreground">{fmtDate(transactionDate(tx))}</TableCell>
|
<TableCell className="text-muted-foreground">{fmtDate(transactionDate(tx))}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
|
<div className="flex min-w-0 items-center gap-2.5">
|
||||||
|
<MerchantAvatar tx={tx} />
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate font-semibold text-foreground">{transactionTitle(tx)}</p>
|
<p className="truncate font-semibold text-foreground">{transactionTitle(tx)}</p>
|
||||||
<p className="truncate text-xs font-medium text-muted-foreground">
|
<p className="truncate text-xs font-medium text-muted-foreground">
|
||||||
{[tx.memo, tx.category].filter(Boolean).join(' - ') || tx.description || 'SimpleFIN'}
|
{[tx.memo, tx.category].filter(Boolean).join(' - ') || tx.description || 'SimpleFIN'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
|
|
@ -496,6 +1008,10 @@ export default function BankTransactionsPage() {
|
||||||
<p className="truncate text-xs font-medium text-muted-foreground">{tx.account_type || tx.currency || 'Bank'}</p>
|
<p className="truncate text-xs font-medium text-muted-foreground">{tx.account_type || tx.currency || 'Bank'}</p>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<CategoryPicker categories={categories} current={tx.spending_category_id} onSelect={categoryId => handleCategorize(tx, categoryId)} />
|
||||||
|
<SuggestionBadge tx={tx} onApply={handleApplySuggestion} applying={applyingSuggestionId === tx.id} />
|
||||||
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<StatusBadge tx={tx} />
|
<StatusBadge tx={tx} />
|
||||||
|
|
@ -510,25 +1026,50 @@ export default function BankTransactionsPage() {
|
||||||
<TableCell className={cn('tracker-number text-right font-bold', isCredit ? 'text-emerald-600 dark:text-emerald-300' : 'text-rose-600 dark:text-rose-300')}>
|
<TableCell className={cn('tracker-number text-right font-bold', isCredit ? 'text-emerald-600 dark:text-emerald-300' : 'text-rose-600 dark:text-rose-300')}>
|
||||||
{formatCents(cents, { signed: true })}
|
{formatCents(cents, { signed: true })}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<RowActionsMenu tx={tx} onMatch={setMatchTarget} onUnmatch={handleUnmatch} onIgnore={handleIgnore} onUnignore={handleUnignore} />
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-3 lg:hidden">
|
<div className="grid gap-3 lg:hidden">
|
||||||
{loading && transactions.length === 0 && (
|
{loading && transactions.length === 0 && (
|
||||||
<div className="rounded-lg border border-border/70 bg-card/85 p-8 text-center text-sm font-medium text-muted-foreground">
|
Array.from({ length: 4 }).map((_, i) => <Skeleton key={`skeleton-${i}`} variant="card" />)
|
||||||
Loading transactions...
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{!loading && transactions.length === 0 && (
|
{!loading && transactions.length === 0 && (
|
||||||
<div className="rounded-lg border border-border/70 bg-card/85 p-8 text-center text-sm font-medium text-muted-foreground">
|
<div className="rounded-lg border border-border/70 bg-card/85 p-8 text-center text-sm font-medium text-muted-foreground">
|
||||||
No transactions found.
|
No transactions found.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{transactions.map(tx => <TransactionMobileCard key={tx.id} tx={tx} />)}
|
{(dateGroups ?? [{ label: null, items: transactions }]).map((group, gi) => (
|
||||||
|
<div key={group.label ?? gi} className="space-y-3">
|
||||||
|
{group.label && (
|
||||||
|
<p className="px-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">{group.label}</p>
|
||||||
|
)}
|
||||||
|
{group.items.map(tx => (
|
||||||
|
<TransactionMobileCard
|
||||||
|
key={tx.id}
|
||||||
|
tx={tx}
|
||||||
|
categories={categories}
|
||||||
|
onCategorize={handleCategorize}
|
||||||
|
onApplySuggestion={handleApplySuggestion}
|
||||||
|
applyingSuggestionId={applyingSuggestionId}
|
||||||
|
onMatch={setMatchTarget}
|
||||||
|
onUnmatch={handleUnmatch}
|
||||||
|
onIgnore={handleIgnore}
|
||||||
|
onUnignore={handleUnignore}
|
||||||
|
selected={selectedIds.has(tx.id)}
|
||||||
|
onToggleSelected={toggleSelected}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 rounded-lg border border-border/70 bg-card/85 px-4 py-3 shadow-sm sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-3 rounded-lg border border-border/70 bg-card/85 px-4 py-3 shadow-sm sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
|
@ -562,6 +1103,59 @@ export default function BankTransactionsPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<MatchBillDialog
|
||||||
|
open={!!matchTarget}
|
||||||
|
onOpenChange={open => { if (!open) setMatchTarget(null); }}
|
||||||
|
transaction={matchTarget}
|
||||||
|
bills={bills}
|
||||||
|
loading={matchSubmitting}
|
||||||
|
onConfirm={handleConfirmMatch}
|
||||||
|
onCreateBill={openCreateBill}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{createBillSourceTx && (
|
||||||
|
<BillModal
|
||||||
|
key={`create-from-tx-${createBillSourceTx.tx.id}`}
|
||||||
|
initialBill={createBillSourceTx.initialBill}
|
||||||
|
categories={categories}
|
||||||
|
onClose={() => setCreateBillSourceTx(null)}
|
||||||
|
onSave={handleBillCreated}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Dialog open={!!autoCategorizePreview} onOpenChange={open => { if (!open) setAutoCategorizePreview(null); }}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Auto-categorize transactions</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{autoCategorizePreview?.changes?.length} transaction{autoCategorizePreview?.changes?.length === 1 ? '' : 's'} will be
|
||||||
|
categorized based on the merchant/store reference list. This also saves merchant
|
||||||
|
rules, so future transactions from these merchants will be categorized
|
||||||
|
automatically too.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{(autoCategorizePreview?.categories || []).map(cat => {
|
||||||
|
const tone = categoryColor(cat.name);
|
||||||
|
return (
|
||||||
|
<Badge key={cat.name} className={cn(tone.border, tone.bg, tone.text)}>
|
||||||
|
{cat.name} · {cat.count}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => setAutoCategorizePreview(null)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="button" onClick={handleConfirmAutoCategorize} disabled={autoCategorizing}>
|
||||||
|
<Sparkles className={cn('h-4 w-4', autoCategorizing && 'animate-pulse')} />
|
||||||
|
Apply
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { ChevronLeft, ChevronRight, ChevronDown, Tag, ReceiptText, TrendingDown,
|
||||||
import { api } from '@/api';
|
import { api } from '@/api';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { CategoryPicker } from '@/components/transactions/CategoryPicker';
|
||||||
|
|
||||||
const MONTH_NAMES = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
const MONTH_NAMES = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||||||
|
|
||||||
|
|
@ -18,61 +19,6 @@ function pctBar(amount, budget) {
|
||||||
return { pct, over };
|
return { pct, over };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Category picker dropdown ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function CategoryPicker({ categories, current, onSelect }) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const ref = useRef(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
const close = e => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
|
|
||||||
document.addEventListener('mousedown', close);
|
|
||||||
return () => document.removeEventListener('mousedown', close);
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
const currentCat = categories.find(c => c.id === current);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={ref} className="relative">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setOpen(v => !v)}
|
|
||||||
className="flex items-center gap-1.5 rounded-md border border-border/60 bg-background px-2 py-1 text-xs text-muted-foreground hover:border-primary/40 hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
<Tag className="h-3 w-3 shrink-0" />
|
|
||||||
<span className="max-w-[100px] truncate">{currentCat?.name ?? 'Uncategorized'}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{open && (
|
|
||||||
<div className="absolute right-0 top-full z-50 mt-1 w-48 rounded-lg border border-border/60 bg-popover shadow-lg overflow-hidden">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onMouseDown={e => { e.preventDefault(); onSelect(null, false); setOpen(false); }}
|
|
||||||
className="w-full px-3 py-2 text-left text-xs text-muted-foreground hover:bg-muted/50 transition-colors"
|
|
||||||
>
|
|
||||||
— Uncategorized
|
|
||||||
</button>
|
|
||||||
{categories.length === 0 ? (
|
|
||||||
<p className="px-3 py-2 text-xs text-muted-foreground italic">
|
|
||||||
No spending categories. Enable some in Categories.
|
|
||||||
</p>
|
|
||||||
) : categories.map(cat => (
|
|
||||||
<button
|
|
||||||
key={cat.id}
|
|
||||||
type="button"
|
|
||||||
onMouseDown={e => { e.preventDefault(); onSelect(cat.id, false); setOpen(false); }}
|
|
||||||
className={`w-full px-3 py-2 text-left text-xs hover:bg-muted/50 transition-colors ${cat.id === current ? 'text-primary font-medium' : ''}`}
|
|
||||||
>
|
|
||||||
{cat.name}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Transaction row ──────────────────────────────────────────────────────────
|
// ── Transaction row ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function TxRow({ tx, categories, onCategorize }) {
|
function TxRow({ tx, categories, onCategorize }) {
|
||||||
|
|
|
||||||
|
|
@ -472,6 +472,64 @@ function runAdvisoryFiltersMigration(database) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function runMerchantStoreMatchMigration(database) {
|
||||||
|
database.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS merchant_store_matches (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
entry_kind TEXT NOT NULL,
|
||||||
|
canonical_merchant_id TEXT NOT NULL,
|
||||||
|
canonical_name TEXT NOT NULL,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
category TEXT NOT NULL,
|
||||||
|
merchant_type TEXT,
|
||||||
|
scope TEXT NOT NULL,
|
||||||
|
priority INTEGER NOT NULL DEFAULT 0,
|
||||||
|
match_patterns TEXT NOT NULL,
|
||||||
|
negative_patterns TEXT,
|
||||||
|
locality_city TEXT,
|
||||||
|
locality_state TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_merchant_store_matches_canonical
|
||||||
|
ON merchant_store_matches(canonical_merchant_id);
|
||||||
|
`);
|
||||||
|
|
||||||
|
const count = database.prepare('SELECT COUNT(*) as n FROM merchant_store_matches').get();
|
||||||
|
if (count.n === 0) {
|
||||||
|
const jsonPath = path.join(__dirname, '..', 'docs', 'merchant_store_match_us_nems_online_5k_v0_2.json');
|
||||||
|
const raw = fs.readFileSync(jsonPath, 'utf8');
|
||||||
|
const data = JSON.parse(raw);
|
||||||
|
|
||||||
|
const insertEntry = database.prepare(`
|
||||||
|
INSERT INTO merchant_store_matches
|
||||||
|
(id, entry_kind, canonical_merchant_id, canonical_name, display_name, category,
|
||||||
|
merchant_type, scope, priority, match_patterns, negative_patterns,
|
||||||
|
locality_city, locality_state)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||||
|
`);
|
||||||
|
const insertEntries = database.transaction((rows) => {
|
||||||
|
for (const row of rows) {
|
||||||
|
insertEntry.run(
|
||||||
|
row.id,
|
||||||
|
row.entry_kind,
|
||||||
|
row.canonical_merchant_id,
|
||||||
|
row.canonical_name,
|
||||||
|
row.display_name,
|
||||||
|
row.category,
|
||||||
|
row.merchant_type || null,
|
||||||
|
row.scope,
|
||||||
|
row.priority || 0,
|
||||||
|
JSON.stringify(row.match_patterns || []),
|
||||||
|
JSON.stringify(row.negative_patterns || []),
|
||||||
|
row.locality?.city || null,
|
||||||
|
row.locality?.state || null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
insertEntries(data.merchant_store_entries || []);
|
||||||
|
console.log(`[migration] merchant_store_matches: seeded ${(data.merchant_store_entries || []).length} rows`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function runSubscriptionCatalogMigration(database) {
|
function runSubscriptionCatalogMigration(database) {
|
||||||
database.exec(`
|
database.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS subscription_catalog (
|
CREATE TABLE IF NOT EXISTS subscription_catalog (
|
||||||
|
|
@ -3407,6 +3465,14 @@ function runMigrations() {
|
||||||
console.log('[v1.04] bill_templates.data money fields converted to integer cents');
|
console.log('[v1.04] bill_templates.data money fields converted to integer cents');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
version: 'v1.05',
|
||||||
|
description: 'merchant_store_matches: 5k merchant/store matching pack for bank transaction categorization',
|
||||||
|
dependsOn: ['v1.04'],
|
||||||
|
run: function() {
|
||||||
|
runMerchantStoreMatchMigration(db);
|
||||||
|
}
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// ── users: notification columns ───────────────────────────────────────────
|
// ── users: notification columns ───────────────────────────────────────────
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -15,6 +15,12 @@ const {
|
||||||
unmatchTransaction,
|
unmatchTransaction,
|
||||||
} = require('../services/transactionMatchService');
|
} = require('../services/transactionMatchService');
|
||||||
const { reactivatePaymentsOverriddenBy } = require('../services/paymentAccountingService');
|
const { reactivatePaymentsOverriddenBy } = require('../services/paymentAccountingService');
|
||||||
|
const {
|
||||||
|
findMerchantMatch,
|
||||||
|
applyMerchantStoreMatches,
|
||||||
|
findOrCreateCategory,
|
||||||
|
} = require('../services/merchantStoreMatchService');
|
||||||
|
const { categorizeTransaction } = require('../services/spendingService');
|
||||||
const { todayLocal } = require('../utils/dates');
|
const { todayLocal } = require('../utils/dates');
|
||||||
const { roundMoney } = require('../utils/money');
|
const { roundMoney } = require('../utils/money');
|
||||||
|
|
||||||
|
|
@ -288,6 +294,7 @@ function emptyBankLedger(enabled, hasConnections = false) {
|
||||||
matched: 0,
|
matched: 0,
|
||||||
unmatched: 0,
|
unmatched: 0,
|
||||||
latest_date: null,
|
latest_date: null,
|
||||||
|
category_breakdown: [],
|
||||||
},
|
},
|
||||||
transactions: [],
|
transactions: [],
|
||||||
total: 0,
|
total: 0,
|
||||||
|
|
@ -313,8 +320,8 @@ function bankLedgerWhere(query, userId) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const flow = query.flow ? String(query.flow).trim() : 'all';
|
const flow = query.flow ? String(query.flow).trim() : 'all';
|
||||||
if (!['all', 'in', 'out', 'pending', 'matched', 'unmatched', 'ignored'].includes(flow)) {
|
if (!['all', 'in', 'out', 'pending', 'matched', 'unmatched', 'ignored', 'uncategorized'].includes(flow)) {
|
||||||
return { error: standardizeError('flow must be all, in, out, pending, matched, unmatched, or ignored', 'VALIDATION_ERROR', 'flow') };
|
return { error: standardizeError('flow must be all, in, out, pending, matched, unmatched, ignored, or uncategorized', 'VALIDATION_ERROR', 'flow') };
|
||||||
}
|
}
|
||||||
if (flow === 'in') where.push('t.amount > 0');
|
if (flow === 'in') where.push('t.amount > 0');
|
||||||
if (flow === 'out') where.push('t.amount < 0');
|
if (flow === 'out') where.push('t.amount < 0');
|
||||||
|
|
@ -322,6 +329,7 @@ function bankLedgerWhere(query, userId) {
|
||||||
if (flow === 'matched') where.push("t.match_status = 'matched'");
|
if (flow === 'matched') where.push("t.match_status = 'matched'");
|
||||||
if (flow === 'unmatched') where.push("t.match_status = 'unmatched' AND t.ignored = 0");
|
if (flow === 'unmatched') where.push("t.match_status = 'unmatched' AND t.ignored = 0");
|
||||||
if (flow === 'ignored') where.push('t.ignored = 1');
|
if (flow === 'ignored') where.push('t.ignored = 1');
|
||||||
|
if (flow === 'uncategorized') where.push('t.spending_category_id IS NULL AND t.amount < 0 AND t.ignored = 0');
|
||||||
|
|
||||||
if (query.start_date) {
|
if (query.start_date) {
|
||||||
const parsed = parseDate(query.start_date, 'start_date');
|
const parsed = parseDate(query.start_date, 'start_date');
|
||||||
|
|
@ -340,9 +348,9 @@ function bankLedgerWhere(query, userId) {
|
||||||
const q = `%${String(query.q).trim()}%`;
|
const q = `%${String(query.q).trim()}%`;
|
||||||
where.push(`(
|
where.push(`(
|
||||||
t.description LIKE ? OR t.payee LIKE ? OR t.memo LIKE ? OR t.category LIKE ?
|
t.description LIKE ? OR t.payee LIKE ? OR t.memo LIKE ? OR t.category LIKE ?
|
||||||
OR fa.name LIKE ? OR fa.org_name LIKE ? OR b.name LIKE ?
|
OR fa.name LIKE ? OR fa.org_name LIKE ? OR b.name LIKE ? OR c.name LIKE ?
|
||||||
)`);
|
)`);
|
||||||
params.push(q, q, q, q, q, q, q);
|
params.push(q, q, q, q, q, q, q, q);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { whereClause: where.join(' AND '), params };
|
return { whereClause: where.join(' AND '), params };
|
||||||
|
|
@ -531,6 +539,7 @@ router.get('/bank-ledger', (req, res) => {
|
||||||
JOIN data_sources ds ON ds.id = t.data_source_id AND ds.user_id = t.user_id
|
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 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 bills b ON b.id = t.matched_bill_id AND b.user_id = t.user_id AND b.deleted_at IS NULL
|
||||||
|
LEFT JOIN categories c ON c.id = t.spending_category_id
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const summary = db.prepare(`
|
const summary = db.prepare(`
|
||||||
|
|
@ -547,6 +556,18 @@ router.get('/bank-ledger', (req, res) => {
|
||||||
WHERE ${filtered.whereClause}
|
WHERE ${filtered.whereClause}
|
||||||
`).get(...filtered.params);
|
`).get(...filtered.params);
|
||||||
|
|
||||||
|
summary.category_breakdown = db.prepare(`
|
||||||
|
SELECT
|
||||||
|
COALESCE(c.name, 'Uncategorized') AS name,
|
||||||
|
COUNT(*) AS count,
|
||||||
|
COALESCE(SUM(CASE WHEN t.amount < 0 THEN ABS(t.amount) ELSE 0 END), 0) AS total
|
||||||
|
${joins}
|
||||||
|
WHERE ${filtered.whereClause}
|
||||||
|
GROUP BY COALESCE(c.name, 'Uncategorized')
|
||||||
|
ORDER BY total DESC
|
||||||
|
LIMIT 6
|
||||||
|
`).all(...filtered.params);
|
||||||
|
|
||||||
const total = summary.total;
|
const total = summary.total;
|
||||||
const rows = db.prepare(`
|
const rows = db.prepare(`
|
||||||
SELECT
|
SELECT
|
||||||
|
|
@ -554,6 +575,7 @@ router.get('/bank-ledger', (req, res) => {
|
||||||
t.source_type, t.transaction_type, t.posted_date, t.transacted_at, t.amount,
|
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.currency, t.description, t.payee, t.memo, t.category, t.matched_bill_id,
|
||||||
t.match_status, t.ignored, t.pending, t.created_at, t.updated_at,
|
t.match_status, t.ignored, t.pending, t.created_at, t.updated_at,
|
||||||
|
t.spending_category_id, c.name AS spending_category_name,
|
||||||
ds.type AS data_source_type, ds.provider AS data_source_provider,
|
ds.type AS data_source_type, ds.provider AS data_source_provider,
|
||||||
ds.name AS data_source_name, ds.status AS data_source_status,
|
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.name AS account_name, fa.org_name AS account_org_name,
|
||||||
|
|
@ -572,7 +594,13 @@ router.get('/bank-ledger', (req, res) => {
|
||||||
sources,
|
sources,
|
||||||
accounts,
|
accounts,
|
||||||
summary,
|
summary,
|
||||||
transactions: rows.map(row => decorateTransaction(row)),
|
transactions: rows.map(row => {
|
||||||
|
const decorated = decorateTransaction(row);
|
||||||
|
if (!row.spending_category_id) {
|
||||||
|
decorated.suggested_match = findMerchantMatch(db, row.payee || row.description || row.memo || '');
|
||||||
|
}
|
||||||
|
return decorated;
|
||||||
|
}),
|
||||||
total,
|
total,
|
||||||
limit: page.limit,
|
limit: page.limit,
|
||||||
offset: page.offset,
|
offset: page.offset,
|
||||||
|
|
@ -810,4 +838,58 @@ router.post('/:id/unignore', (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /api/transactions/:id/merchant-match
|
||||||
|
router.get('/:id/merchant-match', (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDb();
|
||||||
|
const id = parseInteger(req.params.id, 'id');
|
||||||
|
if (id.error) return res.status(400).json(id.error);
|
||||||
|
|
||||||
|
const existing = getTransactionForUser(db, req.user.id, id.value);
|
||||||
|
if (!existing) return res.status(404).json(standardizeError('Transaction not found', 'NOT_FOUND', 'id'));
|
||||||
|
|
||||||
|
const match = findMerchantMatch(db, existing.payee || existing.description || existing.memo || '');
|
||||||
|
res.json({ match });
|
||||||
|
} catch (err) {
|
||||||
|
return sendTransactionServiceError(res, err, 'Failed to look up merchant match');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/transactions/:id/apply-merchant-match
|
||||||
|
router.post('/:id/apply-merchant-match', (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDb();
|
||||||
|
const id = parseInteger(req.params.id, 'id');
|
||||||
|
if (id.error) return res.status(400).json(id.error);
|
||||||
|
|
||||||
|
const existing = getTransactionForUser(db, req.user.id, id.value);
|
||||||
|
if (!existing) return res.status(404).json(standardizeError('Transaction not found', 'NOT_FOUND', 'id'));
|
||||||
|
|
||||||
|
const match = findMerchantMatch(db, existing.payee || existing.description || existing.memo || '');
|
||||||
|
if (!match) return res.json({ matched: false });
|
||||||
|
|
||||||
|
const categoryId = findOrCreateCategory(db, req.user.id, match.category);
|
||||||
|
categorizeTransaction(db, req.user.id, id.value, categoryId, true);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
matched: true,
|
||||||
|
category: { id: categoryId, name: match.category },
|
||||||
|
display_name: match.display_name,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return sendTransactionServiceError(res, err, 'Failed to apply merchant match');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/transactions/auto-categorize
|
||||||
|
router.post('/auto-categorize', (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDb();
|
||||||
|
const result = applyMerchantStoreMatches(db, req.user.id, { dryRun: Boolean(req.body?.dry_run) });
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
return sendTransactionServiceError(res, err, 'Failed to auto-categorize transactions');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,9 @@ const { getBankSyncConfig, SYNC_DAYS_EFFECTIVE, SYNC_DAYS_DEFAULT } = require('.
|
||||||
const { decorateDataSource } = require('./transactionService');
|
const { decorateDataSource } = require('./transactionService');
|
||||||
const { applyMerchantRules } = require('./billMerchantRuleService');
|
const { applyMerchantRules } = require('./billMerchantRuleService');
|
||||||
const { applySpendingCategoryRules } = require('./spendingService');
|
const { applySpendingCategoryRules } = require('./spendingService');
|
||||||
|
const { applyMerchantStoreMatches } = require('./merchantStoreMatchService');
|
||||||
const { autoMatchForUser } = require('./matchSuggestionService');
|
const { autoMatchForUser } = require('./matchSuggestionService');
|
||||||
|
const { getUserSettings } = require('./userSettings');
|
||||||
|
|
||||||
function sinceEpochDays(days) {
|
function sinceEpochDays(days) {
|
||||||
return Math.floor((Date.now() - days * 86400 * 1000) / 1000);
|
return Math.floor((Date.now() - days * 86400 * 1000) / 1000);
|
||||||
|
|
@ -197,6 +199,11 @@ async function runSync(db, userId, dataSource, { days, debug = false } = {}) {
|
||||||
// Apply stored merchant→bill rules, then spending category rules, then score-based auto-match
|
// Apply stored merchant→bill rules, then spending category rules, then score-based auto-match
|
||||||
const { matched: autoMatched, matched_bills: matchedBills, late_attributions: lateAttributions } = applyMerchantRules(db, userId);
|
const { matched: autoMatched, matched_bills: matchedBills, late_attributions: lateAttributions } = applyMerchantRules(db, userId);
|
||||||
try { applySpendingCategoryRules(db, userId); } catch { /* non-blocking */ }
|
try { applySpendingCategoryRules(db, userId); } catch { /* non-blocking */ }
|
||||||
|
try {
|
||||||
|
if (getUserSettings(userId).bank_auto_categorize_merchants !== 'false') {
|
||||||
|
applyMerchantStoreMatches(db, userId);
|
||||||
|
}
|
||||||
|
} catch { /* non-blocking */ }
|
||||||
try { autoMatchForUser(userId); } catch { /* non-blocking */ }
|
try { autoMatchForUser(userId); } catch { /* non-blocking */ }
|
||||||
|
|
||||||
if (debug) console.log(`[bankSync:debug] Source #${dataSource.id}: auto-matched ${autoMatched} transaction(s)`);
|
if (debug) console.log(`[bankSync:debug] Source #${dataSource.id}: auto-matched ${autoMatched} transaction(s)`);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,157 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { categorizeTransaction } = require('./spendingService');
|
||||||
|
|
||||||
|
// Mirrors the pack's match_order_recommendation: regional descriptor variants
|
||||||
|
// (most specific — tied to a city/county) are checked first, then online
|
||||||
|
// billing descriptors (PAYPAL/STRIPE/APPLE.COM/BILL/etc.), then canonical
|
||||||
|
// national merchants as the fallback.
|
||||||
|
const ENTRY_KIND_ORDER = {
|
||||||
|
regional_descriptor_variant: 0,
|
||||||
|
online_billing_descriptor_variant: 1,
|
||||||
|
canonical_merchant: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
let _entries = null;
|
||||||
|
|
||||||
|
function maxPatternLength(patterns) {
|
||||||
|
return patterns.reduce((max, p) => Math.max(max, p.length), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lazily load and pre-sort all merchant_store_matches rows. Cached for the
|
||||||
|
// lifetime of the process — this is static reference data seeded once by the
|
||||||
|
// v1.05 migration.
|
||||||
|
function loadMerchantMatchEntries(db) {
|
||||||
|
if (_entries) return _entries;
|
||||||
|
|
||||||
|
const rows = db.prepare(`
|
||||||
|
SELECT id, entry_kind, canonical_merchant_id, canonical_name, display_name,
|
||||||
|
category, merchant_type, scope, priority, match_patterns, negative_patterns,
|
||||||
|
locality_city, locality_state
|
||||||
|
FROM merchant_store_matches
|
||||||
|
`).all();
|
||||||
|
|
||||||
|
_entries = rows.map(row => ({
|
||||||
|
...row,
|
||||||
|
match_patterns: JSON.parse(row.match_patterns || '[]'),
|
||||||
|
negative_patterns: JSON.parse(row.negative_patterns || '[]'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
_entries.sort((a, b) => {
|
||||||
|
const orderA = ENTRY_KIND_ORDER[a.entry_kind] ?? 99;
|
||||||
|
const orderB = ENTRY_KIND_ORDER[b.entry_kind] ?? 99;
|
||||||
|
if (orderA !== orderB) return orderA - orderB;
|
||||||
|
if (b.priority !== a.priority) return b.priority - a.priority;
|
||||||
|
return maxPatternLength(b.match_patterns) - maxPatternLength(a.match_patterns);
|
||||||
|
});
|
||||||
|
|
||||||
|
return _entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply the pack's normalization rules: uppercase, "&" -> "AND", strip
|
||||||
|
// apostrophes/punctuation, collapse whitespace. match_patterns in the pack
|
||||||
|
// are pre-normalized to this same shape (e.g. "THE CHILDREN S PLACE").
|
||||||
|
function normalizeForMatch(value) {
|
||||||
|
return String(value || '')
|
||||||
|
.toUpperCase()
|
||||||
|
.replace(/&/g, ' AND ')
|
||||||
|
.replace(/['’]/g, '')
|
||||||
|
.replace(/[^A-Z0-9\s]/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the best merchant/store match for a raw transaction description.
|
||||||
|
// Returns { entry_id, canonical_name, display_name, category, scope, priority } or null.
|
||||||
|
function findMerchantMatch(db, description) {
|
||||||
|
const normalized = normalizeForMatch(description);
|
||||||
|
if (!normalized) return null;
|
||||||
|
|
||||||
|
const entries = loadMerchantMatchEntries(db);
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.negative_patterns.some(p => normalized.includes(p))) continue;
|
||||||
|
if (entry.match_patterns.some(p => p && normalized.includes(p))) {
|
||||||
|
return {
|
||||||
|
entry_id: entry.id,
|
||||||
|
canonical_name: entry.canonical_name,
|
||||||
|
display_name: entry.display_name,
|
||||||
|
category: entry.category,
|
||||||
|
scope: entry.scope,
|
||||||
|
priority: entry.priority,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find-or-create a spending category by name for this user, matching the
|
||||||
|
// COLLATE NOCASE convention used by ensureUserDefaultCategories (db/database.js).
|
||||||
|
function findOrCreateCategory(db, userId, name) {
|
||||||
|
const existing = db.prepare('SELECT id FROM categories WHERE user_id = ? AND name = ? COLLATE NOCASE')
|
||||||
|
.get(userId, name);
|
||||||
|
if (existing) return existing.id;
|
||||||
|
const result = db.prepare('INSERT INTO categories (user_id, name) VALUES (?, ?)').run(userId, name);
|
||||||
|
return result.lastInsertRowid;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan the user's uncategorized outflows, apply merchant/store pack matches,
|
||||||
|
// and categorize them (writing spending_category_rules so future syncs hit
|
||||||
|
// the cheaper rule path instead of rescanning the full pack).
|
||||||
|
//
|
||||||
|
// With { dryRun: true }, computes the same matches without writing anything
|
||||||
|
// (no categories created, no transactions updated) — used to preview an
|
||||||
|
// auto-categorize run before applying it.
|
||||||
|
//
|
||||||
|
// Returns { updated: number, categories: [{ name, count }], changes: [{ transaction_id, display_name, category }] }.
|
||||||
|
function applyMerchantStoreMatches(db, userId, { dryRun = false } = {}) {
|
||||||
|
const txRows = db.prepare(`
|
||||||
|
SELECT id, payee, description, memo
|
||||||
|
FROM transactions
|
||||||
|
WHERE user_id = ?
|
||||||
|
AND amount < 0
|
||||||
|
AND ignored = 0
|
||||||
|
AND match_status != 'matched'
|
||||||
|
AND spending_category_id IS NULL
|
||||||
|
`).all(userId);
|
||||||
|
|
||||||
|
if (txRows.length === 0) return { updated: 0, categories: [], changes: [] };
|
||||||
|
|
||||||
|
const categoryCounts = new Map(); // category name -> count
|
||||||
|
const changes = [];
|
||||||
|
let updated = 0;
|
||||||
|
|
||||||
|
const apply = () => {
|
||||||
|
for (const tx of txRows) {
|
||||||
|
const match = findMerchantMatch(db, tx.payee || tx.description || tx.memo || '');
|
||||||
|
if (!match) continue;
|
||||||
|
|
||||||
|
if (!dryRun) {
|
||||||
|
const categoryId = findOrCreateCategory(db, userId, match.category);
|
||||||
|
categorizeTransaction(db, userId, tx.id, categoryId, true);
|
||||||
|
}
|
||||||
|
updated++;
|
||||||
|
changes.push({ transaction_id: tx.id, display_name: match.display_name, category: match.category });
|
||||||
|
categoryCounts.set(match.category, (categoryCounts.get(match.category) || 0) + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (dryRun) {
|
||||||
|
apply();
|
||||||
|
} else {
|
||||||
|
db.transaction(apply)();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
updated,
|
||||||
|
categories: [...categoryCounts.entries()].map(([name, count]) => ({ name, count })),
|
||||||
|
changes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
loadMerchantMatchEntries,
|
||||||
|
normalizeForMatch,
|
||||||
|
findMerchantMatch,
|
||||||
|
findOrCreateCategory,
|
||||||
|
applyMerchantStoreMatches,
|
||||||
|
};
|
||||||
|
|
@ -21,10 +21,12 @@ const USER_SETTING_KEYS = [
|
||||||
'tracker_show_overdue_command_center',
|
'tracker_show_overdue_command_center',
|
||||||
'tracker_show_drift_insights',
|
'tracker_show_drift_insights',
|
||||||
'tracker_table_columns',
|
'tracker_table_columns',
|
||||||
|
'bank_auto_categorize_merchants',
|
||||||
];
|
];
|
||||||
|
|
||||||
const USER_SETTING_DEFAULTS = {
|
const USER_SETTING_DEFAULTS = {
|
||||||
search_bars_collapsed: 'false',
|
search_bars_collapsed: 'false',
|
||||||
|
bank_auto_categorize_merchants: 'true',
|
||||||
tracker_show_bank_projection_banner: 'true',
|
tracker_show_bank_projection_banner: 'true',
|
||||||
tracker_bank_projection_banner_snoozed_until: '',
|
tracker_bank_projection_banner_snoozed_until: '',
|
||||||
tracker_show_search_sort: 'true',
|
tracker_show_search_sort: 'true',
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue