This commit is contained in:
null 2026-05-18 09:44:16 -05:00
parent 55837b8b25
commit 82de135186
5 changed files with 568 additions and 241 deletions

View File

@ -67,6 +67,26 @@ function isTransactionLinkedPayment(payment) {
return payment?.payment_source === 'transaction_match' || payment?.transaction_id != null; return payment?.payment_source === 'transaction_match' || payment?.transaction_id != null;
} }
function paymentSourceLabel(source) {
const labels = {
manual: 'Manual',
file_import: 'File import',
provider_sync: 'Sync',
transaction_match: 'Transaction',
};
return labels[source] || source || 'Manual';
}
function paymentSourceTone(source) {
const tones = {
manual: 'border-emerald-500/25 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
file_import: 'border-sky-500/25 bg-sky-500/10 text-sky-600 dark:text-sky-400',
provider_sync: 'border-violet-500/25 bg-violet-500/10 text-violet-600 dark:text-violet-400',
transaction_match: 'border-primary/25 bg-primary/10 text-primary',
};
return tones[source] || tones.manual;
}
function isDebtCat(categories, catId) { function isDebtCat(categories, catId) {
if (!catId || catId === CAT_NONE) return false; if (!catId || catId === CAT_NONE) return false;
const cat = categories.find(c => String(c.id) === catId); const cat = categories.find(c => String(c.id) === catId);
@ -886,8 +906,11 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
<div className="min-w-0"> <div className="min-w-0">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<p className="font-mono text-sm font-semibold text-foreground">{fmt(payment.amount)}</p> <p className="font-mono text-sm font-semibold text-foreground">{fmt(payment.amount)}</p>
<span className="rounded-md border border-emerald-500/25 bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-emerald-600 dark:text-emerald-400"> <span className={cn(
{payment.payment_source || 'manual'} 'rounded-md border px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide',
paymentSourceTone(payment.payment_source),
)}>
{paymentSourceLabel(payment.payment_source)}
</span> </span>
</div> </div>
<p className="mt-0.5 truncate text-xs text-muted-foreground"> <p className="mt-0.5 truncate text-xs text-muted-foreground">
@ -926,9 +949,14 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Linked transactions</p> <p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Linked transactions</p>
<p className="text-[11px] text-muted-foreground/75">{linkedTransactions.length} confirmed matches</p> <p className="text-[11px] text-muted-foreground/75">{linkedTransactions.length} confirmed matches</p>
</div> </div>
<span className="inline-flex h-7 items-center gap-1.5 rounded-md border border-primary/20 bg-primary/5 px-2 text-[11px] font-medium text-primary"> <span className={cn(
'inline-flex h-7 items-center gap-1.5 rounded-md border px-2 text-[11px] font-medium',
linkedTransactions.length > 0
? 'border-primary/20 bg-primary/5 text-primary'
: 'border-border/60 bg-muted/30 text-muted-foreground',
)}>
<Link2 className="h-3.5 w-3.5" /> <Link2 className="h-3.5 w-3.5" />
Matched {linkedTransactions.length}
</span> </span>
</div> </div>

View File

@ -8,7 +8,9 @@ import {
CircleDollarSign, CircleDollarSign,
PiggyBank, PiggyBank,
RefreshCw, RefreshCw,
Target,
TrendingDown, TrendingDown,
Trophy,
WalletCards, WalletCards,
} from 'lucide-react'; } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
@ -297,50 +299,117 @@ function CalendarGrid({ data, selectedDate, onSelectDay, moneyMarkers }) {
function DebtPayoffGlance({ projection }) { function DebtPayoffGlance({ projection }) {
const snowball = projection?.snowball; const snowball = projection?.snowball;
const comparison = projection?.comparison; const comparison = projection?.comparison;
const nextDebt = snowball?.debts?.find(debt => Number(debt.balance) > 0) || snowball?.debts?.[0]; const targetDebt = snowball?.debts?.[0] || null;
const targetMonths = Number(targetDebt?.months || 0);
const monthsSaved = comparison?.months_saved;
return ( return (
<Card> <Card className="overflow-hidden">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-center gap-2"> <div className="flex items-center justify-between gap-3">
<TrendingDown className="h-4 w-4 text-emerald-500" /> <div className="flex items-center gap-2">
<CardTitle className="text-base">Debt Payoff</CardTitle> <Target className="h-4 w-4 text-emerald-500" />
<CardTitle className="text-base">Snowball Target</CardTitle>
</div>
<Badge variant="outline" className="border-emerald-500/25 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300">
Focus
</Badge>
</div> </div>
<CardDescription>Quick snowball projection. Full controls stay on Snowball.</CardDescription> <CardDescription>Current payoff focus, with the final debt-free date close by.</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{snowball?.months_to_freedom ? ( {snowball?.months_to_freedom ? (
<div className="space-y-3"> <div className="space-y-3">
<div> {targetDebt && (
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Projected payoff</p> <div className="rounded-xl border border-emerald-500/25 bg-emerald-500/[0.08] p-3">
<p className="mt-1 text-2xl font-semibold tracking-tight">{snowball.payoff_display}</p> <div className="flex items-start gap-3">
<p className="mt-1 text-sm text-muted-foreground">{snowball.months_to_freedom} months remaining</p> <span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-emerald-500/15 text-emerald-600 dark:text-emerald-300">
</div> <Target className="h-5 w-5" />
<div className="grid grid-cols-2 gap-2 text-sm"> </span>
<div className="rounded-lg bg-muted/40 p-3"> <div className="min-w-0">
<p className="text-xs text-muted-foreground">Interest</p> <p className="text-xs font-semibold uppercase tracking-wide text-emerald-600 dark:text-emerald-300">
<p className="font-mono font-semibold">{fmt(snowball.total_interest_paid)}</p> Target debt
</p>
<p className="mt-1 truncate text-lg font-semibold tracking-tight">{targetDebt.name}</p>
<p className="mt-1 text-sm text-muted-foreground">
Clears {targetDebt.payoff_display || 'on the current plan'}
</p>
</div>
</div>
<div className="mt-3 grid grid-cols-2 gap-2 text-sm">
<div className="rounded-lg border border-emerald-500/15 bg-background/65 p-3">
<p className="text-xs text-muted-foreground">Target runway</p>
<p className="font-mono font-semibold text-emerald-600 dark:text-emerald-300">
{targetMonths ? `${targetMonths} mo` : '—'}
</p>
</div>
<div className="rounded-lg border border-sky-500/15 bg-sky-500/[0.08] p-3">
<p className="text-xs text-muted-foreground">Debt-free</p>
<p className="font-semibold text-sky-600 dark:text-sky-300">{snowball.payoff_display}</p>
</div>
</div>
</div> </div>
<div className="rounded-lg bg-muted/40 p-3">
<p className="text-xs text-muted-foreground">Saved</p>
<p className="font-mono font-semibold text-emerald-500">{comparison ? `${comparison.months_saved} mo` : '—'}</p>
</div>
</div>
{nextDebt && (
<p className="rounded-md bg-muted/35 px-3 py-2 text-sm text-muted-foreground">
Next focus: <span className="font-medium text-foreground">{nextDebt.name}</span>
</p>
)} )}
<Button asChild variant="outline" size="sm" className="w-full">
<div className="grid grid-cols-2 gap-2 text-sm">
<div className="rounded-lg border border-border/60 bg-muted/35 p-3">
<div className="flex items-center gap-2">
<Trophy className="h-4 w-4 text-amber-500" />
<p className="text-xs text-muted-foreground">Time saved</p>
</div>
<p className="mt-2 font-mono font-semibold text-amber-600 dark:text-amber-300">
{monthsSaved !== undefined ? `${monthsSaved} mo` : '—'}
</p>
</div>
<div className="rounded-lg border border-border/60 bg-muted/35 p-3">
<div className="flex items-center gap-2">
<TrendingDown className="h-4 w-4 text-teal-500" />
<p className="text-xs text-muted-foreground">Interest</p>
</div>
<p className="mt-2 font-mono font-semibold">{fmt(snowball.total_interest_paid)}</p>
</div>
</div>
{!targetDebt && (
<div className="rounded-lg border border-sky-500/25 bg-sky-500/[0.08] p-3">
<p className="text-sm font-medium text-sky-700 dark:text-sky-300">Projection ready</p>
<p className="mt-1 text-sm text-muted-foreground">
Open Snowball to review the active payoff order.
</p>
</div>
)}
<div className="grid grid-cols-2 gap-2 text-sm">
<div className="rounded-lg bg-muted/30 p-3">
<p className="text-xs text-muted-foreground">Full plan</p>
<p className="font-mono font-semibold">{snowball.months_to_freedom} mo</p>
</div>
<div className="rounded-lg bg-muted/30 p-3">
<p className="text-xs text-muted-foreground">Interest saved</p>
<p className="font-mono font-semibold text-emerald-500">
{comparison ? fmt(comparison.interest_saved) : '—'}
</p>
</div>
</div>
<Button asChild variant="outline" size="sm" className="w-full border-emerald-500/30 text-emerald-700 hover:bg-emerald-500/10 dark:text-emerald-300">
<Link to="/snowball">Open Snowball</Link> <Link to="/snowball">Open Snowball</Link>
</Button> </Button>
</div> </div>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3 rounded-xl border border-sky-500/25 bg-sky-500/[0.08] p-3">
<p className="text-sm text-muted-foreground"> <div className="flex items-start gap-3">
Add debt balances and minimum payments to see a payoff date here. <span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-sky-500/15 text-sky-600 dark:text-sky-300">
</p> <Target className="h-5 w-5" />
<Button asChild variant="outline" size="sm" className="w-full"> </span>
<div>
<p className="text-sm font-medium">Choose a first target</p>
<p className="mt-1 text-sm text-muted-foreground">
Add debt balances and minimum payments to see the next payoff milestone here.
</p>
</div>
</div>
<Button asChild variant="outline" size="sm" className="w-full border-sky-500/30 text-sky-700 hover:bg-sky-500/10 dark:text-sky-300">
<Link to="/snowball">Set up Snowball</Link> <Link to="/snowball">Set up Snowball</Link>
</Button> </Button>
</div> </div>

View File

@ -387,6 +387,123 @@ function transactionTitle(tx) {
return tx?.payee || tx?.description || tx?.memo || 'Transaction'; return tx?.payee || tx?.description || tx?.memo || 'Transaction';
} }
function matchScoreTone(score) {
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 >= 55) return 'border-sky-500/30 bg-sky-500/10 text-sky-600 dark:text-sky-400';
return 'border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400';
}
function SuggestedMatchesPanel({ suggestions, loading, actionId, onAccept, onReject }) {
return (
<div className="rounded-lg border border-sky-500/20 bg-sky-500/[0.035]">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-sky-500/10 px-4 py-3">
<div className="flex items-center gap-2">
<span className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-sky-500/20 bg-sky-500/10 text-sky-600 dark:text-sky-400">
<Sparkles className="h-4 w-4" />
</span>
<div>
<p className="text-sm font-semibold">Suggested matches</p>
<p className="text-xs text-muted-foreground">{loading ? 'Checking transactions' : `${suggestions.length} ready for review`}</p>
</div>
</div>
</div>
{loading ? (
<div className="px-4 py-6 text-sm text-muted-foreground">
<Loader2 className="mr-2 inline h-4 w-4 animate-spin" />
Finding likely bill matches...
</div>
) : suggestions.length === 0 ? (
<div className="px-4 py-6 text-sm text-muted-foreground">
No suggested matches right now.
</div>
) : (
<div className="grid gap-2 p-3 xl:grid-cols-2">
{suggestions.map(suggestion => {
const tx = suggestion.transaction || {};
const bill = suggestion.bill || {};
const acceptBusy = actionId === `suggestion-match:${suggestion.id}`;
const rejectBusy = actionId === `suggestion-reject:${suggestion.id}`;
const busy = acceptBusy || rejectBusy;
return (
<div
key={suggestion.id}
className="rounded-lg border border-border/60 bg-background/80 p-3 shadow-sm transition-colors hover:border-sky-500/30"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className={cn('rounded-full border px-2 py-0.5 text-[10px] font-semibold uppercase', matchScoreTone(suggestion.score))}>
{suggestion.score}
</span>
<p className="truncate text-sm font-medium">{transactionTitle(tx)}</p>
</div>
<p className="truncate text-xs text-muted-foreground">
{transactionDate(tx)} · {tx.source_label || tx.source_type_label || 'Transaction'}
</p>
</div>
<p className={cn(
'shrink-0 text-sm font-semibold tabular-nums',
Number(tx.amount) < 0 ? 'text-destructive' : 'text-emerald-600',
)}>
{formatTransactionAmount(tx.amount, tx.currency)}
</p>
</div>
<div className="mt-3 flex items-center gap-2 rounded-md border border-emerald-500/15 bg-emerald-500/[0.045] px-2.5 py-2">
<Link2 className="h-3.5 w-3.5 shrink-0 text-emerald-600 dark:text-emerald-400" />
<div className="min-w-0">
<p className="truncate text-sm font-medium">{bill.name || `Bill ${suggestion.billId}`}</p>
<p className="text-xs text-muted-foreground">
Expected ${Number(bill.expected_amount || 0).toFixed(2)}
</p>
</div>
</div>
{suggestion.reasons?.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
{suggestion.reasons.slice(0, 4).map(reason => (
<span key={reason} className="rounded-full border border-border/60 bg-muted/30 px-2 py-0.5 text-[10px] text-muted-foreground">
{reason}
</span>
))}
</div>
)}
<div className="mt-3 flex justify-end gap-2">
<Button
type="button"
size="sm"
variant="ghost"
disabled={busy}
onClick={() => onReject(suggestion)}
className="h-8 text-xs text-muted-foreground hover:text-destructive"
>
{rejectBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <XCircle className="h-3.5 w-3.5" />}
<span className="ml-1.5">Reject</span>
</Button>
<Button
type="button"
size="sm"
disabled={busy}
onClick={() => onAccept(suggestion)}
className="h-8 gap-1.5 text-xs"
>
{acceptBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <CheckCircle2 className="h-3.5 w-3.5" />}
Match
</Button>
</div>
</div>
);
})}
</div>
)}
</div>
);
}
function MatchBillDialog({ open, onOpenChange, transaction, bills, onConfirm, loading }) { function MatchBillDialog({ open, onOpenChange, transaction, bills, onConfirm, loading }) {
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [selectedBillId, setSelectedBillId] = useState(''); const [selectedBillId, setSelectedBillId] = useState('');
@ -504,9 +621,11 @@ function MatchBillDialog({ open, onOpenChange, transaction, bills, onConfirm, lo
function TransactionMatchingSection({ refreshKey }) { function TransactionMatchingSection({ refreshKey }) {
const [transactions, setTransactions] = useState([]); const [transactions, setTransactions] = useState([]);
const [suggestions, setSuggestions] = useState([]);
const [bills, setBills] = useState([]); const [bills, setBills] = useState([]);
const [filter, setFilter] = useState('open'); const [filter, setFilter] = useState('open');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [suggestionsLoading, setSuggestionsLoading] = useState(true);
const [billsLoading, setBillsLoading] = useState(true); const [billsLoading, setBillsLoading] = useState(true);
const [actionId, setActionId] = useState(null); const [actionId, setActionId] = useState(null);
const [matchOpen, setMatchOpen] = useState(false); const [matchOpen, setMatchOpen] = useState(false);
@ -527,6 +646,23 @@ function TransactionMatchingSection({ refreshKey }) {
} }
}; };
const loadSuggestions = async () => {
setSuggestionsLoading(true);
try {
const data = await api.matchSuggestions({ limit: 8 });
setSuggestions(data || []);
} catch (err) {
toast.error(err.message || 'Failed to load match suggestions.');
setSuggestions([]);
} finally {
setSuggestionsLoading(false);
}
};
const refreshTransactionWorkbench = async () => {
await Promise.all([loadTransactions(), loadSuggestions()]);
};
const loadBills = async () => { const loadBills = async () => {
setBillsLoading(true); setBillsLoading(true);
try { try {
@ -541,6 +677,7 @@ function TransactionMatchingSection({ refreshKey }) {
useEffect(() => { loadBills(); }, []); useEffect(() => { loadBills(); }, []);
useEffect(() => { loadTransactions(); }, [filter, refreshKey]); useEffect(() => { loadTransactions(); }, [filter, refreshKey]);
useEffect(() => { loadSuggestions(); }, [refreshKey]);
const openMatchDialog = (tx) => { const openMatchDialog = (tx) => {
setMatchTransaction(tx); setMatchTransaction(tx);
@ -561,7 +698,7 @@ function TransactionMatchingSection({ refreshKey }) {
await api.unignoreTransaction(tx.id); await api.unignoreTransaction(tx.id);
toast.success('Transaction restored.'); toast.success('Transaction restored.');
} }
await loadTransactions(); await refreshTransactionWorkbench();
} catch (err) { } catch (err) {
toast.error(err.message || 'Transaction action failed.'); toast.error(err.message || 'Transaction action failed.');
} finally { } finally {
@ -577,7 +714,7 @@ function TransactionMatchingSection({ refreshKey }) {
toast.success('Transaction matched to bill.'); toast.success('Transaction matched to bill.');
setMatchOpen(false); setMatchOpen(false);
setMatchTransaction(null); setMatchTransaction(null);
await loadTransactions(); await refreshTransactionWorkbench();
} catch (err) { } catch (err) {
toast.error(err.message || 'Transaction match failed.'); toast.error(err.message || 'Transaction match failed.');
} finally { } finally {
@ -585,6 +722,32 @@ function TransactionMatchingSection({ refreshKey }) {
} }
}; };
const acceptSuggestion = async (suggestion) => {
setActionId(`suggestion-match:${suggestion.id}`);
try {
await api.matchTransaction(suggestion.transactionId, suggestion.billId);
toast.success('Suggested match confirmed.');
await refreshTransactionWorkbench();
} catch (err) {
toast.error(err.message || 'Suggested match failed.');
} finally {
setActionId(null);
}
};
const rejectSuggestion = async (suggestion) => {
setActionId(`suggestion-reject:${suggestion.id}`);
try {
await api.rejectMatchSuggestion(suggestion.id);
toast.success('Suggestion rejected.');
await loadSuggestions();
} catch (err) {
toast.error(err.message || 'Suggestion could not be rejected.');
} finally {
setActionId(null);
}
};
return ( return (
<SectionCard <SectionCard
title="Transactions" title="Transactions"
@ -609,12 +772,20 @@ function TransactionMatchingSection({ refreshKey }) {
</button> </button>
))} ))}
</div> </div>
<Button size="sm" variant="outline" type="button" onClick={loadTransactions} disabled={loading}> <Button size="sm" variant="outline" type="button" onClick={refreshTransactionWorkbench} disabled={loading || suggestionsLoading}>
{loading ? <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5 mr-1.5" />} {loading ? <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5 mr-1.5" />}
Refresh Refresh
</Button> </Button>
</div> </div>
<SuggestedMatchesPanel
suggestions={suggestions}
loading={suggestionsLoading}
actionId={actionId}
onAccept={acceptSuggestion}
onReject={rejectSuggestion}
/>
<div className="overflow-x-auto rounded-lg border border-border/60"> <div className="overflow-x-auto rounded-lg border border-border/60">
{loading ? ( {loading ? (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">Loading transactions</div> <div className="px-4 py-8 text-center text-sm text-muted-foreground">Loading transactions</div>

View File

@ -2,7 +2,7 @@
**Status:** Current code reference **Status:** Current code reference
**Last Updated:** 2026-05-16 **Last Updated:** 2026-05-16
**Version:** 0.28.01 **Version:** 0.28.1
**Primary stack:** Node.js + Express, React + Vite, Tailwind CSS + shadcn/ui, Sonner, SQLite via `better-sqlite3` **Primary stack:** Node.js + Express, React + Vite, Tailwind CSS + shadcn/ui, Sonner, SQLite via `better-sqlite3`
This manual reflects the current application code in `server.js`, `routes/`, `services/`, `middleware/`, `db/`, `client/`, `package.json`, `Dockerfile`, and `docker-compose.yml`. It is written as a current-state reference, not a changelog. This manual reflects the current application code in `server.js`, `routes/`, `services/`, `middleware/`, `db/`, `client/`, `package.json`, `Dockerfile`, and `docker-compose.yml`. It is written as a current-state reference, not a changelog.
@ -33,8 +33,24 @@ Runtime flow:
## 2. Project Layout ## 2. Project Layout
- `server.js` — Express entry point and route mounting. - `server.js` — Express entry point and route mounting.
- `routes/` — HTTP API handlers. - `routes/` — HTTP API handlers:
- `services/` — auth, OIDC, backup, cleanup, notification, import, status, audit, transaction, CSV import business logic. - `auth.js` — login, logout, password change, OIDC callback.
- `bills.js` — bills CRUD, auto-mark paid, history.
- `payments.js` — payments CRUD, status matching, snowball handling.
- `categories.js` — category CRUD, tree support.
- `tracker.js` — monthly tracker data, bucket resolution, cycle handling.
- `summary.js` — summary stats, starting amounts.
- `analytics.js` — expense reports, category breakdown.
- `settings.js` — user settings, admin config, notification settings.
- `notifications.js` — notification management.
- `profile.js` — user profile, demo data.
- `import.js` — CSV, Excel, user-SQLite import workflows.
- `export.js` — CSV, Excel, SQLite export.
- `status.js` — system status, health.
- `dataSources.js` — new — data sources CRUD with sync status.
- `transactions.js` — new — transaction CRUD, match/ignore/commit actions.
- `matches.js` — new — match suggestions, rejection tracking.
- `services/` — business logic modules.
- `middleware/` — auth guards, CSRF, rate limits, security headers, error formatting. - `middleware/` — auth guards, CSRF, rate limits, security headers, error formatting.
- `db/schema.sql` — base SQLite schema. - `db/schema.sql` — base SQLite schema.
- `db/database.js` — DB connection, migrations, defaults, settings, rollback support. - `db/database.js` — DB connection, migrations, defaults, settings, rollback support.
@ -779,6 +795,9 @@ Mounted under `/api/import`; auth: user/admin tracker access; import limiter app
- Body: `{import_session_id, mapping, options?}`. - Body: `{import_session_id, mapping, options?}`.
- Response: `{imported, skipped, failed, details}`. - Response: `{imported, skipped, failed, details}`.
- `GET /import/history`
- Response: current user's import history.
### 5.18 Match Suggestions ### 5.18 Match Suggestions
Mounted under `/api/matches`; auth: user/admin tracker access. Mounted under `/api/matches`; auth: user/admin tracker access.
@ -827,9 +846,154 @@ Mounted under `/api/export`; auth: user/admin tracker access; export limiter app
- Public. - Public.
- Response: package version and raw history text, or error if unavailable. - Response: package version and raw history text, or error if unavailable.
--- ### 5.22 Services
## 6. Database Reference Key service modules:
- **`paymentValidation.js`** — Payment input validation with `PAYMENT_SOURCES = ['manual', 'file_import', 'provider_sync', 'transaction_match']` enum, `validatePaymentSource()`, and `validatePaymentInput()`.
- **`csvTransactionImportService.js`** — CSV parsing, field mapping, SHA-256 deduplication, import session management with preview/commit workflow.
- **`transactionService.js`** — Transaction helpers: `ensureManualDataSource()`, `decorateDataSource()`, `decorateTransaction()`.
- **`transactionMatchService.js`** — Match/unmatch transactions to bills: `matchTransactionToBill()`, `unmatchTransaction()`, `ignoreTransaction()`, `unignoreTransaction()`.
- **`matchSuggestionService.js`** — Match suggestion discovery: `listMatchSuggestions()`, `rejectMatchSuggestion()`, `suggestionCounts()`.
- **`snowballService.js`** — Debt snowball/avalanche calculations, Ramsey mode support, order updates.
- **`dataSourcesService.js`** — Data source CRUD with `ensureManualDataSource()` for user-scoped manual sources.
- **`monthlyStartingAmountsService.js`** — Starting cash bucket tracking: first/fifteenth/other bucket amounts, payments, remaining values.
- **`auditService.js`** — Audit logging via `logAudit()`; lazy-loaded in `database.js` to avoid circular dependency.
- **`emailService.js`** — Email dispatch with SMTP configuration, templating, retry logic.
- **`exportService.js`** — Export helpers: CSV, XLSX, SQLite user DB export with metadata.
- **`csvTransactionImportService.js`** — CSV parsing, field mapping, SHA-256 deduplication, import session management with preview/commit workflow.
- **`trackerService.js`** — Tracker calculations, cycle detection (weekly/biweekly/quarterly/annual), debt snowball support.
- **`statusService.js`** — Health status, cycle validation, autopay simulation, budget projections.
- **`analyticsService.js`** — Analytics queries: spending, categories, bills, filters.
- **`notificationService.js`** — Bill notifications: due_3d, due_1d, due_today, overdue.
- **`authService.js`** — Auth helpers: login, JWT, password hashing, session management, OIDC integration.
- **`userService.js`** — User CRUD, profile updates, demo data seeding, role changes.
- **`settingsService.js`** — Settings CRUD, allowed keys validation, SMTP/billing/export settings.
- **`backupService.js`** — SQLite backup, retention, schedule.
- **`cronService.js`** — Scheduled tasks: backup, cleanup, auto-mark paid, cycle updates.
### 5.23 Import and Sync Workflow
Data ingestion follows a layered architecture:
1. **Data Sources** (`data_sources` table)
- `manual`: User-created source (one per user, type='manual', provider='manual')
- `file_import`: CSV/XLSX imports (provider='csv_transactions', 'spreadsheet')
- `provider_sync`: External institution sync (e.g., 'plaid', 'mint')
- Fields: `type`, `provider`, `name`, `status` ('active', 'inactive', 'error'), `config_json`, `encrypted_secret`, `last_sync_at`, `last_error`
2. **Financial Accounts** (`financial_accounts` table)
- Linked to `data_sources` via `data_source_id`
- One data source can have many accounts
- Fields: `provider_account_id`, `name`, `org_name`, `account_type`, `currency`, `balance`, `available_balance`, `raw_data`
3. **Transactions** (`transactions` table)
- Linked to `data_sources` and `financial_accounts`
- Source type: `manual`, `file_import`, `provider_sync`
- Match states: `unmatched`, `matched`, `ignored`
- Optional `provider_transaction_id` for deduplication
- Fields: `amount` (cents), `transaction_type`, `posted_date`, `transacted_at`, `description`, `payee`, `memo`, `category`, `raw_data`, `matched_bill_id`, `match_status`, `ignored`
4. **CSV Import Flow**
- User uploads CSV → `/api/import/csv/preview`
- Preview parses headers, suggests field mapping
- `/api/import/csv/commit` writes to `transactions` with `source_type='file_import'`
- Import history tracked in `import_history` with counts
5. **Transaction Matching**
- Manual transactions (`source_type='manual'`) can be matched to bills
- Match suggestions discovered via `matchSuggestionService`
- Users can reject suggestions to avoid重复 suggestions
6. **Provider Sync** (future)
- External sync jobs write to `data_sources` with `type='provider_sync'`
- Financial accounts created per institution account
- Transactions imported from provider
- Match suggestions offered for unmatched transactions
7. **Payments** (bills → payments)
- Payments link to transactions via `transaction_id` for auto-draft
- `payment_source` indicates origin: `manual`, `file_import`, `provider_sync`, `transaction_match`
- Balance delta tracked for debt payoff
8. **Import Sessions** (`import_sessions` table)
- Temporary storage for CSV/XLSX previews
- 1-hour TTL, auto-cleaned
- Fields: `preview_json`, `expires_at`
9. **Import History** (`import_history` table)
- Audit trail of all imports
- Fields: `imported_at`, `source_filename`, `file_type`, `rows_parsed/created/updated/skipped/errored`, `options_json`, `summary_json`
### 5.24 Validation and Services
Key validation and service patterns:
- **`paymentValidation.js`**
- `PAYMENT_SOURCES = ['manual', 'file_import', 'provider_sync', 'transaction_match']` enum
- `validatePaymentSource(value)` returns error if not in list
- `validatePaymentInput(body)` validates amount, paid_date, bill ownership, payment_source
- Invalid source returns 400 with `VALIDATION_ERROR` code
- **CSV Import Service**
- Field suggestion via header analysis
- SHA-256 hash for deduplication
- Import session management with preview/commit split
- Error collection per row with detailed messages
- **Transaction Service**
- `ensureManualDataSource(db, userId)` creates one manual data source per user
- `decorateTransaction(row)` adds `source_label`, `source_type_label`
- `decorateDataSource(row)` adds `source_label`, `source_type_label`, `account_count`, `transaction_count`
- **Snowball Service**
- Computes snowball/avalanche orderings
- Ramsey mode: minimum payments only vs. full extra payment
- `snowball_include` bills sorted by `snowball_order`
- `snowball_exempt` bills excluded from ordering
- **Match Suggestion Service**
- `listMatchSuggestions(userId, transactionId, billId, limit, offset)`
- `rejectMatchSuggestion(userId, transactionId, billId)`
- `suggestionCounts(userId)`
- Rejects stored to prevent repeated suggestions
- **Monthly Starting Amounts Service**
- Bucketed amounts: first_amount, fifteenth_amount, other_amount
- Payment tracking from each bucket
- Remaining values computed on read
- **Tracker Service**
- Cycle type detection: monthly, weekly, biweekly, quarterly, annually
- Cycle day mapping for non-standard cycles
- Auto-mark paid logic for autopay bills
- **Status Service**
- Cycle validation warnings
- Autopay simulation
- Budget projections
### 6. Database Reference
SQLite uses WAL mode and foreign keys. Base schema is in `db/schema.sql`; `db/database.js` applies migrations to reach the current schema. SQLite uses WAL mode and foreign keys. Base schema is in `db/schema.sql`; `db/database.js` applies migrations to reach the current schema.
@ -973,202 +1137,6 @@ SQLite uses WAL mode and foreign keys. Base schema is in `db/schema.sql`; `db/da
- `end_year INTEGER` - `end_year INTEGER`
- `end_month INTEGER` - `end_month INTEGER`
- `label TEXT` - `label TEXT`
- `created_at TEXT DEFAULT datetime('now')`
- `updated_at TEXT DEFAULT datetime('now')`
#### `data_sources`
- `id INTEGER PRIMARY KEY AUTOINCREMENT`
- `user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE`
- `type TEXT NOT NULL` (`manual`, `file_import`, `provider_sync`)
- `provider TEXT`
- `name TEXT NOT NULL`
- `status TEXT NOT NULL DEFAULT 'active'` (`active`, `inactive`, `error`)
- `config_json TEXT`
- `encrypted_secret TEXT`
- `last_sync_at TEXT`
- `last_error TEXT`
- `created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP`
- `updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP`
- Unique partial index: `(user_id, type, provider)` WHERE `type='manual' AND provider='manual'`
#### `financial_accounts`
- `id INTEGER PRIMARY KEY`
- `user_id INTEGER REFERENCES users(id) ON DELETE CASCADE`
- `data_source_id INTEGER REFERENCES data_sources(id) ON DELETE SET NULL`
- `provider_account_id TEXT NOT NULL`
- `name TEXT`
- `org_name TEXT`
- `account_type TEXT`
- `currency TEXT`
- `balance REAL`
- `available_balance REAL`
- `raw_data TEXT`
- `created_at TEXT DEFAULT datetime('now')`
- `updated_at TEXT DEFAULT datetime('now')`
- Unique on `(data_source_id, provider_account_id)`
#### `transactions`
- `id INTEGER PRIMARY KEY`
- `user_id INTEGER REFERENCES users(id) ON DELETE CASCADE`
- `data_source_id INTEGER REFERENCES data_sources(id) ON DELETE SET NULL`
- `account_id INTEGER REFERENCES financial_accounts(id) ON DELETE SET NULL`
- `provider_transaction_id TEXT`
- `source_type TEXT NOT NULL` (`manual`, `file_import`, `provider_sync`)
- `transaction_type TEXT`
- `posted_date TEXT`
- `transacted_at TEXT`
- `amount INTEGER NOT NULL` (cents)
- `currency TEXT`
- `description TEXT`
- `payee TEXT`
- `memo TEXT`
- `category TEXT`
- `raw_data TEXT`
- `matched_bill_id INTEGER REFERENCES bills(id) ON DELETE SET NULL`
- `match_status TEXT` (`unmatched`, `matched`, `ignored`)
- `ignored INTEGER NOT NULL DEFAULT 0`
- `created_at TEXT DEFAULT datetime('now')`
- `updated_at TEXT DEFAULT datetime('now')`
- Unique partial index on `(data_source_id, provider_transaction_id)` WHERE `provider_transaction_id IS NOT NULL`
- Indexes on `(user_id, COALESCE(posted_date, transacted_at, created_at))`, `(user_id, match_status, ignored)`, `account_id`, `matched_bill_id`
#### `import_sessions`
- `id TEXT PRIMARY KEY`
- `user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE`
- `created_at TEXT NOT NULL`
- `expires_at TEXT NOT NULL`
- `preview_json TEXT NOT NULL`
#### `import_history`
- `id INTEGER PRIMARY KEY`
- `user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE`
- `imported_at TEXT NOT NULL`
- `source_filename TEXT`
- `file_type TEXT DEFAULT 'csv_transactions'`
- `rows_parsed INTEGER DEFAULT 0`
- `rows_created INTEGER DEFAULT 0`
- `rows_updated INTEGER DEFAULT 0`
- `rows_skipped INTEGER DEFAULT 0`
- `rows_errored INTEGER DEFAULT 0`
- `options_json TEXT`
- `summary_json TEXT`
- `created_at TEXT DEFAULT datetime('now')`
#### `autopay_suggestion_dismissals`
- `id INTEGER PRIMARY KEY AUTOINCREMENT`
- `user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE`
- `bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE`
- `year INTEGER NOT NULL CHECK(year BETWEEN 2000 AND 2100)`
- `month INTEGER NOT NULL CHECK(month BETWEEN 1 AND 12)`
- `dismissed_at TEXT NOT NULL DEFAULT (datetime('now'))`
- Unique: `(user_id, bill_id, year, month)`
#### `bill_templates`
- `id INTEGER PRIMARY KEY AUTOINCREMENT`
- `user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE`
- `name TEXT NOT NULL`
- `data TEXT NOT NULL`
- `created_at TEXT DEFAULT (datetime('now'))`
- `updated_at TEXT DEFAULT (datetime('now'))`
- Unique: `(user_id, name)`
#### `match_suggestion_rejections`
- `id INTEGER PRIMARY KEY AUTOINCREMENT`
- `user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE`
- `transaction_id INTEGER NOT NULL REFERENCES transactions(id) ON DELETE CASCADE`
- `bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE`
- `rejected_at TEXT NOT NULL DEFAULT (datetime('now'))`
- Unique: `(user_id, transaction_id, bill_id)`
#### `user_login_history`
- `id INTEGER PRIMARY KEY AUTOINCREMENT`
- `user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE`
- `logged_in_at TEXT NOT NULL DEFAULT (datetime('now'))`
- `ip_address TEXT`
- `user_agent TEXT`
- `browser TEXT`
- `os TEXT`
- `device_type TEXT`
- `device_fingerprint TEXT`
#### `settings`
- `key TEXT PRIMARY KEY`
- `value TEXT NOT NULL`
- `updated_at TEXT DEFAULT datetime('now')`
Used for app settings, auth mode, OIDC settings, SMTP settings, backup schedule, cleanup settings, and worker state.
#### `notifications`
- `id INTEGER PRIMARY KEY`
- `bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE`
- `user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE`
- `year INTEGER NOT NULL`
- `month INTEGER NOT NULL`
- `type TEXT NOT NULL` (`due_3d`, `due_1d`, `due_today`, `overdue`)
- `sent_date TEXT NOT NULL DEFAULT date('now')`
- Unique: `(bill_id, user_id, year, month, type, sent_date)`
#### `import_sessions`
- `id TEXT PRIMARY KEY`
- `user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE`
- `created_at TEXT NOT NULL`
- `expires_at TEXT NOT NULL`
- `preview_json TEXT NOT NULL`
#### `import_history`
- `id INTEGER PRIMARY KEY`
- `user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE`
- `imported_at TEXT NOT NULL`
- `source_filename TEXT`
- `file_type TEXT DEFAULT 'xlsx'`
- `sheet_name TEXT`
- `rows_parsed INTEGER DEFAULT 0`
- `rows_created INTEGER DEFAULT 0`
- `rows_updated INTEGER DEFAULT 0`
- `rows_skipped INTEGER DEFAULT 0`
- `rows_ambiguous INTEGER DEFAULT 0`
- `rows_errored INTEGER DEFAULT 0`
- `options_json TEXT`
- `summary_json TEXT`
#### `oidc_states`
- `id TEXT PRIMARY KEY`
- `nonce TEXT NOT NULL`
- `code_verifier TEXT NOT NULL`
- `redirect_to TEXT`
- `created_at TEXT NOT NULL`
- `expires_at TEXT NOT NULL`
#### `audit_log`
- `id INTEGER PRIMARY KEY`
- `user_id INTEGER`
- `action TEXT NOT NULL`
- `entity_type TEXT`
- `entity_id INTEGER`
- `details_json TEXT`
- `ip_address TEXT`
- `user_agent TEXT`
- `created_at TEXT DEFAULT datetime('now')`
#### `schema_migrations`
- `id INTEGER PRIMARY KEY`
- `version TEXT NOT NULL UNIQUE`
- `description TEXT NOT NULL` - `description TEXT NOT NULL`
- `applied_at TEXT NOT NULL DEFAULT datetime('now')` - `applied_at TEXT NOT NULL DEFAULT datetime('now')`
@ -1254,6 +1222,71 @@ Important indexes include:
- `idx_notifications_lookup(bill_id, user_id, year, month)` - `idx_notifications_lookup(bill_id, user_id, year, month)`
- `idx_import_sessions_user(user_id)`, `idx_import_sessions_expires(expires_at)` - `idx_import_sessions_user(user_id)`, `idx_import_sessions_expires(expires_at)`
- `idx_import_history_user(user_id)`, `idx_import_history_imported_at(imported_at)` - `idx_import_history_user(user_id)`, `idx_import_history_imported_at(imported_at)`
#### `import_sessions`
- `id TEXT PRIMARY KEY`
- `user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE`
- `created_at TEXT NOT NULL`
- `expires_at TEXT NOT NULL`
- `preview_json TEXT NOT NULL`
#### `import_history`
- `id INTEGER PRIMARY KEY AUTOINCREMENT`
- `user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE`
- `imported_at TEXT NOT NULL`
- `source_filename TEXT`
- `file_type TEXT DEFAULT 'csv_transactions'`
- `rows_parsed INTEGER DEFAULT 0`
- `rows_created INTEGER DEFAULT 0`
- `rows_updated INTEGER DEFAULT 0`
- `rows_skipped INTEGER DEFAULT 0`
- `rows_errored INTEGER DEFAULT 0`
- `options_json TEXT`
- `summary_json TEXT`
- `created_at TEXT DEFAULT (datetime('now'))`
#### `autopay_suggestion_dismissals`
- `id INTEGER PRIMARY KEY AUTOINCREMENT`
- `user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE`
- `bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE`
- `year INTEGER NOT NULL CHECK(year BETWEEN 2000 AND 2100)`
- `month INTEGER NOT NULL CHECK(month BETWEEN 1 AND 12)`
- `dismissed_at TEXT NOT NULL DEFAULT (datetime('now'))`
- Unique: `(user_id, bill_id, year, month)`
#### `bill_templates`
- `id INTEGER PRIMARY KEY AUTOINCREMENT`
- `user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE`
- `name TEXT NOT NULL`
- `data TEXT NOT NULL`
- `created_at TEXT DEFAULT (datetime('now'))`
- `updated_at TEXT DEFAULT (datetime('now'))`
- Unique: `(user_id, name)`
#### `match_suggestion_rejections`
- `id INTEGER PRIMARY KEY AUTOINCREMENT`
- `user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE`
- `transaction_id INTEGER NOT NULL REFERENCES transactions(id) ON DELETE CASCADE`
- `bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE`
- `rejected_at TEXT NOT NULL DEFAULT (datetime('now'))`
- Unique: `(user_id, transaction_id, bill_id)`
#### `user_login_history`
- `id INTEGER PRIMARY KEY AUTOINCREMENT`
- `user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE`
- `logged_in_at TEXT NOT NULL DEFAULT (datetime('now'))`
- `ip_address TEXT`
- `user_agent TEXT`
- `browser TEXT`
- `os TEXT`
- `device_type TEXT`
- `device_fingerprint TEXT`
- `idx_oidc_states_expires(expires_at)` - `idx_oidc_states_expires(expires_at)`
- `idx_bill_history_ranges_bill(bill_id)` - `idx_bill_history_ranges_bill(bill_id)`
- `idx_audit_log_user(user_id, created_at)`, `idx_audit_log_action(action, created_at)` - `idx_audit_log_user(user_id, created_at)`, `idx_audit_log_action(action, created_at)`
@ -1428,9 +1461,35 @@ Routes:
- `_fetch(method, path, body)` calls `/api${path}` with JSON headers and `credentials: include`. - `_fetch(method, path, body)` calls `/api${path}` with JSON headers and `credentials: include`.
- Mutating methods read `bt_csrf_token` cookie and send `x-csrf-token`. - Mutating methods read `bt_csrf_token` cookie and send `x-csrf-token`.
- Non-OK responses throw `Error` with `status`, `data`, `details`, and `code`. - Non-OK responses throw `Error` with `status`, `data`, `details`, and `code`.
- Exposes grouped functions for auth, admin, notifications, profile, tracker, calendar, summary, bills, payments, categories, settings, analytics, status, version/about, import, and export. - Exposes grouped functions for auth, admin, notifications, profile, tracker, calendar, summary, bills, payments, categories, settings, analytics, status, version/about, import, export, data-sources, transactions, and matches.
- File download/upload endpoints use raw `fetch` because responses/bodies are blobs or octet streams. - File download/upload endpoints use raw `fetch` because responses/bodies are blobs or octet streams.
#### Client API v0.28.1 additions
- `api.dataSources(type, status)``/api/data-sources`
- `api.transactions(filters)``/api/transactions`
- `api.transactions.create(payload)``/api/transactions/manual`
- `api.transactions.update(id, payload)``/api/transactions/:id`
- `api.transactions.delete(id)``/api/transactions/:id`
- `api.transactions.match(id, {billId})``/api/transactions/:id/match`
- `api.transactions.unmatch(id)``/api/transactions/:id/unmatch`
- `api.transactions.ignore(id)``/api/transactions/:id/ignore`
- `api.transactions.unignore(id)``/api/transactions/:id/unignore`
- `api.csvImport.preview(file, options)``/api/import/csv/preview`
- `api.csvImport.commit(importSessionId, mapping, options)``/api/import/csv/commit`
- `api.import.history()``/api/import/history`
- `api.matchSuggestions.list(filters)``/api/matches/suggestions`
- `api.matchSuggestions.reject(id)``/api/matches/:id/reject`
- `api.snowball.get()``/api/snowball`
- `api.snowball.settings.get()``/api/snowball/settings`
- `api.snowball.settings.patch(payload)``/api/snowball/settings`
- `api.snowball.projection.get()``/api/snowball/projection`
- `api.snowball.order.patch(bills)``/api/snowball/order`
- `api.monthlyStartingAmounts.get(year, month)``/api/monthly-starting-amounts`
- `api.monthlyStartingAmounts.update(payload)``/api/monthly-starting-amounts`
### Auth state
### Auth state ### Auth state
`client/hooks/useAuth.jsx`: `client/hooks/useAuth.jsx`:
@ -1459,7 +1518,7 @@ These use TanStack Query keys and cache server data for common pages.
- `CategoriesPage.jsx` — category list/create/update/delete and related bill info. - `CategoriesPage.jsx` — category list/create/update/delete and related bill info.
- `AnalyticsPage.jsx` — analytics summary filters and charts. - `AnalyticsPage.jsx` — analytics summary filters and charts.
- `SettingsPage.jsx` — user/app settings and demo data seed. - `SettingsPage.jsx` — user/app settings and demo data seed.
- `DataPage.jsx` — export, spreadsheet import, user DB import, import history. - `DataPage.jsx` — export, spreadsheet import, user DB import, import history, CSV transaction import with preview and commit flow (`ImportTransactionCsvSection`).
- `ProfilePage.jsx` — display name, notification preferences, password change, export/import-history links. - `ProfilePage.jsx` — display name, notification preferences, password change, export/import-history links.
- `AdminPage.jsx` — users, auth mode/OIDC, backups, cleanup, notifications, migrations, admin settings. - `AdminPage.jsx` — users, auth mode/OIDC, backups, cleanup, notifications, migrations, admin settings.
- `StatusPage.jsx` — admin system status. - `StatusPage.jsx` — admin system status.

View File

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