corrections

This commit is contained in:
null 2026-05-14 01:17:05 -05:00
parent d2acf44846
commit 48fe87ea25
9 changed files with 355 additions and 198 deletions

View File

@ -1450,44 +1450,30 @@ export function ImportSpreadsheetSection({ onHistoryRefresh }) {
function SeedDemoDataSection({ onSeeded }) {
const [loading, setLoading] = 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 [showClearConfirm, setShowClearConfirm] = useState(false);
const [statusLoading, setStatusLoading] = useState(true);
// Check seeded status on mount
useEffect(() => {
const checkSeededStatus = async () => {
try {
const data = await api.seededStatus();
if (data.seeded) {
setSeeded(true);
setResult(data);
}
} catch (err) {
console.error('Failed to check seeded status:', err);
} finally {
setStatusLoading(false);
}
};
checkSeededStatus();
api.seededStatus()
.then(data => {
setSeeded(data.seeded);
if (data.seeded) setCounts({ bills: data.seededBills || 0, categories: data.seededCategories || 0 });
})
.catch(err => console.error('Failed to check seeded status:', err))
.finally(() => setStatusLoading(false));
}, []);
const handleSeed = async () => {
setLoading(true);
try {
const data = await api.seedDemoData();
// Ensure data has expected structure
if (!data || typeof data !== 'object') {
throw new Error('Invalid response from server');
}
setResult(data);
if (!data || typeof data !== 'object') throw new Error('Invalid response from server');
setCounts({ bills: data.billsCreated || 0, categories: data.categoriesCreated || 0 });
setSeeded(true);
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) {
console.error('Seed error:', err);
toast.error(err?.message || err?.error || 'Failed to seed demo data.');
@ -1501,40 +1487,51 @@ function SeedDemoDataSection({ onSeeded }) {
try {
const data = await api.clearDemoData();
setSeeded(false);
setResult(null);
setCounts({ bills: 0, categories: 0 });
setShowClearConfirm(false);
toast.success(`Removed ${data.billsDeleted || 0} demo bills and ${data.categoriesDeleted || 0} demo categories.`);
onSeeded?.();
} catch (err) {
toast.error(err.message || "Failed to clear demo data.");
toast.error(err.message || 'Failed to clear demo data.');
} finally {
setClearing(false);
}
};
if (seeded) {
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 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>
<p className="text-muted-foreground">Bills Created</p>
<p className="font-semibold">{result?.billsCreated || 0}</p>
<p className="text-muted-foreground">Bills</p>
<p className="font-semibold">{counts.bills}</p>
</div>
<div>
<p className="text-muted-foreground">Categories Created</p>
<p className="font-semibold">{result?.categoriesCreated || 0}</p>
<p className="text-muted-foreground">Categories</p>
<p className="font-semibold">{counts.categories}</p>
</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); }}>
Reset
</>
) : (
<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 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>
<AlertDialog open={showClearConfirm} onOpenChange={setShowClearConfirm}>
<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'}
</Button>
</AlertDialogTrigger>
@ -1542,7 +1539,7 @@ function SeedDemoDataSection({ onSeeded }) {
<AlertDialogHeader>
<AlertDialogTitle>Clear Demo Data</AlertDialogTitle>
<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>
</AlertDialogHeader>
<AlertDialogFooter>
@ -1555,35 +1552,6 @@ function SeedDemoDataSection({ onSeeded }) {
</AlertDialog>
</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>
);
}

View File

@ -12,7 +12,6 @@ import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
} 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';
export default function LoginPage() {
@ -156,12 +155,6 @@ export default function LoginPage() {
className="w-full"
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}
</Button>
)}

View File

