feat(banking): bank transactions ledger page with route, sidebar link, and API endpoint
This commit is contained in:
parent
d9a441dff6
commit
ee7026872c
|
|
@ -48,6 +48,7 @@ const SnowballPage = lazy(() => import('@/pages/SnowballPage'));
|
||||||
const HealthPage = lazy(() => import('@/pages/HealthPage'));
|
const HealthPage = lazy(() => import('@/pages/HealthPage'));
|
||||||
const PayoffPage = lazy(() => import('@/pages/PayoffPage'));
|
const PayoffPage = lazy(() => import('@/pages/PayoffPage'));
|
||||||
const SpendingPage = lazy(() => import('@/pages/SpendingPage'));
|
const SpendingPage = lazy(() => import('@/pages/SpendingPage'));
|
||||||
|
const BankTransactionsPage = lazy(() => import('@/pages/BankTransactionsPage'));
|
||||||
|
|
||||||
function RequireAuth({ children, role }) {
|
function RequireAuth({ children, role }) {
|
||||||
const { user, singleUserMode } = useAuth();
|
const { user, singleUserMode } = useAuth();
|
||||||
|
|
@ -221,6 +222,7 @@ export default function App() {
|
||||||
<Route path="snowball" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><SnowballPage /></Suspense></ErrorBoundary>} />
|
<Route path="snowball" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><SnowballPage /></Suspense></ErrorBoundary>} />
|
||||||
<Route path="payoff" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><PayoffPage /></Suspense></ErrorBoundary>} />
|
<Route path="payoff" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><PayoffPage /></Suspense></ErrorBoundary>} />
|
||||||
<Route path="spending" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><SpendingPage /></Suspense></ErrorBoundary>} />
|
<Route path="spending" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><SpendingPage /></Suspense></ErrorBoundary>} />
|
||||||
|
<Route path="bank-transactions" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><BankTransactionsPage /></Suspense></ErrorBoundary>} />
|
||||||
<Route path="data" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><DataPage /></Suspense></ErrorBoundary>} />
|
<Route path="data" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><DataPage /></Suspense></ErrorBoundary>} />
|
||||||
<Route path="profile" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><ProfilePage /></Suspense></ErrorBoundary>} />
|
<Route path="profile" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><ProfilePage /></Suspense></ErrorBoundary>} />
|
||||||
<Route path="*" element={<NotFoundPage />} />
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
|
|
|
||||||
|
|
@ -403,6 +403,7 @@ export const api = {
|
||||||
|
|
||||||
// Transactions
|
// Transactions
|
||||||
transactions: (params = {}) => get(`/transactions${queryString(params)}`),
|
transactions: (params = {}) => get(`/transactions${queryString(params)}`),
|
||||||
|
bankTransactionsLedger: (params = {}) => get(`/transactions/bank-ledger${queryString(params)}`),
|
||||||
createManualTransaction: (data) => post('/transactions/manual', data),
|
createManualTransaction: (data) => post('/transactions/manual', data),
|
||||||
updateTransaction: (id, data) => put(`/transactions/${id}`, data),
|
updateTransaction: (id, data) => put(`/transactions/${id}`, data),
|
||||||
deleteTransaction: (id) => del(`/transactions/${id}`),
|
deleteTransaction: (id) => del(`/transactions/${id}`),
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import { useState, useMemo } from 'react';
|
import { useEffect, useState, useMemo } from 'react';
|
||||||
import { NavLink, useLocation, useNavigate } from 'react-router-dom';
|
import { NavLink, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Activity, BarChart3, Calculator, CalendarDays, ChevronDown, ClipboardCheck, ClipboardList, Database, Info, LayoutGrid, LogOut, Map, Menu, Receipt,
|
Activity, BarChart3, Calculator, CalendarDays, ChevronDown, ClipboardCheck, ClipboardList, Database, Info, LayoutGrid, LogOut, Map, Menu, Receipt,
|
||||||
Search, Settings, ShieldCheck, Tag, TrendingDown, User, X,
|
Search, Settings, ShieldCheck, Tag, TrendingDown, User, X,
|
||||||
Repeat, ShoppingCart,
|
Landmark, Repeat, ShoppingCart,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { api } from '@/api';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import { useOverdueCount } from '@/hooks/useQueries';
|
import { useOverdueCount } from '@/hooks/useQueries';
|
||||||
|
|
@ -42,16 +43,17 @@ const trackerItems = [
|
||||||
{ to: '/categories', icon: Tag, label: 'Categories' },
|
{ to: '/categories', icon: Tag, label: 'Categories' },
|
||||||
{ to: '/health', icon: ClipboardCheck, label: 'Health' },
|
{ to: '/health', icon: ClipboardCheck, label: 'Health' },
|
||||||
{ to: '/spending', icon: ShoppingCart, label: 'Spending' },
|
{ to: '/spending', icon: ShoppingCart, label: 'Spending' },
|
||||||
|
{ to: '/bank-transactions', icon: Landmark, label: 'Banking', simplefinOnly: true },
|
||||||
{ to: '/snowball', icon: TrendingDown, label: 'Snowball' },
|
{ to: '/snowball', icon: TrendingDown, label: 'Snowball' },
|
||||||
{ to: '/payoff', icon: Calculator, label: 'Payoff' },
|
{ to: '/payoff', icon: Calculator, label: 'Payoff' },
|
||||||
];
|
];
|
||||||
|
|
||||||
function TrackerMenu({ onNavigate, badge, badgeNames = [] }) {
|
function TrackerMenu({ onNavigate, badge, badgeNames = [], items = trackerItems }) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const isTrackerActive = useMemo(() => trackerItems.some(item => (
|
const isTrackerActive = useMemo(() => items.some(item => (
|
||||||
item.end ? location.pathname === item.to : location.pathname.startsWith(item.to)
|
item.end ? location.pathname === item.to : location.pathname.startsWith(item.to)
|
||||||
)), [location.pathname]);
|
)), [items, location.pathname]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
|
|
@ -93,7 +95,7 @@ function TrackerMenu({ onNavigate, badge, badgeNames = [] }) {
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start" className="w-48">
|
<DropdownMenuContent align="start" className="w-48">
|
||||||
{trackerItems.map(item => {
|
{items.map(item => {
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
return (
|
return (
|
||||||
<DropdownMenuItem key={item.to} onSelect={() => { navigate(item.to); onNavigate?.(); }}>
|
<DropdownMenuItem key={item.to} onSelect={() => { navigate(item.to); onNavigate?.(); }}>
|
||||||
|
|
@ -193,9 +195,27 @@ export default function Sidebar({ adminMode = false }) {
|
||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const items = useMemo(() => adminMode ? adminNavItems : userNavItems, [adminMode]);
|
const items = useMemo(() => adminMode ? adminNavItems : userNavItems, [adminMode]);
|
||||||
|
const [simplefinReady, setSimplefinReady] = useState(false);
|
||||||
const { data: overdueData } = useOverdueCount();
|
const { data: overdueData } = useOverdueCount();
|
||||||
const overdueCount = (!adminMode && overdueData?.count) ? overdueData.count : 0;
|
const overdueCount = (!adminMode && overdueData?.count) ? overdueData.count : 0;
|
||||||
const overdueNames = (!adminMode && overdueData?.names) ? overdueData.names : [];
|
const overdueNames = (!adminMode && overdueData?.names) ? overdueData.names : [];
|
||||||
|
const trackerMenuItems = useMemo(
|
||||||
|
() => trackerItems.filter(item => !item.simplefinOnly || simplefinReady),
|
||||||
|
[simplefinReady],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (adminMode) return undefined;
|
||||||
|
let cancelled = false;
|
||||||
|
api.simplefinStatus()
|
||||||
|
.then(status => {
|
||||||
|
if (!cancelled) setSimplefinReady(Boolean(status?.enabled && status?.has_connections));
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setSimplefinReady(false);
|
||||||
|
});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [adminMode]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-40 border-b border-border/80 bg-card/95 shadow-sm shadow-foreground/10 backdrop-blur-md supports-[backdrop-filter]:bg-card/90">
|
<header className="sticky top-0 z-40 border-b border-border/80 bg-card/95 shadow-sm shadow-foreground/10 backdrop-blur-md supports-[backdrop-filter]:bg-card/90">
|
||||||
|
|
@ -203,7 +223,7 @@ export default function Sidebar({ adminMode = false }) {
|
||||||
<BrandBlock adminMode={adminMode} />
|
<BrandBlock adminMode={adminMode} />
|
||||||
|
|
||||||
<nav className="hidden items-center gap-1 lg:flex">
|
<nav className="hidden items-center gap-1 lg:flex">
|
||||||
{!adminMode && <TrackerMenu badge={overdueCount} badgeNames={overdueNames} />}
|
{!adminMode && <TrackerMenu badge={overdueCount} badgeNames={overdueNames} items={trackerMenuItems} />}
|
||||||
{items.map(item => (
|
{items.map(item => (
|
||||||
<NavPill key={item.to} item={item} />
|
<NavPill key={item.to} item={item} />
|
||||||
))}
|
))}
|
||||||
|
|
@ -244,7 +264,7 @@ export default function Sidebar({ adminMode = false }) {
|
||||||
{mobileOpen && (
|
{mobileOpen && (
|
||||||
<div className="max-h-[70vh] overflow-y-auto border-t border-border/70 bg-card/95 px-4 py-3 shadow-lg shadow-foreground/10 lg:hidden">
|
<div className="max-h-[70vh] overflow-y-auto border-t border-border/70 bg-card/95 px-4 py-3 shadow-lg shadow-foreground/10 lg:hidden">
|
||||||
<nav className="mx-auto grid max-w-[1500px] gap-1">
|
<nav className="mx-auto grid max-w-[1500px] gap-1">
|
||||||
{!adminMode && trackerItems.map(item => (
|
{!adminMode && trackerMenuItems.map(item => (
|
||||||
<NavPill
|
<NavPill
|
||||||
key={item.to}
|
key={item.to}
|
||||||
item={item}
|
item={item}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,567 @@
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
ArrowDown,
|
||||||
|
ArrowDownUp,
|
||||||
|
ArrowUp,
|
||||||
|
Building2,
|
||||||
|
CheckCircle2,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Clock3,
|
||||||
|
Landmark,
|
||||||
|
Link2,
|
||||||
|
RefreshCw,
|
||||||
|
Search,
|
||||||
|
TrendingDown,
|
||||||
|
TrendingUp,
|
||||||
|
WalletCards,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { api } from '@/api';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import { cn, fmt, fmtDate } from '@/lib/utils';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
|
const flowOptions = [
|
||||||
|
{ value: 'all', label: 'All' },
|
||||||
|
{ value: 'in', label: 'Money in' },
|
||||||
|
{ value: 'out', label: 'Money out' },
|
||||||
|
{ value: 'pending', label: 'Pending' },
|
||||||
|
{ value: 'unmatched', label: 'Needs review' },
|
||||||
|
{ value: 'matched', label: 'Matched' },
|
||||||
|
{ value: 'ignored', label: 'Ignored' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const sortOptions = [
|
||||||
|
{ value: 'date', label: 'Date' },
|
||||||
|
{ value: 'amount', label: 'Amount' },
|
||||||
|
{ value: 'merchant', label: 'Merchant' },
|
||||||
|
{ value: 'account', label: 'Account' },
|
||||||
|
{ value: 'status', label: 'Status' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function formatCents(value, { signed = false } = {}) {
|
||||||
|
const cents = Number(value || 0);
|
||||||
|
const amount = fmt(Math.abs(cents) / 100);
|
||||||
|
if (!signed || cents === 0) return amount;
|
||||||
|
return `${cents > 0 ? '+' : '-'}${amount}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSyncTime(value) {
|
||||||
|
if (!value) return 'Not synced yet';
|
||||||
|
const normalized = String(value).includes('T') ? String(value) : String(value).replace(' ', 'T');
|
||||||
|
const date = new Date(normalized);
|
||||||
|
if (Number.isNaN(date.getTime())) return String(value);
|
||||||
|
return new Intl.DateTimeFormat(undefined, {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
function transactionTitle(tx) {
|
||||||
|
return tx.payee || tx.description || tx.memo || 'Transaction';
|
||||||
|
}
|
||||||
|
|
||||||
|
function transactionDate(tx) {
|
||||||
|
return tx.posted_date || (tx.transacted_at ? tx.transacted_at.slice(0, 10) : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function accountLabel(account) {
|
||||||
|
if (!account) return 'Unknown account';
|
||||||
|
return [account.org_name, account.name].filter(Boolean).join(' - ') || account.name || 'Account';
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ tx }) {
|
||||||
|
if (tx.pending) {
|
||||||
|
return (
|
||||||
|
<Badge className="border-amber-300/50 bg-amber-400/15 text-amber-700 dark:text-amber-200">
|
||||||
|
<Clock3 className="mr-1 h-3 w-3" />
|
||||||
|
Pending
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (tx.ignored || tx.match_status === 'ignored') {
|
||||||
|
return <Badge variant="outline" className="border-muted-foreground/30 text-muted-foreground">Ignored</Badge>;
|
||||||
|
}
|
||||||
|
if (tx.match_status === 'matched') {
|
||||||
|
return (
|
||||||
|
<Badge className="border-emerald-300/50 bg-emerald-400/15 text-emerald-700 dark:text-emerald-200">
|
||||||
|
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||||
|
Matched
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <Badge className="border-sky-300/50 bg-sky-400/15 text-sky-700 dark:text-sky-200">Review</Badge>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummaryTile({ icon: Icon, label, value, tone, detail }) {
|
||||||
|
const tones = {
|
||||||
|
emerald: 'border-emerald-300/40 bg-emerald-400/10 text-emerald-700 dark:text-emerald-200',
|
||||||
|
rose: 'border-rose-300/40 bg-rose-400/10 text-rose-700 dark:text-rose-200',
|
||||||
|
sky: 'border-sky-300/40 bg-sky-400/10 text-sky-700 dark:text-sky-200',
|
||||||
|
amber: 'border-amber-300/40 bg-amber-400/10 text-amber-700 dark:text-amber-200',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-border/70 bg-card/80 p-4 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">{label}</p>
|
||||||
|
<span className={cn('flex h-9 w-9 items-center justify-center rounded-full border', tones[tone])}>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="tracker-number mt-3 text-2xl font-bold text-foreground">{value}</p>
|
||||||
|
{detail && <p className="mt-1 text-xs font-medium text-muted-foreground">{detail}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TransactionMobileCard({ tx }) {
|
||||||
|
const cents = Number(tx.amount || 0);
|
||||||
|
const isCredit = cents > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-border/70 bg-card/85 p-4 shadow-sm">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-semibold text-foreground">{transactionTitle(tx)}</p>
|
||||||
|
<p className="mt-1 truncate text-xs font-medium text-muted-foreground">
|
||||||
|
{[tx.account_org_name, tx.account_name].filter(Boolean).join(' - ') || 'Account'}
|
||||||
|
</p>
|
||||||
|
</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')}>
|
||||||
|
{formatCents(cents, { signed: true })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||||
|
<Badge variant="outline" className="text-muted-foreground">{fmtDate(transactionDate(tx))}</Badge>
|
||||||
|
<StatusBadge tx={tx} />
|
||||||
|
{tx.matched_bill_name && (
|
||||||
|
<Badge className="border-indigo-300/50 bg-indigo-400/15 text-indigo-700 dark:text-indigo-200">
|
||||||
|
<Link2 className="mr-1 h-3 w-3" />
|
||||||
|
{tx.matched_bill_name}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BankTransactionsPage() {
|
||||||
|
const [ledger, setLedger] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [accountId, setAccountId] = useState('all');
|
||||||
|
const [flow, setFlow] = useState('all');
|
||||||
|
const [sortBy, setSortBy] = useState('date');
|
||||||
|
const [sortDir, setSortDir] = useState('desc');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
// Monotonic request id: a response only lands if it belongs to the newest
|
||||||
|
// request, so a slow Refresh can never overwrite fresher filter results.
|
||||||
|
const requestSeq = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const id = window.setTimeout(() => {
|
||||||
|
setQuery(search.trim());
|
||||||
|
setPage(1);
|
||||||
|
}, 250);
|
||||||
|
return () => window.clearTimeout(id);
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(1);
|
||||||
|
}, [accountId, flow, sortBy, sortDir]);
|
||||||
|
|
||||||
|
// Single fetch path — used by both the load effect and the Refresh button.
|
||||||
|
const loadLedger = useCallback(async () => {
|
||||||
|
const seq = ++requestSeq.current;
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const data = await api.bankTransactionsLedger({
|
||||||
|
limit: PAGE_SIZE,
|
||||||
|
offset: (page - 1) * PAGE_SIZE,
|
||||||
|
q: query,
|
||||||
|
account_id: accountId === 'all' ? undefined : accountId,
|
||||||
|
flow,
|
||||||
|
sort_by: sortBy,
|
||||||
|
sort_dir: sortDir,
|
||||||
|
});
|
||||||
|
if (seq === requestSeq.current) setLedger(data);
|
||||||
|
} catch (err) {
|
||||||
|
if (seq === requestSeq.current) setError(err.message || 'Unable to load bank transactions');
|
||||||
|
} finally {
|
||||||
|
if (seq === requestSeq.current) setLoading(false);
|
||||||
|
}
|
||||||
|
}, [accountId, flow, page, query, sortBy, sortDir]);
|
||||||
|
|
||||||
|
useEffect(() => { loadLedger(); }, [loadLedger]);
|
||||||
|
|
||||||
|
const accounts = ledger?.accounts || [];
|
||||||
|
const transactions = ledger?.transactions || [];
|
||||||
|
const summary = ledger?.summary || {};
|
||||||
|
const total = Number(ledger?.total || 0);
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loading && page > totalPages) setPage(totalPages);
|
||||||
|
}, [loading, page, totalPages]);
|
||||||
|
const latestSync = useMemo(() => {
|
||||||
|
const times = (ledger?.sources || []).map(source => source.last_sync_at).filter(Boolean).sort();
|
||||||
|
return times[times.length - 1] || null;
|
||||||
|
}, [ledger?.sources]);
|
||||||
|
const connected = Boolean(ledger?.enabled && ledger?.has_connections);
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
if (!loading && error && !ledger) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<section className="rounded-lg border border-rose-300/40 bg-rose-400/10 p-6 shadow-sm">
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-foreground">Bank Transactions</h1>
|
||||||
|
<p className="mt-1 text-sm font-medium text-rose-700 dark:text-rose-200">{error}</p>
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="outline" onClick={loadLedger}>
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
Try again
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!loading && !connected) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<section className="rounded-lg border border-border/70 bg-card/85 p-6 shadow-sm">
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="flex h-10 w-10 items-center justify-center rounded-full border border-sky-300/40 bg-sky-400/10 text-sky-700 dark:text-sky-200">
|
||||||
|
<Landmark className="h-5 w-5" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-normal text-foreground">Bank Transactions</h1>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">SimpleFIN bridge required</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button asChild>
|
||||||
|
<Link to="/data">
|
||||||
|
<WalletCards className="h-4 w-4" />
|
||||||
|
Open Data
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<section className="rounded-lg border border-border/70 bg-[linear-gradient(135deg,oklch(var(--card))_0%,oklch(var(--muted)/0.72)_58%,oklch(var(--primary)/0.10)_100%)] p-5 shadow-sm">
|
||||||
|
<div className="flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<span className="flex h-11 w-11 items-center justify-center rounded-full border border-sky-300/40 bg-sky-400/10 text-sky-700 dark:text-sky-200">
|
||||||
|
<Landmark className="h-5 w-5" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-normal text-foreground sm:text-3xl">Bank Transactions</h1>
|
||||||
|
<p className="mt-1 text-sm font-medium text-muted-foreground">
|
||||||
|
{accounts.length} monitored {accounts.length === 1 ? 'account' : 'accounts'} synced through SimpleFIN
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge className={cn(
|
||||||
|
connected
|
||||||
|
? 'border-emerald-300/50 bg-emerald-400/15 text-emerald-700 dark:text-emerald-200'
|
||||||
|
: 'border-sky-300/50 bg-sky-400/15 text-sky-700 dark:text-sky-200',
|
||||||
|
)}>
|
||||||
|
{connected ? 'Connected' : 'Loading'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
|
<div className="rounded-lg border border-border/70 bg-card/75 px-3 py-2 text-sm shadow-sm">
|
||||||
|
<span className="font-medium text-muted-foreground">Last sync </span>
|
||||||
|
<span className="font-semibold text-foreground">{formatSyncTime(latestSync)}</span>
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="outline" onClick={loadLedger} disabled={loading}>
|
||||||
|
<RefreshCw className={cn('h-4 w-4', loading && 'animate-spin')} />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg border border-rose-300/40 bg-rose-400/10 px-4 py-3 text-sm font-medium text-rose-700 dark:text-rose-200">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<SummaryTile
|
||||||
|
icon={TrendingUp}
|
||||||
|
label="Money in"
|
||||||
|
value={formatCents(summary.inflow)}
|
||||||
|
tone="emerald"
|
||||||
|
detail={`${Number(summary.matched || 0)} matched`}
|
||||||
|
/>
|
||||||
|
<SummaryTile
|
||||||
|
icon={TrendingDown}
|
||||||
|
label="Money out"
|
||||||
|
value={formatCents(summary.outflow)}
|
||||||
|
tone="rose"
|
||||||
|
detail={`${Number(summary.unmatched || 0)} need review`}
|
||||||
|
/>
|
||||||
|
<SummaryTile
|
||||||
|
icon={ArrowDownUp}
|
||||||
|
label="Net flow"
|
||||||
|
value={formatCents(summary.net, { signed: true })}
|
||||||
|
tone="sky"
|
||||||
|
detail={summary.latest_date ? `Latest ${fmtDate(summary.latest_date)}` : 'No posted activity'}
|
||||||
|
/>
|
||||||
|
<SummaryTile
|
||||||
|
icon={Clock3}
|
||||||
|
label="Pending"
|
||||||
|
value={String(Number(summary.pending || 0))}
|
||||||
|
tone="amber"
|
||||||
|
detail={`${Number(summary.total || 0)} total transactions`}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{accounts.length > 0 && (
|
||||||
|
<section className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{accounts.map(account => (
|
||||||
|
<div key={account.id} className="rounded-lg border border-border/70 bg-card/85 p-4 shadow-sm">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-semibold text-foreground">{accountLabel(account)}</p>
|
||||||
|
<p className="mt-1 truncate text-xs font-medium text-muted-foreground">
|
||||||
|
{[account.account_type, account.currency].filter(Boolean).join(' - ') || 'Bank account'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full border border-indigo-300/40 bg-indigo-400/10 text-indigo-700 dark:text-indigo-200">
|
||||||
|
<Building2 className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">Balance</p>
|
||||||
|
<p className="tracker-number mt-1 text-lg font-bold text-foreground">{formatCents(account.balance)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">Available</p>
|
||||||
|
<p className="tracker-number mt-1 text-lg font-bold text-emerald-600 dark:text-emerald-300">
|
||||||
|
{account.available_balance == null ? '—' : formatCents(account.available_balance)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex items-center justify-between text-xs font-medium text-muted-foreground">
|
||||||
|
<span>{Number(account.transaction_count || 0)} transactions</span>
|
||||||
|
<span>{account.last_transaction_date ? fmtDate(account.last_transaction_date) : 'No activity'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="space-y-4">
|
||||||
|
<div className="grid gap-3 rounded-lg border border-border/70 bg-card/85 p-3 shadow-sm lg:grid-cols-[minmax(18rem,1fr)_12rem_12rem_10rem_auto]">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={event => setSearch(event.target.value)}
|
||||||
|
placeholder="Search merchant, memo, category"
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Select value={accountId} onValueChange={setAccountId}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Account" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All accounts</SelectItem>
|
||||||
|
{accounts.map(account => (
|
||||||
|
<SelectItem key={account.id} value={String(account.id)}>{accountLabel(account)}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select value={flow} onValueChange={setFlow}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Type" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{flowOptions.map(option => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select value={sortBy} onValueChange={setSortBy}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Sort" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{sortOptions.map(option => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setSortDir(value => (value === 'asc' ? 'desc' : 'asc'))}
|
||||||
|
title={`Sort ${sortDir === 'asc' ? 'ascending' : 'descending'}`}
|
||||||
|
>
|
||||||
|
{sortDir === 'asc' ? <ArrowUp className="h-4 w-4" /> : <ArrowDown className="h-4 w-4" />}
|
||||||
|
{sortDir === 'asc' ? 'Ascending' : 'Descending'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hidden overflow-hidden rounded-lg border border-border/70 bg-card/85 shadow-sm lg:block">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="bg-muted/35 hover:bg-muted/35">
|
||||||
|
<TableHead className="w-32">Date</TableHead>
|
||||||
|
<TableHead>Merchant</TableHead>
|
||||||
|
<TableHead>Account</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead className="text-right">Amount</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{loading && transactions.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} className="py-10 text-center text-muted-foreground">Loading transactions...</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{!loading && transactions.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} className="py-10 text-center text-muted-foreground">No transactions found.</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{transactions.map(tx => {
|
||||||
|
const cents = Number(tx.amount || 0);
|
||||||
|
const isCredit = cents > 0;
|
||||||
|
return (
|
||||||
|
<TableRow key={tx.id}>
|
||||||
|
<TableCell className="text-muted-foreground">{fmtDate(transactionDate(tx))}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate font-semibold text-foreground">{transactionTitle(tx)}</p>
|
||||||
|
<p className="truncate text-xs font-medium text-muted-foreground">
|
||||||
|
{[tx.memo, tx.category].filter(Boolean).join(' - ') || tx.description || 'SimpleFIN'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-semibold text-foreground">
|
||||||
|
{[tx.account_org_name, tx.account_name].filter(Boolean).join(' - ') || 'Account'}
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-xs font-medium text-muted-foreground">{tx.account_type || tx.currency || 'Bank'}</p>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<StatusBadge tx={tx} />
|
||||||
|
{tx.matched_bill_name && (
|
||||||
|
<Badge className="border-indigo-300/50 bg-indigo-400/15 text-indigo-700 dark:text-indigo-200">
|
||||||
|
<Link2 className="mr-1 h-3 w-3" />
|
||||||
|
{tx.matched_bill_name}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<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 })}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 lg:hidden">
|
||||||
|
{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">
|
||||||
|
Loading transactions...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!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">
|
||||||
|
No transactions found.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{transactions.map(tx => <TransactionMobileCard key={tx.id} tx={tx} />)}
|
||||||
|
</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">
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">
|
||||||
|
{total === 0
|
||||||
|
? '0 transactions'
|
||||||
|
: `${(page - 1) * PAGE_SIZE + 1}-${Math.min(page * PAGE_SIZE, total)} of ${total} transactions`}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={page <= 1 || loading}
|
||||||
|
onClick={() => setPage(value => Math.max(1, value - 1))}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<span className="min-w-20 text-center text-sm font-semibold text-foreground">
|
||||||
|
{page} / {totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={page >= totalPages || loading}
|
||||||
|
onClick={() => setPage(value => Math.min(totalPages, value + 1))}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ const {
|
||||||
getTransactionForUser,
|
getTransactionForUser,
|
||||||
} = require('../services/transactionService');
|
} = require('../services/transactionService');
|
||||||
const { checkTransaction: advisoryCheck } = require('../services/advisoryFilterService');
|
const { checkTransaction: advisoryCheck } = require('../services/advisoryFilterService');
|
||||||
|
const { getBankSyncConfig } = require('../services/bankSyncConfigService');
|
||||||
const {
|
const {
|
||||||
ignoreTransaction,
|
ignoreTransaction,
|
||||||
matchTransactionToBill,
|
matchTransactionToBill,
|
||||||
|
|
@ -272,6 +273,81 @@ function sendTransactionServiceError(res, err, fallbackMessage = 'Transaction op
|
||||||
return res.status(500).json(standardizeError(fallbackMessage, 'TRANSACTION_ERROR'));
|
return res.status(500).json(standardizeError(fallbackMessage, 'TRANSACTION_ERROR'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function emptyBankLedger(enabled, hasConnections = false) {
|
||||||
|
return {
|
||||||
|
enabled: Boolean(enabled),
|
||||||
|
has_connections: Boolean(hasConnections),
|
||||||
|
sources: [],
|
||||||
|
accounts: [],
|
||||||
|
summary: {
|
||||||
|
total: 0,
|
||||||
|
inflow: 0,
|
||||||
|
outflow: 0,
|
||||||
|
net: 0,
|
||||||
|
pending: 0,
|
||||||
|
matched: 0,
|
||||||
|
unmatched: 0,
|
||||||
|
latest_date: null,
|
||||||
|
},
|
||||||
|
transactions: [],
|
||||||
|
total: 0,
|
||||||
|
limit: 50,
|
||||||
|
offset: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function bankLedgerWhere(query, userId) {
|
||||||
|
const where = [
|
||||||
|
't.user_id = ?',
|
||||||
|
"ds.type = 'provider_sync'",
|
||||||
|
"ds.provider = 'simplefin'",
|
||||||
|
'(t.account_id IS NULL OR fa.id IS NULL OR fa.monitored = 1)',
|
||||||
|
];
|
||||||
|
const params = [userId];
|
||||||
|
|
||||||
|
if (query.account_id && query.account_id !== 'all') {
|
||||||
|
const parsed = parseInteger(query.account_id, 'account_id');
|
||||||
|
if (parsed.error) return { error: parsed.error };
|
||||||
|
where.push('t.account_id = ?');
|
||||||
|
params.push(parsed.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const flow = query.flow ? String(query.flow).trim() : 'all';
|
||||||
|
if (!['all', 'in', 'out', 'pending', 'matched', 'unmatched', 'ignored'].includes(flow)) {
|
||||||
|
return { error: standardizeError('flow must be all, in, out, pending, matched, unmatched, or ignored', 'VALIDATION_ERROR', 'flow') };
|
||||||
|
}
|
||||||
|
if (flow === 'in') where.push('t.amount > 0');
|
||||||
|
if (flow === 'out') where.push('t.amount < 0');
|
||||||
|
if (flow === 'pending') where.push('t.pending = 1');
|
||||||
|
if (flow === 'matched') where.push("t.match_status = 'matched'");
|
||||||
|
if (flow === 'unmatched') where.push("t.match_status = 'unmatched' AND t.ignored = 0");
|
||||||
|
if (flow === 'ignored') where.push('t.ignored = 1');
|
||||||
|
|
||||||
|
if (query.start_date) {
|
||||||
|
const parsed = parseDate(query.start_date, 'start_date');
|
||||||
|
if (parsed.error) return { error: parsed.error };
|
||||||
|
where.push("COALESCE(t.posted_date, substr(t.transacted_at, 1, 10)) >= ?");
|
||||||
|
params.push(parsed.value);
|
||||||
|
}
|
||||||
|
if (query.end_date) {
|
||||||
|
const parsed = parseDate(query.end_date, 'end_date');
|
||||||
|
if (parsed.error) return { error: parsed.error };
|
||||||
|
where.push("COALESCE(t.posted_date, substr(t.transacted_at, 1, 10)) <= ?");
|
||||||
|
params.push(parsed.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.q) {
|
||||||
|
const q = `%${String(query.q).trim()}%`;
|
||||||
|
where.push(`(
|
||||||
|
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 ?
|
||||||
|
)`);
|
||||||
|
params.push(q, q, q, q, q, q, q);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { whereClause: where.join(' AND '), params };
|
||||||
|
}
|
||||||
|
|
||||||
// GET /api/transactions
|
// GET /api/transactions
|
||||||
router.get('/', (req, res) => {
|
router.get('/', (req, res) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
@ -400,6 +476,109 @@ router.get('/', (req, res) => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /api/transactions/bank-ledger
|
||||||
|
router.get('/bank-ledger', (req, res) => {
|
||||||
|
const config = getBankSyncConfig();
|
||||||
|
const page = parseLimitOffset(req.query);
|
||||||
|
if (page.error) return res.status(400).json(page.error);
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const sources = db.prepare(`
|
||||||
|
SELECT id, user_id, type, provider, name, status, last_sync_at, created_at, updated_at
|
||||||
|
FROM data_sources
|
||||||
|
WHERE user_id = ? AND type = 'provider_sync' AND provider = 'simplefin'
|
||||||
|
ORDER BY updated_at DESC, id DESC
|
||||||
|
`).all(req.user.id);
|
||||||
|
|
||||||
|
if (!config.enabled || sources.length === 0) {
|
||||||
|
return res.json({ ...emptyBankLedger(config.enabled, sources.length > 0), sources, limit: page.limit, offset: page.offset });
|
||||||
|
}
|
||||||
|
|
||||||
|
const accounts = db.prepare(`
|
||||||
|
SELECT
|
||||||
|
fa.id, fa.data_source_id, fa.name, fa.org_name, fa.account_type, fa.currency,
|
||||||
|
fa.balance, fa.available_balance, fa.monitored, fa.updated_at,
|
||||||
|
ds.name AS source_name, ds.status AS source_status, ds.last_sync_at,
|
||||||
|
COUNT(t.id) AS transaction_count,
|
||||||
|
MAX(COALESCE(t.posted_date, substr(t.transacted_at, 1, 10))) AS last_transaction_date
|
||||||
|
FROM financial_accounts fa
|
||||||
|
JOIN data_sources ds ON ds.id = fa.data_source_id AND ds.user_id = fa.user_id
|
||||||
|
LEFT JOIN transactions t ON t.account_id = fa.id AND t.user_id = fa.user_id
|
||||||
|
WHERE fa.user_id = ?
|
||||||
|
AND fa.monitored = 1
|
||||||
|
AND ds.type = 'provider_sync'
|
||||||
|
AND ds.provider = 'simplefin'
|
||||||
|
GROUP BY fa.id
|
||||||
|
ORDER BY COALESCE(fa.org_name, ''), fa.name
|
||||||
|
`).all(req.user.id);
|
||||||
|
|
||||||
|
const filtered = bankLedgerWhere(req.query, req.user.id);
|
||||||
|
if (filtered.error) return res.status(400).json(filtered.error);
|
||||||
|
|
||||||
|
const SORT_COLUMNS = {
|
||||||
|
date: "COALESCE(t.posted_date, substr(t.transacted_at, 1, 10), t.created_at)",
|
||||||
|
amount: 't.amount',
|
||||||
|
merchant: "LOWER(COALESCE(t.payee, t.description, t.memo, ''))",
|
||||||
|
account: "LOWER(COALESCE(fa.org_name, fa.name, ''))",
|
||||||
|
status: 't.match_status',
|
||||||
|
};
|
||||||
|
const sortBy = SORT_COLUMNS[req.query.sort_by] ? req.query.sort_by : 'date';
|
||||||
|
const sortDir = req.query.sort_dir === 'asc' ? 'ASC' : 'DESC';
|
||||||
|
const orderBy = `${SORT_COLUMNS[sortBy]} ${sortDir}, t.id ${sortDir}`;
|
||||||
|
|
||||||
|
const joins = `
|
||||||
|
FROM transactions t
|
||||||
|
JOIN data_sources ds ON ds.id = t.data_source_id AND ds.user_id = t.user_id
|
||||||
|
LEFT JOIN financial_accounts fa ON fa.id = t.account_id AND fa.user_id = t.user_id
|
||||||
|
LEFT JOIN bills b ON b.id = t.matched_bill_id AND b.user_id = t.user_id AND b.deleted_at IS NULL
|
||||||
|
`;
|
||||||
|
|
||||||
|
const summary = db.prepare(`
|
||||||
|
SELECT
|
||||||
|
COUNT(*) AS total,
|
||||||
|
COALESCE(SUM(CASE WHEN t.amount > 0 THEN t.amount ELSE 0 END), 0) AS inflow,
|
||||||
|
COALESCE(SUM(CASE WHEN t.amount < 0 THEN ABS(t.amount) ELSE 0 END), 0) AS outflow,
|
||||||
|
COALESCE(SUM(t.amount), 0) AS net,
|
||||||
|
COALESCE(SUM(CASE WHEN t.pending = 1 THEN 1 ELSE 0 END), 0) AS pending,
|
||||||
|
COALESCE(SUM(CASE WHEN t.match_status = 'matched' THEN 1 ELSE 0 END), 0) AS matched,
|
||||||
|
COALESCE(SUM(CASE WHEN t.match_status = 'unmatched' AND t.ignored = 0 THEN 1 ELSE 0 END), 0) AS unmatched,
|
||||||
|
MAX(COALESCE(t.posted_date, substr(t.transacted_at, 1, 10))) AS latest_date
|
||||||
|
${joins}
|
||||||
|
WHERE ${filtered.whereClause}
|
||||||
|
`).get(...filtered.params);
|
||||||
|
|
||||||
|
const total = summary.total;
|
||||||
|
const rows = db.prepare(`
|
||||||
|
SELECT
|
||||||
|
t.id, t.user_id, t.data_source_id, t.account_id, t.provider_transaction_id,
|
||||||
|
t.source_type, t.transaction_type, t.posted_date, t.transacted_at, t.amount,
|
||||||
|
t.currency, t.description, t.payee, t.memo, t.category, t.matched_bill_id,
|
||||||
|
t.match_status, t.ignored, t.pending, t.created_at, t.updated_at,
|
||||||
|
ds.type AS data_source_type, ds.provider AS data_source_provider,
|
||||||
|
ds.name AS data_source_name, ds.status AS data_source_status,
|
||||||
|
fa.name AS account_name, fa.org_name AS account_org_name,
|
||||||
|
fa.account_type AS account_type, fa.balance AS account_balance,
|
||||||
|
fa.available_balance AS account_available_balance,
|
||||||
|
b.name AS matched_bill_name
|
||||||
|
${joins}
|
||||||
|
WHERE ${filtered.whereClause}
|
||||||
|
ORDER BY ${orderBy}
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
`).all(...filtered.params, page.limit, page.offset);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
enabled: true,
|
||||||
|
has_connections: true,
|
||||||
|
sources,
|
||||||
|
accounts,
|
||||||
|
summary,
|
||||||
|
transactions: rows.map(row => decorateTransaction(row)),
|
||||||
|
total,
|
||||||
|
limit: page.limit,
|
||||||
|
offset: page.offset,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// POST /api/transactions/manual
|
// POST /api/transactions/manual
|
||||||
router.post('/manual', (req, res) => {
|
router.post('/manual', (req, res) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue