push
This commit is contained in:
parent
55837b8b25
commit
82de135186
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue