v0.28.01
This commit is contained in:
parent
0c628212a0
commit
9d933f70cc
|
|
@ -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
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
@ -1767,9 +1851,15 @@ function Bucket({ label, rows, year, month, refresh, onEditBill, loading }) {
|
|||
// Use actual_amount (if set) as the per-row threshold; exclude skipped rows from totals
|
||||
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 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>
|
||||
|
||||
|
|
|
|||
187
db/database.js
187
db/database.js
|
|
@ -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']
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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 = ?")
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,34 +332,47 @@ 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 (const bill of bills) {
|
||||
const dueDate = resolveDueDate(bill, year, month);
|
||||
if (dueDate < todayStr || dueDate > cutoffStr) continue;
|
||||
for (let offset = 0; offset < monthCount; offset += 1) {
|
||||
const target = monthOffset(now.getFullYear(), now.getMonth() + 1, offset);
|
||||
|
||||
const row = buildTrackerRow(bill, allPayments[bill.id] || [], year, month, todayStr, rowOptions);
|
||||
if (row.status === 'paid') continue;
|
||||
for (const bill of bills) {
|
||||
const dueDate = resolveDueDate(bill, target.year, target.month);
|
||||
if (!dueDate || dueDate < todayStr || dueDate > cutoffStr) continue;
|
||||
|
||||
upcoming.push({
|
||||
id: bill.id,
|
||||
name: bill.name,
|
||||
category_name: bill.category_name,
|
||||
due_date: dueDate,
|
||||
expected_amount: bill.expected_amount,
|
||||
status: row.status,
|
||||
days_until_due: Math.floor((new Date(dueDate) - now) / 86400000),
|
||||
});
|
||||
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,
|
||||
name: bill.name,
|
||||
category_name: bill.category_name,
|
||||
due_date: dueDate,
|
||||
expected_amount: bill.expected_amount,
|
||||
status: row.status,
|
||||
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));
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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++;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
@ -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);
|
||||
});
|
||||
Loading…
Reference in New Issue