diff --git a/client/pages/DataPage.jsx b/client/pages/DataPage.jsx index def7194..bf0652f 100644 --- a/client/pages/DataPage.jsx +++ b/client/pages/DataPage.jsx @@ -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,87 +1487,69 @@ 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 ( - -
-

Seed complete

-
-
-

Bills Created

-

{result?.billsCreated || 0}

-
-
-

Categories Created

-

{result?.categoriesCreated || 0}

-
-
-
-
- - - - - - - - Clear Demo Data - - This action will remove {result?.billsCreated || 0} demo bills and {result?.categoriesCreated || 0} demo categories from your account. This action cannot be undone. - - - - Cancel - - {clearing ? <>Clearing… : 'Clear Data'} - - - - -
-
-
-
- ); - } - - if (statusLoading) { - return ( - -
Loading…
-
- ); - } - return (
-

- Create 20 realistic demo bills and 8 demo categories for testing purposes. - The data will be associated with your account. -

- -
-
- -
+ {statusLoading ? ( +

Loading…

+ ) : seeded ? ( + <> +

Demo data seeded

+
+
+

Bills

+

{counts.bills}

+
+
+

Categories

+

{counts.categories}

+
+
+ + ) : ( +

+ Create 20 realistic demo bills and 8 demo categories for testing purposes. + The data will be associated with your account. +

+ )} + +
+ + + + + + + + + Clear Demo Data + + This will remove {counts.bills} demo bills and {counts.categories} demo categories from your account. This cannot be undone. + + + + Cancel + + {clearing ? <>Clearing… : 'Clear Data'} + + + +
diff --git a/client/pages/LoginPage.jsx b/client/pages/LoginPage.jsx index e202b55..06ed368 100644 --- a/client/pages/LoginPage.jsx +++ b/client/pages/LoginPage.jsx @@ -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; }} > - Continue with {providerName} )} diff --git a/client/pages/TrackerPage.jsx b/client/pages/TrackerPage.jsx index ce4537c..012c83b 100644 --- a/client/pages/TrackerPage.jsx +++ b/client/pages/TrackerPage.jsx @@ -853,18 +853,34 @@ function Row({ row, year, month, refresh, index, onEditBill }) { )}
- + +
{row.category_name && (

{row.category_name}

)} @@ -961,27 +977,6 @@ function Row({ row, year, month, refresh, index, onEditBill }) {
)} - {/* Edit payment (pencil) */} - {row.payments && row.payments.length > 0 && ( - - )} - - {/* Monthly state editor (gear icon) — always available */} - @@ -1080,18 +1075,32 @@ function MobileTrackerRow({ row, year, month, refresh, index, onEditBill }) { AP )} - + + {row.monthly_notes && (

@@ -1164,27 +1173,6 @@ function MobileTrackerRow({ row, year, month, refresh, index, onEditBill }) { )} - {row.payments && row.payments.length > 0 && ( - - )} - - diff --git a/db/database.js b/db/database.js index 40a2d27..41bbc61 100644 --- a/db/database.js +++ b/db/database.js @@ -605,9 +605,73 @@ 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'); + } } ]; - + // Check for legacy notification columns const userCols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name); const newUserCols = [ @@ -1071,14 +1135,26 @@ 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) - db.exec(`ALTER TABLE bills ADD COLUMN cycle_type TEXT NOT NULL DEFAULT 'monthly'`); - // Add cycle_day column for specific day within the cycle - db.exec(`ALTER TABLE bills ADD COLUMN cycle_day TEXT`); + 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', + 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 ─────────────────────────────────────────── // This migration needs to run first since it's not versioned in the schema console.log('[migration] Applying unversioned user 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'" + ] } }; diff --git a/routes/admin.js b/routes/admin.js index e4e674f..89a2443 100644 --- a/routes/admin.js +++ b/routes/admin.js @@ -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(); diff --git a/routes/profile.js b/routes/profile.js index 444ca3b..931bfda 100644 --- a/routes/profile.js +++ b/routes/profile.js @@ -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); diff --git a/services/backupScheduler.js b/services/backupScheduler.js index 9ae1352..7053b26 100644 --- a/services/backupScheduler.js +++ b/services/backupScheduler.js @@ -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', }); } diff --git a/services/backupService.js b/services/backupService.js index 6fa43b1..e89de66 100644 --- a/services/backupService.js +++ b/services/backupService.js @@ -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, diff --git a/services/spreadsheetImportService.js b/services/spreadsheetImportService.js index 67d615c..c81a8df 100644 --- a/services/spreadsheetImportService.js +++ b/services/spreadsheetImportService.js @@ -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 []; - // raw:false → formatted string values; no formula results can leak through - return xlsx.utils.sheet_to_json(sheet, { header: 1, defval: null, raw: false }); + 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. @@ -999,13 +1041,17 @@ function parseSheetRows({ name, rawRows, year: sheetYear, month: sheetMonth, row const isLeftoverCalcRow = !billName && amount !== null && amount < 0; if (isDashSeparator || isLeftoverCalcRow) continue; - - rows.push(analyzeRow( - i, cells, headerMap, headerLabels, userBills, categories, - name, sheetYear, sheetMonth, - defaultYear, defaultMonth, rowIdPrefix, - defaultDueDay, setIdx, allColumnsIndices, - )); + + 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' });