This commit is contained in:
null 2026-05-16 20:26:09 -05:00
parent 0c628212a0
commit 9d933f70cc
29 changed files with 4338 additions and 147 deletions

4
.gitignore vendored
View File

@ -3,11 +3,11 @@ DEVELOPMENT_LOG.md
PROJECT.md
STRUCTURE.md
FUTURE.md
HISTORY.md
BUILD_SUMMARY.md
SCRIPTS.md
project-requirements.md
.learnings/
simplefin_no_git/
# Dependencies
node_modules/
@ -18,3 +18,5 @@ db/*.db-wal
backups/
.env
*.log
simplefin-bank-sync-issue.md
project-wide-data-input-and-sync-issue.md

1394
HISTORY.md Normal file

File diff suppressed because it is too large Load Diff

View File

@ -262,6 +262,29 @@ export const api = {
return data;
},
applySpreadsheetImport: (data) => post('/import/spreadsheet/apply', data),
previewCsvTransactionImport: async (file) => {
const res = await fetch('/api/import/csv/preview', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'text/csv',
'x-csrf-token': getCsrfToken(),
...(file.name ? { 'X-Filename': file.name } : {}),
},
body: file,
});
const data = await res.json();
if (!res.ok) {
const err = new Error(data.message || data.error || `HTTP ${res.status}`);
err.status = res.status;
err.data = data;
err.details = data.details || [];
err.code = data.code;
throw err;
}
return data;
},
commitCsvTransactionImport: (data) => post('/import/csv/commit', data),
importHistory: () => get('/import/history'),
// User SQLite import

View File

@ -1,5 +1,5 @@
import { useState } from 'react';
import { ChevronDown, Copy } from 'lucide-react';
import { useEffect, useState } from 'react';
import { ChevronDown, Copy, Pencil, Plus, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@ -7,11 +7,15 @@ import { Label } from '@/components/ui/label';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from '@/components/ui/dialog';
import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select';
import { api } from '@/api';
import { cn } from '@/lib/utils';
import { cn, fmt, fmtDate, todayStr } from '@/lib/utils';
function getOrdinalSuffix(day) {
if (day > 3 && day < 21) return 'th';
@ -23,8 +27,20 @@ function getOrdinalSuffix(day) {
}
}
function defaultCycleDayFor(type) {
return type === 'weekly' || type === 'biweekly' ? 'monday' : '1';
}
// Radix Select crashes on empty string value
const CAT_NONE = 'none';
const PAYMENT_METHODS = [
['manual', 'Manual'],
['bank', 'Bank Transfer'],
['card', 'Card'],
['autopay', 'Autopay'],
['check', 'Check'],
['cash', 'Cash'],
];
const DEBT_KEYWORDS = ['credit', 'loan', 'mortgage', 'housing', 'debt'];
const SNOWBALL_KEYWORDS = ['credit', 'loan', 'debt'];
@ -75,12 +91,39 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
const [templateName, setTemplateName] = useState('');
const [busy, setBusy] = useState(false);
const [errors, setErrors] = useState({});
const [payments, setPayments] = useState([]);
const [paymentsLoading, setPaymentsLoading] = useState(false);
const [paymentBusy, setPaymentBusy] = useState(false);
const [paymentFormOpen, setPaymentFormOpen] = useState(false);
const [editingPayment, setEditingPayment] = useState(null);
const [deletePaymentTarget, setDeletePaymentTarget] = useState(null);
const [paymentAmount, setPaymentAmount] = useState('');
const [paymentDate, setPaymentDate] = useState(todayStr());
const [paymentMethod, setPaymentMethod] = useState('manual');
const [paymentNotes, setPaymentNotes] = useState('');
const isDebtCategory = isDebtCat(categories, categoryId);
const isSnowballCategory = isSnowballCat(categories, categoryId);
const showOnSnowball = snowballInclude || (isSnowballCategory && !snowballExempt);
const canAutoMarkPaid = autopay && autodraftStatus === 'assumed_paid';
async function loadPayments() {
if (isNew || !bill?.id) return;
setPaymentsLoading(true);
try {
const data = await api.billPayments(bill.id, 1, 100);
setPayments(data.payments || []);
} catch (err) {
toast.error(err.message || 'Failed to load payment history.');
} finally {
setPaymentsLoading(false);
}
}
useEffect(() => {
loadPayments();
}, [bill?.id]);
const validateName = (val) => {
if (!val || val.trim() === '') return 'Name is required';
if (val.trim().length < 2) return 'Name must be at least 2 characters';
@ -174,6 +217,107 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
}
};
const handleCycleTypeChange = (value) => {
setCycleType(value);
setCycleDay(defaultCycleDayFor(value));
};
function resetPaymentForm() {
setPaymentAmount('');
setPaymentDate(todayStr());
setPaymentMethod('manual');
setPaymentNotes('');
setEditingPayment(null);
}
function startAddPayment() {
resetPaymentForm();
setPaymentFormOpen(true);
}
function startEditPayment(payment) {
setEditingPayment(payment);
setPaymentAmount(String(payment.amount ?? ''));
setPaymentDate(payment.paid_date || todayStr());
setPaymentMethod(payment.method || 'manual');
setPaymentNotes(payment.notes || '');
setPaymentFormOpen(true);
}
async function handlePaymentSubmit(e) {
e.preventDefault();
const parsedAmount = parseFloat(paymentAmount);
if (!Number.isFinite(parsedAmount) || parsedAmount <= 0) {
toast.error('Enter a positive payment amount.');
return;
}
if (!paymentDate) {
toast.error('Choose a paid date.');
return;
}
setPaymentBusy(true);
try {
const payload = {
amount: parsedAmount,
paid_date: paymentDate,
method: paymentMethod,
notes: paymentNotes || null,
payment_source: 'manual',
};
if (editingPayment) {
await api.updatePayment(editingPayment.id, payload);
toast.success('Payment updated');
} else {
await api.createPayment({ ...payload, bill_id: bill.id });
toast.success('Payment added');
}
resetPaymentForm();
setPaymentFormOpen(false);
await loadPayments();
onSave?.();
} catch (err) {
toast.error(err.message || 'Payment could not be saved.');
} finally {
setPaymentBusy(false);
}
}
async function handleDeletePayment() {
if (!deletePaymentTarget) return;
const payment = deletePaymentTarget;
setPaymentBusy(true);
try {
await api.deletePayment(payment.id);
setDeletePaymentTarget(null);
toast.success('Payment moved to recovery.', {
action: {
label: 'Undo',
onClick: async () => {
try {
await api.restorePayment(payment.id);
toast.success('Payment restored');
await loadPayments();
onSave?.();
} catch (err) {
toast.error(err.message || 'Failed to restore payment.');
}
},
},
});
if (editingPayment?.id === payment.id) {
resetPaymentForm();
setPaymentFormOpen(false);
}
await loadPayments();
onSave?.();
} catch (err) {
toast.error(err.message || 'Payment could not be removed.');
} finally {
setPaymentBusy(false);
}
}
async function handleSubmit(e) {
e.preventDefault();
@ -252,7 +396,7 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
return (
<Dialog open onOpenChange={v => { if (!v) onClose(); }}>
<DialogContent className="sm:max-w-2xl border-border/60 bg-card/95 backdrop-blur-xl">
<DialogContent className="max-h-[92svh] overflow-y-auto border-border/60 bg-card/95 backdrop-blur-xl sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="text-base font-semibold tracking-tight">
{isNew ? 'Add Bill' : 'Edit Bill'}
@ -355,7 +499,7 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
{/* Cycle Type */}
<div className="space-y-1.5">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Cycle Type</Label>
<Select value={cycleType} onValueChange={setCycleType}>
<Select value={cycleType} onValueChange={handleCycleTypeChange}>
<SelectTrigger className={cn(inp, 'w-full')}>
<SelectValue />
</SelectTrigger>
@ -399,18 +543,25 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
</SelectContent>
</Select>
) : (
<Input
className={inp}
type="text"
placeholder="Day of period"
value={cycleDay}
onChange={e => setCycleDay(e.target.value)}
/>
<Select value={cycleDay} onValueChange={setCycleDay}>
<SelectTrigger className={cn(inp, 'w-full')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{[
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
].map((label, index) => (
<SelectItem key={label} value={String(index + 1)}>{label}</SelectItem>
))}
</SelectContent>
</Select>
)}
<p className="text-[10px] text-muted-foreground/70">
{cycleType === 'monthly' ? 'Day of the month' :
cycleType === 'weekly' || cycleType === 'biweekly' ? 'Day of the week' :
'Day of the period'}
cycleType === 'quarterly' ? 'First month of the quarterly cycle' :
'Month due each year'}
</p>
</div>
@ -651,6 +802,136 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
</div>
</form>
{!isNew && (
<div className="mt-4 border-t border-border/50 pt-4">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Payment history</p>
<p className="text-[11px] text-muted-foreground/75">{payments.length} recorded</p>
</div>
<Button type="button" size="sm" variant="outline" disabled={paymentBusy} onClick={startAddPayment} className="h-8 gap-2 text-xs">
<Plus className="h-3.5 w-3.5" />
Add Payment
</Button>
</div>
{paymentsLoading ? (
<div className="rounded-lg border border-border/60 bg-muted/20 px-3 py-8 text-center text-sm text-muted-foreground">
Loading payment history...
</div>
) : payments.length === 0 ? (
<div className="rounded-lg border border-dashed border-border/70 px-3 py-8 text-center text-sm text-muted-foreground">
No payments recorded for this bill.
</div>
) : (
<div className="max-h-52 space-y-2 overflow-y-auto pr-1">
{payments.map(payment => (
<div key={payment.id} className="flex items-start justify-between gap-3 rounded-lg border border-border/60 bg-background/35 px-3 py-2.5">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<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">
{payment.payment_source || 'manual'}
</span>
</div>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{fmtDate(payment.paid_date)} · {payment.method || 'manual'}
</p>
{payment.notes && (
<p className="mt-0.5 truncate text-xs text-muted-foreground/80">{payment.notes}</p>
)}
</div>
<div className="flex shrink-0 gap-1">
<Button type="button" size="icon" variant="ghost" disabled={paymentBusy} onClick={() => startEditPayment(payment)} className="h-8 w-8" aria-label="Edit payment">
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button type="button" size="icon" variant="ghost" disabled={paymentBusy} onClick={() => setDeletePaymentTarget(payment)} className="h-8 w-8 text-destructive hover:bg-destructive/10 hover:text-destructive" aria-label="Remove payment">
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
))}
</div>
)}
{paymentFormOpen && (
<form onSubmit={handlePaymentSubmit} className="mt-3 rounded-lg border border-border/60 bg-muted/20 p-3">
<div className="mb-3 flex items-center justify-between gap-3">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{editingPayment ? 'Edit payment' : 'Add payment'}
</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">
manual
</span>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Amount ($)</Label>
<Input
type="number"
min="0"
step="0.01"
value={paymentAmount}
onChange={e => setPaymentAmount(e.target.value)}
className={cn(inp, 'font-mono')}
required
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Paid Date</Label>
<Input
type="date"
value={paymentDate}
onChange={e => setPaymentDate(e.target.value)}
className={cn(inp, 'font-mono')}
required
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Method</Label>
<Select value={paymentMethod} onValueChange={setPaymentMethod}>
<SelectTrigger className={cn(inp, 'w-full')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{PAYMENT_METHODS.map(([value, label]) => (
<SelectItem key={value} value={value}>{label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Notes</Label>
<Input
value={paymentNotes}
onChange={e => setPaymentNotes(e.target.value)}
className={inp}
placeholder="Paid from checking"
/>
</div>
</div>
<div className="mt-3 flex justify-end gap-2">
<Button
type="button"
variant="ghost"
disabled={paymentBusy}
onClick={() => {
resetPaymentForm();
setPaymentFormOpen(false);
}}
className="text-xs"
>
Cancel
</Button>
<Button type="submit" disabled={paymentBusy} className="text-xs">
{paymentBusy ? 'Saving...' : editingPayment ? 'Save Payment' : 'Add Payment'}
</Button>
</div>
</form>
)}
</div>
)}
<DialogFooter className="mt-2 gap-2 sm:justify-between">
{!isNew && onDuplicate && (
<Button
@ -673,6 +954,32 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
</Button>
</div>
</DialogFooter>
<AlertDialog
open={!!deletePaymentTarget}
onOpenChange={open => {
if (!open && !paymentBusy) setDeletePaymentTarget(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove this payment?</AlertDialogTitle>
<AlertDialogDescription>
This moves the payment to recovery and removes it from bill status calculations.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={paymentBusy}>Cancel</AlertDialogCancel>
<AlertDialogAction
disabled={paymentBusy}
onClick={handleDeletePayment}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{paymentBusy ? 'Removing...' : 'Remove Payment'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</DialogContent>
</Dialog>
);

View File

@ -1,6 +1,16 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { CalendarDays, ChevronLeft, ChevronRight, CircleDollarSign, RefreshCw } from 'lucide-react';
import {
Banknote,
CalendarDays,
ChevronLeft,
ChevronRight,
CircleDollarSign,
PiggyBank,
RefreshCw,
TrendingDown,
WalletCards,
} from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api';
import { cn, fmt, fmtDate, todayStr } from '@/lib/utils';
@ -47,6 +57,91 @@ function LegendItem({ className, label }) {
);
}
function MoneyMetric({ icon: Icon, label, value, hint, valueClassName }) {
return (
<div className="min-w-0 rounded-lg border border-border/60 bg-background/55 p-3">
<div className="flex items-center gap-2">
<Icon className="h-4 w-4 text-muted-foreground" />
<p className="truncate text-xs font-semibold uppercase tracking-wide text-muted-foreground">{label}</p>
</div>
<p className={cn('mt-2 font-mono text-xl font-bold tracking-tight', valueClassName || 'text-foreground')}>
{fmt(value)}
</p>
{hint && <p className="mt-1 truncate text-xs text-muted-foreground">{hint}</p>}
</div>
);
}
function MoneyMap({ summaryData, loading }) {
if (loading) {
return (
<Card>
<CardContent className="grid gap-3 p-4 sm:grid-cols-2 xl:grid-cols-4">
{Array.from({ length: 4 }).map((_, index) => (
<div key={index} className="h-24 animate-pulse rounded-lg bg-muted" />
))}
</CardContent>
</Card>
);
}
const starting = summaryData?.starting_amounts || {};
const summary = summaryData?.summary || {};
const available = Number(starting.combined_amount || 0);
const assigned = Number(summary.expense_total || 0);
const paid = Number(summary.paid_total || 0);
const remaining = Number(summary.result || 0);
const extraIncome = Number(starting.other_amount || 0);
return (
<Card className="overflow-hidden">
<CardHeader className="pb-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<CardTitle className="text-base">Monthly Money Map</CardTitle>
<CardDescription>Available money, extra income, assigned bills, and what remains.</CardDescription>
</div>
<Button asChild variant="outline" size="sm" className="w-full gap-2 sm:w-auto">
<Link to="/summary">
<WalletCards className="h-4 w-4" />
Edit money plan
</Link>
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<MoneyMetric icon={Banknote} label="Available" value={available} hint="1st + 15th + extra" />
<MoneyMetric icon={PiggyBank} label="Extra Income" value={extraIncome} hint="Extra beyond paychecks" valueClassName={extraIncome > 0 ? 'text-teal-500' : ''} />
<MoneyMetric icon={CalendarDays} label="Assigned Bills" value={assigned} hint={`${summary.expense_count || 0} active bills`} />
<MoneyMetric
icon={CircleDollarSign}
label="After Bills"
value={remaining}
hint={`${fmt(paid)} already paid`}
valueClassName={remaining >= 0 ? 'text-emerald-500' : 'text-destructive'}
/>
</div>
<div className="grid gap-2 text-sm md:grid-cols-3">
<div className="flex items-center justify-between gap-3 rounded-md bg-muted/35 px-3 py-2">
<span className="text-muted-foreground">1st available</span>
<span className="font-mono font-semibold">{fmt(starting.first_amount)}</span>
</div>
<div className="flex items-center justify-between gap-3 rounded-md bg-muted/35 px-3 py-2">
<span className="text-muted-foreground">15th available</span>
<span className="font-mono font-semibold">{fmt(starting.fifteenth_amount)}</span>
</div>
<div className="flex items-center justify-between gap-3 rounded-md bg-muted/35 px-3 py-2">
<span className="text-muted-foreground">Monthly income</span>
<span className="truncate font-mono font-semibold">{fmt(summaryData?.income?.amount)}</span>
</div>
</div>
</CardContent>
</Card>
);
}
function SummaryProgress({ summary }) {
const percent = Number(summary?.paid_percent || 0);
@ -93,7 +188,7 @@ function SummaryProgress({ summary }) {
);
}
function DayIndicators({ day }) {
function DayIndicators({ day, moneyMarker }) {
const summary = day.status_summary;
const hasPaid = summary.paid_count > 0;
const hasDue = summary.due_count > summary.paid_count + summary.skipped_count + summary.missed_count;
@ -103,6 +198,7 @@ function DayIndicators({ day }) {
return (
<div className="mt-auto flex flex-wrap gap-1">
{moneyMarker && <span className="h-1.5 w-1.5 rounded-full bg-teal-500 ring-1 ring-teal-500/30" title="Available money" />}
{hasPaid && <span className="h-1.5 w-1.5 rounded-full bg-emerald-500" title="Paid" />}
{(hasDue || paymentOnly) && <span className="h-1.5 w-1.5 rounded-full bg-primary" title="Due or payment" />}
{hasSkipped && <span className="h-1.5 w-1.5 rounded-full bg-muted-foreground/50" title="Skipped" />}
@ -111,7 +207,7 @@ function DayIndicators({ day }) {
);
}
function CalendarGrid({ data, selectedDate, onSelectDay }) {
function CalendarGrid({ data, selectedDate, onSelectDay, moneyMarkers }) {
const firstWeekday = new Date(data.year, data.month - 1, 1).getDay();
const cells = [
...Array.from({ length: firstWeekday }, (_, index) => ({ type: 'blank', key: `blank-${index}` })),
@ -139,7 +235,8 @@ function CalendarGrid({ data, selectedDate, onSelectDay }) {
const isToday = day.date === today;
const isSelected = day.date === selectedDate;
const summary = day.status_summary;
const hasActivity = day.bills_due.length > 0 || day.payments.length > 0;
const moneyMarker = moneyMarkers?.[day.date] || null;
const hasActivity = day.bills_due.length > 0 || day.payments.length > 0 || !!moneyMarker;
const isPaidDay = summary.due_count > 0 && summary.paid_count >= summary.due_count - summary.skipped_count;
const hasMissed = summary.missed_count > 0;
@ -173,6 +270,11 @@ function CalendarGrid({ data, selectedDate, onSelectDay }) {
</div>
<div className="mt-1 hidden min-w-0 space-y-0.5 sm:block">
{moneyMarker && (
<p className="truncate font-mono text-[11px] font-semibold text-teal-500">
+{fmt(moneyMarker.amount)}
</p>
)}
{day.bills_due.slice(0, 2).map(bill => (
<p key={bill.bill_id} className="truncate text-[11px] text-muted-foreground">
{bill.name}
@ -183,7 +285,7 @@ function CalendarGrid({ data, selectedDate, onSelectDay }) {
)}
</div>
<DayIndicators day={day} />
<DayIndicators day={day} moneyMarker={moneyMarker} />
</button>
);
})}
@ -192,7 +294,63 @@ function CalendarGrid({ data, selectedDate, onSelectDay }) {
);
}
function DayDetailDialog({ day, open, onOpenChange }) {
function DebtPayoffGlance({ projection }) {
const snowball = projection?.snowball;
const comparison = projection?.comparison;
const nextDebt = snowball?.debts?.find(debt => Number(debt.balance) > 0) || snowball?.debts?.[0];
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<TrendingDown className="h-4 w-4 text-emerald-500" />
<CardTitle className="text-base">Debt Payoff</CardTitle>
</div>
<CardDescription>Quick snowball projection. Full controls stay on Snowball.</CardDescription>
</CardHeader>
<CardContent>
{snowball?.months_to_freedom ? (
<div className="space-y-3">
<div>
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Projected payoff</p>
<p className="mt-1 text-2xl font-semibold tracking-tight">{snowball.payoff_display}</p>
<p className="mt-1 text-sm text-muted-foreground">{snowball.months_to_freedom} months remaining</p>
</div>
<div className="grid grid-cols-2 gap-2 text-sm">
<div className="rounded-lg bg-muted/40 p-3">
<p className="text-xs text-muted-foreground">Interest</p>
<p className="font-mono font-semibold">{fmt(snowball.total_interest_paid)}</p>
</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">
<Link to="/snowball">Open Snowball</Link>
</Button>
</div>
) : (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
Add debt balances and minimum payments to see a payoff date here.
</p>
<Button asChild variant="outline" size="sm" className="w-full">
<Link to="/snowball">Set up Snowball</Link>
</Button>
</div>
)}
</CardContent>
</Card>
);
}
function DayDetailDialog({ day, open, onOpenChange, moneyMarker }) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg border-border/60 bg-card/95 backdrop-blur-xl">
@ -203,6 +361,17 @@ function DayDetailDialog({ day, open, onOpenChange }) {
{day && (
<div className="space-y-5">
{moneyMarker && (
<section className="rounded-lg border border-teal-500/25 bg-teal-500/10 p-3">
<h3 className="text-xs font-semibold uppercase tracking-wide text-teal-600 dark:text-teal-300">
Available Money
</h3>
<p className="mt-1 font-mono text-lg font-semibold text-teal-600 dark:text-teal-300">
+{fmt(moneyMarker.amount)}
</p>
<p className="text-xs text-muted-foreground">{moneyMarker.label}</p>
</section>
)}
<section>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Bills Due</h3>
{day.bills_due.length === 0 ? (
@ -283,6 +452,8 @@ export default function CalendarPage() {
const [year, setYear] = useState(initial.year);
const [month, setMonth] = useState(initial.month);
const [data, setData] = useState(null);
const [summaryData, setSummaryData] = useState(null);
const [snowballProjection, setSnowballProjection] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [selectedDay, setSelectedDay] = useState(null);
@ -292,8 +463,20 @@ export default function CalendarPage() {
setLoading(true);
setError('');
try {
const result = await api.calendar(year, month);
const [calendarResult, summaryResult, snowballResult] = await Promise.allSettled([
api.calendar(year, month),
api.summary(year, month),
api.snowballProjection(),
]);
if (calendarResult.status === 'rejected') {
throw calendarResult.reason;
}
const result = calendarResult.value;
setData(result);
setSummaryData(summaryResult.status === 'fulfilled' ? summaryResult.value : null);
setSnowballProjection(snowballResult.status === 'fulfilled' ? snowballResult.value : null);
setSelectedDay(current => current ? result.days.find(day => day.date === current.date) || null : null);
} catch (err) {
setError(err.message || 'Calendar data could not be loaded.');
@ -307,6 +490,30 @@ export default function CalendarPage() {
const monthLabel = useMemo(() => `${MONTHS[month - 1]} ${year}`, [year, month]);
const hasAnyBills = Number(data?.summary?.bill_count || 0) + Number(data?.summary?.skipped_count || 0) > 0;
const moneyMarkers = useMemo(() => {
const starting = summaryData?.starting_amounts;
if (!starting) return {};
const markers = {};
const firstAmount = Number(starting.first_amount || 0);
const fifteenthAmount = Number(starting.fifteenth_amount || 0);
if (firstAmount > 0) {
markers[`${year}-${String(month).padStart(2, '0')}-01`] = {
label: '1st available',
amount: firstAmount,
};
}
if (fifteenthAmount > 0) {
markers[`${year}-${String(month).padStart(2, '0')}-15`] = {
label: '15th available',
amount: fifteenthAmount,
};
}
return markers;
}, [month, summaryData, year]);
const selectedMoneyMarker = selectedDay ? moneyMarkers[selectedDay.date] || null : null;
function navigate(delta) {
const next = shiftMonth(year, month, delta);
@ -354,11 +561,14 @@ export default function CalendarPage() {
</div>
</div>
<MoneyMap summaryData={summaryData} loading={loading} />
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_360px]">
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3 rounded-xl border border-border/70 bg-card/70 px-4 py-3">
<LegendItem className="border-emerald-500 bg-emerald-500" label="Paid" />
<LegendItem className="border-primary bg-primary" label="Due" />
<LegendItem className="border-teal-500 bg-teal-500 ring-1 ring-teal-500/30" label="Available money" />
<LegendItem className="border-muted-foreground/50 bg-muted-foreground/50" label="Skipped" />
<LegendItem className="border-destructive bg-destructive" label="Missed/Late" />
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
@ -389,6 +599,7 @@ export default function CalendarPage() {
<CalendarGrid
data={data}
selectedDate={selectedDay?.date}
moneyMarkers={moneyMarkers}
onSelectDay={day => {
setSelectedDay(day);
setDetailOpen(true);
@ -414,6 +625,7 @@ export default function CalendarPage() {
<div className="space-y-4">
<SummaryProgress summary={data?.summary} />
<DebtPayoffGlance projection={snowballProjection} />
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Selected Day</CardTitle>
@ -447,6 +659,7 @@ export default function CalendarPage() {
<DayDetailDialog
day={selectedDay}
moneyMarker={selectedMoneyMarker}
open={detailOpen}
onOpenChange={setDetailOpen}
/>

View File

@ -4,7 +4,7 @@ import {
Upload, FileSpreadsheet, Database, Download, CheckCircle2, XCircle,
AlertTriangle, Loader2, RefreshCw, Clock, ChevronDown,
ChevronUp, SkipForward, Plus, CheckCheck, Sparkles,
List, Building2, ChevronLeft,
List, Building2, ChevronLeft, FileText,
} from 'lucide-react';
import { api } from '@/api';
import { cn } from '@/lib/utils';
@ -328,6 +328,363 @@ function CountPill({ label, value }) {
);
}
// Section 1: Import Transaction CSV
const CSV_MAPPING_FIELDS = [
'posted_date',
'amount',
'debit_amount',
'credit_amount',
'description',
'payee',
'memo',
'category',
'account',
'transaction_id',
'transaction_type',
'currency',
'transacted_at',
];
function compactMapping(mapping) {
return Object.fromEntries(
Object.entries(mapping || {}).filter(([, value]) => value),
);
}
function canCommitCsvMapping(mapping) {
return !!mapping?.posted_date && !!(mapping.amount || mapping.debit_amount || mapping.credit_amount);
}
function CsvMappingSelect({ field, label, headers, mapping, onChange }) {
const current = mapping[field] || '';
const used = new Set(Object.entries(mapping)
.filter(([key, value]) => key !== field && value)
.map(([, value]) => value));
return (
<label className="space-y-1.5">
<span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
{label}
{field === 'posted_date' && <span className="text-destructive">*</span>}
{field === 'amount' && !mapping.debit_amount && !mapping.credit_amount && (
<span className="text-destructive">*</span>
)}
</span>
<select
value={current}
onChange={e => onChange(field, e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-2 text-sm focus:outline-none focus:ring-1 focus:ring-ring"
>
<option value="">Not mapped</option>
{headers.map(header => (
<option key={header} value={header} disabled={used.has(header)}>
{header}
</option>
))}
</select>
</label>
);
}
function CsvSampleTable({ preview }) {
const headers = preview?.headers || [];
const sampleRows = preview?.sampleRows || [];
const visibleHeaders = headers.slice(0, 8);
const hiddenCount = Math.max(0, headers.length - visibleHeaders.length);
if (sampleRows.length === 0) {
return <p className="py-4 text-center text-sm text-muted-foreground">No sample rows found.</p>;
}
return (
<div className="overflow-x-auto rounded-lg border border-border/60">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border/50 bg-muted/40 text-muted-foreground">
{visibleHeaders.map(header => (
<th key={header} className="px-3 py-2 text-left font-medium whitespace-nowrap">{header}</th>
))}
{hiddenCount > 0 && (
<th className="px-3 py-2 text-left font-medium whitespace-nowrap">+{hiddenCount}</th>
)}
</tr>
</thead>
<tbody className="divide-y divide-border/30">
{sampleRows.map((row, index) => (
<tr key={index} className="hover:bg-muted/20">
{visibleHeaders.map(header => (
<td key={header} className="max-w-48 truncate px-3 py-2 text-muted-foreground">
{row[header] || '—'}
</td>
))}
{hiddenCount > 0 && (
<td className="px-3 py-2 text-muted-foreground">more columns</td>
)}
</tr>
))}
</tbody>
</table>
</div>
);
}
export function ImportTransactionCsvSection({ onHistoryRefresh }) {
const fileRef = useRef(null);
const [file, setFile] = useState(null);
const [preview, setPreview] = useState({ status: 'idle', data: null, error: null });
const [mapping, setMapping] = useState({});
const [commitState, setCommitState] = useState({ status: 'idle', result: null, error: null });
const reset = () => {
setFile(null);
setPreview({ status: 'idle', data: null, error: null });
setMapping({});
setCommitState({ status: 'idle', result: null, error: null });
if (fileRef.current) fileRef.current.value = '';
};
const handleMappingChange = (field, header) => {
setMapping(prev => {
const next = { ...prev };
if (header) next[field] = header;
else delete next[field];
return next;
});
setCommitState({ status: 'idle', result: null, error: null });
};
const handlePreview = async () => {
if (!file) {
toast.error('Choose a CSV file first.');
return;
}
setPreview({ status: 'loading', data: null, error: null });
setMapping({});
setCommitState({ status: 'idle', result: null, error: null });
try {
const data = await api.previewCsvTransactionImport(file);
setPreview({ status: 'ready', data, error: null });
setMapping(compactMapping(data.suggestedMapping || {}));
toast.success('CSV preview ready.');
} catch (err) {
const errorState = importErrorState(err, 'CSV preview failed.');
setPreview({ status: 'error', data: null, error: errorState });
toast.error(errorState.message || 'CSV preview failed.');
}
};
const handleCommit = async () => {
if (!preview.data?.import_session_id || !canCommitCsvMapping(mapping)) return;
setCommitState({ status: 'loading', result: null, error: null });
try {
const result = await api.commitCsvTransactionImport({
import_session_id: preview.data.import_session_id,
mapping: compactMapping(mapping),
});
setCommitState({ status: 'done', result, error: null });
toast.success(`CSV imported — ${result.imported} imported, ${result.skipped} skipped.`);
onHistoryRefresh?.();
} catch (err) {
const errorState = importErrorState(err, 'CSV import failed.');
setCommitState({ status: 'error', result: null, error: errorState });
toast.error(errorState.message || 'CSV import failed.');
}
};
const fields = preview.data?.fields || {};
const mappingFields = CSV_MAPPING_FIELDS.filter(field => fields[field]);
const canCommit = preview.status === 'ready'
&& preview.data?.import_session_id
&& canCommitCsvMapping(mapping)
&& commitState.status !== 'loading'
&& commitState.status !== 'done';
const failedRows = (commitState.result?.details || []).filter(d => d.result === 'failed').slice(0, 5);
const skippedRows = (commitState.result?.details || []).filter(d => d.result === 'skipped_duplicate').slice(0, 5);
return (
<SectionCard
title="Import Transaction CSV"
subtitle="Upload a bank or credit-card CSV, map its columns, then save normalized transactions for matching later."
>
<div className="px-6 py-5 space-y-5">
<div className="rounded-lg border border-border/60 bg-muted/25 p-4">
<div className="flex items-start gap-3">
<FileText className="mt-0.5 h-5 w-5 text-primary shrink-0" />
<div>
<p className="text-sm font-medium">Import transaction rows from CSV.</p>
<p className="mt-1 text-xs text-muted-foreground">
This importer creates shared transaction records only. It does not match transactions to bills yet.
</p>
</div>
</div>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
<label className="flex-1 space-y-1.5">
<span className="text-xs font-medium text-muted-foreground">CSV file</span>
<Input
ref={fileRef}
type="file"
accept=".csv,text/csv"
onChange={e => {
setFile(e.target.files?.[0] || null);
setPreview({ status: 'idle', data: null, error: null });
setMapping({});
setCommitState({ status: 'idle', result: null, error: null });
}}
/>
</label>
<div className="flex gap-2">
<Button size="sm" variant="outline" type="button" onClick={reset}>
Clear
</Button>
<Button size="sm" type="button" disabled={!file || preview.status === 'loading'} onClick={handlePreview}>
{preview.status === 'loading'
? <><Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />Previewing</>
: <><Upload className="h-3.5 w-3.5 mr-1.5" />Preview</>}
</Button>
</div>
</div>
{preview.status === 'error' && (
<div className="rounded-lg border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
<AlertTriangle className="mr-1.5 inline h-4 w-4" />
{preview.error?.message || 'CSV preview failed.'}
{preview.error?.details?.length > 0 && (
<ul className="mt-2 list-disc pl-5 text-xs">
{preview.error.details.map((d, i) => (
<li key={i}>{d.message || JSON.stringify(d)}</li>
))}
</ul>
)}
</div>
)}
{preview.status === 'ready' && preview.data && (
<div className="space-y-5">
<div className="flex flex-wrap gap-2">
<CountPill label="Rows" value={preview.data.rowCount} />
<CountPill label="Columns" value={preview.data.headers?.length || 0} />
<CountPill label="Issues" value={preview.data.errors?.length || 0} />
</div>
{preview.data.errors?.length > 0 && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-3">
<p className="text-xs font-semibold uppercase tracking-widest text-amber-600 dark:text-amber-400">Review mapping</p>
<ul className="mt-2 space-y-1 text-xs text-amber-700 dark:text-amber-300">
{preview.data.errors.map((issue, i) => (
<li key={i} className="flex items-start gap-1.5">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>{issue.message || JSON.stringify(issue)}</span>
</li>
))}
</ul>
</div>
)}
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<p className="text-sm font-medium">Column mapping</p>
<span className="text-xs text-muted-foreground">
Posted date and one amount mapping are required.
</span>
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{mappingFields.map(field => (
<CsvMappingSelect
key={field}
field={field}
label={fields[field]}
headers={preview.data.headers || []}
mapping={mapping}
onChange={handleMappingChange}
/>
))}
</div>
{!canCommitCsvMapping(mapping) && (
<p className="text-xs text-destructive">
Map a posted date column and either amount, debit amount, or credit amount before importing.
</p>
)}
</div>
<div className="space-y-3">
<p className="text-sm font-medium">Sample rows</p>
<CsvSampleTable preview={preview.data} />
</div>
<div className="rounded-lg border border-border/60 bg-muted/25 px-4 py-3 flex items-center justify-between gap-3 flex-wrap">
<p className="text-xs text-muted-foreground">
Duplicate rows are skipped using a CSV transaction ID when available, otherwise a stable row hash.
</p>
{commitState.status === 'done' ? (
<Button size="sm" variant="outline" type="button" onClick={reset}>
<Plus className="h-3.5 w-3.5 mr-1.5" />New CSV import
</Button>
) : (
<Button size="sm" type="button" disabled={!canCommit} onClick={handleCommit}>
{commitState.status === 'loading'
? <><Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />Importing</>
: <><CheckCheck className="h-3.5 w-3.5 mr-1.5" />Commit Import</>}
</Button>
)}
</div>
</div>
)}
{commitState.status === 'done' && commitState.result && (
<div className="rounded-lg border border-emerald-500/30 bg-emerald-500/5 p-4">
<div className="flex items-center gap-2">
<CheckCircle2 className="h-5 w-5 text-emerald-500" />
<p className="text-sm font-medium text-emerald-600">CSV transaction import complete</p>
</div>
<div className="mt-3 grid grid-cols-3 gap-2 text-xs">
<CountPill label="Imported" value={commitState.result.imported} />
<CountPill label="Skipped" value={commitState.result.skipped} />
<CountPill label="Failed" value={commitState.result.failed} />
</div>
{skippedRows.length > 0 && (
<div className="mt-3 text-xs text-muted-foreground">
<p className="font-medium text-foreground">Skipped duplicates</p>
<ul className="mt-1 space-y-0.5">
{skippedRows.map(row => (
<li key={row.row}>Row {row.row}: {row.provider_transaction_id}</li>
))}
</ul>
</div>
)}
{failedRows.length > 0 && (
<div className="mt-3 text-xs text-destructive">
<p className="font-medium">Failed rows</p>
<ul className="mt-1 space-y-0.5">
{failedRows.map(row => (
<li key={row.row}>Row {row.row}: {row.message}</li>
))}
</ul>
</div>
)}
</div>
)}
{commitState.status === 'error' && (
<div className="rounded-lg border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
<AlertTriangle className="mr-1.5 inline h-4 w-4" />
{commitState.error?.message || 'CSV import failed.'}
{commitState.error?.details?.length > 0 && (
<ul className="mt-2 list-disc pl-5 text-xs">
{commitState.error.details.map((d, i) => (
<li key={i}>{d.message || JSON.stringify(d)}</li>
))}
</ul>
)}
</div>
)}
</div>
</SectionCard>
);
}
// Section 3: Import My Data Export
export function ImportMyDataSection({ onHistoryRefresh }) {
@ -1997,6 +2354,7 @@ export default function DataPage() {
</div>
<div className="space-y-5">
<ImportTransactionCsvSection onHistoryRefresh={loadHistory} />
<ImportSpreadsheetSection onHistoryRefresh={loadHistory} />
<ImportMyDataSection onHistoryRefresh={loadHistory} />
</div>

View File

@ -428,11 +428,19 @@ function EditableCell({ row, field, threshold, defaultPaymentDate, refresh }) {
function paymentSummary(row, threshold) {
const target = Number(threshold) || 0;
const paid = Number(row.total_paid) || 0;
const remaining = Math.max(target - paid, 0);
const percent = target > 0 ? Math.min(100, Math.round((paid / target) * 100)) : 0;
const paidTowardDue = Number.isFinite(Number(row.paid_toward_due))
? Number(row.paid_toward_due)
: Math.min(paid, target);
const overpaid = Number.isFinite(Number(row.overpaid_amount))
? Number(row.overpaid_amount)
: Math.max(paid - target, 0);
const remaining = Math.max(target - paidTowardDue, 0);
const percent = target > 0 ? Math.min(100, Math.round((paidTowardDue / target) * 100)) : 0;
return {
target,
paid,
paidTowardDue,
overpaid,
remaining,
percent,
count: Array.isArray(row.payments) ? row.payments.length : 0,
@ -460,7 +468,7 @@ function PaymentProgress({ row, threshold, onOpen, compact = false }) {
>
<div className="flex items-center justify-between gap-2 text-xs">
<span className={cn('font-mono', summary.paid > 0 ? 'text-emerald-500' : 'text-muted-foreground')}>
{summary.paid > 0 ? `${fmt(summary.paid)} of ${fmt(summary.target)}` : `Paid ${fmt(0)} of ${fmt(summary.target)}`}
{summary.paid > 0 ? `${fmt(summary.paidTowardDue)} of ${fmt(summary.target)}` : `Paid ${fmt(0)} of ${fmt(summary.target)}`}
</span>
{summary.count > 1 && (
<span className="shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
@ -476,13 +484,64 @@ function PaymentProgress({ row, threshold, onOpen, compact = false }) {
</div>
<div className="mt-1 flex items-center justify-between gap-2 text-[11px] text-muted-foreground">
<span>{summary.percent}%</span>
<span>{summary.remaining > 0 ? `${fmt(summary.remaining)} remaining` : 'Paid in full'}</span>
<span>
{summary.overpaid > 0
? `${fmt(summary.overpaid)} overpaid`
: summary.remaining > 0
? `${fmt(summary.remaining)} remaining`
: 'Paid in full'}
</span>
</div>
</button>
);
}
function PaymentLedgerDialog({ row, threshold, defaultPaymentDate, onClose, onSaved }) {
function LowerThisMonthButton({ row, year, month, refresh, compact = false }) {
const threshold = rowThreshold(row);
const summary = paymentSummary(row, threshold);
const [saving, setSaving] = useState(false);
if (row.is_skipped || !summary.partial) return null;
async function handleClick() {
setSaving(true);
try {
await api.saveBillMonthlyState(row.id, {
year,
month,
actual_amount: summary.paid,
notes: row.monthly_notes || null,
is_skipped: row.is_skipped,
});
toast.success(`${MONTHS[month - 1]} amount set to ${fmt(summary.paid)}`);
refresh?.();
} catch (err) {
toast.error(err.message || 'Failed to update monthly amount');
} finally {
setSaving(false);
}
}
return (
<Button
type="button"
size="sm"
variant="ghost"
disabled={saving}
onClick={handleClick}
className={cn(
'h-7 px-2.5 text-xs font-semibold text-amber-600 hover:bg-amber-500/10 hover:text-amber-700',
'dark:text-amber-400 dark:hover:text-amber-300',
compact && 'h-8',
)}
title={`Set ${MONTHS[month - 1]} amount to ${fmt(summary.paid)} because this bill was lower this month`}
>
{saving ? 'Saving...' : 'Bill was lower'}
</Button>
);
}
function PaymentLedgerDialog({ row, year, month, threshold, defaultPaymentDate, onClose, onSaved }) {
const summary = paymentSummary(row, threshold);
const [amount, setAmount] = useState(String(summary.remaining || summary.target || ''));
const [date, setDate] = useState(defaultPaymentDate);
@ -534,6 +593,14 @@ function PaymentLedgerDialog({ row, threshold, defaultPaymentDate, onClose, onSa
<div className="space-y-4">
<div className="rounded-lg border border-border/60 bg-muted/25 p-3">
<PaymentProgress row={row} threshold={threshold} onOpen={() => {}} />
<div className="mt-2 flex justify-end">
<LowerThisMonthButton
row={row}
year={year}
month={month}
refresh={onSaved}
/>
</div>
</div>
<div className="grid gap-3 md:grid-cols-[1fr_1fr]">
@ -1400,6 +1467,12 @@ function Row({ row, year, month, refresh, index, onEditBill }) {
</Button>
</div>
)}
<LowerThisMonthButton
row={row}
year={year}
month={month}
refresh={refresh}
/>
</div>
</TableCell>
@ -1421,6 +1494,8 @@ function Row({ row, year, month, refresh, index, onEditBill }) {
{paymentLedgerOpen && (
<PaymentLedgerDialog
row={row}
year={year}
month={month}
threshold={threshold}
defaultPaymentDate={defaultPaymentDate}
onClose={() => setPaymentLedgerOpen(false)}
@ -1701,6 +1776,13 @@ function MobileTrackerRow({ row, year, month, refresh, index, onEditBill }) {
</Button>
</div>
)}
<LowerThisMonthButton
row={row}
year={year}
month={month}
refresh={refresh}
compact
/>
</div>
</div>
@ -1721,6 +1803,8 @@ function MobileTrackerRow({ row, year, month, refresh, index, onEditBill }) {
{paymentLedgerOpen && (
<PaymentLedgerDialog
row={row}
year={year}
month={month}
threshold={threshold}
defaultPaymentDate={defaultPaymentDate}
onClose={() => setPaymentLedgerOpen(false)}
@ -1768,8 +1852,14 @@ function Bucket({ label, rows, year, month, refresh, onEditBill, loading }) {
const activeRows = rows.filter(r => !r.is_skipped);
const totalThreshold = activeRows.reduce((s, r) => s + (r.actual_amount ?? r.expected_amount ?? 0), 0);
const totalPaid = activeRows.reduce((s, r) => s + (r.total_paid || 0), 0);
const totalPaidTowardDue = activeRows.reduce((s, r) => {
const threshold = Number(r.actual_amount ?? r.expected_amount ?? 0) || 0;
const cappedPaid = Number(r.paid_toward_due);
return s + (Number.isFinite(cappedPaid) ? cappedPaid : Math.min(Number(r.total_paid) || 0, threshold));
}, 0);
const totalOverpaid = Math.max(totalPaid - totalPaidTowardDue, 0);
const skippedCount = rows.length - activeRows.length;
const pct = totalThreshold > 0 ? Math.min((totalPaid / totalThreshold) * 100, 100) : 0;
const pct = totalThreshold > 0 ? Math.min((totalPaidTowardDue / totalThreshold) * 100, 100) : 0;
const allPaid = pct >= 100;
return (
@ -1803,10 +1893,13 @@ function Bucket({ label, rows, year, month, refresh, onEditBill, loading }) {
</div>
<span className="text-xs font-mono text-muted-foreground">
<span className={cn(allPaid ? 'text-emerald-500' : 'text-foreground')}>
{fmt(totalPaid)}
{fmt(totalPaidTowardDue)}
</span>
<span className="text-muted-foreground/50 mx-1">/</span>
{fmt(totalThreshold)}
{totalOverpaid > 0 && (
<span className="ml-1 text-emerald-500">+{fmt(totalOverpaid)}</span>
)}
</span>
</div>

View File

@ -42,7 +42,7 @@ const COLUMN_WHITELIST = new Set([
'display_name', 'last_password_change_at', 'auth_provider', 'external_subject',
'email', 'last_login_at',
// payments table columns
'deleted_at',
'deleted_at', 'payment_source', 'transaction_id',
// monthly_starting_amounts table columns
'other_amount',
// bills table columns
@ -68,6 +68,107 @@ function isValidSqlDefinition(def) {
return /^[\w\s\(\)\',!@#$%^&*+=\[\]<>\-.]+$/i.test(def);
}
function seedManualDataSources(database = db) {
if (!database) return;
const hasDataSources = database.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='data_sources'").get();
const hasUsers = database.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='users'").get();
if (!hasDataSources || !hasUsers) return;
database.exec(`
INSERT INTO data_sources (user_id, type, provider, name, status)
SELECT u.id, 'manual', 'manual', 'Manual Entry', 'active'
FROM users u
WHERE NOT EXISTS (
SELECT 1
FROM data_sources ds
WHERE ds.user_id = u.id
AND ds.type = 'manual'
AND ds.provider = 'manual'
)
`);
}
function ensureTransactionFoundationSchema(database = db) {
database.exec(`
CREATE TABLE IF NOT EXISTS data_sources (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
type TEXT NOT NULL,
provider TEXT,
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
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
);
CREATE TABLE IF NOT EXISTS financial_accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
data_source_id INTEGER,
provider_account_id TEXT,
name TEXT NOT NULL,
org_name TEXT,
account_type TEXT,
currency TEXT,
balance INTEGER,
available_balance INTEGER,
raw_data TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (data_source_id) REFERENCES data_sources(id) ON DELETE SET NULL,
UNIQUE(data_source_id, provider_account_id)
);
CREATE TABLE IF NOT EXISTS transactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
data_source_id INTEGER,
account_id INTEGER,
provider_transaction_id TEXT,
source_type TEXT NOT NULL,
transaction_type TEXT,
posted_date TEXT,
transacted_at TEXT,
amount INTEGER NOT NULL,
currency TEXT,
description TEXT,
payee TEXT,
memo TEXT,
category TEXT,
raw_data TEXT,
matched_bill_id INTEGER,
match_status TEXT NOT NULL DEFAULT 'unmatched',
ignored INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (data_source_id) REFERENCES data_sources(id) ON DELETE SET NULL,
FOREIGN KEY (account_id) REFERENCES financial_accounts(id) ON DELETE SET NULL,
FOREIGN KEY (matched_bill_id) REFERENCES bills(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_data_sources_user_type ON data_sources(user_id, type, status);
CREATE UNIQUE INDEX IF NOT EXISTS idx_data_sources_user_manual
ON data_sources(user_id, type, provider)
WHERE type = 'manual' AND provider = 'manual';
CREATE INDEX IF NOT EXISTS idx_financial_accounts_user_source ON financial_accounts(user_id, data_source_id);
CREATE INDEX IF NOT EXISTS idx_transactions_user_date ON transactions(user_id, posted_date, transacted_at);
CREATE INDEX IF NOT EXISTS idx_transactions_user_match ON transactions(user_id, match_status, ignored);
CREATE INDEX IF NOT EXISTS idx_transactions_account ON transactions(account_id);
CREATE INDEX IF NOT EXISTS idx_transactions_matched_bill ON transactions(matched_bill_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_transactions_provider_dedupe
ON transactions (data_source_id, provider_transaction_id)
WHERE provider_transaction_id IS NOT NULL;
`);
seedManualDataSources(database);
}
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
let db = null;
@ -848,6 +949,41 @@ function reconcileLegacyMigrations() {
`);
console.log('[migration] bill_templates table ensured');
}
},
{
version: 'v0.59',
description: 'payments: source metadata for future transaction matching',
check: function() {
const cols = db.prepare('PRAGMA table_info(payments)').all().map(c => c.name);
return cols.includes('payment_source') && cols.includes('transaction_id');
},
run: function() {
const cols = db.prepare('PRAGMA table_info(payments)').all().map(c => c.name);
if (!cols.includes('payment_source')) {
db.exec("ALTER TABLE payments ADD COLUMN payment_source TEXT NOT NULL DEFAULT 'manual'");
}
if (!cols.includes('transaction_id')) {
db.exec('ALTER TABLE payments ADD COLUMN transaction_id INTEGER');
}
console.log('[migration] payments: source metadata columns added');
}
},
{
version: 'v0.60',
description: 'transactions: shared transaction foundation tables',
check: function() {
const tables = db.prepare(`
SELECT name
FROM sqlite_master
WHERE type = 'table'
AND name IN ('data_sources', 'financial_accounts', 'transactions')
`).all();
return tables.length === 3;
},
run: function() {
ensureTransactionFoundationSchema(db);
console.log('[migration] transaction foundation tables ensured');
}
}
];
@ -1539,6 +1675,30 @@ function runMigrations() {
`);
console.log('[migration] bill_templates table ensured');
}
},
{
version: 'v0.59',
description: 'payments: source metadata for future transaction matching',
dependsOn: ['v0.58'],
run: function() {
const cols = db.prepare('PRAGMA table_info(payments)').all().map(c => c.name);
if (!cols.includes('payment_source')) {
db.exec("ALTER TABLE payments ADD COLUMN payment_source TEXT NOT NULL DEFAULT 'manual'");
}
if (!cols.includes('transaction_id')) {
db.exec('ALTER TABLE payments ADD COLUMN transaction_id INTEGER');
}
console.log('[migration] payments: source metadata columns added');
}
},
{
version: 'v0.60',
description: 'transactions: shared transaction foundation tables',
dependsOn: ['v0.59'],
run: function() {
ensureTransactionFoundationSchema(db);
console.log('[migration] transaction foundation tables ensured');
}
}
];
@ -1842,6 +2002,8 @@ function seedDefaults() {
`).run(initUser, password_hash, initUser + '@local');
console.log(`[seed] Created initial admin user: ${initUser}`);
}
seedManualDataSources(db);
}
function ensureUserDefaultCategories(userId) {
@ -1926,6 +2088,29 @@ const ROLLBACK_SQL_MAP = {
description: 'payments: balance_delta column',
sql: ['ALTER TABLE payments DROP COLUMN balance_delta']
},
'v0.59': {
description: 'payments: source metadata columns',
sql: [
'ALTER TABLE payments DROP COLUMN transaction_id',
'ALTER TABLE payments DROP COLUMN payment_source',
]
},
'v0.60': {
description: 'transactions: shared transaction foundation tables',
sql: [
'DROP INDEX IF EXISTS idx_transactions_provider_dedupe',
'DROP INDEX IF EXISTS idx_transactions_matched_bill',
'DROP INDEX IF EXISTS idx_transactions_account',
'DROP INDEX IF EXISTS idx_transactions_user_match',
'DROP INDEX IF EXISTS idx_transactions_user_date',
'DROP INDEX IF EXISTS idx_financial_accounts_user_source',
'DROP INDEX IF EXISTS idx_data_sources_user_manual',
'DROP INDEX IF EXISTS idx_data_sources_user_type',
'DROP TABLE IF EXISTS transactions',
'DROP TABLE IF EXISTS financial_accounts',
'DROP TABLE IF EXISTS data_sources',
]
},
'v0.51': {
description: 'bills: snowball_exempt column',
sql: ['ALTER TABLE bills DROP COLUMN snowball_exempt']

View File

@ -48,6 +48,8 @@ CREATE TABLE IF NOT EXISTS payments (
method TEXT,
notes TEXT,
balance_delta REAL,
payment_source TEXT NOT NULL DEFAULT 'manual',
transaction_id INTEGER,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
@ -80,6 +82,68 @@ CREATE TABLE IF NOT EXISTS user_settings (
PRIMARY KEY (user_id, key)
);
CREATE TABLE IF NOT EXISTS data_sources (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
type TEXT NOT NULL,
provider TEXT,
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
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
);
CREATE TABLE IF NOT EXISTS financial_accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
data_source_id INTEGER,
provider_account_id TEXT,
name TEXT NOT NULL,
org_name TEXT,
account_type TEXT,
currency TEXT,
balance INTEGER,
available_balance INTEGER,
raw_data TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (data_source_id) REFERENCES data_sources(id) ON DELETE SET NULL,
UNIQUE(data_source_id, provider_account_id)
);
CREATE TABLE IF NOT EXISTS transactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
data_source_id INTEGER,
account_id INTEGER,
provider_transaction_id TEXT,
source_type TEXT NOT NULL,
transaction_type TEXT,
posted_date TEXT,
transacted_at TEXT,
amount INTEGER NOT NULL,
currency TEXT,
description TEXT,
payee TEXT,
memo TEXT,
category TEXT,
raw_data TEXT,
matched_bill_id INTEGER,
match_status TEXT NOT NULL DEFAULT 'unmatched',
ignored INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (data_source_id) REFERENCES data_sources(id) ON DELETE SET NULL,
FOREIGN KEY (account_id) REFERENCES financial_accounts(id) ON DELETE SET NULL,
FOREIGN KEY (matched_bill_id) REFERENCES bills(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@ -116,6 +180,18 @@ CREATE INDEX IF NOT EXISTS idx_payments_bill_id ON payments(bill_id);
CREATE INDEX IF NOT EXISTS idx_payments_paid_date ON payments(paid_date);
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
CREATE INDEX IF NOT EXISTS idx_data_sources_user_type ON data_sources(user_id, type, status);
CREATE UNIQUE INDEX IF NOT EXISTS idx_data_sources_user_manual
ON data_sources(user_id, type, provider)
WHERE type = 'manual' AND provider = 'manual';
CREATE INDEX IF NOT EXISTS idx_financial_accounts_user_source ON financial_accounts(user_id, data_source_id);
CREATE INDEX IF NOT EXISTS idx_transactions_user_date ON transactions(user_id, posted_date, transacted_at);
CREATE INDEX IF NOT EXISTS idx_transactions_user_match ON transactions(user_id, match_status, ignored);
CREATE INDEX IF NOT EXISTS idx_transactions_account ON transactions(account_id);
CREATE INDEX IF NOT EXISTS idx_transactions_matched_bill ON transactions(matched_bill_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_transactions_provider_dedupe
ON transactions (data_source_id, provider_transaction_id)
WHERE provider_transaction_id IS NOT NULL;
CREATE TABLE IF NOT EXISTS monthly_bill_state (
id INTEGER PRIMARY KEY AUTOINCREMENT,

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "bill-tracker",
"version": "0.28.0",
"version": "0.28.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bill-tracker",
"version": "0.28.0",
"version": "0.28.1",
"license": "ISC",
"dependencies": {
"@radix-ui/react-alert-dialog": "^1.1.2",

View File

@ -1,6 +1,6 @@
{
"name": "bill-tracker",
"version": "0.28.0",
"version": "0.28.1",
"description": "Monthly bill tracking system",
"main": "server.js",
"scripts": {
@ -10,6 +10,7 @@
"build": "vite build",
"check:server": "find server.js db middleware routes services utils -name '*.js' -print0 | xargs -0 -n1 node --check",
"check": "npm run check:server && npm run build",
"test": "node --test tests/*.test.js",
"start": "node server.js"
},
"dependencies": {

View File

@ -468,7 +468,7 @@ router.post('/:id/toggle-paid', (req, res) => {
const notes = req.body.notes || null;
const paymentValidation = validatePaymentInput(
{ amount, paid_date: paidDate },
{ amount, paid_date: paidDate, payment_source: req.body.payment_source ?? 'manual' },
{ requireBillId: false },
);
if (paymentValidation.error) {
@ -480,8 +480,8 @@ router.post('/:id/toggle-paid', (req, res) => {
const balCalc = computeBalanceDelta(bill, payment.amount);
const result = db.prepare(
'INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta) VALUES (?, ?, ?, ?, ?, ?)'
).run(billId, payment.amount, payment.paid_date, method, notes, balCalc?.balance_delta ?? null);
'INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, payment_source) VALUES (?, ?, ?, ?, ?, ?, ?)'
).run(billId, payment.amount, payment.paid_date, method, notes, balCalc?.balance_delta ?? null, payment.payment_source);
if (balCalc) {
db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?")

View File

@ -110,8 +110,13 @@ router.get('/', (req, res) => {
}
const calendarBills = bills.map(bill => {
const billPayments = paymentsByBillStmt.all(bill.id, start, end);
const billRange = getCycleRange(year, month, bill);
if (!billRange) return null;
const billPayments = paymentsByBillStmt.all(bill.id, billRange.start, billRange.end);
const row = buildTrackerRow(bill, billPayments, year, month, today, rowOptions);
if (!row) return null;
const monthlyState = monthlyStateStmt.get(bill.id, year, month);
const actualAmount = monthlyState?.actual_amount ?? null;
const isSkipped = !!monthlyState?.is_skipped;
@ -124,14 +129,12 @@ router.get('/', (req, res) => {
? 'paid'
: row.status;
const isPaid = status === 'paid' || isAutodraft;
const dueDay = clampDay(year, month, bill.due_day);
const dueDate = toDateString(year, month, dueDay);
return {
bill_id: bill.id,
name: bill.name,
due_date: dueDate,
due_day: dueDay,
due_date: row.due_date,
due_day: Number(row.due_date.slice(8, 10)),
expected_amount: row.expected_amount,
actual_amount: actualAmount,
effective_amount: effectiveAmount,
@ -141,7 +144,7 @@ router.get('/', (req, res) => {
paid_amount: row.total_paid || 0,
status,
};
});
}).filter(Boolean);
for (const bill of calendarBills) {
const day = dayByDate.get(bill.due_date);

60
routes/dataSources.js Normal file
View File

@ -0,0 +1,60 @@
const router = require('express').Router();
const { getDb } = require('../db/database');
const { standardizeError } = require('../middleware/errorFormatter');
const { decorateDataSource, ensureManualDataSource } = require('../services/transactionService');
const VALID_TYPES = new Set(['manual', 'file_import', 'provider_sync']);
const VALID_STATUSES = new Set(['active', 'inactive', 'error']);
function cleanFilter(value) {
return typeof value === 'string' ? value.trim() : '';
}
// GET /api/data-sources?type=&status=
router.get('/', (req, res) => {
const db = getDb();
ensureManualDataSource(db, req.user.id);
const type = cleanFilter(req.query.type);
const status = cleanFilter(req.query.status);
if (type && !VALID_TYPES.has(type)) {
return res.status(400).json(standardizeError('type must be manual, file_import, or provider_sync', 'VALIDATION_ERROR', 'type'));
}
if (status && !VALID_STATUSES.has(status)) {
return res.status(400).json(standardizeError('status must be active, inactive, or error', 'VALIDATION_ERROR', 'status'));
}
let query = `
SELECT
ds.id, ds.user_id, ds.type, ds.provider, ds.name, ds.status,
ds.config_json, ds.last_sync_at, ds.last_error, ds.created_at, ds.updated_at,
COUNT(DISTINCT fa.id) AS account_count,
COUNT(DISTINCT t.id) AS transaction_count
FROM data_sources ds
LEFT JOIN financial_accounts fa ON fa.data_source_id = ds.id AND fa.user_id = ds.user_id
LEFT JOIN transactions t ON t.data_source_id = ds.id AND t.user_id = ds.user_id
WHERE ds.user_id = ?
`;
const params = [req.user.id];
if (type) {
query += ' AND ds.type = ?';
params.push(type);
}
if (status) {
query += ' AND ds.status = ?';
params.push(status);
}
query += `
GROUP BY ds.id
ORDER BY
CASE WHEN ds.type = 'manual' THEN 0 ELSE 1 END,
ds.name COLLATE NOCASE ASC
`;
res.json(db.prepare(query).all(...params).map(decorateDataSource));
});
module.exports = router;

View File

@ -98,7 +98,8 @@ function getUserExportData(userId) {
ORDER BY active DESC, due_day ASC, name ASC
`).all(userId);
const payments = db.prepare(`
SELECT p.id, p.bill_id, p.amount, p.paid_date, p.method, p.notes, p.created_at, p.updated_at
SELECT p.id, p.bill_id, p.amount, p.paid_date, p.method, p.notes,
p.payment_source, NULL AS transaction_id, p.created_at, p.updated_at
FROM payments p
JOIN bills b ON b.id = p.bill_id
WHERE b.user_id = ? AND b.deleted_at IS NULL AND p.deleted_at IS NULL
@ -171,7 +172,7 @@ router.get('/user-db', (req, res) => {
CREATE TABLE export_metadata (key TEXT PRIMARY KEY, value TEXT);
CREATE TABLE categories (id INTEGER PRIMARY KEY, name TEXT, created_at TEXT, updated_at TEXT);
CREATE TABLE bills (id INTEGER PRIMARY KEY, name TEXT, category_id INTEGER, due_day INTEGER, override_due_date TEXT, bucket TEXT, expected_amount REAL, interest_rate REAL, billing_cycle TEXT, cycle_type TEXT, cycle_day TEXT, autopay_enabled INTEGER, autodraft_status TEXT, website TEXT, username TEXT, account_info TEXT, has_2fa INTEGER, active INTEGER, notes TEXT, created_at TEXT, updated_at TEXT);
CREATE TABLE payments (id INTEGER PRIMARY KEY, bill_id INTEGER, amount REAL, paid_date TEXT, method TEXT, notes TEXT, created_at TEXT, updated_at TEXT);
CREATE TABLE payments (id INTEGER PRIMARY KEY, bill_id INTEGER, amount REAL, paid_date TEXT, method TEXT, notes TEXT, payment_source TEXT, transaction_id INTEGER, created_at TEXT, updated_at TEXT);
CREATE TABLE monthly_bill_state (id INTEGER PRIMARY KEY, bill_id INTEGER, year INTEGER, month INTEGER, actual_amount REAL, notes TEXT, is_skipped INTEGER, created_at TEXT, updated_at TEXT);
CREATE TABLE monthly_starting_amounts (id INTEGER PRIMARY KEY, year INTEGER, month INTEGER, first_amount REAL, fifteenth_amount REAL, other_amount REAL, notes TEXT, created_at TEXT, updated_at TEXT);
CREATE TABLE bill_history_ranges (id INTEGER PRIMARY KEY, bill_id INTEGER, start_year INTEGER, start_month INTEGER, end_year INTEGER, end_month INTEGER, label TEXT, created_at TEXT, updated_at TEXT);

View File

@ -12,6 +12,21 @@ const {
previewUserDbImport,
applyUserDbImport,
} = require('../services/userDbImportService');
const {
previewCsvTransactions,
commitCsvTransactions,
} = require('../services/csvTransactionImportService');
function dataImportEnabled() {
return String(process.env.DATA_IMPORT_ENABLED ?? 'true').toLowerCase() !== 'false';
}
function requireDataImportEnabled(req, res, next) {
if (!dataImportEnabled()) {
return res.status(403).json(standardizeError('Data import is disabled by DATA_IMPORT_ENABLED=false', 'FORBIDDEN'));
}
next();
}
function makeErrorId() {
return `imp_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
@ -45,6 +60,7 @@ function sendImportError(res, err, fallback, defaultCode) {
router.post(
'/spreadsheet/preview',
requireDataImportEnabled,
express.raw({
type: [
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
@ -96,7 +112,7 @@ router.post(
// Each decision must have: row_id, action, and action-specific fields.
// Only writes data for explicitly confirmed decisions; skips ambiguous rows.
router.post('/spreadsheet/apply', express.json({ limit: '2mb' }), async (req, res) => {
router.post('/spreadsheet/apply', requireDataImportEnabled, express.json({ limit: '2mb' }), async (req, res) => {
try {
const { import_session_id, decisions, options } = req.body || {};
@ -128,6 +144,7 @@ router.post('/spreadsheet/apply', express.json({ limit: '2mb' }), async (req, re
router.post(
'/user-db/preview',
requireDataImportEnabled,
express.raw({
type: [
'application/octet-stream',
@ -156,7 +173,7 @@ router.post(
// Applies a previously previewed user SQLite export session. User ownership is
// derived from req.user only; existing data is skipped by default.
router.post('/user-db/apply', express.json({ limit: '1mb' }), async (req, res) => {
router.post('/user-db/apply', requireDataImportEnabled, express.json({ limit: '1mb' }), async (req, res) => {
try {
const { import_session_id, options } = req.body || {};
if (!import_session_id || typeof import_session_id !== 'string') {
@ -169,6 +186,57 @@ router.post('/user-db/apply', express.json({ limit: '1mb' }), async (req, res) =
}
});
// ─── POST /api/import/csv/preview ────────────────────────────────────────────
// Accepts a transaction CSV as raw text/binary and returns headers, sample rows,
// suggested mappings, and validation issues. Writes no transactions.
router.post(
'/csv/preview',
requireDataImportEnabled,
express.raw({
type: [
'text/csv',
'application/csv',
'application/vnd.ms-excel',
'text/plain',
'application/octet-stream',
],
limit: '10mb',
}),
async (req, res) => {
try {
const rawFilename = req.headers['x-filename'];
const originalFilename = rawFilename
? rawFilename.replace(/[^a-zA-Z0-9._\-\s]/g, '').trim().slice(0, 255)
: null;
const result = previewCsvTransactions(req.user.id, req.body, { original_filename: originalFilename });
res.json(result);
} catch (err) {
return sendImportError(res, err, 'CSV transaction preview failed', 'CSV_TRANSACTION_PREVIEW_ERROR');
}
},
);
// ─── POST /api/import/csv/commit ─────────────────────────────────────────────
// Commits a previously-previewed CSV import session using the confirmed column
// mapping. Writes normalized rows into the shared transactions table.
router.post('/csv/commit', requireDataImportEnabled, express.json({ limit: '1mb' }), async (req, res) => {
try {
const { import_session_id, mapping, options } = req.body || {};
if (!import_session_id || typeof import_session_id !== 'string') {
return res.status(400).json(standardizeError('import_session_id is required', 'VALIDATION_ERROR', 'import_session_id'));
}
if (!mapping || typeof mapping !== 'object' || Array.isArray(mapping)) {
return res.status(400).json(standardizeError('mapping is required', 'VALIDATION_ERROR', 'mapping'));
}
const result = commitCsvTransactions(req.user.id, import_session_id, mapping, options || {});
res.json(result);
} catch (err) {
return sendImportError(res, err, 'CSV transaction import failed', 'CSV_TRANSACTION_COMMIT_ERROR');
}
});
// ─── GET /api/import/history ──────────────────────────────────────────────────
// Returns the authenticated user's import history (last 100 imports).

View File

@ -4,7 +4,7 @@ const router = require('express').Router();
const { getDb } = require('../db/database');
const { computeBalanceDelta } = require('../services/billsService');
const { validatePaymentInput } = require('../services/paymentValidation');
const { resolveDueDate } = require('../services/statusService');
const { getCycleRange, resolveDueDate } = require('../services/statusService');
const LIVE = 'deleted_at IS NULL'; // filter for non-deleted payments
@ -41,6 +41,9 @@ function getAutopaySuggestionContext(db, userId, billId, year, month) {
}
const dueDate = resolveDueDate(bill, year, month);
if (!dueDate) {
return { error: standardizeError('Bill does not occur in the selected month', 'VALIDATION_ERROR', 'month'), status: 400 };
}
const amount = state?.actual_amount ?? bill.expected_amount;
return { bill, dueDate, amount };
}
@ -96,9 +99,9 @@ router.get('/:id', (req, res) => {
// POST /api/payments — create single payment
router.post('/', (req, res) => {
const db = getDb();
const { bill_id, amount, paid_date, method, notes } = req.body;
const { bill_id, amount, paid_date, method, notes, payment_source } = req.body;
const validation = validatePaymentInput({ bill_id, amount, paid_date });
const validation = validatePaymentInput({ bill_id, amount, paid_date, payment_source: payment_source ?? 'manual' });
if (validation.error) {
return res.status(400).json(standardizeError(validation.error, 'VALIDATION_ERROR', validation.field));
}
@ -110,8 +113,8 @@ router.post('/', (req, res) => {
const balCalc = computeBalanceDelta(bill, payment.amount);
const result = db.prepare(
'INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta) VALUES (?, ?, ?, ?, ?, ?)'
).run(payment.bill_id, payment.amount, payment.paid_date, method || null, notes || null, balCalc?.balance_delta ?? null);
'INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, payment_source) VALUES (?, ?, ?, ?, ?, ?, ?)'
).run(payment.bill_id, payment.amount, payment.paid_date, method || null, notes || null, balCalc?.balance_delta ?? null, payment.payment_source);
if (balCalc) {
db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?")
@ -124,7 +127,7 @@ router.post('/', (req, res) => {
// POST /api/payments/quick — pay a bill (expected amount, today)
router.post('/quick', (req, res) => {
const db = getDb();
const { bill_id, amount, paid_date, method, notes } = req.body;
const { bill_id, amount, paid_date, method, notes, payment_source } = req.body;
const billValidation = validatePaymentInput({ bill_id }, { requireAmount: false, requirePaidDate: false });
if (billValidation.error) {
@ -138,6 +141,7 @@ router.post('/quick', (req, res) => {
{
amount: amount != null ? amount : bill.expected_amount,
paid_date: paid_date || new Date().toISOString().slice(0, 10),
payment_source: payment_source ?? 'manual',
},
{ requireBillId: false },
);
@ -146,12 +150,13 @@ router.post('/quick', (req, res) => {
}
const payAmount = paymentValidation.normalized.amount;
const payDate = paymentValidation.normalized.paid_date;
const paySource = paymentValidation.normalized.payment_source;
const balCalc = computeBalanceDelta(bill, payAmount);
const result = db.prepare(
'INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta) VALUES (?, ?, ?, ?, ?, ?)'
).run(bill.id, payAmount, payDate, method || null, notes || null, balCalc?.balance_delta ?? null);
'INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, payment_source) VALUES (?, ?, ?, ?, ?, ?, ?)'
).run(bill.id, payAmount, payDate, method || null, notes || null, balCalc?.balance_delta ?? null, paySource);
if (balCalc) {
db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?")
@ -187,6 +192,7 @@ router.post('/autopay-suggestions/:billId/confirm', (req, res) => {
}
const suggestedPayment = paymentValidation.normalized;
const suggestionRange = getCycleRange(ym.year, ym.month, bill);
const existing = db.prepare(`
SELECT p.*
FROM payments p
@ -194,11 +200,10 @@ router.post('/autopay-suggestions/:billId/confirm', (req, res) => {
WHERE p.bill_id = ?
AND b.user_id = ?
AND p.deleted_at IS NULL
AND strftime('%Y', p.paid_date) = ?
AND strftime('%m', p.paid_date) = ?
AND p.paid_date BETWEEN ? AND ?
ORDER BY p.paid_date DESC
LIMIT 1
`).get(bill.id, req.user.id, String(ym.year), String(ym.month).padStart(2, '0'));
`).get(bill.id, req.user.id, suggestionRange.start, suggestionRange.end);
if (existing) {
db.prepare('DELETE FROM autopay_suggestion_dismissals WHERE user_id = ? AND bill_id = ? AND year = ? AND month = ?')
@ -208,8 +213,8 @@ router.post('/autopay-suggestions/:billId/confirm', (req, res) => {
const balCalc = computeBalanceDelta(bill, suggestedPayment.amount);
const result = db.prepare(`
INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta)
VALUES (?, ?, ?, ?, ?, ?)
INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, payment_source)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(
bill.id,
suggestedPayment.amount,
@ -217,6 +222,7 @@ router.post('/autopay-suggestions/:billId/confirm', (req, res) => {
'autopay',
'Confirmed autopay suggestion',
balCalc?.balance_delta ?? null,
'manual',
);
if (balCalc) {
@ -276,14 +282,17 @@ router.post('/bulk', (req, res) => {
// Validate each payment item
for (let i = 0; i < payments.length; i++) {
const item = payments[i];
const validation = validatePaymentInput(item, { fieldPrefix: `payments[${i}].` });
const validation = validatePaymentInput(
{ ...item, payment_source: item.payment_source ?? 'manual' },
{ fieldPrefix: `payments[${i}].` },
);
if (validation.error) {
return res.status(400).json(standardizeError(`Payment at index ${i}: ${validation.error}`, 'VALIDATION_ERROR', validation.field));
}
}
const insert = db.prepare(
'INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta) VALUES (?, ?, ?, ?, ?, ?)'
'INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, payment_source) VALUES (?, ?, ?, ?, ?, ?, ?)'
);
const getBillForBalance = db.prepare('SELECT current_balance, interest_rate FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL');
const applyBalance = db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?");
@ -306,8 +315,8 @@ router.post('/bulk', (req, res) => {
const runBulk = db.transaction(() => {
for (const item of payments) {
const payment = validatePaymentInput(item).normalized;
const { bill_id, amount: parsedAmt, paid_date } = payment;
const payment = validatePaymentInput({ ...item, payment_source: item.payment_source ?? 'manual' }).normalized;
const { bill_id, amount: parsedAmt, paid_date, payment_source } = payment;
const { method, notes } = item;
// Check for duplicates using composite key (bill_id + paid_date + amount)
@ -324,7 +333,7 @@ router.post('/bulk', (req, res) => {
}
const balCalc = computeBalanceDelta(billRow, parsedAmt);
const r = insert.run(bill_id, parsedAmt, paid_date, method || null, notes || null, balCalc?.balance_delta ?? null);
const r = insert.run(bill_id, parsedAmt, paid_date, method || null, notes || null, balCalc?.balance_delta ?? null, payment_source);
if (balCalc) applyBalance.run(balCalc.new_balance, bill_id);
created.push(db.prepare('SELECT * FROM payments WHERE id = ?').get(r.lastInsertRowid));
@ -341,9 +350,9 @@ router.put('/:id', (req, res) => {
const existing = db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${LIVE} AND b.user_id = ? AND b.deleted_at IS NULL`).get(req.params.id, req.user.id);
if (!existing) return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id'));
const { amount, paid_date, method, notes } = req.body;
const { amount, paid_date, method, notes, payment_source } = req.body;
const validation = validatePaymentInput(
{ amount, paid_date },
{ amount, paid_date, payment_source },
{ requireBillId: false, requireAmount: false, requirePaidDate: false },
);
if (validation.error) {
@ -352,6 +361,7 @@ router.put('/:id', (req, res) => {
const nextAmount = validation.normalized.amount ?? existing.amount;
const nextPaidDate = validation.normalized.paid_date ?? existing.paid_date;
const nextPaymentSource = validation.normalized.payment_source ?? existing.payment_source ?? 'manual';
let nextBalanceDelta = existing.balance_delta;
const bill = db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(existing.bill_id, req.user.id);
@ -374,7 +384,7 @@ router.put('/:id', (req, res) => {
db.prepare(`
UPDATE payments SET
amount = ?, paid_date = ?, method = ?, notes = ?, balance_delta = ?,
amount = ?, paid_date = ?, method = ?, notes = ?, balance_delta = ?, payment_source = ?,
updated_at = datetime('now')
WHERE id = ?
`).run(
@ -383,6 +393,7 @@ router.put('/:id', (req, res) => {
method !== undefined ? (method || null) : existing.method,
notes !== undefined ? (notes || null) : existing.notes,
nextBalanceDelta,
nextPaymentSource,
req.params.id,
);

496
routes/transactions.js Normal file
View File

@ -0,0 +1,496 @@
const router = require('express').Router();
const { getDb } = require('../db/database');
const { standardizeError } = require('../middleware/errorFormatter');
const {
decorateTransaction,
ensureManualDataSource,
getTransactionForUser,
} = require('../services/transactionService');
const MATCH_STATUSES = new Set(['unmatched', 'matched', 'ignored']);
const SOURCE_TYPES = new Set(['manual', 'file_import', 'provider_sync']);
const TEXT_FIELDS = {
transaction_type: 64,
currency: 16,
description: 500,
payee: 255,
memo: 500,
category: 255,
};
function todayStr() {
return new Date().toISOString().slice(0, 10);
}
function cleanText(value, maxLength) {
if (value === undefined) return undefined;
if (value === null) return null;
const text = String(value).trim();
if (!text) return null;
return text.slice(0, maxLength);
}
function parseInteger(value, field, { allowNull = false, min = null, max = null } = {}) {
if (value === null && allowNull) return { value: null };
if (value === undefined) return { value: undefined };
const n = typeof value === 'number' ? value : Number(value);
if (!Number.isSafeInteger(n)) {
return { error: standardizeError(`${field} must be an integer`, 'VALIDATION_ERROR', field) };
}
if (min !== null && n < min) {
return { error: standardizeError(`${field} must be at least ${min}`, 'VALIDATION_ERROR', field) };
}
if (max !== null && n > max) {
return { error: standardizeError(`${field} must be at most ${max}`, 'VALIDATION_ERROR', field) };
}
return { value: n };
}
function parseBooleanInt(value, field) {
if (value === undefined) return { value: undefined };
if (value === true || value === 'true' || value === '1' || value === 1) return { value: 1 };
if (value === false || value === 'false' || value === '0' || value === 0) return { value: 0 };
return { error: standardizeError(`${field} must be true or false`, 'VALIDATION_ERROR', field) };
}
function parseDate(value, field, { allowNull = false } = {}) {
if (value === null && allowNull) return { value: null };
if (value === undefined) return { value: undefined };
const text = String(value).trim();
if (!/^\d{4}-\d{2}-\d{2}$/.test(text)) {
return { error: standardizeError(`${field} must be a valid YYYY-MM-DD date`, 'VALIDATION_ERROR', field) };
}
const date = new Date(`${text}T00:00:00Z`);
if (
Number.isNaN(date.getTime()) ||
date.toISOString().slice(0, 10) !== text
) {
return { error: standardizeError(`${field} must be a valid calendar date`, 'VALIDATION_ERROR', field) };
}
return { value: text };
}
function parseOptionalDateTime(value, field) {
if (value === undefined) return { value: undefined };
if (value === null) return { value: null };
const text = String(value).trim();
if (!text) return { value: null };
const match = /^(\d{4}-\d{2}-\d{2})(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d{1,9})?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/.exec(text);
if (!match) {
return { error: standardizeError(`${field} must be a valid ISO date or date-time`, 'VALIDATION_ERROR', field) };
}
const parsedDate = parseDate(match[1], field);
if (parsedDate.error) {
return parsedDate;
}
return { value: text };
}
function hasOwn(obj, field) {
return Object.prototype.hasOwnProperty.call(obj, field);
}
function getOwnedAccount(db, userId, accountId) {
if (accountId == null) return null;
return db.prepare('SELECT * FROM financial_accounts WHERE id = ? AND user_id = ?').get(accountId, userId);
}
function getOwnedBill(db, userId, billId) {
if (billId == null) return null;
return db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(billId, userId);
}
function normalizeTransactionFields(db, userId, body = {}, { partial = false } = {}) {
const normalized = {};
if (!partial || body.amount !== undefined) {
const parsed = parseInteger(body.amount, 'amount');
if (parsed.error) return { error: parsed.error };
if (parsed.value === 0) {
return { error: standardizeError('amount must be non-zero integer cents', 'VALIDATION_ERROR', 'amount') };
}
normalized.amount = parsed.value;
}
if (!partial || body.posted_date !== undefined) {
const postedDateValue = body.posted_date === undefined && !partial
? todayStr()
: body.posted_date;
const parsed = parseDate(postedDateValue, 'posted_date', { allowNull: partial });
if (parsed.error) return { error: parsed.error };
normalized.posted_date = parsed.value;
}
if (body.transacted_at !== undefined) {
const parsed = parseOptionalDateTime(body.transacted_at, 'transacted_at');
if (parsed.error) return { error: parsed.error };
normalized.transacted_at = parsed.value;
} else if (!partial) {
normalized.transacted_at = null;
}
for (const [field, maxLength] of Object.entries(TEXT_FIELDS)) {
if (!partial || body[field] !== undefined) {
const value = cleanText(body[field], maxLength);
normalized[field] = value === undefined ? null : value;
}
}
if (!partial && !normalized.currency) normalized.currency = 'USD';
if (body.account_id !== undefined) {
const parsed = parseInteger(body.account_id, 'account_id', { allowNull: true });
if (parsed.error) return { error: parsed.error };
if (parsed.value !== null && !getOwnedAccount(db, userId, parsed.value)) {
return { error: standardizeError('Financial account not found', 'NOT_FOUND', 'account_id'), status: 404 };
}
normalized.account_id = parsed.value;
} else if (!partial) {
normalized.account_id = null;
}
if (body.matched_bill_id !== undefined) {
const parsed = parseInteger(body.matched_bill_id, 'matched_bill_id', { allowNull: true });
if (parsed.error) return { error: parsed.error };
if (parsed.value !== null && !getOwnedBill(db, userId, parsed.value)) {
return { error: standardizeError('Matched bill not found', 'NOT_FOUND', 'matched_bill_id'), status: 404 };
}
normalized.matched_bill_id = parsed.value;
} else if (!partial) {
normalized.matched_bill_id = null;
}
if (body.match_status !== undefined) {
const matchStatus = cleanText(body.match_status, 32);
if (!MATCH_STATUSES.has(matchStatus)) {
return { error: standardizeError('match_status must be unmatched, matched, or ignored', 'VALIDATION_ERROR', 'match_status') };
}
normalized.match_status = matchStatus;
}
if (body.ignored !== undefined) {
const parsed = parseBooleanInt(body.ignored, 'ignored');
if (parsed.error) return { error: parsed.error };
normalized.ignored = parsed.value;
}
return { normalized };
}
function resolveTransactionState(next, existing = {}) {
const hasStatus = hasOwn(next, 'match_status');
const hasIgnored = hasOwn(next, 'ignored');
const hasMatchedBill = hasOwn(next, 'matched_bill_id');
if (hasStatus && next.match_status === 'ignored' && hasIgnored && next.ignored === 0) {
return { error: standardizeError('ignored must be true when match_status is ignored', 'VALIDATION_ERROR', 'ignored') };
}
if (hasIgnored && next.ignored === 1 && hasStatus && next.match_status !== 'ignored') {
return { error: standardizeError('match_status must be ignored when ignored is true', 'VALIDATION_ERROR', 'match_status') };
}
if (hasStatus && next.match_status === 'unmatched' && hasMatchedBill && next.matched_bill_id !== null) {
return { error: standardizeError('matched_bill_id must be null when match_status is unmatched', 'VALIDATION_ERROR', 'matched_bill_id') };
}
if (hasStatus && next.match_status === 'matched' && hasMatchedBill && next.matched_bill_id === null) {
return { error: standardizeError('matched_bill_id is required when match_status is matched', 'VALIDATION_ERROR', 'matched_bill_id') };
}
let matchedBillId = hasMatchedBill ? next.matched_bill_id : (existing.matched_bill_id ?? null);
let matchStatus = hasStatus ? next.match_status : (existing.match_status ?? (matchedBillId ? 'matched' : 'unmatched'));
let ignored = hasIgnored ? next.ignored : (existing.ignored ?? 0);
if (hasIgnored && ignored === 0 && matchStatus === 'ignored') {
matchStatus = 'unmatched';
matchedBillId = null;
}
if (ignored === 1 || matchStatus === 'ignored') {
return {
matched_bill_id: null,
match_status: 'ignored',
ignored: 1,
};
}
if (matchStatus === 'matched' || matchedBillId !== null) {
if (matchedBillId === null) {
return { error: standardizeError('matched_bill_id is required when match_status is matched', 'VALIDATION_ERROR', 'matched_bill_id') };
}
return {
matched_bill_id: matchedBillId,
match_status: 'matched',
ignored: 0,
};
}
return {
matched_bill_id: null,
match_status: 'unmatched',
ignored: 0,
};
}
function parseLimitOffset(query) {
const limit = parseInteger(query.limit ?? 50, 'limit', { min: 1, max: 200 });
if (limit.error) return { error: limit.error };
const offset = parseInteger(query.offset ?? 0, 'offset', { min: 0 });
if (offset.error) return { error: offset.error };
return { limit: limit.value, offset: offset.value };
}
function selectedTransaction(db, userId, id) {
return decorateTransaction(getTransactionForUser(db, userId, id));
}
// GET /api/transactions
router.get('/', (req, res) => {
const db = getDb();
ensureManualDataSource(db, req.user.id);
const page = parseLimitOffset(req.query);
if (page.error) return res.status(400).json(page.error);
const where = ['t.user_id = ?'];
const params = [req.user.id];
const matchStatusFilter = req.query.match_status ? String(req.query.match_status).trim() : '';
if (matchStatusFilter && !MATCH_STATUSES.has(matchStatusFilter)) {
return res.status(400).json(standardizeError('match_status must be unmatched, matched, or ignored', 'VALIDATION_ERROR', 'match_status'));
}
const ignored = req.query.ignored;
if (ignored === 'true' || ignored === '1') {
where.push('t.ignored = 1');
} else if (ignored === 'false' || ignored === '0') {
where.push('t.ignored = 0');
} else if (ignored !== 'all') {
if (ignored !== undefined) {
return res.status(400).json(standardizeError('ignored must be true, false, or all', 'VALIDATION_ERROR', 'ignored'));
}
where.push(matchStatusFilter === 'ignored' ? 't.ignored = 1' : 't.ignored = 0');
}
if (req.query.source_type) {
const sourceType = String(req.query.source_type).trim();
if (!SOURCE_TYPES.has(sourceType)) {
return res.status(400).json(standardizeError('source_type must be manual, file_import, or provider_sync', 'VALIDATION_ERROR', 'source_type'));
}
where.push('t.source_type = ?');
params.push(sourceType);
}
if (matchStatusFilter) {
where.push('t.match_status = ?');
params.push(matchStatusFilter);
}
for (const field of ['transaction_type']) {
if (req.query[field]) {
where.push(`t.${field} = ?`);
params.push(String(req.query[field]).trim());
}
}
for (const field of ['data_source_id', 'account_id', 'matched_bill_id']) {
if (req.query[field] !== undefined) {
const parsed = parseInteger(req.query[field], field);
if (parsed.error) return res.status(400).json(parsed.error);
where.push(`t.${field} = ?`);
params.push(parsed.value);
}
}
if (req.query.start_date) {
const parsed = parseDate(req.query.start_date, 'start_date');
if (parsed.error) return res.status(400).json(parsed.error);
where.push("COALESCE(t.posted_date, substr(t.transacted_at, 1, 10)) >= ?");
params.push(parsed.value);
}
if (req.query.end_date) {
const parsed = parseDate(req.query.end_date, 'end_date');
if (parsed.error) return res.status(400).json(parsed.error);
where.push("COALESCE(t.posted_date, substr(t.transacted_at, 1, 10)) <= ?");
params.push(parsed.value);
}
if (req.query.q) {
const q = `%${String(req.query.q).trim()}%`;
where.push('(t.description LIKE ? OR t.payee LIKE ? OR t.memo LIKE ? OR t.category LIKE ?)');
params.push(q, q, q, q);
}
const rows = db.prepare(`
SELECT
t.id, t.user_id, t.data_source_id, t.account_id, t.provider_transaction_id,
t.source_type, t.transaction_type, t.posted_date, t.transacted_at, t.amount,
t.currency, t.description, t.payee, t.memo, t.category, t.matched_bill_id,
t.match_status, t.ignored, t.created_at, t.updated_at,
ds.type AS data_source_type, ds.provider AS data_source_provider,
ds.name AS data_source_name, ds.status AS data_source_status,
fa.name AS account_name, fa.org_name AS account_org_name,
fa.account_type AS account_type,
b.name AS matched_bill_name
FROM transactions t
LEFT JOIN data_sources ds ON ds.id = t.data_source_id AND ds.user_id = t.user_id
LEFT JOIN financial_accounts fa ON fa.id = t.account_id AND fa.user_id = t.user_id
LEFT JOIN bills b ON b.id = t.matched_bill_id AND b.user_id = t.user_id AND b.deleted_at IS NULL
WHERE ${where.join(' AND ')}
ORDER BY COALESCE(t.posted_date, substr(t.transacted_at, 1, 10), t.created_at) DESC, t.id DESC
LIMIT ? OFFSET ?
`).all(...params, page.limit, page.offset);
res.json(rows.map(decorateTransaction));
});
// POST /api/transactions/manual
router.post('/manual', (req, res) => {
const db = getDb();
const validation = normalizeTransactionFields(db, req.user.id, req.body);
if (validation.error) return res.status(validation.status || 400).json(validation.error);
const tx = validation.normalized;
const source = ensureManualDataSource(db, req.user.id);
const state = resolveTransactionState(tx);
if (state.error) return res.status(400).json(state.error);
Object.assign(tx, state);
const result = db.prepare(`
INSERT INTO transactions
(user_id, data_source_id, account_id, source_type, transaction_type,
posted_date, transacted_at, amount, currency, description, payee, memo,
category, matched_bill_id, match_status, ignored)
VALUES (?, ?, ?, 'manual', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
req.user.id,
source.id,
tx.account_id,
tx.transaction_type,
tx.posted_date,
tx.transacted_at,
tx.amount,
tx.currency,
tx.description,
tx.payee,
tx.memo,
tx.category,
tx.matched_bill_id,
tx.match_status,
tx.ignored,
);
res.status(201).json(selectedTransaction(db, req.user.id, result.lastInsertRowid));
});
// PUT /api/transactions/:id
router.put('/:id', (req, res) => {
const db = getDb();
const id = parseInteger(req.params.id, 'id');
if (id.error) return res.status(400).json(id.error);
const existing = getTransactionForUser(db, req.user.id, id.value);
if (!existing) return res.status(404).json(standardizeError('Transaction not found', 'NOT_FOUND', 'id'));
const validation = normalizeTransactionFields(db, req.user.id, req.body, { partial: true });
if (validation.error) return res.status(validation.status || 400).json(validation.error);
const tx = validation.normalized;
const state = resolveTransactionState(tx, existing);
if (state.error) return res.status(400).json(state.error);
Object.assign(tx, state);
const nextPostedDate = hasOwn(tx, 'posted_date') ? tx.posted_date : existing.posted_date;
const nextTransactedAt = hasOwn(tx, 'transacted_at') ? tx.transacted_at : existing.transacted_at;
if (!nextPostedDate && !nextTransactedAt) {
return res.status(400).json(standardizeError('posted_date or transacted_at is required', 'VALIDATION_ERROR', 'posted_date'));
}
const fields = [
'account_id', 'transaction_type', 'posted_date', 'transacted_at', 'amount',
'currency', 'description', 'payee', 'memo', 'category', 'matched_bill_id',
'match_status', 'ignored',
];
const sets = [];
const params = [];
for (const field of fields) {
if (hasOwn(tx, field)) {
sets.push(`${field} = ?`);
params.push(tx[field]);
}
}
if (!sets.length) {
return res.status(400).json(standardizeError('No transaction fields provided', 'VALIDATION_ERROR'));
}
sets.push("updated_at = datetime('now')");
db.prepare(`
UPDATE transactions
SET ${sets.join(', ')}
WHERE id = ? AND user_id = ?
`).run(...params, id.value, req.user.id);
res.json(selectedTransaction(db, req.user.id, id.value));
});
// DELETE /api/transactions/:id
router.delete('/:id', (req, res) => {
const db = getDb();
const id = parseInteger(req.params.id, 'id');
if (id.error) return res.status(400).json(id.error);
const existing = getTransactionForUser(db, req.user.id, id.value);
if (!existing) return res.status(404).json(standardizeError('Transaction not found', 'NOT_FOUND', 'id'));
db.transaction(() => {
db.prepare(`
UPDATE payments
SET transaction_id = NULL, updated_at = datetime('now')
WHERE transaction_id = ?
AND bill_id IN (SELECT id FROM bills WHERE user_id = ?)
`).run(id.value, req.user.id);
db.prepare('DELETE FROM transactions WHERE id = ? AND user_id = ?').run(id.value, req.user.id);
})();
res.json({ success: true, deleted: true, id: id.value });
});
// POST /api/transactions/:id/ignore
router.post('/:id/ignore', (req, res) => {
const db = getDb();
const id = parseInteger(req.params.id, 'id');
if (id.error) return res.status(400).json(id.error);
if (!getTransactionForUser(db, req.user.id, id.value)) {
return res.status(404).json(standardizeError('Transaction not found', 'NOT_FOUND', 'id'));
}
db.prepare(`
UPDATE transactions
SET ignored = 1, match_status = 'ignored', matched_bill_id = NULL, updated_at = datetime('now')
WHERE id = ? AND user_id = ?
`).run(id.value, req.user.id);
res.json(selectedTransaction(db, req.user.id, id.value));
});
// POST /api/transactions/:id/unignore
router.post('/:id/unignore', (req, res) => {
const db = getDb();
const id = parseInteger(req.params.id, 'id');
if (id.error) return res.status(400).json(id.error);
if (!getTransactionForUser(db, req.user.id, id.value)) {
return res.status(404).json(standardizeError('Transaction not found', 'NOT_FOUND', 'id'));
}
db.prepare(`
UPDATE transactions
SET ignored = 0,
match_status = 'unmatched',
matched_bill_id = NULL,
updated_at = datetime('now')
WHERE id = ? AND user_id = ?
`).run(id.value, req.user.id);
res.json(selectedTransaction(db, req.user.id, id.value));
});
module.exports = router;

View File

@ -83,6 +83,8 @@ app.use('/api/admin', csrfMiddleware, requireAuth, requireAdmin, adminAct
app.use('/api/tracker', csrfMiddleware, requireAuth, requireUser, require('./routes/tracker'));
app.use('/api/bills', csrfMiddleware, requireAuth, requireUser, require('./routes/bills'));
app.use('/api/payments', csrfMiddleware, requireAuth, requireUser, require('./routes/payments'));
app.use('/api/data-sources', csrfMiddleware, requireAuth, requireUser, require('./routes/dataSources'));
app.use('/api/transactions', csrfMiddleware, requireAuth, requireUser, require('./routes/transactions'));
app.use('/api/categories', csrfMiddleware, requireAuth, requireUser, require('./routes/categories'));
app.use('/api/settings', csrfMiddleware, requireAuth, requireUser, require('./routes/settings'));
app.use('/api/user', csrfMiddleware, requireAuth, requireUser, require('./routes/user'));
@ -102,8 +104,10 @@ app.use('/api/version', require('./routes/ver
app.use('/api/profile', csrfMiddleware, requireAuth, requireUser, require('./routes/profile'));
// Export / Import — per-IP rate limited to deter abuse and resource exhaustion
const importRoutes = require('./routes/import');
app.use('/api/export', csrfMiddleware, requireAuth, requireUser, exportLimiter, require('./routes/export'));
app.use('/api/import', csrfMiddleware, requireAuth, requireUser, importLimiter, require('./routes/import'));
app.use('/api/import', csrfMiddleware, requireAuth, requireUser, importLimiter, importRoutes);
app.use('/api/imports', csrfMiddleware, requireAuth, requireUser, importLimiter, importRoutes);
// ── Legacy UI ("Remember When" mode) ─────────────────────────────────────────
app.use('/legacy', express.static(path.join(__dirname, 'legacy')));
@ -129,7 +133,7 @@ app.use((err, req, res, next) => {
recordError('Express', err);
console.error('[error]', err.message || String(err));
if (req.path?.startsWith('/api/import/')) {
if (/^\/api\/imports?\//.test(req.path || '')) {
const isBodyError = err.type === 'entity.too.large' || err instanceof SyntaxError;
return res.status(err.status || (isBodyError ? 400 : 500)).json({
error: 'Import request failed',

View File

@ -156,9 +156,9 @@ function getDefaultCycleDay(cycleType) {
case 'biweekly':
return 'monday'; // Monday
case 'quarterly':
return '1'; // 1st of the quarter
return '1'; // January/first quarter cycle
case 'annual':
return '1'; // 1st of the year
return '1'; // January
default:
return '1';
}
@ -181,8 +181,11 @@ function validateCycleDay(cycleType, cycleDay) {
return { value: String(cycleDay).toLowerCase() };
}
case 'quarterly':
case 'annual':
return { value: String(cycleDay).slice(0, 50) };
case 'annual': {
const month = Number(cycleDay);
if (!Number.isInteger(month) || month < 1 || month > 12) return { error: 'quarterly/annual cycle_day must be a month number 1-12' };
return { value: String(month) };
}
default:
return { value: getDefaultCycleDay(ct) };
}
@ -310,7 +313,11 @@ function validateBillData(data, existingBill = null) {
}
}
const cycleDayResult = validateCycleDay(nextCycleType, data.cycle_day !== undefined ? data.cycle_day : nextCycleDay);
const cycleDayInput = data.cycle_day !== undefined ? data.cycle_day : nextCycleDay;
let cycleDayResult = validateCycleDay(nextCycleType, cycleDayInput);
if (cycleDayResult.error && data.cycle_day === undefined && ['quarterly', 'annual'].includes(nextCycleType)) {
cycleDayResult = validateCycleDay(nextCycleType, getDefaultCycleDay(nextCycleType));
}
if (cycleDayResult.error) {
errors.push({ field: 'cycle_day', message: cycleDayResult.error });
} else {

View File

@ -0,0 +1,556 @@
'use strict';
const crypto = require('crypto');
const { getDb } = require('../db/database');
const { decorateTransaction, ensureManualDataSource } = require('./transactionService');
const SESSION_TTL_MS = 24 * 60 * 60 * 1000;
const MAX_ROWS = 25000;
const SAMPLE_SIZE = 10;
const FIELD_LABELS = {
posted_date: 'Posted date',
transacted_at: 'Transaction date/time',
amount: 'Amount',
debit_amount: 'Debit amount',
credit_amount: 'Credit amount',
description: 'Description',
payee: 'Payee',
memo: 'Memo',
category: 'Category',
account: 'Account',
transaction_id: 'Transaction ID',
transaction_type: 'Transaction type',
currency: 'Currency',
};
function importError(status, message, code, details = []) {
const err = new Error(message);
err.status = status;
err.code = code;
err.details = details;
return err;
}
function makeSessionId() {
return crypto.randomBytes(16).toString('hex');
}
function cleanFilename(value) {
return value
? String(value).replace(/[^a-zA-Z0-9._\-\s]/g, '').trim().slice(0, 255)
: null;
}
function normalizeHeader(value, index) {
const text = String(value ?? '').trim();
return text || `Column ${index + 1}`;
}
function parseCsv(text) {
const rows = [];
let row = [];
let cell = '';
let inQuotes = false;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
const next = text[i + 1];
if (inQuotes) {
if (ch === '"' && next === '"') {
cell += '"';
i++;
} else if (ch === '"') {
inQuotes = false;
} else {
cell += ch;
}
continue;
}
if (ch === '"') {
inQuotes = true;
} else if (ch === ',') {
row.push(cell);
cell = '';
} else if (ch === '\n') {
row.push(cell);
rows.push(row);
row = [];
cell = '';
} else if (ch === '\r') {
if (next === '\n') continue;
row.push(cell);
rows.push(row);
row = [];
cell = '';
} else {
cell += ch;
}
}
if (inQuotes) {
throw importError(400, 'CSV has an unterminated quoted field.', 'CSV_PARSE_ERROR');
}
if (cell !== '' || row.length > 0) {
row.push(cell);
rows.push(row);
}
return rows.filter(r => r.some(c => String(c ?? '').trim() !== ''));
}
function csvBufferToText(buffer) {
if (!Buffer.isBuffer(buffer) || buffer.length === 0) {
throw importError(400, 'CSV file is required.', 'CSV_REQUIRED');
}
return buffer.toString('utf8').replace(/^\uFEFF/, '');
}
function rowToObject(headers, row) {
const out = {};
headers.forEach((header, index) => {
out[header] = String(row[index] ?? '').trim();
});
return out;
}
function normalizeHeaderToken(value) {
return String(value || '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
}
function headerMatches(header, patterns) {
const token = normalizeHeaderToken(header);
return patterns.some(pattern => (
pattern instanceof RegExp ? pattern.test(token) : token === pattern
));
}
function suggestMapping(headers) {
const mapping = {};
const candidates = [
['posted_date', [/^date$/, 'posted date', 'post date', 'posting date', 'transaction date', 'trans date']],
['transacted_at', ['authorized date', 'authorization date', 'datetime', 'date time', 'timestamp']],
['transaction_id', ['transaction id', 'transaction number', 'trans id', 'id', 'fitid', 'reference', 'reference number']],
['description', ['description', 'transaction description', 'name', 'details', 'details description']],
['payee', ['payee', 'merchant', 'merchant name', 'vendor', 'name']],
['memo', ['memo', 'notes', 'note']],
['amount', [/^amount$/, 'transaction amount', 'net amount']],
['debit_amount', ['debit', 'debits', 'withdrawal', 'withdrawals', 'charge', 'charges']],
['credit_amount', ['credit', 'credits', 'deposit', 'deposits']],
['category', ['category', 'transaction category']],
['account', ['account', 'account name', 'account number']],
['transaction_type', ['type', 'transaction type']],
['currency', ['currency', 'currency code']],
];
for (const [field, patterns] of candidates) {
const found = headers.find(header => !Object.values(mapping).includes(header) && headerMatches(header, patterns));
if (found) mapping[field] = found;
}
if (!mapping.payee && mapping.description) {
const payee = headers.find(header => header !== mapping.description && headerMatches(header, ['name', 'merchant name']));
if (payee) mapping.payee = payee;
}
return mapping;
}
function parseCsvPreview(buffer, options = {}) {
const text = csvBufferToText(buffer);
const parsed = parseCsv(text);
if (parsed.length < 2) {
throw importError(400, 'CSV must include a header row and at least one data row.', 'CSV_EMPTY');
}
const headers = parsed[0].map(normalizeHeader);
const seenHeaders = new Set();
const duplicateHeaders = [];
for (const header of headers) {
const key = header.toLowerCase();
if (seenHeaders.has(key)) duplicateHeaders.push(header);
seenHeaders.add(key);
}
if (duplicateHeaders.length > 0) {
throw importError(400, `CSV contains duplicate headers: ${duplicateHeaders.join(', ')}`, 'CSV_DUPLICATE_HEADERS');
}
const dataRows = parsed.slice(1).slice(0, MAX_ROWS).map(row => rowToObject(headers, row));
const truncated = parsed.length - 1 > MAX_ROWS;
const suggestedMapping = suggestMapping(headers);
const errors = [];
if (!suggestedMapping.posted_date) {
errors.push({ field: 'posted_date', message: 'Could not detect a posted date column.' });
}
if (!suggestedMapping.amount && !(suggestedMapping.debit_amount || suggestedMapping.credit_amount)) {
errors.push({ field: 'amount', message: 'Could not detect an amount column.' });
}
if (!suggestedMapping.description && !suggestedMapping.payee && !suggestedMapping.memo) {
errors.push({ field: 'description', message: 'No description, payee, or memo column was detected. Dedupe will be less useful.' });
}
if (truncated) {
errors.push({ field: 'file', message: `Only the first ${MAX_ROWS} rows will be imported from this CSV.` });
}
return {
headers,
rows: dataRows,
rowCount: dataRows.length,
sampleRows: dataRows.slice(0, SAMPLE_SIZE),
suggestedMapping,
errors,
original_filename: cleanFilename(options.original_filename),
};
}
function pruneExpiredSessions(db) {
db.prepare('DELETE FROM import_sessions WHERE expires_at <= ?').run(new Date().toISOString());
}
function saveImportSession(db, userId, sessionData) {
const id = makeSessionId();
const now = new Date().toISOString();
const expiresAt = new Date(Date.now() + SESSION_TTL_MS).toISOString();
db.prepare(`
INSERT INTO import_sessions (id, user_id, created_at, expires_at, preview_json)
VALUES (?, ?, ?, ?, ?)
`).run(id, userId, now, expiresAt, JSON.stringify(sessionData));
return id;
}
function loadImportSession(db, userId, sessionId) {
const row = db.prepare(`
SELECT preview_json
FROM import_sessions
WHERE id = ? AND user_id = ? AND expires_at > ?
`).get(sessionId, userId, new Date().toISOString());
if (!row) {
throw importError(404, 'CSV import session not found or expired. Please re-upload the file.', 'CSV_SESSION_NOT_FOUND');
}
return JSON.parse(row.preview_json);
}
function deleteImportSession(db, sessionId) {
db.prepare('DELETE FROM import_sessions WHERE id = ?').run(sessionId);
}
function previewCsvTransactions(userId, buffer, options = {}) {
const db = getDb();
pruneExpiredSessions(db);
const preview = parseCsvPreview(buffer, options);
const sessionId = saveImportSession(db, userId, {
kind: 'csv_transactions',
...preview,
});
return {
import_session_id: sessionId,
headers: preview.headers,
sampleRows: preview.sampleRows,
rowCount: preview.rowCount,
suggestedMapping: preview.suggestedMapping,
errors: preview.errors,
fields: FIELD_LABELS,
};
}
function headerValue(row, mapping, field) {
const header = mapping?.[field];
if (!header) return '';
return String(row[header] ?? '').trim();
}
function parseDateValue(value, field, rowNumber) {
const text = String(value || '').trim();
if (!text) {
throw importError(400, `${FIELD_LABELS[field] || field} is required`, 'CSV_ROW_VALIDATION', [
{ row: rowNumber, field, message: `${FIELD_LABELS[field] || field} is required` },
]);
}
const iso = /^(\d{4})-(\d{1,2})-(\d{1,2})/.exec(text);
if (iso) {
const normalized = `${iso[1]}-${iso[2].padStart(2, '0')}-${iso[3].padStart(2, '0')}`;
if (isRealDate(normalized)) return normalized;
}
const slash = /^(\d{1,2})\/(\d{1,2})\/(\d{2,4})$/.exec(text);
if (slash) {
const year = slash[3].length === 2 ? `20${slash[3]}` : slash[3];
const normalized = `${year}-${slash[1].padStart(2, '0')}-${slash[2].padStart(2, '0')}`;
if (isRealDate(normalized)) return normalized;
}
throw importError(400, `${FIELD_LABELS[field] || field} must be a valid date`, 'CSV_ROW_VALIDATION', [
{ row: rowNumber, field, value: text, message: `${FIELD_LABELS[field] || field} must be YYYY-MM-DD or MM/DD/YYYY` },
]);
}
function isRealDate(value) {
const [year, month, day] = String(value).split('-').map(Number);
const date = new Date(Date.UTC(year, month - 1, day));
return date.getUTCFullYear() === year
&& date.getUTCMonth() === month - 1
&& date.getUTCDate() === day;
}
function parseCents(value, { negative = false } = {}) {
const text = String(value ?? '').trim();
if (!text) return null;
const parenNegative = /^\(.*\)$/.test(text);
const cleaned = text.replace(/[,$\s]/g, '').replace(/^\((.*)\)$/, '$1');
if (!/^[+-]?\d+(?:\.\d{1,4})?$/.test(cleaned)) return null;
const number = Number(cleaned);
if (!Number.isFinite(number)) return null;
const explicitNegative = cleaned.startsWith('-') || parenNegative;
const sign = explicitNegative || negative ? -1 : 1;
return Math.round(Math.abs(number) * 100) * sign;
}
function parseMappedAmount(row, mapping) {
const amount = parseCents(headerValue(row, mapping, 'amount'));
if (amount !== null) return amount;
const debit = parseCents(headerValue(row, mapping, 'debit_amount'), { negative: true });
if (debit !== null) return debit;
const credit = parseCents(headerValue(row, mapping, 'credit_amount'));
if (credit !== null) return credit;
return null;
}
function stableHash(parts) {
return crypto
.createHash('sha256')
.update(parts.map(part => String(part || '').trim().toLowerCase()).join('\u001f'))
.digest('hex')
.slice(0, 48);
}
function getOrCreateCsvDataSource(db, userId) {
ensureManualDataSource(db, userId);
const existing = db.prepare(`
SELECT *
FROM data_sources
WHERE user_id = ? AND type = 'file_import' AND provider = 'csv' AND name = 'CSV Import'
ORDER BY id ASC
LIMIT 1
`).get(userId);
if (existing) return existing;
const result = db.prepare(`
INSERT INTO data_sources (user_id, type, provider, name, status)
VALUES (?, 'file_import', 'csv', 'CSV Import', 'active')
`).run(userId);
return db.prepare('SELECT * FROM data_sources WHERE id = ? AND user_id = ?').get(result.lastInsertRowid, userId);
}
function getOrCreateAccount(db, userId, dataSourceId, name) {
const accountName = String(name || '').trim();
if (!accountName) return null;
const providerAccountId = stableHash([accountName]).slice(0, 32);
const existing = db.prepare(`
SELECT *
FROM financial_accounts
WHERE user_id = ? AND data_source_id = ? AND provider_account_id = ?
`).get(userId, dataSourceId, providerAccountId);
if (existing) return existing;
const result = db.prepare(`
INSERT INTO financial_accounts
(user_id, data_source_id, provider_account_id, name, account_type, currency)
VALUES (?, ?, ?, ?, 'csv', 'USD')
`).run(userId, dataSourceId, providerAccountId, accountName);
return db.prepare('SELECT * FROM financial_accounts WHERE id = ? AND user_id = ?').get(result.lastInsertRowid, userId);
}
function validateMapping(headers, mapping = {}) {
const headerSet = new Set(headers);
const required = [];
if (!mapping.posted_date) required.push('posted_date');
if (!mapping.amount && !(mapping.debit_amount || mapping.credit_amount)) required.push('amount');
if (required.length) {
throw importError(400, `Missing required mapping: ${required.map(f => FIELD_LABELS[f] || f).join(', ')}`, 'CSV_MAPPING_REQUIRED');
}
for (const [field, header] of Object.entries(mapping)) {
if (!FIELD_LABELS[field]) {
throw importError(400, `Unsupported mapping field: ${field}`, 'CSV_MAPPING_INVALID');
}
if (header && !headerSet.has(header)) {
throw importError(400, `Mapped column "${header}" for ${field} was not found in the CSV headers.`, 'CSV_MAPPING_INVALID');
}
}
}
function parseOptionalDateTimeValue(value, field, rowNumber) {
const text = String(value || '').trim();
if (!text) return null;
const match = /^(\d{4}-\d{2}-\d{2})(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d{1,9})?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/.exec(text);
if (!match || !isRealDate(match[1])) {
throw importError(400, `${FIELD_LABELS[field] || field} must be a valid ISO date or date-time`, 'CSV_ROW_VALIDATION', [
{ row: rowNumber, field, value: text, message: `${FIELD_LABELS[field] || field} must be YYYY-MM-DD or an ISO date-time` },
]);
}
return text;
}
function normalizeCsvTransaction(row, mapping, rowNumber) {
const postedDate = parseDateValue(headerValue(row, mapping, 'posted_date'), 'posted_date', rowNumber);
const transactedAt = parseOptionalDateTimeValue(headerValue(row, mapping, 'transacted_at'), 'transacted_at', rowNumber);
const amount = parseMappedAmount(row, mapping);
if (!Number.isSafeInteger(amount) || amount === 0) {
throw importError(400, 'Amount must be a non-zero number.', 'CSV_ROW_VALIDATION', [
{ row: rowNumber, field: 'amount', message: 'Amount must be a non-zero number.' },
]);
}
const description = headerValue(row, mapping, 'description');
const payee = headerValue(row, mapping, 'payee');
const memo = headerValue(row, mapping, 'memo');
const accountName = headerValue(row, mapping, 'account');
const transactionId = headerValue(row, mapping, 'transaction_id');
const providerTransactionId = transactionId
? `csv:id:${transactionId}`
: `csv:hash:${stableHash([postedDate, amount, description, payee, accountName])}`;
return {
provider_transaction_id: providerTransactionId,
transaction_type: headerValue(row, mapping, 'transaction_type') || null,
posted_date: postedDate,
transacted_at: transactedAt,
amount,
currency: headerValue(row, mapping, 'currency') || 'USD',
description: description || payee || memo || null,
payee: payee || null,
memo: memo || null,
category: headerValue(row, mapping, 'category') || null,
account_name: accountName || null,
raw_data: JSON.stringify(row),
};
}
function commitCsvTransactions(userId, importSessionId, mapping, options = {}) {
const db = getDb();
const session = loadImportSession(db, userId, importSessionId);
if (session.kind !== 'csv_transactions') {
throw importError(400, 'Import session is not a CSV transaction preview.', 'CSV_SESSION_INVALID');
}
validateMapping(session.headers, mapping);
const dataSource = getOrCreateCsvDataSource(db, userId);
const details = [];
const counts = { imported: 0, skipped: 0, failed: 0 };
const insert = db.prepare(`
INSERT INTO transactions
(user_id, data_source_id, account_id, provider_transaction_id, source_type,
transaction_type, posted_date, transacted_at, amount, currency, description,
payee, memo, category, raw_data, match_status, ignored)
VALUES (?, ?, ?, ?, 'file_import', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'unmatched', 0)
`);
const existing = db.prepare(`
SELECT id
FROM transactions
WHERE user_id = ? AND data_source_id = ? AND provider_transaction_id = ?
`);
const run = db.transaction(() => {
session.rows.forEach((row, index) => {
const rowNumber = index + 2;
try {
const tx = normalizeCsvTransaction(row, mapping, rowNumber);
if (existing.get(userId, dataSource.id, tx.provider_transaction_id)) {
counts.skipped++;
details.push({ row: rowNumber, result: 'skipped_duplicate', provider_transaction_id: tx.provider_transaction_id });
return;
}
const account = getOrCreateAccount(db, userId, dataSource.id, tx.account_name);
const result = insert.run(
userId,
dataSource.id,
account?.id ?? null,
tx.provider_transaction_id,
tx.transaction_type,
tx.posted_date,
tx.transacted_at,
tx.amount,
tx.currency,
tx.description,
tx.payee,
tx.memo,
tx.category,
tx.raw_data,
);
counts.imported++;
details.push({
row: rowNumber,
result: 'imported',
transaction: decorateTransaction({
...tx,
id: result.lastInsertRowid,
user_id: userId,
data_source_id: dataSource.id,
source_type: 'file_import',
data_source_type: dataSource.type,
data_source_provider: dataSource.provider,
data_source_name: dataSource.name,
data_source_status: dataSource.status,
account_id: account?.id ?? null,
account_name: account?.name ?? null,
match_status: 'unmatched',
ignored: 0,
}),
});
} catch (err) {
counts.failed++;
details.push({ row: rowNumber, result: 'failed', message: err.message, details: err.details || [] });
}
});
db.prepare(`
INSERT INTO import_history (
user_id, imported_at, source_filename, file_type, sheet_name,
rows_parsed, rows_created, rows_updated, rows_skipped, rows_ambiguous,
rows_errored, options_json, summary_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
userId,
new Date().toISOString(),
session.original_filename,
'csv_transactions',
null,
session.rows.length,
counts.imported,
0,
counts.skipped,
0,
counts.failed,
JSON.stringify({ mapping, options }),
JSON.stringify(details.slice(0, 500)),
);
});
run();
deleteImportSession(db, importSessionId);
return {
success: true,
imported: counts.imported,
skipped: counts.skipped,
failed: counts.failed,
details,
};
}
module.exports = {
FIELD_LABELS,
commitCsvTransactions,
previewCsvTransactions,
};

View File

@ -175,7 +175,6 @@ async function runNotifications() {
const today = now.toISOString().slice(0, 10);
const { getCycleRange, resolveDueDate } = require('./statusService');
const { start, end } = getCycleRange(year, month);
// Fetch all active bills. In global-notification mode, the single global recipient
// legitimately receives every bill. In per-user mode, each recipient must only
@ -205,15 +204,18 @@ async function runNotifications() {
const errors = [];
for (const bill of bills) {
const dueDate = resolveDueDate(bill, year, month);
if (!dueDate) continue;
const range = getCycleRange(year, month, bill);
const payments = db.prepare(
'SELECT * FROM payments WHERE bill_id = ? AND paid_date BETWEEN ? AND ? AND deleted_at IS NULL'
).all(bill.id, start, end);
).all(bill.id, range.start, range.end);
const totalPaid = payments.reduce((s, p) => s + p.amount, 0);
const isPaid = totalPaid >= bill.expected_amount;
if (isPaid) continue;
const dueDate = resolveDueDate(bill, year, month);
const due = new Date(dueDate + 'T00:00:00');
// Compare calendar days, not timestamps, to avoid same-day bugs
// (e.g., due today at midnight vs now at 3pm would give -0.625 days → floors to -1)

View File

@ -39,6 +39,20 @@ function validatePositiveAmount(value, field = 'amount') {
return { value: amount };
}
const PAYMENT_SOURCES = ['manual', 'file_import', 'provider_sync'];
function validatePaymentSource(value, field = 'payment_source') {
if (typeof value !== 'string') {
return { error: `${field} must be one of: ${PAYMENT_SOURCES.join(', ')}` };
}
const source = value.trim();
if (!PAYMENT_SOURCES.includes(source)) {
return { error: `${field} must be one of: ${PAYMENT_SOURCES.join(', ')}` };
}
return { value: source };
}
function validatePaymentInput(data, options = {}) {
const {
requireBillId = true,
@ -76,11 +90,19 @@ function validatePaymentInput(data, options = {}) {
normalized.paid_date = paidDate.value;
}
if (data.payment_source !== undefined) {
const source = validatePaymentSource(data.payment_source, `${fieldPrefix}payment_source`);
if (source.error) return { error: source.error, field: `${fieldPrefix}payment_source` };
normalized.payment_source = source.value;
}
return { normalized };
}
module.exports = {
PAYMENT_SOURCES,
validateIsoDate,
validatePaymentInput,
validatePaymentSource,
validatePositiveAmount,
};

View File

@ -21,6 +21,10 @@ function pad(value) {
return String(value).padStart(2, '0');
}
function roundMoney(value) {
return Math.round((Number(value) || 0) * 100) / 100;
}
function dateString(year, month, day) {
return `${year}-${pad(month)}-${pad(day)}`;
}
@ -63,12 +67,6 @@ function parseCycleMonth(value, fallback = 1) {
return fallback;
}
function parseCycleDayOfMonth(bill, fallback = bill?.due_day || 1) {
const parsed = parseInt(bill?.cycle_day, 10);
if (Number.isInteger(parsed) && parsed >= 1 && parsed <= 31) return parsed;
return fallback;
}
function parseWeekday(value, fallback = 'monday') {
const normalized = String(value || fallback).trim().toLowerCase();
return WEEKDAY_INDEX[normalized] ?? WEEKDAY_INDEX[fallback];
@ -127,7 +125,7 @@ function resolveDueDate(bill, year, month) {
return dateString(year, month, clampDay(year, month, bill.due_day));
}
const day = clampDay(year, month, parseCycleDayOfMonth(bill));
const day = clampDay(year, month, bill.due_day);
return dateString(year, month, day);
}
@ -197,9 +195,10 @@ function calculateStatus(bill, payments, dueDate, today, options = {}) {
const gracePeriodDays = resolveGracePeriodDays(options.gracePeriodDays);
const safePayments = Array.isArray(payments) ? payments : [];
const totalPaid = safePayments.reduce((sum, p) => sum + p.amount, 0);
const expectedAmount = Number(bill.expected_amount) || 0;
const totalPaid = roundMoney(safePayments.reduce((sum, p) => sum + (Number(p.amount) || 0), 0));
if (totalPaid >= bill.expected_amount) return 'paid';
if (totalPaid >= expectedAmount) return 'paid';
if (bill.autopay_enabled && bill.autodraft_status === 'assumed_paid') {
return 'autodraft';
@ -225,10 +224,13 @@ function buildTrackerRow(bill, payments, year, month, todayStr, options = {}) {
const bucket = resolveBucket(bill);
const safePayments = Array.isArray(payments) ? payments : [];
const status = calculateStatus(bill, safePayments, dueDate, todayStr, options);
const totalPaid = safePayments.reduce((sum, p) => sum + p.amount, 0);
const expectedAmount = Number(bill.expected_amount) || 0;
const totalPaid = roundMoney(safePayments.reduce((sum, p) => sum + (Number(p.amount) || 0), 0));
const hasPayment = safePayments.length > 0;
const isSettled = status === 'paid' || status === 'autodraft';
const rawBalance = bill.expected_amount - totalPaid;
const paidTowardDue = roundMoney(Math.min(totalPaid, expectedAmount));
const overpaidAmount = roundMoney(Math.max(totalPaid - expectedAmount, 0));
const rawBalance = expectedAmount - totalPaid;
const balance = isSettled ? 0 : Math.max(rawBalance, 0);
const lastPayment = hasPayment
? [...safePayments].sort((a, b) => b.paid_date.localeCompare(a.paid_date))[0]
@ -242,9 +244,11 @@ function buildTrackerRow(bill, payments, year, month, todayStr, options = {}) {
due_date: dueDate,
due_day: bill.due_day,
bucket,
expected_amount: bill.expected_amount,
expected_amount: expectedAmount,
notes: bill.notes || null, // Bill-level notes (always available)
total_paid: totalPaid,
paid_toward_due: paidTowardDue,
overpaid_amount: overpaidAmount,
balance,
has_payment: hasPayment,
is_settled: isSettled,

View File

@ -33,17 +33,6 @@ function monthOffset(year, month, offset) {
return { year: y, month: m };
}
function groupPaymentsByBill(paymentRows) {
const allPayments = {};
paymentRows.forEach(row => {
if (!allPayments[row.bill_id]) {
allPayments[row.bill_id] = [];
}
allPayments[row.bill_id].push(row);
});
return allPayments;
}
function fetchActiveBills(db, userId, orderBy = 'b.due_day ASC, b.name ASC') {
return db.prepare(`
SELECT b.*, c.name AS category_name
@ -65,17 +54,16 @@ function fetchMonthlyStates(db, billIds, year, month) {
return Object.fromEntries(rows.map(row => [row.bill_id, row]));
}
function fetchPaymentsByBill(db, billIds, start, end) {
if (billIds.length === 0) return {};
const placeholders = billIds.map(() => '?').join(',');
const rows = db.prepare(`
SELECT bill_id, id, amount, paid_date, method, notes, created_at, updated_at
function fetchPaymentsForBillCycle(db, bill, year, month) {
const range = getCycleRange(year, month, bill);
if (!range) return [];
return db.prepare(`
SELECT bill_id, id, amount, paid_date, method, notes, payment_source, transaction_id, created_at, updated_at
FROM payments
WHERE bill_id IN (${placeholders}) AND paid_date BETWEEN ? AND ?
WHERE bill_id = ? AND paid_date BETWEEN ? AND ?
AND deleted_at IS NULL
ORDER BY paid_date DESC
`).all(...billIds, start, end);
return groupPaymentsByBill(rows);
`).all(bill.id, range.start, range.end);
}
function fetchPreviousMonthPaid(db, billIds, range) {
@ -101,8 +89,21 @@ function fetchDismissedSuggestions(db, userId, billIds, year, month) {
return new Set(rows.map(row => row.bill_id));
}
function rowDueAmount(row) {
const amount = Number(row.actual_amount ?? row.expected_amount);
return Number.isFinite(amount) ? amount : 0;
}
function rowPaidTowardDue(row) {
const cappedPaid = Number(row.paid_toward_due);
if (Number.isFinite(cappedPaid)) return cappedPaid;
return Math.min(Number(row.total_paid) || 0, rowDueAmount(row));
}
function applyAutopaySuggestions(db, bill, payments, mbs, year, month, todayStr, dismissedSuggestions) {
const dueDate = resolveDueDate(bill, year, month);
if (!dueDate) return null;
const suggestedAmount = Number(mbs?.actual_amount ?? bill.expected_amount);
const hasSuggestedAmount = Number.isFinite(suggestedAmount) && suggestedAmount > 0;
const isEligible = !!(
@ -117,16 +118,16 @@ function applyAutopaySuggestions(db, bill, payments, mbs, year, month, todayStr,
if (!isEligible) return null;
if (bill.auto_mark_paid) {
const range = getCycleRange(year, month, bill);
const existingPayment = db.prepare(`
SELECT bill_id, id, amount, paid_date, method, notes, created_at, updated_at
SELECT bill_id, id, amount, paid_date, method, notes, payment_source, transaction_id, created_at, updated_at
FROM payments
WHERE bill_id = ?
AND deleted_at IS NULL
AND strftime('%Y', paid_date) = ?
AND strftime('%m', paid_date) = ?
AND paid_date BETWEEN ? AND ?
ORDER BY paid_date DESC
LIMIT 1
`).get(bill.id, String(year), String(month).padStart(2, '0'));
`).get(bill.id, range.start, range.end);
if (existingPayment) {
payments.push(existingPayment);
@ -135,8 +136,8 @@ function applyAutopaySuggestions(db, bill, payments, mbs, year, month, todayStr,
const balCalc = computeBalanceDelta(bill, suggestedAmount);
const result = db.prepare(`
INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta)
VALUES (?, ?, ?, ?, ?, ?)
INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, payment_source)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(
bill.id,
suggestedAmount,
@ -144,6 +145,7 @@ function applyAutopaySuggestions(db, bill, payments, mbs, year, month, todayStr,
'autopay',
'Auto-marked paid on due date',
balCalc?.balance_delta ?? null,
'manual',
);
if (balCalc) {
@ -152,7 +154,7 @@ function applyAutopaySuggestions(db, bill, payments, mbs, year, month, todayStr,
bill.current_balance = balCalc.new_balance;
}
payments.push(db.prepare(`
SELECT bill_id, id, amount, paid_date, method, notes, created_at, updated_at
SELECT bill_id, id, amount, paid_date, method, notes, payment_source, transaction_id, created_at, updated_at
FROM payments
WHERE id = ?
`).get(result.lastInsertRowid));
@ -230,12 +232,13 @@ function getTracker(userId, query = {}, now = new Date()) {
const bills = fetchActiveBills(db, userId);
const billIds = bills.map(bill => bill.id);
const monthlyStates = fetchMonthlyStates(db, billIds, year, month);
const allPayments = fetchPaymentsByBill(db, billIds, start, end);
const prevMonthPayments = fetchPreviousMonthPaid(db, billIds, prevMonthRange);
const dismissedSuggestions = fetchDismissedSuggestions(db, userId, billIds, year, month);
const rows = bills.map(bill => {
const payments = allPayments[bill.id] || [];
if (!resolveDueDate(bill, year, month)) return null;
const payments = fetchPaymentsForBillCycle(db, bill, year, month);
const mbs = monthlyStates[bill.id];
const autopaySuggestion = applyAutopaySuggestions(
db,
@ -252,6 +255,8 @@ function getTracker(userId, query = {}, now = new Date()) {
? { ...bill, expected_amount: mbs.actual_amount }
: bill;
const row = buildTrackerRow(billForStatus, payments, year, month, todayStr, rowOptions);
if (!row) return null;
row.expected_amount = bill.expected_amount;
row.actual_amount = mbs?.actual_amount ?? null;
row.monthly_notes = mbs?.notes ?? null;
@ -259,7 +264,7 @@ function getTracker(userId, query = {}, now = new Date()) {
if (autopaySuggestion) row.autopay_suggestion = autopaySuggestion;
row.previous_month_paid = prevMonthPayments[bill.id] || 0;
return row;
});
}).filter(Boolean);
const activeRows = rows.filter(r => !r.is_skipped);
const startingAmounts = db.prepare(`
@ -274,7 +279,7 @@ function getTracker(userId, query = {}, now = new Date()) {
const dayOfMonth = now.getDate();
const activeRemainingPeriod = dayOfMonth < 15 ? '1st' : '15th';
const periodRows = activeRows.filter(r => r.bucket === activeRemainingPeriod);
const periodPaid = periodRows.reduce((s, r) => s + r.total_paid, 0);
const periodPaidTowardDue = periodRows.reduce((s, r) => s + rowPaidTowardDue(r), 0);
const periodOutstandingBalance = periodRows.reduce((s, r) => s + Math.max(r.balance || 0, 0), 0);
const periodStartingAmount = activeRemainingPeriod === '1st'
? (startingAmounts?.first_amount || 0)
@ -284,7 +289,8 @@ function getTracker(userId, query = {}, now = new Date()) {
const totalStarting = startingAmounts?.combined_amount || 0;
const hasStartingAmounts = !!startingAmounts;
const activeTotalPaid = activeRows.reduce((s, r) => s + r.total_paid, 0);
const activeTotalExpected = activeRows.reduce((s, r) => s + r.expected_amount, 0);
const activePaidTowardDue = activeRows.reduce((s, r) => s + rowPaidTowardDue(r), 0);
const activeTotalExpected = activeRows.reduce((s, r) => s + rowDueAmount(r), 0);
const activeOutstandingBalance = activeRows.reduce((s, r) => s + Math.max(r.balance || 0, 0), 0);
const totalOverdue = rows
.filter(r => !r.is_skipped && (r.status === 'late' || r.status === 'missed'))
@ -300,12 +306,13 @@ function getTracker(userId, query = {}, now = new Date()) {
total_starting: totalStarting,
has_starting_amounts: hasStartingAmounts,
total_paid: activeTotalPaid,
remaining: hasStartingAmounts ? periodStartingAmount - periodPaid : periodOutstandingBalance,
total_remaining: hasStartingAmounts ? totalStarting - activeTotalPaid : activeOutstandingBalance,
paid_toward_due: activePaidTowardDue,
remaining: hasStartingAmounts ? periodStartingAmount - periodPaidTowardDue : periodOutstandingBalance,
total_remaining: hasStartingAmounts ? totalStarting - activePaidTowardDue : activeOutstandingBalance,
remaining_period: activeRemainingPeriod,
remaining_label: periodLabel,
remaining_hint: hasStartingAmounts
? `${periodLabel}: ${periodStartingAmount.toFixed(2)} starting minus ${periodPaid.toFixed(2)} paid`
? `${periodLabel}: ${periodStartingAmount.toFixed(2)} starting minus ${periodPaidTowardDue.toFixed(2)} paid toward due`
: `${periodLabel}: unpaid bills due in this period`,
overdue: totalOverdue,
count_paid: activeRows.filter(r => r.status === 'paid').length,
@ -325,24 +332,36 @@ function getUpcomingBills(userId, query = {}, now = new Date()) {
const todayStr = now.toISOString().slice(0, 10);
const userSettings = getUserSettings(userId);
const rowOptions = { gracePeriodDays: userSettings.grace_period_days };
const year = now.getFullYear();
const month = now.getMonth() + 1;
const { start, end } = getCycleRange(year, month);
const bills = fetchActiveBills(db, userId, 'b.id ASC');
const billIds = bills.map(bill => bill.id);
const allPayments = fetchPaymentsByBill(db, billIds, start, end);
const cutoff = new Date(now);
cutoff.setDate(cutoff.getDate() + days);
const cutoffStr = cutoff.toISOString().slice(0, 10);
const upcoming = [];
const seen = new Set();
const monthCount = (cutoff.getFullYear() - now.getFullYear()) * 12
+ (cutoff.getMonth() - now.getMonth()) + 1;
for (let offset = 0; offset < monthCount; offset += 1) {
const target = monthOffset(now.getFullYear(), now.getMonth() + 1, offset);
for (const bill of bills) {
const dueDate = resolveDueDate(bill, year, month);
if (dueDate < todayStr || dueDate > cutoffStr) continue;
const dueDate = resolveDueDate(bill, target.year, target.month);
if (!dueDate || dueDate < todayStr || dueDate > cutoffStr) continue;
const row = buildTrackerRow(bill, allPayments[bill.id] || [], year, month, todayStr, rowOptions);
if (row.status === 'paid') continue;
const key = `${bill.id}:${dueDate}`;
if (seen.has(key)) continue;
seen.add(key);
const row = buildTrackerRow(
bill,
fetchPaymentsForBillCycle(db, bill, target.year, target.month),
target.year,
target.month,
todayStr,
rowOptions,
);
if (!row || row.status === 'paid') continue;
upcoming.push({
id: bill.id,
@ -351,9 +370,10 @@ function getUpcomingBills(userId, query = {}, now = new Date()) {
due_date: dueDate,
expected_amount: bill.expected_amount,
status: row.status,
days_until_due: Math.floor((new Date(dueDate) - now) / 86400000),
days_until_due: Math.floor((new Date(`${dueDate}T00:00:00`) - new Date(`${todayStr}T00:00:00`)) / 86400000),
});
}
}
upcoming.sort((a, b) => a.due_date.localeCompare(b.due_date));
return { days, today: todayStr, upcoming };

View File

@ -0,0 +1,102 @@
const SOURCE_TYPE_LABELS = {
manual: 'Manual',
file_import: 'File import',
provider_sync: 'Provider sync',
};
function sourceTypeLabel(type) {
return SOURCE_TYPE_LABELS[type] || String(type || 'Unknown');
}
function sourceLabel(source = {}) {
if (source.type === 'manual' || source.provider === 'manual') return source.name || 'Manual Entry';
if (source.name && source.provider) return `${source.name} (${source.provider})`;
return source.name || source.provider || sourceTypeLabel(source.type);
}
function decorateDataSource(row) {
if (!row) return null;
const safe = { ...row };
delete safe.encrypted_secret;
return {
...safe,
source_label: sourceLabel(row),
source_type_label: sourceTypeLabel(row.type),
};
}
function decorateTransaction(row) {
if (!row) return null;
const source = row.data_source_id ? {
id: row.data_source_id,
user_id: row.user_id,
type: row.data_source_type || row.source_type,
provider: row.data_source_provider,
name: row.data_source_name,
status: row.data_source_status,
} : null;
return {
...row,
source_label: source ? sourceLabel(source) : sourceTypeLabel(row.source_type),
source_type_label: sourceTypeLabel(row.source_type),
data_source: source ? decorateDataSource(source) : null,
};
}
function ensureManualDataSource(db, userId) {
const existing = db.prepare(`
SELECT *
FROM data_sources
WHERE user_id = ? AND type = 'manual' AND provider = 'manual'
ORDER BY id ASC
LIMIT 1
`).get(userId);
if (existing) {
if (existing.name !== 'Manual Entry' || existing.status !== 'active') {
db.prepare(`
UPDATE data_sources
SET name = 'Manual Entry', status = 'active', updated_at = datetime('now')
WHERE id = ? AND user_id = ?
`).run(existing.id, userId);
return db.prepare('SELECT * FROM data_sources WHERE id = ? AND user_id = ?').get(existing.id, userId);
}
return existing;
}
const result = db.prepare(`
INSERT INTO data_sources (user_id, type, provider, name, status)
VALUES (?, 'manual', 'manual', 'Manual Entry', 'active')
`).run(userId);
return db.prepare('SELECT * FROM data_sources WHERE id = ? AND user_id = ?').get(result.lastInsertRowid, userId);
}
function getTransactionForUser(db, userId, id) {
return db.prepare(`
SELECT
t.id, t.user_id, t.data_source_id, t.account_id, t.provider_transaction_id,
t.source_type, t.transaction_type, t.posted_date, t.transacted_at, t.amount,
t.currency, t.description, t.payee, t.memo, t.category, t.matched_bill_id,
t.match_status, t.ignored, t.created_at, t.updated_at,
ds.type AS data_source_type, ds.provider AS data_source_provider,
ds.name AS data_source_name, ds.status AS data_source_status,
fa.name AS account_name, fa.org_name AS account_org_name,
fa.account_type AS account_type,
b.name AS matched_bill_name
FROM transactions t
LEFT JOIN data_sources ds ON ds.id = t.data_source_id AND ds.user_id = t.user_id
LEFT JOIN financial_accounts fa ON fa.id = t.account_id AND fa.user_id = t.user_id
LEFT JOIN bills b ON b.id = t.matched_bill_id AND b.user_id = t.user_id AND b.deleted_at IS NULL
WHERE t.id = ? AND t.user_id = ?
`).get(id, userId);
}
module.exports = {
SOURCE_TYPE_LABELS,
decorateDataSource,
decorateTransaction,
ensureManualDataSource,
getTransactionForUser,
sourceLabel,
sourceTypeLabel,
};

View File

@ -12,6 +12,7 @@ const SESSION_TTL_HOURS = 24;
const REQUIRED_TABLES = ['export_metadata', 'categories', 'bills', 'payments', 'monthly_bill_state'];
const VALID_BILLING_CYCLES = new Set(['monthly', 'quarterly', 'annually', 'irregular']);
const VALID_AUTODRAFT = new Set(['none', 'pending', 'assumed_paid', 'confirmed']);
const VALID_PAYMENT_SOURCES = new Set(['manual', 'file_import', 'provider_sync']);
function importError(status, message, code, details = []) {
const err = new Error(message);
@ -155,6 +156,7 @@ function sanitizePayment(row, validBillIds) {
const billId = toInt(row.bill_id);
const amount = toNumber(row.amount);
const paidDate = cleanDate(row.paid_date);
const paymentSource = cleanText(row.payment_source, 64) || 'manual';
if (!billId || !validBillIds.has(billId) || amount == null || amount < 0 || !paidDate) return null;
return {
old_id: toInt(row.id),
@ -163,6 +165,8 @@ function sanitizePayment(row, validBillIds) {
paid_date: paidDate,
method: cleanText(row.method, 120),
notes: cleanText(row.notes, 2000),
payment_source: VALID_PAYMENT_SOURCES.has(paymentSource) ? paymentSource : 'manual',
transaction_id: null,
created_at: cleanText(row.created_at, 32),
updated_at: cleanText(row.updated_at, 32),
};
@ -223,7 +227,9 @@ function readExportData(src) {
'active', 'notes', 'created_at', 'updated_at',
]).map(sanitizeBill).filter(Boolean);
const validBillIds = new Set(bills.map(b => b.old_id).filter(Boolean));
const payments = selectKnown(src, 'payments', ['id', 'bill_id', 'amount', 'paid_date', 'method', 'notes', 'created_at', 'updated_at'])
const payments = selectKnown(src, 'payments', [
'id', 'bill_id', 'amount', 'paid_date', 'method', 'notes', 'payment_source', 'transaction_id', 'created_at', 'updated_at',
])
.map(row => sanitizePayment(row, validBillIds)).filter(Boolean);
const monthlyState = selectKnown(src, 'monthly_bill_state', ['id', 'bill_id', 'year', 'month', 'actual_amount', 'notes', 'is_skipped', 'created_at', 'updated_at'])
.map(row => sanitizeMonthlyState(row, validBillIds)).filter(Boolean);
@ -481,9 +487,17 @@ function importPayment(db, targetBillId, payment, summary, details) {
return;
}
db.prepare(`
INSERT INTO payments (bill_id, amount, paid_date, method, notes)
VALUES (?, ?, ?, ?, ?)
`).run(targetBillId, payment.amount, payment.paid_date, payment.method, payment.notes);
INSERT INTO payments (bill_id, amount, paid_date, method, notes, payment_source, transaction_id)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(
targetBillId,
payment.amount,
payment.paid_date,
payment.method,
payment.notes,
payment.payment_source || 'manual',
payment.transaction_id,
);
summary.rows_created++;
details.payments.created++;
}

View File

@ -0,0 +1,71 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const dbPath = path.join(os.tmpdir(), `bill-tracker-csv-import-test-${process.pid}.sqlite`);
process.env.DB_PATH = dbPath;
const { getDb, closeDb } = require('../db/database');
const {
commitCsvTransactions,
previewCsvTransactions,
} = require('../services/csvTransactionImportService');
function createUser(db) {
return db.prepare(`
INSERT INTO users (username, password_hash, role, active, email, created_at, updated_at)
VALUES ('csv-test-user', 'x', 'user', 1, 'csv-test-user@local', datetime('now'), datetime('now'))
`).run().lastInsertRowid;
}
test.after(() => {
closeDb();
for (const suffix of ['', '-wal', '-shm']) {
fs.rmSync(`${dbPath}${suffix}`, { force: true });
}
});
test('CSV transaction import previews, commits, and skips duplicate row hashes', () => {
const db = getDb();
const userId = createUser(db);
const csv = Buffer.from([
'Date,Description,Amount,Account',
'2026-05-16,Water Bill,-85.00,Checking',
'2026-05-17,Paycheck,1200.50,Checking',
'',
].join('\n'));
const preview = previewCsvTransactions(userId, csv, { original_filename: 'bank.csv' });
assert.deepEqual(preview.suggestedMapping, {
posted_date: 'Date',
description: 'Description',
amount: 'Amount',
account: 'Account',
});
assert.equal(preview.rowCount, 2);
assert.equal(preview.sampleRows.length, 2);
const first = commitCsvTransactions(userId, preview.import_session_id, preview.suggestedMapping);
assert.equal(first.imported, 2);
assert.equal(first.skipped, 0);
assert.equal(first.failed, 0);
assert.deepEqual(
db.prepare('SELECT amount, source_type FROM transactions WHERE user_id = ? ORDER BY id').all(userId),
[
{ amount: -8500, source_type: 'file_import' },
{ amount: 120050, source_type: 'file_import' },
],
);
const duplicatePreview = previewCsvTransactions(userId, csv, { original_filename: 'bank.csv' });
const duplicate = commitCsvTransactions(userId, duplicatePreview.import_session_id, duplicatePreview.suggestedMapping);
assert.equal(duplicate.imported, 0);
assert.equal(duplicate.skipped, 2);
assert.equal(duplicate.failed, 0);
});

View File

@ -0,0 +1,98 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const {
buildTrackerRow,
getCycleRange,
resolveDueDate,
} = require('../services/statusService');
function bill(overrides = {}) {
return {
id: 1,
name: 'Test bill',
due_day: 15,
expected_amount: 100,
autopay_enabled: 0,
autodraft_status: 'none',
cycle_type: 'monthly',
cycle_day: '1',
...overrides,
};
}
test('monthly bills use due_day and calendar-month cycle range', () => {
const monthly = bill({ due_day: 31, cycle_day: '1' });
assert.equal(resolveDueDate(monthly, 2026, 2), '2026-02-28');
assert.deepEqual(getCycleRange(2026, 2, monthly), {
start: '2026-02-01',
end: '2026-02-28',
});
});
test('weekly bills use cycle_day as weekday', () => {
const weekly = bill({ cycle_type: 'weekly', cycle_day: 'wednesday' });
assert.equal(resolveDueDate(weekly, 2026, 5), '2026-05-06');
assert.deepEqual(getCycleRange(2026, 5, weekly), {
start: '2026-05-06',
end: '2026-05-12',
});
});
test('biweekly bills use cycle_day weekday on the deterministic two-week cadence', () => {
const biweekly = bill({ cycle_type: 'biweekly', cycle_day: 'monday' });
assert.equal(resolveDueDate(biweekly, 2026, 5), '2026-05-04');
assert.deepEqual(getCycleRange(2026, 5, biweekly), {
start: '2026-05-04',
end: '2026-05-17',
});
});
test('quarterly bills only occur in assigned quarter months', () => {
const quarterly = bill({ cycle_type: 'quarterly', cycle_day: '2', due_day: 30 });
assert.equal(resolveDueDate(quarterly, 2026, 2), '2026-02-28');
assert.equal(resolveDueDate(quarterly, 2026, 3), null);
assert.equal(resolveDueDate(quarterly, 2026, 5), '2026-05-30');
assert.deepEqual(getCycleRange(2026, 5, quarterly), {
start: '2026-05-01',
end: '2026-07-31',
});
});
test('annual bills only occur in their assigned month', () => {
const annual = bill({ cycle_type: 'annual', cycle_day: '11', due_day: 31 });
assert.equal(resolveDueDate(annual, 2026, 10), null);
assert.equal(resolveDueDate(annual, 2026, 11), '2026-11-30');
assert.deepEqual(getCycleRange(2026, 11, annual), {
start: '2026-01-01',
end: '2026-12-31',
});
});
test('tracker rows are skipped when a bill does not occur in the requested month', () => {
const quarterly = bill({ cycle_type: 'quarterly', cycle_day: '1' });
assert.equal(buildTrackerRow(quarterly, [], 2026, 2, '2026-02-01', { gracePeriodDays: 5 }), null);
});
test('tracker rows cap due math when a payment exceeds the amount due', () => {
const row = buildTrackerRow(
bill({ expected_amount: 100 }),
[{ amount: 125, paid_date: '2026-05-10' }],
2026,
5,
'2026-05-16',
{ gracePeriodDays: 5 },
);
assert.equal(row.status, 'paid');
assert.equal(row.total_paid, 125);
assert.equal(row.paid_toward_due, 100);
assert.equal(row.overpaid_amount, 25);
assert.equal(row.balance, 0);
});