-
- 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 }) {
)}
-
)}
- {/* Edit payment (pencil) */}
- {row.payments && row.payments.length > 0 && (
-
setEditPayment(row.payments[0])}
- >
-
-
- )}
-
- {/* Monthly state editor (gear icon) — always available */}
-
setShowMbs(true)}
- >
-
-
@@ -1080,18 +1075,32 @@ function MobileTrackerRow({ row, year, month, refresh, index, onEditBill }) {
AP
)}
- onEditBill?.(row)}
- className={cn(
- 'min-w-0 truncate text-left text-sm font-semibold leading-tight text-foreground',
- 'underline-offset-2 transition-colors hover:text-primary hover:underline',
- isSkipped && 'line-through',
- )}
+ {row.website ? (
+
+ {row.name}
+
+ ) : (
+
+ {row.name}
+
+ )}
+ onEditBill?.(row)}
>
- {row.name}
-
+
+
{row.monthly_notes && (
@@ -1164,27 +1173,6 @@ function MobileTrackerRow({ row, year, month, refresh, index, onEditBill }) {
)}
- {row.payments && row.payments.length > 0 && (
- setEditPayment(row.payments[0])}
- >
-
- Payment
-
- )}
-
- setShowMbs(true)}
- >
-
- Month
-
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' });