corrections
This commit is contained in:
parent
d2acf44846
commit
48fe87ea25
|
|
@ -1450,44 +1450,30 @@ export function ImportSpreadsheetSection({ onHistoryRefresh }) {
|
||||||
function SeedDemoDataSection({ onSeeded }) {
|
function SeedDemoDataSection({ onSeeded }) {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [seeded, setSeeded] = useState(false);
|
const [seeded, setSeeded] = useState(false);
|
||||||
const [result, setResult] = useState(null);
|
const [counts, setCounts] = useState({ bills: 0, categories: 0 });
|
||||||
const [clearing, setClearing] = useState(false);
|
const [clearing, setClearing] = useState(false);
|
||||||
const [showClearConfirm, setShowClearConfirm] = useState(false);
|
const [showClearConfirm, setShowClearConfirm] = useState(false);
|
||||||
const [statusLoading, setStatusLoading] = useState(true);
|
const [statusLoading, setStatusLoading] = useState(true);
|
||||||
|
|
||||||
// Check seeded status on mount
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkSeededStatus = async () => {
|
api.seededStatus()
|
||||||
try {
|
.then(data => {
|
||||||
const data = await api.seededStatus();
|
setSeeded(data.seeded);
|
||||||
if (data.seeded) {
|
if (data.seeded) setCounts({ bills: data.seededBills || 0, categories: data.seededCategories || 0 });
|
||||||
setSeeded(true);
|
})
|
||||||
setResult(data);
|
.catch(err => console.error('Failed to check seeded status:', err))
|
||||||
}
|
.finally(() => setStatusLoading(false));
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to check seeded status:', err);
|
|
||||||
} finally {
|
|
||||||
setStatusLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
checkSeededStatus();
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSeed = async () => {
|
const handleSeed = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await api.seedDemoData();
|
const data = await api.seedDemoData();
|
||||||
// Ensure data has expected structure
|
if (!data || typeof data !== 'object') throw new Error('Invalid response from server');
|
||||||
if (!data || typeof data !== 'object') {
|
setCounts({ bills: data.billsCreated || 0, categories: data.categoriesCreated || 0 });
|
||||||
throw new Error('Invalid response from server');
|
|
||||||
}
|
|
||||||
setResult(data);
|
|
||||||
setSeeded(true);
|
setSeeded(true);
|
||||||
toast.success(`Created ${data.billsCreated || 0} demo bills successfully.`);
|
toast.success(`Created ${data.billsCreated || 0} demo bills successfully.`);
|
||||||
// Delay onSeeded callback to allow UI to update
|
setTimeout(() => onSeeded?.(), 100);
|
||||||
setTimeout(() => {
|
|
||||||
onSeeded?.();
|
|
||||||
}, 100);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Seed error:', err);
|
console.error('Seed error:', err);
|
||||||
toast.error(err?.message || err?.error || 'Failed to seed demo data.');
|
toast.error(err?.message || err?.error || 'Failed to seed demo data.');
|
||||||
|
|
@ -1501,40 +1487,51 @@ function SeedDemoDataSection({ onSeeded }) {
|
||||||
try {
|
try {
|
||||||
const data = await api.clearDemoData();
|
const data = await api.clearDemoData();
|
||||||
setSeeded(false);
|
setSeeded(false);
|
||||||
setResult(null);
|
setCounts({ bills: 0, categories: 0 });
|
||||||
setShowClearConfirm(false);
|
setShowClearConfirm(false);
|
||||||
toast.success(`Removed ${data.billsDeleted || 0} demo bills and ${data.categoriesDeleted || 0} demo categories.`);
|
toast.success(`Removed ${data.billsDeleted || 0} demo bills and ${data.categoriesDeleted || 0} demo categories.`);
|
||||||
onSeeded?.();
|
onSeeded?.();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || "Failed to clear demo data.");
|
toast.error(err.message || 'Failed to clear demo data.');
|
||||||
} finally {
|
} finally {
|
||||||
setClearing(false);
|
setClearing(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (seeded) {
|
|
||||||
return (
|
return (
|
||||||
<SectionCard title="Demo Data" subtitle="Seed your database with demo data for testing" icon={Sparkles}>
|
<SectionCard title="Demo Data" subtitle="Seed your database with demo data for testing" icon={Sparkles}>
|
||||||
<div className="rounded-lg border border-border/60 bg-background/50 p-4">
|
<div className="rounded-lg border border-border/60 bg-background/50 p-4">
|
||||||
<p className="text-sm font-medium text-emerald-600 dark:text-emerald-400">Seed complete</p>
|
{statusLoading ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Loading…</p>
|
||||||
|
) : seeded ? (
|
||||||
|
<>
|
||||||
|
<p className="text-sm font-medium text-emerald-600 dark:text-emerald-400">Demo data seeded</p>
|
||||||
<div className="mt-3 grid grid-cols-2 gap-4 text-xs sm:grid-cols-4">
|
<div className="mt-3 grid grid-cols-2 gap-4 text-xs sm:grid-cols-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-muted-foreground">Bills Created</p>
|
<p className="text-muted-foreground">Bills</p>
|
||||||
<p className="font-semibold">{result?.billsCreated || 0}</p>
|
<p className="font-semibold">{counts.bills}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-muted-foreground">Categories Created</p>
|
<p className="text-muted-foreground">Categories</p>
|
||||||
<p className="font-semibold">{result?.categoriesCreated || 0}</p>
|
<p className="font-semibold">{counts.categories}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 pt-4 border-t border-border">
|
</>
|
||||||
<div className="flex items-center justify-between gap-3">
|
) : (
|
||||||
<Button size="sm" variant="outline" onClick={() => { setSeeded(false); setResult(null); }}>
|
<p className="text-sm text-muted-foreground">
|
||||||
Reset
|
Create 20 realistic demo bills and 8 demo categories for testing purposes.
|
||||||
|
The data will be associated with your account.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-4 flex items-center justify-between gap-3 border-t border-border pt-4">
|
||||||
|
<Button size="sm" variant="outline" onClick={handleSeed} disabled={loading || seeded || statusLoading}>
|
||||||
|
{loading ? <><Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />Seeding…</> : 'Seed Demo Data'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<AlertDialog open={showClearConfirm} onOpenChange={setShowClearConfirm}>
|
<AlertDialog open={showClearConfirm} onOpenChange={setShowClearConfirm}>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button size="sm" variant="destructive" disabled={clearing}>
|
<Button size="sm" variant="destructive" disabled={!seeded || clearing || statusLoading}>
|
||||||
{clearing ? <><Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />Clearing…</> : 'Clear Demo Data'}
|
{clearing ? <><Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />Clearing…</> : 'Clear Demo Data'}
|
||||||
</Button>
|
</Button>
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
|
|
@ -1542,7 +1539,7 @@ function SeedDemoDataSection({ onSeeded }) {
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Clear Demo Data</AlertDialogTitle>
|
<AlertDialogTitle>Clear Demo Data</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
This action will remove {result?.billsCreated || 0} demo bills and {result?.categoriesCreated || 0} demo categories from your account. This action cannot be undone.
|
This will remove {counts.bills} demo bills and {counts.categories} demo categories from your account. This cannot be undone.
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
|
|
@ -1555,35 +1552,6 @@ function SeedDemoDataSection({ onSeeded }) {
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</SectionCard>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (statusLoading) {
|
|
||||||
return (
|
|
||||||
<SectionCard title="Demo Data" subtitle="Seed your database with demo data for testing" icon={Sparkles}>
|
|
||||||
<div className="px-6 py-4 text-sm text-muted-foreground">Loading…</div>
|
|
||||||
</SectionCard>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SectionCard title="Demo Data" subtitle="Seed your database with demo data for testing" icon={Sparkles}>
|
|
||||||
<div className="rounded-lg border border-border/60 bg-background/50 p-4">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Create 20 realistic demo bills and 8 demo categories for testing purposes.
|
|
||||||
The data will be associated with your account.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="mt-4 space-y-4">
|
|
||||||
<div className="border-t border-border pt-4">
|
|
||||||
<Button size="sm" variant="outline" onClick={handleSeed} disabled={loading || seeded}>
|
|
||||||
{loading ? <><Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />Seeding…</> : 'Seed Demo Data'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ import {
|
||||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
|
|
||||||
const AUTHENTIK_ICON_URL = 'https://gate.originalsinners.org/static/dist/assets/icons/icon.png';
|
|
||||||
const BUILD_LINK_URL = 'https://dream.scheller.ltd/null/BillTracker';
|
const BUILD_LINK_URL = 'https://dream.scheller.ltd/null/BillTracker';
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
|
|
@ -156,12 +155,6 @@ export default function LoginPage() {
|
||||||
className="w-full"
|
className="w-full"
|
||||||
onClick={() => { window.location.href = authMode.oidc_login_url; }}
|
onClick={() => { window.location.href = authMode.oidc_login_url; }}
|
||||||
>
|
>
|
||||||
<img
|
|
||||||
src={AUTHENTIK_ICON_URL}
|
|
||||||
alt=""
|
|
||||||
aria-hidden="true"
|
|
||||||
className="mr-2 h-5 w-5 shrink-0 object-contain"
|
|
||||||
/>
|
|
||||||
Continue with {providerName}
|
Continue with {providerName}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -853,18 +853,34 @@ function Row({ row, year, month, refresh, index, onEditBill }) {
|
||||||
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-sky-500" title="Autopay" />
|
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-sky-500" title="Autopay" />
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<button
|
<div className="flex items-center gap-1">
|
||||||
type="button"
|
{row.website ? (
|
||||||
onClick={() => onEditBill?.(row)}
|
<a
|
||||||
|
href={row.website}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
className={cn(
|
className={cn(
|
||||||
'font-medium text-sm leading-tight text-left transition-colors',
|
'font-medium text-sm leading-tight transition-colors',
|
||||||
'hover:underline decoration-muted-foreground/50 underline-offset-2',
|
'hover:underline decoration-muted-foreground/50 underline-offset-2',
|
||||||
isSkipped && 'line-through',
|
isSkipped && 'line-through',
|
||||||
)}
|
)}
|
||||||
title="Edit bill"
|
|
||||||
>
|
>
|
||||||
{row.name}
|
{row.name}
|
||||||
</button>
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className={cn('font-medium text-sm leading-tight', isSkipped && 'line-through')}>
|
||||||
|
{row.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="icon" variant="ghost"
|
||||||
|
className="h-7 w-7 opacity-100 transition-opacity text-muted-foreground hover:text-foreground hover:bg-accent lg:opacity-0 lg:group-hover:opacity-100"
|
||||||
|
title="Edit bill"
|
||||||
|
onClick={() => onEditBill?.(row)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
{row.category_name && (
|
{row.category_name && (
|
||||||
<p className="text-[11px] text-muted-foreground mt-0.5">{row.category_name}</p>
|
<p className="text-[11px] text-muted-foreground mt-0.5">{row.category_name}</p>
|
||||||
)}
|
)}
|
||||||
|
|
@ -961,27 +977,6 @@ function Row({ row, year, month, refresh, index, onEditBill }) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Edit payment (pencil) */}
|
|
||||||
{row.payments && row.payments.length > 0 && (
|
|
||||||
<Button
|
|
||||||
size="icon" variant="ghost"
|
|
||||||
className="h-7 w-7 opacity-100 transition-opacity text-muted-foreground hover:text-foreground hover:bg-accent lg:opacity-0 lg:group-hover:opacity-100"
|
|
||||||
title="Edit payment"
|
|
||||||
onClick={() => setEditPayment(row.payments[0])}
|
|
||||||
>
|
|
||||||
<Pencil className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Monthly state editor (gear icon) — always available */}
|
|
||||||
<Button
|
|
||||||
size="icon" variant="ghost"
|
|
||||||
className="h-7 w-7 opacity-100 transition-opacity text-muted-foreground hover:text-foreground hover:bg-accent lg:opacity-0 lg:group-hover:opacity-100"
|
|
||||||
title={`Edit ${MONTHS[month - 1]} state (actual amount, notes, skip)`}
|
|
||||||
onClick={() => setShowMbs(true)}
|
|
||||||
>
|
|
||||||
<Settings2 className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
|
|
@ -1080,18 +1075,32 @@ function MobileTrackerRow({ row, year, month, refresh, index, onEditBill }) {
|
||||||
AP
|
AP
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<button
|
{row.website ? (
|
||||||
type="button"
|
<a
|
||||||
onClick={() => onEditBill?.(row)}
|
href={row.website}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
className={cn(
|
className={cn(
|
||||||
'min-w-0 truncate text-left text-sm font-semibold leading-tight text-foreground',
|
'min-w-0 truncate text-sm font-semibold leading-tight text-foreground',
|
||||||
'underline-offset-2 transition-colors hover:text-primary hover:underline',
|
'underline-offset-2 transition-colors hover:text-primary hover:underline',
|
||||||
isSkipped && 'line-through',
|
isSkipped && 'line-through',
|
||||||
)}
|
)}
|
||||||
title="Edit bill"
|
|
||||||
>
|
>
|
||||||
{row.name}
|
{row.name}
|
||||||
</button>
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className={cn('min-w-0 truncate text-sm font-semibold leading-tight text-foreground', isSkipped && 'line-through')}>
|
||||||
|
{row.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="icon" variant="ghost"
|
||||||
|
className="h-7 w-7 opacity-100 transition-opacity text-muted-foreground hover:text-foreground hover:bg-accent lg:opacity-0 lg:group-hover:opacity-100"
|
||||||
|
title="Edit bill"
|
||||||
|
onClick={() => onEditBill?.(row)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{row.monthly_notes && (
|
{row.monthly_notes && (
|
||||||
<p className="mt-1 line-clamp-2 text-xs italic text-amber-500/80" title={row.monthly_notes}>
|
<p className="mt-1 line-clamp-2 text-xs italic text-amber-500/80" title={row.monthly_notes}>
|
||||||
|
|
@ -1164,27 +1173,6 @@ function MobileTrackerRow({ row, year, month, refresh, index, onEditBill }) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{row.payments && row.payments.length > 0 && (
|
|
||||||
<Button
|
|
||||||
size="sm" variant="ghost"
|
|
||||||
className="h-8 px-2.5 text-xs text-muted-foreground hover:text-foreground"
|
|
||||||
title="Edit payment"
|
|
||||||
onClick={() => setEditPayment(row.payments[0])}
|
|
||||||
>
|
|
||||||
<Pencil className="mr-1.5 h-3.5 w-3.5" />
|
|
||||||
Payment
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
size="sm" variant="ghost"
|
|
||||||
className="h-8 px-2.5 text-xs text-muted-foreground hover:text-foreground"
|
|
||||||
title={`Edit ${MONTHS[month - 1]} state (actual amount, notes, skip)`}
|
|
||||||
onClick={() => setShowMbs(true)}
|
|
||||||
>
|
|
||||||
<Settings2 className="mr-1.5 h-3.5 w-3.5" />
|
|
||||||
Month
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -605,6 +605,70 @@ function reconcileLegacyMigrations() {
|
||||||
console.log('[migration] sessions.created_at column added');
|
console.log('[migration] sessions.created_at column added');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 'v0.44',
|
||||||
|
description: 'performance: add missing indexes for frequently queried columns',
|
||||||
|
check: function() {
|
||||||
|
return !!db.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_bills_user_name'").get();
|
||||||
|
},
|
||||||
|
run: function() {
|
||||||
|
db.exec('CREATE INDEX IF NOT EXISTS idx_bills_user_name ON bills(user_id, name)');
|
||||||
|
db.exec('CREATE INDEX IF NOT EXISTS idx_payments_method ON payments(method)');
|
||||||
|
db.exec('CREATE INDEX IF NOT EXISTS idx_monthly_starting_amounts_user ON monthly_starting_amounts(user_id)');
|
||||||
|
db.exec('CREATE INDEX IF NOT EXISTS idx_import_history_imported_at ON import_history(imported_at)');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 'v0.45',
|
||||||
|
description: 'audit: add audit_log table for security event tracking',
|
||||||
|
check: function() {
|
||||||
|
return !!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='audit_log'").get();
|
||||||
|
},
|
||||||
|
run: function() {
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS audit_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
entity_type TEXT,
|
||||||
|
entity_id INTEGER,
|
||||||
|
details_json TEXT,
|
||||||
|
ip_address TEXT,
|
||||||
|
user_agent TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
)`);
|
||||||
|
db.exec('CREATE INDEX IF NOT EXISTS idx_audit_log_user ON audit_log(user_id, created_at)');
|
||||||
|
db.exec('CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log(action, created_at)');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 'v0.46',
|
||||||
|
description: 'billing: add cycle_type and cycle_day columns to bills',
|
||||||
|
check: function() {
|
||||||
|
const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
||||||
|
return cols.includes('cycle_type') && cols.includes('cycle_day');
|
||||||
|
},
|
||||||
|
run: function() {
|
||||||
|
const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
||||||
|
if (!cols.includes('cycle_type')) {
|
||||||
|
db.exec(`ALTER TABLE bills ADD COLUMN cycle_type TEXT NOT NULL DEFAULT 'monthly'`);
|
||||||
|
}
|
||||||
|
if (!cols.includes('cycle_day')) {
|
||||||
|
db.exec(`ALTER TABLE bills ADD COLUMN cycle_day TEXT`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 'v0.47',
|
||||||
|
description: 'settings: reset backup_schedule_retention_count default from 14 to 2',
|
||||||
|
check: function() {
|
||||||
|
const row = db.prepare("SELECT value FROM settings WHERE key = 'backup_schedule_retention_count'").get();
|
||||||
|
return !row || row.value !== '14';
|
||||||
|
},
|
||||||
|
run: function() {
|
||||||
|
db.prepare("UPDATE settings SET value = '2' WHERE key = 'backup_schedule_retention_count' AND value = '14'").run();
|
||||||
|
console.log('[migration] backup_schedule_retention_count updated from 14 to 2');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -1071,12 +1135,24 @@ function runMigrations() {
|
||||||
description: 'billing: add cycle_type and cycle_day columns to bills',
|
description: 'billing: add cycle_type and cycle_day columns to bills',
|
||||||
dependsOn: ['v0.45'],
|
dependsOn: ['v0.45'],
|
||||||
run: function() {
|
run: function() {
|
||||||
// Add cycle_type column (default 'monthly' for existing bills)
|
const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
||||||
|
if (!cols.includes('cycle_type')) {
|
||||||
db.exec(`ALTER TABLE bills ADD COLUMN cycle_type TEXT NOT NULL DEFAULT 'monthly'`);
|
db.exec(`ALTER TABLE bills ADD COLUMN cycle_type TEXT NOT NULL DEFAULT 'monthly'`);
|
||||||
// Add cycle_day column for specific day within the cycle
|
}
|
||||||
|
if (!cols.includes('cycle_day')) {
|
||||||
db.exec(`ALTER TABLE bills ADD COLUMN cycle_day TEXT`);
|
db.exec(`ALTER TABLE bills ADD COLUMN cycle_day TEXT`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 'v0.47',
|
||||||
|
description: 'settings: reset backup_schedule_retention_count default from 14 to 2',
|
||||||
|
dependsOn: ['v0.46'],
|
||||||
|
run: function() {
|
||||||
|
db.prepare("UPDATE settings SET value = '2' WHERE key = 'backup_schedule_retention_count' AND value = '14'").run();
|
||||||
|
console.log('[migration] backup_schedule_retention_count updated from 14 to 2');
|
||||||
|
}
|
||||||
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
// ── users: notification columns ───────────────────────────────────────────
|
// ── users: notification columns ───────────────────────────────────────────
|
||||||
|
|
@ -1314,7 +1390,7 @@ function seedDefaults() {
|
||||||
['backup_schedule_enabled', 'false'],
|
['backup_schedule_enabled', 'false'],
|
||||||
['backup_schedule_frequency', 'daily'],
|
['backup_schedule_frequency', 'daily'],
|
||||||
['backup_schedule_time', '02:00'],
|
['backup_schedule_time', '02:00'],
|
||||||
['backup_schedule_retention_count', '14'],
|
['backup_schedule_retention_count', '2'],
|
||||||
['backup_schedule_last_run_at', ''],
|
['backup_schedule_last_run_at', ''],
|
||||||
['backup_schedule_last_error', ''],
|
['backup_schedule_last_error', ''],
|
||||||
['auth_mode', 'multi'],
|
['auth_mode', 'multi'],
|
||||||
|
|
@ -1439,6 +1515,12 @@ const ROLLBACK_SQL_MAP = {
|
||||||
'ALTER TABLE bills DROP COLUMN cycle_day',
|
'ALTER TABLE bills DROP COLUMN cycle_day',
|
||||||
'ALTER TABLE bills DROP COLUMN cycle_type'
|
'ALTER TABLE bills DROP COLUMN cycle_type'
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
'v0.47': {
|
||||||
|
description: 'settings: reset backup_schedule_retention_count default from 14 to 2',
|
||||||
|
sql: [
|
||||||
|
"UPDATE settings SET value = '14' WHERE key = 'backup_schedule_retention_count' AND value = '2'"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -250,6 +250,47 @@ router.put('/users/:id/active', (req, res) => {
|
||||||
).get(targetId));
|
).get(targetId));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// PUT /api/admin/users/:id/username
|
||||||
|
router.put('/users/:id/username', (req, res) => {
|
||||||
|
const { username } = req.body;
|
||||||
|
if (!username || typeof username !== 'string') {
|
||||||
|
return res.status(400).json({ error: 'username is required' });
|
||||||
|
}
|
||||||
|
const trimmed = username.trim();
|
||||||
|
if (trimmed.length < 3) {
|
||||||
|
return res.status(400).json({ error: 'Username must be at least 3 characters' });
|
||||||
|
}
|
||||||
|
if (trimmed.length > 50) {
|
||||||
|
return res.status(400).json({ error: 'Username must be 50 characters or fewer' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetId = Number(req.params.id);
|
||||||
|
const db = getDb();
|
||||||
|
const user = db.prepare('SELECT id, username FROM users WHERE id = ?').get(targetId);
|
||||||
|
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||||
|
|
||||||
|
const taken = db.prepare(
|
||||||
|
'SELECT id FROM users WHERE username = ? COLLATE NOCASE AND id != ?'
|
||||||
|
).get(trimmed, targetId);
|
||||||
|
if (taken) return res.status(409).json({ error: 'Username already taken' });
|
||||||
|
|
||||||
|
const previousUsername = user.username;
|
||||||
|
db.prepare("UPDATE users SET username = ?, updated_at = datetime('now') WHERE id = ?")
|
||||||
|
.run(trimmed, targetId);
|
||||||
|
|
||||||
|
logAudit({
|
||||||
|
user_id: req.user.id, action: 'admin.username.change',
|
||||||
|
entity_type: 'user', entity_id: targetId,
|
||||||
|
details: { old_username: previousUsername, new_username: trimmed },
|
||||||
|
ip_address: req.ip, user_agent: req.get('user-agent'),
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(
|
||||||
|
db.prepare('SELECT id, username, role, active, is_default_admin, must_change_password, first_login, created_at FROM users WHERE id = ?')
|
||||||
|
.get(targetId)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
// DELETE /api/admin/users/:id
|
// DELETE /api/admin/users/:id
|
||||||
router.delete('/users/:id', (req, res) => {
|
router.delete('/users/:id', (req, res) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
|
||||||
|
|
@ -57,10 +57,34 @@ router.get('/', (req, res) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── PATCH /api/profile ────────────────────────────────────────────────────────
|
// ── PATCH /api/profile ────────────────────────────────────────────────────────
|
||||||
// Updates safe profile fields: display_name only.
|
// Updates safe profile fields: username and display_name.
|
||||||
// Ignores any unknown or restricted fields.
|
// Ignores any unknown or restricted fields.
|
||||||
router.patch('/', (req, res) => {
|
router.patch('/', (req, res) => {
|
||||||
const { display_name } = req.body;
|
const { username, display_name } = req.body;
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
if (username !== undefined) {
|
||||||
|
if (typeof username !== 'string') {
|
||||||
|
return res.status(400).json({ error: 'username must be a string' });
|
||||||
|
}
|
||||||
|
const trimmedUsername = username.trim();
|
||||||
|
if (trimmedUsername.length < 3) {
|
||||||
|
return res.status(400).json({ error: 'username must be at least 3 characters' });
|
||||||
|
}
|
||||||
|
if (trimmedUsername.length > 50) {
|
||||||
|
return res.status(400).json({ error: 'username must be 50 characters or fewer' });
|
||||||
|
}
|
||||||
|
const taken = db.prepare(
|
||||||
|
'SELECT id FROM users WHERE username = ? COLLATE NOCASE AND id != ?'
|
||||||
|
).get(trimmedUsername, req.user.id);
|
||||||
|
if (taken) {
|
||||||
|
return res.status(409).json({ error: 'Username already taken' });
|
||||||
|
}
|
||||||
|
db.prepare(
|
||||||
|
"UPDATE users SET username = ?, updated_at = datetime('now') WHERE id = ?"
|
||||||
|
).run(trimmedUsername, req.user.id);
|
||||||
|
logAudit({ user_id: req.user.id, action: 'profile.username.change', ip_address: req.ip, user_agent: req.get('user-agent') });
|
||||||
|
}
|
||||||
|
|
||||||
if (display_name !== undefined) {
|
if (display_name !== undefined) {
|
||||||
if (typeof display_name !== 'string') {
|
if (typeof display_name !== 'string') {
|
||||||
|
|
@ -71,7 +95,7 @@ router.patch('/', (req, res) => {
|
||||||
return res.status(400).json({ error: 'display_name must be 100 characters or fewer' });
|
return res.status(400).json({ error: 'display_name must be 100 characters or fewer' });
|
||||||
}
|
}
|
||||||
|
|
||||||
getDb().prepare(
|
db.prepare(
|
||||||
"UPDATE users SET display_name = ?, updated_at = datetime('now') WHERE id = ?"
|
"UPDATE users SET display_name = ?, updated_at = datetime('now') WHERE id = ?"
|
||||||
).run(trimmed || null, req.user.id);
|
).run(trimmed || null, req.user.id);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ function validateScheduleSettings(input = {}) {
|
||||||
const enabled = parseBool(input.enabled);
|
const enabled = parseBool(input.enabled);
|
||||||
const frequency = input.frequency || 'daily';
|
const frequency = input.frequency || 'daily';
|
||||||
const time = input.time || '02:00';
|
const time = input.time || '02:00';
|
||||||
const retentionCount = parseInt(input.retention_count ?? '14', 10);
|
const retentionCount = parseInt(input.retention_count ?? '2', 10);
|
||||||
|
|
||||||
if (!['daily', 'weekly'].includes(frequency)) {
|
if (!['daily', 'weekly'].includes(frequency)) {
|
||||||
const err = new Error('frequency must be daily or weekly');
|
const err = new Error('frequency must be daily or weekly');
|
||||||
|
|
@ -47,7 +47,7 @@ function readSettings() {
|
||||||
enabled: getSetting('backup_schedule_enabled') === 'true',
|
enabled: getSetting('backup_schedule_enabled') === 'true',
|
||||||
frequency: getSetting('backup_schedule_frequency') || 'daily',
|
frequency: getSetting('backup_schedule_frequency') || 'daily',
|
||||||
time: getSetting('backup_schedule_time') || '02:00',
|
time: getSetting('backup_schedule_time') || '02:00',
|
||||||
retention_count: getSetting('backup_schedule_retention_count') || '14',
|
retention_count: getSetting('backup_schedule_retention_count') || '2',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ const crypto = require('crypto');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const Database = require('better-sqlite3');
|
const Database = require('better-sqlite3');
|
||||||
const { closeDb, getDb, getDbPath } = require('../db/database');
|
const { closeDb, getDb, getDbPath, getSetting } = require('../db/database');
|
||||||
|
|
||||||
const BACKUP_DIR = path.resolve(
|
const BACKUP_DIR = path.resolve(
|
||||||
process.env.BACKUP_PATH || path.join(path.dirname(getDbPath()), '..', 'backups')
|
process.env.BACKUP_PATH || path.join(path.dirname(getDbPath()), '..', 'backups')
|
||||||
|
|
@ -166,7 +166,10 @@ async function createBackup(prefix = 'bill-tracker-backup') {
|
||||||
validateSqliteDatabase(tempPath);
|
validateSqliteDatabase(tempPath);
|
||||||
fs.renameSync(tempPath, finalPath);
|
fs.renameSync(tempPath, finalPath);
|
||||||
fs.chmodSync(finalPath, 0o600);
|
fs.chmodSync(finalPath, 0o600);
|
||||||
return metadataForFile(finalPath);
|
const meta = metadataForFile(finalPath);
|
||||||
|
const retentionCount = parseInt(getSetting('backup_schedule_retention_count') || '2', 10);
|
||||||
|
applyRetention(retentionCount);
|
||||||
|
return meta;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
try { if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath); } catch {}
|
try { if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath); } catch {}
|
||||||
cleanupSqliteSidecars(tempPath);
|
cleanupSqliteSidecars(tempPath);
|
||||||
|
|
@ -239,25 +242,28 @@ function deleteBackup(id) {
|
||||||
return { deleted: true, id: backup.metadata.id, deleted_at: new Date().toISOString() };
|
return { deleted: true, id: backup.metadata.id, deleted_at: new Date().toISOString() };
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyScheduledRetention(retentionCount) {
|
function applyRetention(retentionCount) {
|
||||||
const keep = parseInt(retentionCount, 10);
|
const keep = parseInt(retentionCount, 10);
|
||||||
if (!Number.isInteger(keep) || keep < 1) return { deleted: [] };
|
if (!Number.isInteger(keep) || keep < 1) return { deleted: [] };
|
||||||
|
|
||||||
const scheduled = listBackups().filter(backup => backup.type === 'scheduled');
|
// listBackups() is already sorted newest-first; delete everything beyond `keep`
|
||||||
const toDelete = scheduled.slice(keep);
|
const toDelete = listBackups().slice(keep);
|
||||||
const deleted = [];
|
const deleted = [];
|
||||||
|
|
||||||
for (const backup of toDelete) {
|
for (const backup of toDelete) {
|
||||||
try {
|
try {
|
||||||
deleted.push(deleteBackup(backup.id).id);
|
deleted.push(deleteBackup(backup.id).id);
|
||||||
} catch {
|
} catch {
|
||||||
// Retention should never make a scheduled backup fail.
|
// Retention should never cause a backup operation to fail.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { deleted };
|
return { deleted };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keep old name as an alias so the scheduler import still works.
|
||||||
|
const applyScheduledRetention = applyRetention;
|
||||||
|
|
||||||
async function restoreBackup(id) {
|
async function restoreBackup(id) {
|
||||||
const source = getBackupFile(id);
|
const source = getBackupFile(id);
|
||||||
validateSqliteDatabase(source.path);
|
validateSqliteDatabase(source.path);
|
||||||
|
|
@ -299,6 +305,7 @@ async function restoreBackup(id) {
|
||||||
module.exports = {
|
module.exports = {
|
||||||
BACKUP_DIR,
|
BACKUP_DIR,
|
||||||
assertValidBackupId,
|
assertValidBackupId,
|
||||||
|
applyRetention,
|
||||||
applyScheduledRetention,
|
applyScheduledRetention,
|
||||||
createBackup,
|
createBackup,
|
||||||
deleteBackup,
|
deleteBackup,
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ const HEADER_PATTERNS = {
|
||||||
bill_name: /^(?:bill|name|bill\s*name|description|payee|vendor|service)$/i,
|
bill_name: /^(?:bill|name|bill\s*name|description|payee|vendor|service)$/i,
|
||||||
amount: /^(?:amount|amt|expected|expected\s*amount|cost|price|payment|value)$/i,
|
amount: /^(?:amount|amt|expected|expected\s*amount|cost|price|payment|value)$/i,
|
||||||
due_date: /^(?:due\s*date|due|due\s*day)$/i,
|
due_date: /^(?:due\s*date|due|due\s*day)$/i,
|
||||||
paid_date: /^(?:paid\s*date|date\s*paid|payment\s*date|date\s*cleared|cleared\s*date)$/i,
|
paid_date: /^(?:paid\s*date|date\s*paid|payment\s*date)$/i,
|
||||||
date: /^(?:date|due\s*date|due|paid\s*date|when|day)$/i,
|
date: /^(?:date|due\s*date|due|paid\s*date|when|day)$/i,
|
||||||
category: /^(?:category|cat|type|group)$/i,
|
category: /^(?:category|cat|type|group)$/i,
|
||||||
notes: /^(?:notes?|comment|label|status|memo|remark)$/i,
|
notes: /^(?:notes?|comment|label|status|memo|remark)$/i,
|
||||||
|
|
@ -233,8 +233,13 @@ function parseXlsxBuffer(buffer) {
|
||||||
function getSheetRows(workbook, sheetName) {
|
function getSheetRows(workbook, sheetName) {
|
||||||
const sheet = workbook.Sheets[sheetName];
|
const sheet = workbook.Sheets[sheetName];
|
||||||
if (!sheet) return [];
|
if (!sheet) return [];
|
||||||
|
try {
|
||||||
// raw:false → formatted string values; no formula results can leak through
|
// raw:false → formatted string values; no formula results can leak through
|
||||||
return xlsx.utils.sheet_to_json(sheet, { header: 1, defval: null, raw: false });
|
return xlsx.utils.sheet_to_json(sheet, { header: 1, defval: null, raw: false });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[import] sheet="${sheetName}" failed to parse rows — skipping:`, err.message);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Header Detection ─────────────────────────────────────────────────────────
|
// ─── Header Detection ─────────────────────────────────────────────────────────
|
||||||
|
|
@ -617,6 +622,7 @@ function buildRecommendation({
|
||||||
billName,
|
billName,
|
||||||
detectedAmount,
|
detectedAmount,
|
||||||
parsedDate,
|
parsedDate,
|
||||||
|
parsedPaidDate,
|
||||||
dateHeader,
|
dateHeader,
|
||||||
detectedCategory,
|
detectedCategory,
|
||||||
notesText,
|
notesText,
|
||||||
|
|
@ -634,7 +640,11 @@ function buildRecommendation({
|
||||||
|
|
||||||
const dateDay = parsedDate?.day;
|
const dateDay = parsedDate?.day;
|
||||||
let dueDay = Number.isInteger(dateDay) && dateDay >= 1 && dateDay <= 31 ? dateDay : null;
|
let dueDay = Number.isInteger(dateDay) && dateDay >= 1 && dateDay <= 31 ? dateDay : null;
|
||||||
// Use defaultDueDay from header set if date parsing didn't find a day
|
// Fall back to the paid-date column's day (e.g. column D), then to defaultDueDay
|
||||||
|
if (dueDay === null) {
|
||||||
|
const paidDay = parsedPaidDate?.day;
|
||||||
|
if (Number.isInteger(paidDay) && paidDay >= 1 && paidDay <= 31) dueDay = paidDay;
|
||||||
|
}
|
||||||
if (dueDay === null && defaultDueDay !== null) {
|
if (dueDay === null && defaultDueDay !== null) {
|
||||||
dueDay = defaultDueDay;
|
dueDay = defaultDueDay;
|
||||||
}
|
}
|
||||||
|
|
@ -839,11 +849,32 @@ function analyzeRow(rowIndex, cells, headerMap, headerLabels, userBills, categor
|
||||||
if (!billName) errors.push('No bill name detected');
|
if (!billName) errors.push('No bill name detected');
|
||||||
if (detectedAmount === null) warnings.push('No amount detected');
|
if (detectedAmount === null) warnings.push('No amount detected');
|
||||||
|
|
||||||
|
// ── Diagnostic logging for auto-detected patterns ──────────────────────────
|
||||||
|
const _rawDue = get('due_date') != null ? String(get('due_date')).trim() : '';
|
||||||
|
const _rawPaid = get('paid_date') != null ? String(get('paid_date')).trim() : '';
|
||||||
|
const _loc = `sheet="${sheetName}" row=${rowIndex + 1}${billName ? ` bill="${billName}"` : ''}`;
|
||||||
|
|
||||||
|
if (detectedLabels.includes('autopay') && billName) {
|
||||||
|
if (_rawDue && /auto/i.test(_rawDue) && /\d/.test(_rawDue)) {
|
||||||
|
console.log(`[import] ${_loc} autopay+date in due col: "${_rawDue}" (date portion not extracted)`);
|
||||||
|
} else {
|
||||||
|
console.log(`[import] ${_loc} autopay detected`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (detectedLabels.includes('past_due')) {
|
||||||
|
console.log(`[import] ${_loc} PAST DUE detected`);
|
||||||
|
}
|
||||||
|
if (_rawPaid && !parsedPaidDate) {
|
||||||
|
console.log(`[import] ${_loc} unparseable paid date: "${_rawPaid}"`);
|
||||||
|
}
|
||||||
|
// ───────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const possibleMatches = billName ? findBillMatches(billName, userBills) : [];
|
const possibleMatches = billName ? findBillMatches(billName, userBills) : [];
|
||||||
const recommendation = buildRecommendation({
|
const recommendation = buildRecommendation({
|
||||||
billName,
|
billName,
|
||||||
detectedAmount,
|
detectedAmount,
|
||||||
parsedDate,
|
parsedDate,
|
||||||
|
parsedPaidDate,
|
||||||
dateHeader,
|
dateHeader,
|
||||||
detectedCategory,
|
detectedCategory,
|
||||||
notesText,
|
notesText,
|
||||||
|
|
@ -938,6 +969,17 @@ function parseSheetRows({ name, rawRows, year: sheetYear, month: sheetMonth, row
|
||||||
const hasHeaders = hasValidHeaders;
|
const hasHeaders = hasValidHeaders;
|
||||||
const startRow = hasHeaders ? headerRowIndex + 1 : 0;
|
const startRow = hasHeaders ? headerRowIndex + 1 : 0;
|
||||||
|
|
||||||
|
// Log detected layout for this sheet
|
||||||
|
const _colLetter = (i) => String.fromCharCode(65 + i);
|
||||||
|
if (!hasHeaders) {
|
||||||
|
console.log(`[import] sheet="${name}" no valid headers detected — sheet will be skipped`);
|
||||||
|
} else {
|
||||||
|
for (const [si, set] of allHeaderSets.entries()) {
|
||||||
|
const mapped = Object.entries(set.map).map(([f, i]) => `${f}:${_colLetter(i)}`).join(', ');
|
||||||
|
console.log(`[import] sheet="${name}" group=${si} defaultDueDay=${set.defaultDueDay} columns={${mapped}}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// For dual-column layouts, collect ALL column indices across all header sets
|
// For dual-column layouts, collect ALL column indices across all header sets
|
||||||
// so that fallback lookups (findFirstAmountCell, collectNotesCells) don't
|
// so that fallback lookups (findFirstAmountCell, collectNotesCells) don't
|
||||||
// accidentally pick up values from the other column set.
|
// accidentally pick up values from the other column set.
|
||||||
|
|
@ -1000,12 +1042,16 @@ function parseSheetRows({ name, rawRows, year: sheetYear, month: sheetMonth, row
|
||||||
|
|
||||||
if (isDashSeparator || isLeftoverCalcRow) continue;
|
if (isDashSeparator || isLeftoverCalcRow) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
rows.push(analyzeRow(
|
rows.push(analyzeRow(
|
||||||
i, cells, headerMap, headerLabels, userBills, categories,
|
i, cells, headerMap, headerLabels, userBills, categories,
|
||||||
name, sheetYear, sheetMonth,
|
name, sheetYear, sheetMonth,
|
||||||
defaultYear, defaultMonth, rowIdPrefix,
|
defaultYear, defaultMonth, rowIdPrefix,
|
||||||
defaultDueDay, setIdx, allColumnsIndices,
|
defaultDueDay, setIdx, allColumnsIndices,
|
||||||
));
|
));
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[import] sheet="${name}" row=${i + 1} failed to analyze — skipping:`, err.message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1061,7 +1107,9 @@ function pruneExpiredSessions(db) {
|
||||||
|
|
||||||
async function previewSpreadsheet(userId, buffer, options = {}) {
|
async function previewSpreadsheet(userId, buffer, options = {}) {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
pruneExpiredSessions(db);
|
try { pruneExpiredSessions(db); } catch (err) {
|
||||||
|
console.error('[import] failed to prune expired sessions (non-fatal):', err.message);
|
||||||
|
}
|
||||||
ensureUserDefaultCategories(userId);
|
ensureUserDefaultCategories(userId);
|
||||||
|
|
||||||
const workbook = parseXlsxBuffer(buffer);
|
const workbook = parseXlsxBuffer(buffer);
|
||||||
|
|
@ -1457,11 +1505,12 @@ function applyOneDecision(db, userId, decision, previewRow, sessionData, allowOv
|
||||||
|
|
||||||
const dueDay = decision.due_day ?? 1;
|
const dueDay = decision.due_day ?? 1;
|
||||||
const expectedAmount = decision.expected_amount ?? amount ?? 0;
|
const expectedAmount = decision.expected_amount ?? amount ?? 0;
|
||||||
|
const autopay = decision.autopay_enabled ?? (previewRow?.detected_labels?.includes('autopay') ? 1 : 0);
|
||||||
|
|
||||||
const ins = db.prepare(`
|
const ins = db.prepare(`
|
||||||
INSERT INTO bills (user_id, name, category_id, due_day, bucket, expected_amount, billing_cycle, active)
|
INSERT INTO bills (user_id, name, category_id, due_day, bucket, expected_amount, billing_cycle, autopay_enabled, active)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, 'monthly', 1)
|
VALUES (?, ?, ?, ?, ?, ?, 'monthly', ?, 1)
|
||||||
`).run(userId, name, categoryId, dueDay, dueDay <= 14 ? '1st' : '15th', expectedAmount);
|
`).run(userId, name, categoryId, dueDay, dueDay <= 14 ? '1st' : '15th', expectedAmount, autopay);
|
||||||
|
|
||||||
const newBillId = ins.lastInsertRowid;
|
const newBillId = ins.lastInsertRowid;
|
||||||
summary.created++;
|
summary.created++;
|
||||||
|
|
@ -1500,9 +1549,14 @@ function applyOneDecision(db, userId, decision, previewRow, sessionData, allowOv
|
||||||
|
|
||||||
} else if (['match_existing_bill', 'update_monthly_state', 'add_monthly_note'].includes(action)) {
|
} else if (['match_existing_bill', 'update_monthly_state', 'add_monthly_note'].includes(action)) {
|
||||||
const billId = decision.bill_id;
|
const billId = decision.bill_id;
|
||||||
const bill = db.prepare('SELECT id, name FROM bills WHERE id = ? AND active = 1 AND user_id = ?').get(billId, userId);
|
const bill = db.prepare('SELECT id, name, autopay_enabled FROM bills WHERE id = ? AND active = 1 AND user_id = ?').get(billId, userId);
|
||||||
if (!bill) throw new Error(`Bill id=${billId} not found or inactive`);
|
if (!bill) throw new Error(`Bill id=${billId} not found or inactive`);
|
||||||
|
|
||||||
|
if (!bill.autopay_enabled && previewRow?.detected_labels?.includes('autopay')) {
|
||||||
|
db.prepare(`UPDATE bills SET autopay_enabled = 1, updated_at = datetime('now') WHERE id = ?`).run(billId);
|
||||||
|
console.log(`[import] bill id=${billId} "${bill.name}" autopay_enabled upgraded to 1`);
|
||||||
|
}
|
||||||
|
|
||||||
if (!year || !month) {
|
if (!year || !month) {
|
||||||
summary.ambiguous++;
|
summary.ambiguous++;
|
||||||
summary.details.push({ row_id, action, result: 'ambiguous', error: 'year and month required for monthly state' });
|
summary.details.push({ row_id, action, result: 'ambiguous', error: 'year and month required for monthly state' });
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue