'use strict'; // The legacy-reconcile migration list, extracted from db/database.js. Same // factory pattern as versionedMigrations: each check/run body closes over the // live db + schema helpers, injected here. Runs only for legacy databases whose // schema predates migration tracking (and for a freshly schema.sql'd DB, whose // schema_migrations starts empty). Consumed by reconcileLegacyMigrations(). module.exports = function buildLegacyReconcileMigrations(deps) { const { db, isValidColumnName, isValidSqlDefinition, ensureTransactionFoundationSchema, runSubscriptionCatalogMigration, runSubscriptionCatalogV2Migration, runAdvisoryFiltersMigration, runMerchantStoreMatchMigration, } = deps; return [ { version: 'v0.2', description: 'payments: soft-delete column', check: function() { const paymentCols = db.prepare('PRAGMA table_info(payments)').all().map(c => c.name); return paymentCols.includes('deleted_at'); }, run: function() { const paymentCols = db.prepare('PRAGMA table_info(payments)').all().map(c => c.name); if (!paymentCols.includes('deleted_at')) { db.exec('ALTER TABLE payments ADD COLUMN deleted_at TEXT'); // Index for fast filtering of live payments db.exec('CREATE INDEX IF NOT EXISTS idx_payments_deleted ON payments(deleted_at)'); console.log('[migration] payments.deleted_at column added'); } } }, { version: 'v0.3', description: 'payments: compound index for tracker query', check: function() { // Check if the index exists const indexes = db.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_payments_bill_date_del'").all(); return indexes.length > 0; }, run: function() { // Supports: WHERE bill_id = ? AND paid_date BETWEEN ? AND ? AND deleted_at IS NULL db.exec('CREATE INDEX IF NOT EXISTS idx_payments_bill_date_del ON payments(bill_id, paid_date, deleted_at)'); } }, { version: 'v0.4', description: 'monthly_bill_state: per-bill per-month overrides', check: function() { const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='monthly_bill_state'").all(); return tables.length > 0; }, run: function() { db.exec(` CREATE TABLE IF NOT EXISTS monthly_bill_state ( id INTEGER PRIMARY KEY AUTOINCREMENT, bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE, year INTEGER NOT NULL CHECK(year BETWEEN 2000 AND 2100), month INTEGER NOT NULL CHECK(month BETWEEN 1 AND 12), actual_amount REAL, notes TEXT, is_skipped INTEGER NOT NULL DEFAULT 0, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')), UNIQUE(bill_id, year, month) ) `); db.exec('CREATE INDEX IF NOT EXISTS idx_monthly_bill_state_lookup ON monthly_bill_state(bill_id, year, month)'); console.log('[migration] monthly_bill_state table ensured'); } }, { version: 'v0.13', description: 'users: profile columns', check: function() { const userColsNow = db.prepare('PRAGMA table_info(users)').all().map(c => c.name); const profileCols = ['display_name', 'last_password_change_at']; return profileCols.every(col => userColsNow.includes(col)); }, run: function() { const userColsNow = db.prepare('PRAGMA table_info(users)').all().map(c => c.name); const profileCols = [ ['display_name', 'TEXT'], ['last_password_change_at','TEXT'], ]; for (const [col, def] of profileCols) { if (!userColsNow.includes(col)) { // Security FIX (2026-05-08): Validate column name and definition to prevent SQL injection if (!isValidColumnName(col) || !isValidSqlDefinition(def)) { throw new Error(`Invalid migration: column '${col}' not in whitelist`); } db.exec(`ALTER TABLE users ADD COLUMN ${col} ${def}`); } } } }, { version: 'v0.14', description: 'bills: history visibility mode', check: function() { const billColsHist = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name); return billColsHist.includes('history_visibility'); }, run: function() { const billColsHist = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name); if (!billColsHist.includes('history_visibility')) { db.exec("ALTER TABLE bills ADD COLUMN history_visibility TEXT NOT NULL DEFAULT 'default'"); console.log('[migration] bills.history_visibility column added'); } } }, { version: 'v0.14.4', description: 'bills: optional credit-card APR / interest rate', check: function() { const billColsInterest = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name); return billColsInterest.includes('interest_rate'); }, run: function() { const billColsInterest = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name); if (!billColsInterest.includes('interest_rate')) { db.exec('ALTER TABLE bills ADD COLUMN interest_rate REAL'); console.log('[migration] bills.interest_rate column added'); } } }, { version: 'v0.15', description: 'import_sessions and import_history tables', check: function() { const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name IN ('import_sessions', 'import_history')").all(); return tables.length >= 2; }, run: function() { db.exec(` CREATE TABLE IF NOT EXISTS import_sessions ( id TEXT PRIMARY KEY, user_id INTEGER NOT NULL, created_at TEXT NOT NULL, expires_at TEXT NOT NULL, preview_json TEXT NOT NULL ) `); db.exec('CREATE INDEX IF NOT EXISTS idx_import_sessions_user ON import_sessions(user_id)'); db.exec('CREATE INDEX IF NOT EXISTS idx_import_sessions_expires ON import_sessions(expires_at)'); // ── import_history: per-user audit log (v0.38) ──────────────────────────── db.exec(` CREATE TABLE IF NOT EXISTS import_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, imported_at TEXT NOT NULL, source_filename TEXT, file_type TEXT DEFAULT 'xlsx', sheet_name TEXT, rows_parsed INTEGER DEFAULT 0, rows_created INTEGER DEFAULT 0, rows_updated INTEGER DEFAULT 0, rows_skipped INTEGER DEFAULT 0, rows_ambiguous INTEGER DEFAULT 0, rows_errored INTEGER DEFAULT 0, options_json TEXT, summary_json TEXT ) `); db.exec('CREATE INDEX IF NOT EXISTS idx_import_history_user ON import_history(user_id)'); } }, { version: 'v0.17', description: 'users: external identity / OIDC columns', check: function() { const userColsOidc = db.prepare('PRAGMA table_info(users)').all().map(c => c.name); const oidcUserCols = ['auth_provider', 'external_subject', 'email', 'last_login_at']; return oidcUserCols.every(col => userColsOidc.includes(col)); }, run: function() { const userColsOidc = db.prepare('PRAGMA table_info(users)').all().map(c => c.name); const oidcUserCols = [ ['auth_provider', "TEXT NOT NULL DEFAULT 'local'"], ['external_subject', 'TEXT'], ['email', 'TEXT'], ['last_login_at', 'TEXT'], ]; for (const [col, def] of oidcUserCols) { if (!userColsOidc.includes(col)) { // Security FIX (2026-05-08): Validate column name and definition to prevent SQL injection if (!isValidColumnName(col) || !isValidSqlDefinition(def)) { throw new Error(`Invalid migration: column '${col}' not in whitelist`); } db.exec(`ALTER TABLE users ADD COLUMN ${col} ${def}`); } } // ── oidc_states: short-lived PKCE + nonce state for OIDC login (v0.17) ─── db.exec(` CREATE TABLE IF NOT EXISTS oidc_states ( id TEXT PRIMARY KEY, nonce TEXT NOT NULL, code_verifier TEXT NOT NULL, redirect_to TEXT, created_at TEXT NOT NULL, expires_at TEXT NOT NULL ) `); db.exec('CREATE INDEX IF NOT EXISTS idx_oidc_states_expires ON oidc_states(expires_at)'); } }, { version: 'v0.18.1', description: 'monthly_income: per-user monthly income for Summary planning', check: function() { const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='monthly_income'").all(); return tables.length > 0; }, run: function() { db.exec(` CREATE TABLE IF NOT EXISTS monthly_income ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, year INTEGER NOT NULL CHECK(year BETWEEN 2000 AND 2100), month INTEGER NOT NULL CHECK(month BETWEEN 1 AND 12), label TEXT NOT NULL DEFAULT 'Salary', amount REAL NOT NULL DEFAULT 0, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')), UNIQUE(user_id, year, month) ) `); db.exec('CREATE INDEX IF NOT EXISTS idx_monthly_income_user_month ON monthly_income(user_id, year, month)'); } }, { version: 'v0.18.2', description: 'monthly_starting_amounts: per-user monthly starting amounts for 1st and 15th', check: function() { const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='monthly_starting_amounts'").all(); return tables.length > 0; }, run: function() { db.exec(` CREATE TABLE IF NOT EXISTS monthly_starting_amounts ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, year INTEGER NOT NULL CHECK(year BETWEEN 2000 AND 2100), month INTEGER NOT NULL CHECK(month BETWEEN 1 AND 12), first_amount REAL NOT NULL DEFAULT 0 CHECK(first_amount >= 0), fifteenth_amount REAL NOT NULL DEFAULT 0 CHECK(fifteenth_amount >= 0), other_amount REAL NOT NULL DEFAULT 0 CHECK(other_amount >= 0), notes TEXT, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')), UNIQUE(user_id, year, month) ) `); db.exec('CREATE INDEX IF NOT EXISTS idx_monthly_starting_amounts_user_month ON monthly_starting_amounts(user_id, year, month)'); } }, { version: 'v0.18.3', description: 'monthly_starting_amounts: add other_amount column', check: function() { const startingCols = db.prepare('PRAGMA table_info(monthly_starting_amounts)').all().map(c => c.name); return startingCols.includes('other_amount'); }, run: function() { const startingCols = db.prepare('PRAGMA table_info(monthly_starting_amounts)').all().map(c => c.name); if (!startingCols.includes('other_amount')) { // Security FIX (2026-05-08): Validate column name to prevent SQL injection if (!isValidColumnName('other_amount')) { throw new Error('Invalid migration: column other_amount not in whitelist'); } db.exec('ALTER TABLE monthly_starting_amounts ADD COLUMN other_amount REAL NOT NULL DEFAULT 0 CHECK(other_amount >= 0)'); console.log('[migration] monthly_starting_amounts.other_amount column added'); } } }, { version: 'v0.38', description: 'import_history: per-user audit log', check: function() { // Already handled in v0.15 return true; }, run: function() { // This was already handled in v0.15, but keeping for completeness } }, { version: 'v0.40', description: 'ownership: user-scoped bills/categories', check: function() { const billCols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name); const categoryCols = db.prepare('PRAGMA table_info(categories)').all().map(c => c.name); return billCols.includes('user_id') && categoryCols.includes('user_id'); }, run: function() { const billCols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name); if (!billCols.includes('user_id')) { db.exec('ALTER TABLE bills ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE CASCADE'); } const categoryCols = db.prepare('PRAGMA table_info(categories)').all().map(c => c.name); if (!categoryCols.includes('user_id')) { db.exec('ALTER TABLE categories ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE CASCADE'); } const categorySql = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='categories'").get()?.sql || ''; if (/name\s+TEXT\s+NOT\s+NULL\s+UNIQUE/i.test(categorySql)) { db.exec('PRAGMA foreign_keys = OFF'); db.exec(` CREATE TABLE IF NOT EXISTS categories_v040 ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, name TEXT NOT NULL, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')) ) `); db.exec('INSERT INTO categories_v040 (id, user_id, name, created_at, updated_at) SELECT id, user_id, name, created_at, updated_at FROM categories'); db.exec('DROP TABLE categories'); db.exec('ALTER TABLE categories_v040 RENAME TO categories'); db.exec('PRAGMA foreign_keys = ON'); } const firstAdmin = db.prepare("SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1").get(); if (firstAdmin) { db.prepare('UPDATE bills SET user_id = ? WHERE user_id IS NULL').run(firstAdmin.id); // Drop any NULL-owner categories whose name already exists for this admin (case-insensitive) // to prevent a UNIQUE(user_id, name) violation when we assign them below. db.prepare(` DELETE FROM categories WHERE user_id IS NULL AND LOWER(name) IN ( SELECT LOWER(name) FROM categories WHERE user_id = ? ) `).run(firstAdmin.id); db.prepare('UPDATE categories SET user_id = ? WHERE user_id IS NULL').run(firstAdmin.id); } db.exec('CREATE INDEX IF NOT EXISTS idx_bills_user_active ON bills(user_id, active)'); db.exec('CREATE INDEX IF NOT EXISTS idx_categories_user_name ON categories(user_id, name)'); db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_categories_user_name_unique ON categories(user_id, name COLLATE NOCASE)'); } }, { version: 'v0.41', description: 'bills and categories: is_seeded flag for demo data cleanup', check: function() { const billColsSeeded = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name); const categoryColsSeeded = db.prepare('PRAGMA table_info(categories)').all().map(c => c.name); return billColsSeeded.includes('is_seeded') && categoryColsSeeded.includes('is_seeded'); }, run: function() { // ── bills: is_seeded flag for demo data cleanup (v0.41) ─────────────────── const billColsSeeded = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name); if (!billColsSeeded.includes('is_seeded')) { db.exec('ALTER TABLE bills ADD COLUMN is_seeded INTEGER NOT NULL DEFAULT 0'); console.log('[migration] bills.is_seeded column added'); } // ── categories: is_seeded flag for demo data cleanup (v0.41) ────────────── const categoryColsSeeded = db.prepare('PRAGMA table_info(categories)').all().map(c => c.name); if (!categoryColsSeeded.includes('is_seeded')) { db.exec('ALTER TABLE categories ADD COLUMN is_seeded INTEGER NOT NULL DEFAULT 0'); console.log('[migration] categories.is_seeded column added'); } } }, { version: 'v0.42', description: 'bill_history_ranges: per-bill date ranges for history visibility', check: function() { const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='bill_history_ranges'").all(); return tables.length > 0; }, run: function() { db.exec(` CREATE TABLE IF NOT EXISTS bill_history_ranges ( id INTEGER PRIMARY KEY AUTOINCREMENT, bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE, start_year INTEGER NOT NULL, start_month INTEGER NOT NULL, end_year INTEGER, end_month INTEGER, label TEXT, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')) ) `); db.exec('CREATE INDEX IF NOT EXISTS idx_bill_history_ranges_bill ON bill_history_ranges(bill_id)'); } }, { version: 'v0.43', description: 'sessions: add created_at column', check: function() { const sessionCols = db.prepare('PRAGMA table_info(sessions)').all().map(c => c.name); return sessionCols.includes('created_at'); }, run: function() { const sessionCols = db.prepare('PRAGMA table_info(sessions)').all().map(c => c.name); if (!sessionCols.includes('created_at')) { // Security FIX (2026-05-09): Validate column name to prevent SQL injection if (!isValidColumnName('created_at')) { throw new Error('Invalid migration: column created_at not in whitelist'); } db.exec("ALTER TABLE sessions ADD COLUMN created_at TEXT DEFAULT (datetime('now'))"); 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'); } }, { version: 'v0.48', description: 'bills: debt snowball fields (current_balance, minimum_payment, snowball_order, snowball_include)', check: function() { const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name); return ['current_balance', 'minimum_payment', 'snowball_order', 'snowball_include'].every(c => cols.includes(c)); }, run: function() { const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name); if (!cols.includes('current_balance')) db.exec('ALTER TABLE bills ADD COLUMN current_balance REAL'); if (!cols.includes('minimum_payment')) db.exec('ALTER TABLE bills ADD COLUMN minimum_payment REAL'); if (!cols.includes('snowball_order')) db.exec('ALTER TABLE bills ADD COLUMN snowball_order INTEGER'); if (!cols.includes('snowball_include')) db.exec('ALTER TABLE bills ADD COLUMN snowball_include INTEGER NOT NULL DEFAULT 0'); console.log('[migration] bills: debt snowball columns added'); } }, { version: 'v0.49', description: 'users: snowball_extra_payment column', check: function() { const cols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name); return cols.includes('snowball_extra_payment'); }, run: function() { const cols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name); if (!cols.includes('snowball_extra_payment')) { db.exec('ALTER TABLE users ADD COLUMN snowball_extra_payment REAL NOT NULL DEFAULT 0'); } console.log('[migration] users: snowball_extra_payment column added'); } }, { version: 'v0.50', description: 'payments: balance_delta column for debt payoff tracking', check: function() { const cols = db.prepare('PRAGMA table_info(payments)').all().map(c => c.name); return cols.includes('balance_delta'); }, run: function() { const cols = db.prepare('PRAGMA table_info(payments)').all().map(c => c.name); if (!cols.includes('balance_delta')) { db.exec('ALTER TABLE payments ADD COLUMN balance_delta REAL'); } console.log('[migration] payments: balance_delta column added'); } }, { version: 'v0.51', description: 'bills: snowball_exempt column for hiding debt-like bills', check: function() { const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name); return cols.includes('snowball_exempt'); }, run: function() { const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name); if (!cols.includes('snowball_exempt')) { db.exec('ALTER TABLE bills ADD COLUMN snowball_exempt INTEGER NOT NULL DEFAULT 0'); } console.log('[migration] bills: snowball_exempt column added'); } }, { version: 'v0.52', description: 'users: last_seen_version for release-notes notifications', check: function() { const cols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name); return cols.includes('last_seen_version'); }, run: function() { const cols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name); if (!cols.includes('last_seen_version')) { db.exec('ALTER TABLE users ADD COLUMN last_seen_version TEXT'); } console.log('[migration] users: last_seen_version column added'); } }, { version: 'v0.53', description: 'user_login_history: track last 3 logins per user', check: function() { return !!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='user_login_history'").get(); }, run: function() { db.exec(` CREATE TABLE IF NOT EXISTS user_login_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, logged_in_at TEXT NOT NULL DEFAULT (datetime('now')), ip_address TEXT, user_agent TEXT, browser TEXT, os TEXT, device_type TEXT, device_fingerprint TEXT ) `); console.log('[migration] user_login_history table created'); } }, { version: 'v0.54', description: 'user_settings: per-user display and billing preferences', check: function() { return !!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='user_settings'").get(); }, run: function() { db.exec(` CREATE TABLE IF NOT EXISTS user_settings ( user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, key TEXT NOT NULL, value TEXT NOT NULL, updated_at TEXT DEFAULT (datetime('now')), PRIMARY KEY (user_id, key) ) `); const userSettingKeys = ['currency', 'date_format', 'grace_period_days', 'notify_days_before']; const users = db.prepare('SELECT id FROM users').all(); const getCurrent = db.prepare('SELECT value FROM settings WHERE key = ?'); const insert = db.prepare('INSERT OR IGNORE INTO user_settings (user_id, key, value) VALUES (?, ?, ?)'); for (const user of users) { for (const key of userSettingKeys) { const row = getCurrent.get(key); if (row) insert.run(user.id, key, row.value); } } console.log('[migration] user_settings table ensured'); } }, { version: 'v0.55', description: 'user_login_history: parsed device metadata and fingerprint', check: function() { const cols = db.prepare('PRAGMA table_info(user_login_history)').all().map(c => c.name); return ['browser', 'os', 'device_type', 'device_fingerprint'].every(c => cols.includes(c)); }, run: function() { const cols = db.prepare('PRAGMA table_info(user_login_history)').all().map(c => c.name); for (const col of ['browser', 'os', 'device_type', 'device_fingerprint']) { if (!cols.includes(col)) db.exec(`ALTER TABLE user_login_history ADD COLUMN ${col} TEXT`); } console.log('[migration] user_login_history device metadata columns ensured'); } }, { version: 'v0.56', description: 'bills/categories: soft-delete columns', check: function() { const billCols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name); const catCols = db.prepare('PRAGMA table_info(categories)').all().map(c => c.name); return billCols.includes('deleted_at') && catCols.includes('deleted_at'); }, run: function() { const billCols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name); const catCols = db.prepare('PRAGMA table_info(categories)').all().map(c => c.name); if (!billCols.includes('deleted_at')) { db.exec('ALTER TABLE bills ADD COLUMN deleted_at TEXT'); db.exec('CREATE INDEX IF NOT EXISTS idx_bills_deleted ON bills(user_id, deleted_at)'); } if (!catCols.includes('deleted_at')) { db.exec('ALTER TABLE categories ADD COLUMN deleted_at TEXT'); db.exec('CREATE INDEX IF NOT EXISTS idx_categories_deleted ON categories(user_id, deleted_at)'); } console.log('[migration] bills/categories deleted_at columns added'); } }, { version: 'v0.57', description: 'autopay: suggestions and auto-mark paid', check: function() { const billCols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name); const hasDismissals = !!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='autopay_suggestion_dismissals'").get(); return billCols.includes('auto_mark_paid') && hasDismissals; }, run: function() { const billCols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name); if (!billCols.includes('auto_mark_paid')) { db.exec('ALTER TABLE bills ADD COLUMN auto_mark_paid INTEGER NOT NULL DEFAULT 0'); } db.exec(` CREATE TABLE IF NOT EXISTS autopay_suggestion_dismissals ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE, year INTEGER NOT NULL CHECK(year BETWEEN 2000 AND 2100), month INTEGER NOT NULL CHECK(month BETWEEN 1 AND 12), dismissed_at TEXT NOT NULL DEFAULT (datetime('now')), UNIQUE(user_id, bill_id, year, month) ); CREATE INDEX IF NOT EXISTS idx_autopay_suggestion_dismissals_user_month ON autopay_suggestion_dismissals(user_id, year, month); `); console.log('[migration] autopay auto_mark_paid and suggestion dismissals ensured'); } }, { version: 'v0.58', description: 'bills: saved bill templates', check: function() { return !!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='bill_templates'").get(); }, run: function() { db.exec(` CREATE TABLE IF NOT EXISTS bill_templates ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, name TEXT NOT NULL, data TEXT NOT NULL, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')), UNIQUE(user_id, name) ); CREATE INDEX IF NOT EXISTS idx_bill_templates_user_name ON bill_templates(user_id, name); `); console.log('[migration] bill_templates table ensured'); } }, { version: 'v0.59', description: 'payments: source metadata for future transaction matching', check: function() { const cols = db.prepare('PRAGMA table_info(payments)').all().map(c => c.name); return cols.includes('payment_source') && cols.includes('transaction_id'); }, run: function() { const cols = db.prepare('PRAGMA table_info(payments)').all().map(c => c.name); if (!cols.includes('payment_source')) { db.exec("ALTER TABLE payments ADD COLUMN payment_source TEXT NOT NULL DEFAULT 'manual'"); } if (!cols.includes('transaction_id')) { db.exec('ALTER TABLE payments ADD COLUMN transaction_id INTEGER'); } console.log('[migration] payments: source metadata columns added'); } }, { version: 'v0.60', description: 'transactions: shared transaction foundation tables', check: function() { const tables = db.prepare(` SELECT name FROM sqlite_master WHERE type = 'table' AND name IN ('data_sources', 'financial_accounts', 'transactions') `).all(); return tables.length === 3; }, run: function() { ensureTransactionFoundationSchema(db); console.log('[migration] transaction foundation tables ensured'); } }, { version: 'v0.61', description: 'payments: one active payment per linked transaction', check: function() { return !!db.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_payments_transaction_active'").get(); }, run: function() { db.exec(` CREATE UNIQUE INDEX IF NOT EXISTS idx_payments_transaction_active ON payments(transaction_id) WHERE transaction_id IS NOT NULL AND deleted_at IS NULL `); console.log('[migration] payments: transaction active unique index ensured'); } }, { version: 'v0.62', description: 'matches: rejected transaction match suggestions', check: function() { return !!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='match_suggestion_rejections'").get(); }, run: function() { db.exec(` CREATE TABLE IF NOT EXISTS match_suggestion_rejections ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, transaction_id INTEGER NOT NULL REFERENCES transactions(id) ON DELETE CASCADE, bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE, rejected_at TEXT NOT NULL DEFAULT (datetime('now')), UNIQUE(user_id, transaction_id, bill_id) ); CREATE INDEX IF NOT EXISTS idx_match_suggestion_rejections_user ON match_suggestion_rejections(user_id, transaction_id, bill_id); `); console.log('[migration] match suggestion rejections table ensured'); } }, { version: 'v0.63', description: 'bills: subscription metadata fields', check: function() { const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name); return ['is_subscription', 'subscription_type', 'reminder_days_before', 'subscription_source', 'subscription_detected_at'] .every(col => cols.includes(col)); }, run: function() { const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name); if (!cols.includes('is_subscription')) db.exec('ALTER TABLE bills ADD COLUMN is_subscription INTEGER NOT NULL DEFAULT 0'); if (!cols.includes('subscription_type')) db.exec('ALTER TABLE bills ADD COLUMN subscription_type TEXT'); if (!cols.includes('reminder_days_before')) db.exec('ALTER TABLE bills ADD COLUMN reminder_days_before INTEGER NOT NULL DEFAULT 3'); if (!cols.includes('subscription_source')) db.exec("ALTER TABLE bills ADD COLUMN subscription_source TEXT NOT NULL DEFAULT 'manual'"); if (!cols.includes('subscription_detected_at')) db.exec('ALTER TABLE bills ADD COLUMN subscription_detected_at TEXT'); db.exec('CREATE INDEX IF NOT EXISTS idx_bills_user_subscription ON bills(user_id, is_subscription, active)'); console.log('[migration] bills: subscription metadata columns added'); } }, { version: 'v0.64', description: 'financial_accounts: monitored flag for bill matching', check: function() { const cols = db.prepare('PRAGMA table_info(financial_accounts)').all().map(c => c.name); return cols.includes('monitored'); }, run: function() { const cols = db.prepare('PRAGMA table_info(financial_accounts)').all().map(c => c.name); if (!cols.includes('monitored')) { db.exec('ALTER TABLE financial_accounts ADD COLUMN monitored INTEGER NOT NULL DEFAULT 1'); console.log('[migration] financial_accounts: monitored column added'); } } }, { version: 'v0.65', description: 'subscription_catalog: top-200 known subscription services', check: function() { return !!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='subscription_catalog'").get(); }, run: function() { runSubscriptionCatalogMigration(db); } }, { version: 'v0.66', description: 'declined_subscription_hints: per-user dismissed recommendation store', check: function() { return !!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='declined_subscription_hints'").get(); }, run: function() { db.exec(` CREATE TABLE IF NOT EXISTS declined_subscription_hints ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, decline_key TEXT NOT NULL, declined_at TEXT NOT NULL DEFAULT (datetime('now')), UNIQUE(user_id, decline_key) ); CREATE INDEX IF NOT EXISTS idx_declined_hints_user ON declined_subscription_hints(user_id); `); } }, { version: 'v0.67', description: 'bill_merchant_rules: persistent merchant→bill auto-match rules', check: function() { return !!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='bill_merchant_rules'").get(); }, run: function() { db.exec(` CREATE TABLE IF NOT EXISTS bill_merchant_rules ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE, merchant TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')), UNIQUE(user_id, bill_id, merchant) ); CREATE INDEX IF NOT EXISTS idx_bill_merchant_rules_user ON bill_merchant_rules(user_id); `); } } ]; };