@ -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" />
)}
<div>
<button
type="button"
onClick={() => onEditBill?.(row)}
<div className="flex items-center gap-1">
{row.website ? (
<a
href={row.website}
target="_blank"
rel="noreferrer"
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',
isSkipped && 'line-through',
)}
title="Edit bill"
>
{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 && (
<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>
)}
{/* 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>
</TableCell>
@ -1080,18 +1075,32 @@ function MobileTrackerRow({ row, year, month, refresh, index, onEditBill }) {
AP
</span>
)}
<button
type="button"
onClick={() => onEditBill?.(row)}
{row.website ? (
<a
href={row.website}
target="_blank"
rel="noreferrer"
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',
isSkipped && 'line-through',
)}
title="Edit bill"
>
{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>
{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>
)}
{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>

View File

@ -605,6 +605,70 @@ function reconcileLegacyMigrations() {
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',
dependsOn: ['v0.45'],
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'`);
// 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`);
}
}
},
{
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 ───────────────────────────────────────────
@ -1314,7 +1390,7 @@ function seedDefaults() {
['backup_schedule_enabled', 'false'],
['backup_schedule_frequency', 'daily'],
['backup_schedule_time', '02:00'],
['backup_schedule_retention_count', '14'],
['backup_schedule_retention_count', '2'],
['backup_schedule_last_run_at', ''],
['backup_schedule_last_error', ''],
['auth_mode', 'multi'],
@ -1439,6 +1515,12 @@ const ROLLBACK_SQL_MAP = {
'ALTER TABLE bills DROP COLUMN cycle_day',
'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'"
]
}
};

View File

@ -250,6 +250,47 @@ router.put('/users/:id/active', (req, res) => {
).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
router.delete('/users/:id', (req, res) => {
const db = getDb();

View File

@ -57,10 +57,34 @@ router.get('/', (req, res) => {
});
// ── PATCH /api/profile ────────────────────────────────────────────────────────
// Updates safe profile fields: display_name only.
// Updates safe profile fields: username and display_name.
// Ignores any unknown or restricted fields.
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 (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' });
}
getDb().prepare(
db.prepare(
"UPDATE users SET display_name = ?, updated_at = datetime('now') WHERE id = ?"
).run(trimmed || null, req.user.id);

View File

@ -14,7 +14,7 @@ function validateScheduleSettings(input = {}) {
const enabled = parseBool(input.enabled);
const frequency = input.frequency || 'daily';
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)) {
const err = new Error('frequency must be daily or weekly');
@ -47,7 +47,7 @@ function readSettings() {
enabled: getSetting('backup_schedule_enabled') === 'true',
frequency: getSetting('backup_schedule_frequency') || 'daily',
time: getSetting('backup_schedule_time') || '02:00',
retention_count: getSetting('backup_schedule_retention_count') || '14',
retention_count: getSetting('backup_schedule_retention_count') || '2',
});
}

View File

@ -2,7 +2,7 @@ const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
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(
process.env.BACKUP_PATH || path.join(path.dirname(getDbPath()), '..', 'backups')
@ -166,7 +166,10 @@ async function createBackup(prefix = 'bill-tracker-backup') {
validateSqliteDatabase(tempPath);
fs.renameSync(tempPath, finalPath);
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) {
try { if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath); } catch {}
cleanupSqliteSidecars(tempPath);
@ -239,25 +242,28 @@ function deleteBackup(id) {
return { deleted: true, id: backup.metadata.id, deleted_at: new Date().toISOString() };
}
function applyScheduledRetention(retentionCount) {
function applyRetention(retentionCount) {
const keep = parseInt(retentionCount, 10);
if (!Number.isInteger(keep) || keep < 1) return { deleted: [] };
const scheduled = listBackups().filter(backup => backup.type === 'scheduled');
const toDelete = scheduled.slice(keep);
// listBackups() is already sorted newest-first; delete everything beyond `keep`
const toDelete = listBackups().slice(keep);
const deleted = [];
for (const backup of toDelete) {
try {
deleted.push(deleteBackup(backup.id).id);
} catch {
// Retention should never make a scheduled backup fail.
// Retention should never cause a backup operation to fail.
}
}
return { deleted };
}
// Keep old name as an alias so the scheduler import still works.
const applyScheduledRetention = applyRetention;
async function restoreBackup(id) {
const source = getBackupFile(id);
validateSqliteDatabase(source.path);
@ -299,6 +305,7 @@ async function restoreBackup(id) {
module.exports = {
BACKUP_DIR,
assertValidBackupId,
applyRetention,
applyScheduledRetention,
createBackup,
deleteBackup,

View File

@ -31,7 +31,7 @@ const HEADER_PATTERNS = {
bill_name: /^(?:bill|name|bill\s*name|description|payee|vendor|service)$/i,
amount: /^(?:amount|amt|expected|expected\s*amount|cost|price|payment|value)$/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,
category: /^(?:category|cat|type|group)$/i,
notes: /^(?:notes?|comment|label|status|memo|remark)$/i,
@ -233,8 +233,13 @@ function parseXlsxBuffer(buffer) {
function getSheetRows(workbook, sheetName) {
const sheet = workbook.Sheets[sheetName];
if (!sheet) return [];
try {
// raw:false → formatted string values; no formula results can leak through
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 ─────────────────────────────────────────────────────────
@ -617,6 +622,7 @@ function buildRecommendation({
billName,
detectedAmount,
parsedDate,
parsedPaidDate,
dateHeader,
detectedCategory,
notesText,
@ -634,7 +640,11 @@ function buildRecommendation({
const dateDay = parsedDate?.day;
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) {
dueDay = defaultDueDay;
}
@ -839,11 +849,32 @@ function analyzeRow(rowIndex, cells, headerMap, headerLabels, userBills, categor
if (!billName) errors.push('No bill name 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 recommendation = buildRecommendation({
billName,
detectedAmount,
parsedDate,
parsedPaidDate,
dateHeader,
detectedCategory,
notesText,
@ -938,6 +969,17 @@ function parseSheetRows({ name, rawRows, year: sheetYear, month: sheetMonth, row
const hasHeaders = hasValidHeaders;
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
// so that fallback lookups (findFirstAmountCell, collectNotesCells) don't
// 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;
try {
rows.push(analyzeRow(
i, cells, headerMap, headerLabels, userBills, categories,
name, sheetYear, sheetMonth,
defaultYear, defaultMonth, rowIdPrefix,
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 = {}) {
const db = getDb();
pruneExpiredSessions(db);
try { pruneExpiredSessions(db); } catch (err) {
console.error('[import] failed to prune expired sessions (non-fatal):', err.message);
}
ensureUserDefaultCategories(userId);
const workbook = parseXlsxBuffer(buffer);
@ -1457,11 +1505,12 @@ function applyOneDecision(db, userId, decision, previewRow, sessionData, allowOv
const dueDay = decision.due_day ?? 1;
const expectedAmount = decision.expected_amount ?? amount ?? 0;
const autopay = decision.autopay_enabled ?? (previewRow?.detected_labels?.includes('autopay') ? 1 : 0);
const ins = db.prepare(`
INSERT INTO bills (user_id, name, category_id, due_day, bucket, expected_amount, billing_cycle, active)
VALUES (?, ?, ?, ?, ?, ?, 'monthly', 1)
`).run(userId, name, categoryId, dueDay, dueDay <= 14 ? '1st' : '15th', expectedAmount);
INSERT INTO bills (user_id, name, category_id, due_day, bucket, expected_amount, billing_cycle, autopay_enabled, active)
VALUES (?, ?, ?, ?, ?, ?, 'monthly', ?, 1)
`).run(userId, name, categoryId, dueDay, dueDay <= 14 ? '1st' : '15th', expectedAmount, autopay);
const newBillId = ins.lastInsertRowid;
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)) {
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.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) {
summary.ambiguous++;
summary.details.push({ row_id, action, result: 'ambiguous', error: 'year and month required for monthly state' });