'use strict'; // The versioned migration list, extracted from db/database.js to keep that // module manageable. A factory: each migration's run/check body closes over the // live `db` connection and a handful of schema helpers, which are injected here // so behavior is byte-identical to when the array lived inline. Consumed by // runMigrations() in db/database.js. module.exports = function buildVersionedMigrations(deps) { const { db, isValidColumnName, isValidSqlDefinition, ensureTransactionFoundationSchema, runSubscriptionCatalogMigration, runSubscriptionCatalogV2Migration, runAdvisoryFiltersMigration, runMerchantStoreMatchMigration, } = deps; return [ { version: 'v0.2', dependsOn: [], description: 'payments: soft-delete column', 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', dependsOn: ['v0.2'], description: 'payments: compound index for tracker query', 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', dependsOn: ['v0.3'], description: 'monthly_bill_state: per-bill per-month overrides', 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', dependsOn: ['v0.4'], description: 'users: profile columns', 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', dependsOn: ['v0.13'], description: 'bills: history visibility mode', 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', dependsOn: ['v0.14'], description: 'bills: optional credit-card APR / 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', dependsOn: ['v0.14.4'], description: 'import_sessions and import_history tables', 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', dependsOn: ['v0.15'], description: 'users: external identity / OIDC columns', 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', dependsOn: ['v0.17'], description: 'monthly_income: per-user monthly income for Summary planning', 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', dependsOn: ['v0.18.1'], description: 'monthly_starting_amounts: per-user monthly starting amounts for 1st and 15th', 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', dependsOn: ['v0.18.2'], description: 'monthly_starting_amounts: add other_amount column', 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', dependsOn: ['v0.18.3'], description: 'import_history: per-user audit log', run: function() { // This was already handled in v0.15, but keeping for completeness } }, { version: 'v0.40', dependsOn: ['v0.38'], description: 'ownership: user-scoped bills/categories', 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', dependsOn: ['v0.40'], description: 'bills and categories: is_seeded flag for demo data cleanup', 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', dependsOn: ['v0.41'], description: 'bill_history_ranges: per-bill date ranges for history visibility', 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', dependsOn: ['v0.42'], description: 'sessions: add created_at column', 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', dependsOn: ['v0.43'], description: 'performance: add missing indexes for frequently queried columns', 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)'); console.log('[migration] Added indexes for frequently queried columns'); } }, { version: 'v0.45', dependsOn: ['v0.44'], description: 'audit: add audit_log table for security event tracking', 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')) ); CREATE INDEX IF NOT EXISTS idx_audit_log_user ON audit_log(user_id, created_at); 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', dependsOn: ['v0.45'], 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', 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'); } }, { version: 'v0.48', description: 'bills: debt snowball fields (current_balance, minimum_payment, snowball_order, snowball_include)', dependsOn: ['v0.47'], 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', dependsOn: ['v0.48'], 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', dependsOn: ['v0.49'], 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', dependsOn: ['v0.50'], 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', dependsOn: ['v0.51'], 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', dependsOn: ['v0.52'], 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 ) `); console.log('[migration] user_login_history table created'); } }, { version: 'v0.54', description: 'user_settings: per-user display and billing preferences', dependsOn: ['v0.53'], 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 created and seeded from current global defaults'); } }, { version: 'v0.55', description: 'user_login_history: parsed device metadata and fingerprint', dependsOn: ['v0.54'], 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 ) `); const cols = db.prepare('PRAGMA table_info(user_login_history)').all().map(c => c.name); const newCols = [ ['browser', 'TEXT'], ['os', 'TEXT'], ['device_type', 'TEXT'], ['device_fingerprint', 'TEXT'], ]; for (const [col, def] of newCols) { if (!cols.includes(col)) { db.exec(`ALTER TABLE user_login_history ADD COLUMN ${col} ${def}`); } } console.log('[migration] user_login_history device metadata columns ensured'); } }, { version: 'v0.56', description: 'bills/categories: soft-delete columns', dependsOn: ['v0.55'], 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', dependsOn: ['v0.56'], 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', dependsOn: ['v0.57'], 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', dependsOn: ['v0.58'], 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', dependsOn: ['v0.59'], run: function() { ensureTransactionFoundationSchema(db); console.log('[migration] transaction foundation tables ensured'); } }, { version: 'v0.61', description: 'payments: one active payment per linked transaction', dependsOn: ['v0.60'], 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', dependsOn: ['v0.61'], 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', dependsOn: ['v0.62'], 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', dependsOn: ['v0.63'], 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', dependsOn: ['v0.64'], run: function() { runSubscriptionCatalogMigration(db); } }, { version: 'v0.66', description: 'declined_subscription_hints: per-user dismissed recommendation store', dependsOn: ['v0.65'], 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', dependsOn: ['v0.66'], 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); `); } }, { version: 'v0.68', description: 'advisory_non_bill_filters: 5k advisory patterns + bill-like override terms', dependsOn: ['v0.67'], run: function() { runAdvisoryFiltersMigration(db); } }, { version: 'v0.69', description: 'subscription_catalog v2: 90 new services + category fixes', dependsOn: ['v0.68'], run: function() { runSubscriptionCatalogV2Migration(db); } }, { version: 'v0.70', description: 'monthly_bill_state: add snoozed_until for overdue command center', dependsOn: ['v0.69'], run: function() { const cols = db.prepare('PRAGMA table_info(monthly_bill_state)').all().map(c => c.name); if (!cols.includes('snoozed_until')) db.exec('ALTER TABLE monthly_bill_state ADD COLUMN snoozed_until TEXT'); } }, { version: 'v0.71', description: 'bills: add drift_snoozed_until; users: add notify_amount_change', dependsOn: ['v0.70'], run: function() { const billCols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name); const userCols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name); if (!billCols.includes('drift_snoozed_until')) db.exec('ALTER TABLE bills ADD COLUMN drift_snoozed_until TEXT'); if (!userCols.includes('notify_amount_change')) db.exec('ALTER TABLE users ADD COLUMN notify_amount_change INTEGER NOT NULL DEFAULT 1'); } }, { version: 'v0.72', description: 'bills: persistent tracker sort order', dependsOn: ['v0.71'], run: function() { const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name); if (!cols.includes('sort_order')) db.exec('ALTER TABLE bills ADD COLUMN sort_order INTEGER'); db.exec('CREATE INDEX IF NOT EXISTS idx_bills_user_sort ON bills(user_id, active, sort_order, due_day)'); } }, { version: 'v0.73', description: 'add snowball_plans table for plan lifecycle + history', dependsOn: ['v0.72'], run: function() { db.exec(` CREATE TABLE IF NOT EXISTS snowball_plans ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, name TEXT NOT NULL DEFAULT 'Snowball Plan', method TEXT NOT NULL DEFAULT 'snowball', status TEXT NOT NULL DEFAULT 'active', started_at TEXT NOT NULL DEFAULT (datetime('now')), paused_at TEXT, completed_at TEXT, extra_payment REAL NOT NULL DEFAULT 0, plan_snapshot TEXT NOT NULL, notes TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ) `); db.exec('CREATE INDEX IF NOT EXISTS idx_snowball_plans_user ON snowball_plans(user_id, status, created_at)'); } }, { version: 'v0.74', description: 'subscription_catalog: Claude.ai Anthropic matching', dependsOn: ['v0.73'], run: function() { db.prepare(` UPDATE subscription_catalog SET name = 'Claude.ai', category = 'AI', subscription_type = 'software', website = 'https://claude.ai/upgrade', domain = 'anthropic.com' WHERE name IN ('Claude', 'Claude.ai') OR domain IN ('claude.ai', 'anthropic.com') `).run(); db.prepare(` INSERT INTO subscription_catalog (rank, name, category, subscription_type, website, domain) SELECT 94, 'Claude.ai', 'AI', 'software', 'https://claude.ai/upgrade', 'anthropic.com' WHERE NOT EXISTS ( SELECT 1 FROM subscription_catalog WHERE name = 'Claude.ai' OR domain IN ('claude.ai', 'anthropic.com') ) `).run(); } }, { version: 'v0.75', description: 'categories: persistent sort order', dependsOn: ['v0.74'], run: function() { const cols = db.prepare('PRAGMA table_info(categories)').all().map(c => c.name); if (!cols.includes('sort_order')) db.exec('ALTER TABLE categories ADD COLUMN sort_order INTEGER'); db.exec('CREATE INDEX IF NOT EXISTS idx_categories_user_sort ON categories(user_id, sort_order, name)'); } }, { version: 'v0.76', description: 'bills: canonical billing schedule cleanup', dependsOn: ['v0.75'], run: function() { const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name); if (!cols.includes('cycle_type') || !cols.includes('cycle_day') || !cols.includes('billing_cycle')) return; db.exec(` UPDATE bills SET cycle_type = CASE WHEN LOWER(COALESCE(cycle_type, '')) IN ('', 'monthly') AND LOWER(COALESCE(billing_cycle, '')) = 'quarterly' THEN 'quarterly' WHEN LOWER(COALESCE(cycle_type, '')) IN ('', 'monthly') AND LOWER(COALESCE(billing_cycle, '')) IN ('annually', 'annual') THEN 'annual' WHEN LOWER(COALESCE(cycle_type, '')) IN ('monthly', 'weekly', 'biweekly', 'quarterly', 'annual') THEN LOWER(cycle_type) ELSE 'monthly' END; UPDATE bills SET cycle_day = CASE WHEN cycle_type IN ('weekly', 'biweekly') AND LOWER(COALESCE(cycle_day, '')) IN ('monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday') THEN LOWER(cycle_day) WHEN cycle_type IN ('weekly', 'biweekly') THEN 'monday' WHEN cycle_type IN ('quarterly', 'annual') AND CAST(cycle_day AS INTEGER) BETWEEN 1 AND 12 THEN CAST(CAST(cycle_day AS INTEGER) AS TEXT) WHEN cycle_type IN ('quarterly', 'annual') THEN '1' WHEN cycle_type = 'monthly' AND CAST(cycle_day AS INTEGER) BETWEEN 1 AND 31 THEN CAST(CAST(cycle_day AS INTEGER) AS TEXT) ELSE CAST(CASE WHEN due_day BETWEEN 1 AND 31 THEN due_day ELSE 1 END AS TEXT) END; UPDATE bills SET billing_cycle = CASE WHEN cycle_type = 'quarterly' THEN 'quarterly' WHEN cycle_type = 'annual' THEN 'annually' WHEN cycle_type IN ('weekly', 'biweekly') THEN 'irregular' ELSE 'monthly' END; `); } }, { version: 'v0.77', description: 'encrypt SMTP password at rest', dependsOn: ['v0.76'], run: function() { try { const { decryptSecret, encryptSecret } = require('../../services/encryptionService'); const row = db.prepare("SELECT value FROM settings WHERE key = 'notify_smtp_password'").get(); if (row?.value) { try { decryptSecret(row.value); // already encrypted — skip } catch { // plaintext — encrypt it db.prepare("UPDATE settings SET value = ?, updated_at = datetime('now') WHERE key = 'notify_smtp_password'") .run(encryptSecret(row.value)); } } } catch (err) { console.warn('[v0.77] SMTP password encryption migration failed:', err.message); } } }, { version: 'v0.78', description: 're-encrypt secrets from SHA-256 to HKDF key derivation', dependsOn: ['v0.77'], run: function() { try { const { decryptSecret, encryptSecret } = require('../../services/encryptionService'); // Re-encrypt SimpleFIN tokens in data_sources const sources = db.prepare( "SELECT id, encrypted_secret FROM data_sources WHERE encrypted_secret IS NOT NULL AND encrypted_secret NOT LIKE 'v2:%'" ).all(); const updateSource = db.prepare('UPDATE data_sources SET encrypted_secret = ? WHERE id = ?'); for (const row of sources) { try { updateSource.run(encryptSecret(decryptSecret(row.encrypted_secret)), row.id); } catch (err) { console.warn(`[v0.78] Could not re-encrypt data_source id=${row.id}:`, err.message); } } // Re-encrypt SMTP password const smtp = db.prepare("SELECT value FROM settings WHERE key = 'notify_smtp_password'").get(); if (smtp?.value && !smtp.value.startsWith('v2:')) { try { db.prepare("UPDATE settings SET value = ?, updated_at = datetime('now') WHERE key = 'notify_smtp_password'") .run(encryptSecret(decryptSecret(smtp.value))); } catch (err) { console.warn('[v0.78] Could not re-encrypt SMTP password:', err.message); } } } catch (err) { console.warn('[v0.78] HKDF re-encryption migration failed:', err.message); } } }, { version: 'v0.79', description: 'encrypt OIDC client secret at rest', dependsOn: ['v0.78'], run: function() { try { const { decryptSecret, encryptSecret } = require('../../services/encryptionService'); const row = db.prepare("SELECT value FROM settings WHERE key = 'oidc_client_secret'").get(); if (row?.value) { try { decryptSecret(row.value); // already encrypted — skip } catch { // plaintext — encrypt it db.prepare("UPDATE settings SET value = ?, updated_at = datetime('now') WHERE key = 'oidc_client_secret'") .run(encryptSecret(row.value)); } } } catch (err) { console.warn('[v0.79] OIDC client secret encryption migration failed:', err.message); } } }, { version: 'v0.80', description: 'users: push notification columns (ntfy / Gotify / Discord / Telegram)', dependsOn: ['v0.79'], run: function() { const cols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name); const add = (col, def) => { if (!cols.includes(col)) db.exec(`ALTER TABLE users ADD COLUMN ${col} ${def}`); }; add('notify_push_enabled', 'INTEGER NOT NULL DEFAULT 0'); add('push_channel', 'TEXT'); add('push_url', 'TEXT'); add('push_token', 'TEXT'); add('push_chat_id', 'TEXT'); console.log('[v0.80] push notification columns added'); } }, { version: 'v0.81', description: 'bill_merchant_rules: composite index on (user_id, bill_id) for faster EXISTS lookups', dependsOn: ['v0.80'], run: function() { db.exec(` CREATE INDEX IF NOT EXISTS idx_bill_merchant_rules_user_bill ON bill_merchant_rules(user_id, bill_id) `); console.log('[v0.81] bill_merchant_rules composite index added'); } }, { version: 'v0.82', description: 'payments: normalise auto_match source to provider_sync', dependsOn: ['v0.81'], run: function() { const result = db.prepare( "UPDATE payments SET payment_source = 'provider_sync' WHERE payment_source = 'auto_match'" ).run(); console.log(`[v0.82] Normalised ${result.changes} auto_match payment(s) to provider_sync`); } }, { version: 'v0.83', description: 'bill_merchant_rules: auto_attribute_late flag for bills that always post after month end', dependsOn: ['v0.82'], run: function() { const cols = db.prepare('PRAGMA table_info(bill_merchant_rules)').all().map(c => c.name); if (!cols.includes('auto_attribute_late')) { db.exec('ALTER TABLE bill_merchant_rules ADD COLUMN auto_attribute_late INTEGER NOT NULL DEFAULT 0'); console.log('[v0.83] bill_merchant_rules.auto_attribute_late added'); } } }, { version: 'v0.84', description: 'user_login_history: encrypt ip/useragent at rest + add location + keep 10 records', dependsOn: ['v0.83'], run: function() { const { encryptSecret, decryptSecret } = require('../../services/encryptionService'); const cols = db.prepare('PRAGMA table_info(user_login_history)').all().map(c => c.name); // Add location columns const newCols = ['location_city', 'location_country', 'location_region', 'location_isp']; for (const col of newCols) { if (!cols.includes(col)) db.exec(`ALTER TABLE user_login_history ADD COLUMN ${col} TEXT`); } // Encrypt existing plaintext ip_address and user_agent rows const rows = db.prepare('SELECT id, ip_address, user_agent FROM user_login_history').all(); const updIp = db.prepare("UPDATE user_login_history SET ip_address=? WHERE id=?"); const updUa = db.prepare("UPDATE user_login_history SET user_agent=? WHERE id=?"); for (const row of rows) { if (row.ip_address && !row.ip_address.startsWith('v2:')) { try { updIp.run(encryptSecret(row.ip_address), row.id); } catch {} } if (row.user_agent && !row.user_agent.startsWith('v2:')) { try { updUa.run(encryptSecret(row.user_agent), row.id); } catch {} } } console.log(`[v0.84] login history: location columns added, ${rows.length} rows encrypted`); } }, { version: 'v0.85', description: 'user_login_history: failed attempt tracking + session fingerprint for current-session detection', dependsOn: ['v0.84'], run: function() { const cols = db.prepare('PRAGMA table_info(user_login_history)').all().map(c => c.name); if (!cols.includes('success')) { db.exec('ALTER TABLE user_login_history ADD COLUMN success INTEGER NOT NULL DEFAULT 1'); } if (!cols.includes('session_fingerprint')) { db.exec('ALTER TABLE user_login_history ADD COLUMN session_fingerprint TEXT'); } console.log('[v0.85] user_login_history: success + session_fingerprint columns added'); } }, { version: 'v0.86', description: 'users: TOTP/authenticator 2FA columns + totp_challenges table', dependsOn: ['v0.85'], run: function() { const cols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name); if (!cols.includes('totp_enabled')) db.exec('ALTER TABLE users ADD COLUMN totp_enabled INTEGER NOT NULL DEFAULT 0'); if (!cols.includes('totp_secret')) db.exec('ALTER TABLE users ADD COLUMN totp_secret TEXT'); if (!cols.includes('totp_recovery_codes')) db.exec('ALTER TABLE users ADD COLUMN totp_recovery_codes TEXT'); db.exec(` CREATE TABLE IF NOT EXISTS totp_challenges ( id TEXT PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, expires_at TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')) ) `); console.log('[v0.86] users: TOTP columns + totp_challenges table'); } }, { version: 'v0.87', description: 'spending: category assignment on transactions + rules + budgets + default categories', dependsOn: ['v0.86'], run: function() { // spending_category_id on transactions const txCols = db.prepare('PRAGMA table_info(transactions)').all().map(c => c.name); if (!txCols.includes('spending_category_id')) db.exec('ALTER TABLE transactions ADD COLUMN spending_category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL'); // spending category rules (merchant → category) db.exec(` CREATE TABLE IF NOT EXISTS spending_category_rules ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, category_id INTEGER NOT NULL REFERENCES categories(id) ON DELETE CASCADE, merchant TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')), UNIQUE(user_id, merchant) ) `); // monthly spending budgets db.exec(` CREATE TABLE IF NOT EXISTS spending_budgets ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, category_id INTEGER NOT NULL REFERENCES categories(id) ON DELETE CASCADE, year INTEGER NOT NULL, month INTEGER NOT NULL, amount REAL NOT NULL DEFAULT 0, updated_at TEXT NOT NULL DEFAULT (datetime('now')), UNIQUE(user_id, category_id, year, month) ) `); // Seed default spending categories for each user that has none yet const DEFAULTS = ['Groceries','Dining','Fuel & Transport','Shopping','Entertainment','Health','Travel','Other']; const users = db.prepare("SELECT id FROM users WHERE role='user' AND active=1").all(); const insert = db.prepare("INSERT OR IGNORE INTO categories (user_id, name, sort_order, is_seeded) VALUES (?, ?, ?, 1)"); for (const user of users) { const existing = db.prepare("SELECT COUNT(*) AS n FROM categories WHERE user_id=? AND deleted_at IS NULL").get(user.id); if ((existing?.n ?? 0) === 0) { DEFAULTS.forEach((name, i) => insert.run(user.id, name, 100 + i)); } } console.log('[v0.87] spending: transactions.spending_category_id, spending_category_rules, spending_budgets'); } }, { version: 'v0.88', description: 'categories: spending_enabled flag to separate bill vs spending categories', dependsOn: ['v0.87'], run: function() { const cols = db.prepare('PRAGMA table_info(categories)').all().map(c => c.name); if (!cols.includes('spending_enabled')) db.exec('ALTER TABLE categories ADD COLUMN spending_enabled INTEGER NOT NULL DEFAULT 0'); // Mark the v0.87-seeded defaults as spending-enabled const SPENDING_DEFAULTS = ['Groceries','Dining','Fuel & Transport','Shopping','Entertainment','Health','Travel','Other']; const placeholder = SPENDING_DEFAULTS.map(() => '?').join(','); db.exec(`UPDATE categories SET spending_enabled=1 WHERE is_seeded=1 AND name IN (${SPENDING_DEFAULTS.map(n => `'${n.replace("'", "''")}'`).join(',')})`); // Mark any category already linked to a spending rule as spending-enabled try { db.exec(` UPDATE categories SET spending_enabled=1 WHERE id IN (SELECT DISTINCT category_id FROM spending_category_rules) `); } catch { /* spending_category_rules may not exist on legacy paths */ } console.log('[v0.88] categories.spending_enabled added, seeded defaults marked'); } }, { version: 'v0.89', description: 'categories: seed spending defaults for users who had existing categories before v0.87', dependsOn: ['v0.88'], run: function() { const SPENDING_DEFAULTS = [ 'Groceries','Dining','Fuel & Transport','Shopping', 'Entertainment','Health','Travel','Other' ]; const users = db.prepare("SELECT id FROM users WHERE role='user' AND active=1").all(); const insert = db.prepare(` INSERT OR IGNORE INTO categories (user_id, name, sort_order, is_seeded, spending_enabled) VALUES (?, ?, ?, 1, 1) `); let seeded = 0; for (const user of users) { const hasSpending = db.prepare('SELECT 1 FROM categories WHERE user_id=? AND spending_enabled=1 AND deleted_at IS NULL LIMIT 1').get(user.id); if (!hasSpending) { SPENDING_DEFAULTS.forEach((name, i) => { insert.run(user.id, name, 200 + i); seeded++; }); } } console.log(`[v0.89] spending defaults seeded for users missing them (${seeded} categories inserted)`); } }, { version: 'v0.90', description: 're-normalize merchant rules after & fix; ensure rejection expiry column', dependsOn: ['v0.89'], run: function() { const { normalizeMerchant } = require('../../services/subscriptionService'); // Re-normalize bill_merchant_rules stored under old normalization ("at t" → "att") let billFixed = 0; try { const rules = db.prepare('SELECT id, merchant FROM bill_merchant_rules').all(); const updBill = db.prepare('UPDATE bill_merchant_rules SET merchant=? WHERE id=?'); for (const r of rules) { try { const fixed = normalizeMerchant(r.merchant); if (fixed && fixed !== r.merchant) { updBill.run(fixed, r.id); billFixed++; } } catch { /* skip invalid entries */ } } } catch (err) { console.warn('[v0.90] bill_merchant_rules re-normalize skipped:', err.message); } // Re-normalize spending_category_rules try { const srules = db.prepare('SELECT id, merchant FROM spending_category_rules').all(); const updSpend = db.prepare('UPDATE spending_category_rules SET merchant=? WHERE id=?'); let spendFixed = 0; for (const r of srules) { const fixed = normalizeMerchant(r.merchant); if (fixed !== r.merchant) { updSpend.run(fixed, r.id); spendFixed++; } } if (spendFixed) console.log(`[v0.90] spending_category_rules: ${spendFixed} re-normalized`); } catch { /* spending_category_rules may not exist on legacy DBs */ } // Ensure match_suggestion_rejections has created_at for expiry queries const rejCols = db.prepare('PRAGMA table_info(match_suggestion_rejections)').all().map(c => c.name); if (!rejCols.includes('created_at')) { // Static default — existing rejections get a past date so they expire immediately on next cleanup db.exec("ALTER TABLE match_suggestion_rejections ADD COLUMN created_at TEXT NOT NULL DEFAULT '2000-01-01'"); } console.log(`[v0.90] merchant rules re-normalized (${billFixed} bill rules updated), rejection expiry column ensured`); } }, { version: 'v0.91', description: 'performance: composite indexes on user_id+deleted_at for categories, bills, payments', dependsOn: ['v0.90'], run: function() { db.exec(` CREATE INDEX IF NOT EXISTS idx_categories_user_deleted ON categories(user_id, deleted_at); CREATE INDEX IF NOT EXISTS idx_bills_user_deleted ON bills(user_id, deleted_at); CREATE INDEX IF NOT EXISTS idx_bills_user_active ON bills(user_id, active, deleted_at); CREATE INDEX IF NOT EXISTS idx_payments_bill_deleted ON payments(bill_id, deleted_at); `); console.log('[v0.91] composite indexes created on categories, bills, payments'); } }, { version: 'v0.92', description: 'auth: WebAuthn/FIDO2 security key support — webauthn_credentials + webauthn_challenges tables', dependsOn: ['v0.91'], run: function() { const cols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name); if (!cols.includes('webauthn_enabled')) db.exec('ALTER TABLE users ADD COLUMN webauthn_enabled INTEGER NOT NULL DEFAULT 0'); if (!cols.includes('webauthn_user_id')) db.exec('ALTER TABLE users ADD COLUMN webauthn_user_id TEXT'); db.exec(` CREATE TABLE IF NOT EXISTS webauthn_credentials ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, credential_id TEXT NOT NULL UNIQUE, public_key TEXT NOT NULL, sign_count INTEGER NOT NULL DEFAULT 0, transports TEXT, backup_eligible INTEGER NOT NULL DEFAULT 0, backup_state INTEGER NOT NULL DEFAULT 0, credential_name TEXT NOT NULL DEFAULT 'Security Key', aaguid TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_webauthn_creds_user ON webauthn_credentials(user_id); CREATE TABLE IF NOT EXISTS webauthn_challenges ( id TEXT PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, challenge_type TEXT NOT NULL CHECK(challenge_type IN ('registration','authentication','login')), challenge TEXT NOT NULL DEFAULT '', expires_at TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_user ON webauthn_challenges(user_id); CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_expires ON webauthn_challenges(expires_at); `); console.log('[v0.92] WebAuthn tables + users columns added'); } }, { version: 'v0.93', description: 'bills: interest_accrued_month; payments: interest_delta; transactions: stable provider key + dedupe index', dependsOn: ['v0.92'], run: function() { // 1. Track the calendar month when interest was last applied to a debt bill // so computeBalanceDelta can skip interest if it was already charged this month. const billCols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name); if (!billCols.includes('interest_accrued_month')) { db.exec('ALTER TABLE bills ADD COLUMN interest_accrued_month TEXT'); console.log('[v0.93] bills.interest_accrued_month column added'); } // 2. Track the interest component of each payment separately so delete/restore // can handle it without double-charging interest. const paymentCols = db.prepare('PRAGMA table_info(payments)').all().map(c => c.name); if (!paymentCols.includes('interest_delta')) { db.exec('ALTER TABLE payments ADD COLUMN interest_delta REAL'); console.log('[v0.93] payments.interest_delta column added'); } // 3. Strip the data_source_id from existing provider_transaction_id keys so // they survive disconnect/reconnect. Old: "simplefin:{dsId}:{acctId}:{txId}" // New: "simplefin:{acctId}:{txId}". // Only rows where the segment after "simplefin:" is a numeric id are migrated. db.exec(` UPDATE transactions SET provider_transaction_id = 'simplefin:' || SUBSTR( provider_transaction_id, INSTR(SUBSTR(provider_transaction_id, 11), ':') + 11 ) WHERE provider_transaction_id LIKE 'simplefin:%' AND CAST( SUBSTR(provider_transaction_id, 11, INSTR(SUBSTR(provider_transaction_id, 11), ':') - 1) AS INTEGER) > 0 `); console.log('[v0.93] transactions: stripped data_source_id from provider_transaction_id'); // 4. Dedup: after the key change, users who disconnected and reconnected now // have duplicate (user_id, provider_transaction_id) pairs. Keep the best row // (prefer linked rows; break ties by most-recent created_at). db.exec(` DELETE FROM transactions WHERE id IN ( SELECT id FROM ( SELECT id, ROW_NUMBER() OVER ( PARTITION BY user_id, provider_transaction_id ORDER BY (data_source_id IS NULL) ASC, created_at DESC ) AS rn FROM transactions WHERE provider_transaction_id IS NOT NULL ) WHERE rn > 1 ) `); console.log('[v0.93] transactions: removed duplicate provider keys from disconnect/reconnect'); // 5. Replace the old dedupe index (data_source_id, provider_transaction_id) // with a user-scoped one (user_id, provider_transaction_id) so reconnect // with a new data_source_id still deduplicates correctly. db.exec(` DROP INDEX IF EXISTS idx_transactions_provider_dedupe; CREATE UNIQUE INDEX idx_transactions_provider_dedupe ON transactions (user_id, provider_transaction_id) WHERE provider_transaction_id IS NOT NULL; `); console.log('[v0.93] transactions: dedupe index changed to (user_id, provider_transaction_id)'); } }, { version: 'v0.94', description: 'security: session token hashing + geolocation opt-in setting', run() { // Seed the geolocation setting (default off) for existing installations db.prepare("INSERT OR IGNORE INTO settings (key, value) VALUES ('geolocation_enabled', 'false')").run(); console.log('[v0.94] geolocation_enabled setting seeded'); // All existing plaintext session IDs are invalidated so everyone re-authenticates. // Going forward, sessions.id stores SHA-256(token); the raw token stays in the cookie. const count = db.prepare('SELECT COUNT(*) as n FROM sessions').get().n; db.exec('DELETE FROM sessions'); console.log(`[v0.94] sessions: cleared ${count} existing plaintext sessions (re-login required)`); } }, { version: 'v0.95', description: 'subscription_catalog: bank descriptors + pricing from 2026 researched dataset', run() { // 1. Add new columns to subscription_catalog const cols = db.prepare('PRAGMA table_info(subscription_catalog)').all().map(c => c.name); if (!cols.includes('subcategory')) db.exec('ALTER TABLE subscription_catalog ADD COLUMN subcategory TEXT'); if (!cols.includes('starting_monthly_usd')) db.exec('ALTER TABLE subscription_catalog ADD COLUMN starting_monthly_usd REAL'); if (!cols.includes('starting_annual_usd')) db.exec('ALTER TABLE subscription_catalog ADD COLUMN starting_annual_usd REAL'); if (!cols.includes('price_notes')) db.exec('ALTER TABLE subscription_catalog ADD COLUMN price_notes TEXT'); // 2. Create descriptors table (bank statement strings + slang/nicknames per service) db.exec(` CREATE TABLE IF NOT EXISTS subscription_catalog_descriptors ( id INTEGER PRIMARY KEY AUTOINCREMENT, catalog_id INTEGER NOT NULL REFERENCES subscription_catalog(id) ON DELETE CASCADE, descriptor TEXT NOT NULL, descriptor_type TEXT NOT NULL CHECK(descriptor_type IN ('bank', 'slang')) ); CREATE INDEX IF NOT EXISTS idx_scd_catalog_id ON subscription_catalog_descriptors(catalog_id); `); // 3. Load researched JSON — path is relative to this file's directory (db/) const path = require('path'); // eslint-disable-next-line global-require const { subscriptions } = require(path.join(__dirname, '../../docs/top_200_us_subscriptions_researched_2026-06-06.json')); // Map rich category labels from the JSON to our internal subscription_type values const CATEGORY_TYPE = { 'Video Streaming': 'streaming', 'Sports Streaming': 'streaming', 'Live TV Streaming': 'streaming', 'Sports Media': 'streaming', 'Music & Audio': 'music', 'Podcasts': 'music', 'Gaming': 'gaming', 'News & Magazines': 'news', 'Fitness & Wellness': 'fitness', 'Meditation & Wellness': 'fitness', 'Sleep & Wellness': 'fitness', 'Software & Productivity': 'software', 'Software & Design': 'software', 'Developer Tools': 'software', 'Finance Software': 'software', 'AI': 'software', 'Writing & AI': 'software', 'Cloud & Storage': 'cloud', 'Security': 'security', 'Food & Meal Kits': 'food', 'Prepared Meals': 'food', 'Food Delivery': 'food', 'Food & Rides': 'food', 'Coffee & Tea': 'food', 'Snacks': 'food', 'Grocery & Delivery': 'shopping', 'Shopping & Delivery': 'shopping', 'Retail Memberships': 'shopping', 'Warehouse Clubs': 'shopping', 'Pet Retail': 'shopping', 'Education': 'education', 'Audiobooks': 'education', 'Audiobooks & Ebooks': 'education', 'Ebooks & Audiobooks': 'education', 'Ebooks': 'education', 'Documents & Ebooks': 'education', 'Books & Learning': 'education', 'Books & Subscription Boxes': 'education', 'Creator & Social': 'other', 'Creator Media': 'other', 'Dating': 'other', 'Career & Social': 'other', }; const getByName = db.prepare('SELECT id FROM subscription_catalog WHERE name = ? LIMIT 1'); const updateCatalog = db.prepare(` UPDATE subscription_catalog SET rank = ?, category = ?, subcategory = ?, subscription_type = ?, website = ?, domain = ?, starting_monthly_usd = ?, starting_annual_usd = ?, price_notes = ? WHERE id = ? `); const insertCatalog = db.prepare(` INSERT INTO subscription_catalog (rank, name, category, subcategory, subscription_type, website, domain, starting_monthly_usd, starting_annual_usd, price_notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); const clearDescs = db.prepare('DELETE FROM subscription_catalog_descriptors WHERE catalog_id = ?'); const insertDesc = db.prepare('INSERT INTO subscription_catalog_descriptors (catalog_id, descriptor, descriptor_type) VALUES (?, ?, ?)'); let nUpdated = 0, nInserted = 0, nDescs = 0; db.transaction(() => { for (const sub of subscriptions) { const subType = CATEGORY_TYPE[sub.category] || 'other'; let domain = null; try { domain = new URL(sub.website || '').hostname.replace(/^www\./, ''); } catch {} const existing = getByName.get(sub.service); let catalogId; if (existing) { updateCatalog.run( sub.rank, sub.category, sub.subcategory || null, subType, sub.website || null, domain, sub.starting_monthly_usd ?? null, sub.starting_annual_usd ?? null, sub.price_notes || null, existing.id, ); catalogId = existing.id; nUpdated++; } else { const r = insertCatalog.run( sub.rank, sub.service, sub.category, sub.subcategory || null, subType, sub.website || null, domain, sub.starting_monthly_usd ?? null, sub.starting_annual_usd ?? null, sub.price_notes || null, ); catalogId = r.lastInsertRowid; nInserted++; } // Replace all descriptors for this entry clearDescs.run(catalogId); for (const d of (sub.bank_statement_name_variables || [])) { if (String(d).trim().length >= 3) { insertDesc.run(catalogId, String(d).trim(), 'bank'); nDescs++; } } for (const d of (sub.known_names_and_slang || [])) { if (String(d).trim().length >= 2) { insertDesc.run(catalogId, String(d).trim(), 'slang'); nDescs++; } } } })(); console.log(`[v0.95] catalog: ${nUpdated} updated, ${nInserted} inserted, ${nDescs} descriptors added`); } }, { version: 'v0.96', description: 'bills: catalog_id FK; user_catalog_descriptors for custom bank descriptors', run() { // 1. Add catalog_id to bills const billCols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name); if (!billCols.includes('catalog_id')) { db.exec('ALTER TABLE bills ADD COLUMN catalog_id INTEGER REFERENCES subscription_catalog(id)'); } // 2. Create per-user custom descriptor table db.exec(` CREATE TABLE IF NOT EXISTS user_catalog_descriptors ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, catalog_id INTEGER NOT NULL REFERENCES subscription_catalog(id) ON DELETE CASCADE, descriptor TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_ucd_user_catalog ON user_catalog_descriptors(user_id, catalog_id); `); // 3. Backfill catalog_id for existing subscription bills using name normalization function normSimple(s) { return String(s || '').toLowerCase().replace(/[^a-z0-9]/g, ''); } const catalogEntries = db.prepare('SELECT id, name FROM subscription_catalog').all(); const subBills = db.prepare( "SELECT id, name FROM bills WHERE is_subscription = 1 AND deleted_at IS NULL AND catalog_id IS NULL" ).all(); const updateBillCatalog = db.prepare('UPDATE bills SET catalog_id = ? WHERE id = ?'); let backfilled = 0; db.transaction(() => { for (const bill of subBills) { const billNorm = normSimple(bill.name); if (billNorm.length < 3) continue; let best = null; let bestScore = 0; for (const cat of catalogEntries) { const catNorm = normSimple(cat.name); if (catNorm.length < 3) continue; let score = 0; if (billNorm === catNorm) score = 2000 + catNorm.length; else if (billNorm.includes(catNorm) || catNorm.includes(billNorm)) score = 1000 + Math.min(billNorm.length, catNorm.length); if (score > bestScore) { best = cat; bestScore = score; } } if (best) { updateBillCatalog.run(best.id, bill.id); backfilled++; } } })(); console.log(`[v0.96] catalog_id added to bills; ${backfilled}/${subBills.length} subscriptions backfilled`); } }, { version: 'v0.97', description: 'subscription recommendation feedback: per-user learning signals', run() { db.exec(` CREATE TABLE IF NOT EXISTS subscription_recommendation_feedback ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, catalog_id INTEGER REFERENCES subscription_catalog(id) ON DELETE SET NULL, bill_id INTEGER REFERENCES bills(id) ON DELETE SET NULL, merchant TEXT, action TEXT NOT NULL, confidence INTEGER, descriptor TEXT, metadata_json TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_srf_user_catalog ON subscription_recommendation_feedback(user_id, catalog_id); CREATE INDEX IF NOT EXISTS idx_srf_user_merchant ON subscription_recommendation_feedback(user_id, merchant); CREATE INDEX IF NOT EXISTS idx_srf_user_action ON subscription_recommendation_feedback(user_id, action); `); console.log('[v0.97] subscription recommendation feedback table ensured'); } }, { version: 'v0.98', description: 'payments: bank override metadata for provisional manual payments', run() { const cols = db.prepare('PRAGMA table_info(payments)').all().map(c => c.name); if (!cols.includes('accounting_excluded')) { db.exec('ALTER TABLE payments ADD COLUMN accounting_excluded INTEGER NOT NULL DEFAULT 0'); } if (!cols.includes('exclusion_reason')) { db.exec('ALTER TABLE payments ADD COLUMN exclusion_reason TEXT'); } if (!cols.includes('excluded_at')) { db.exec('ALTER TABLE payments ADD COLUMN excluded_at TEXT'); } if (!cols.includes('overridden_by_payment_id')) { db.exec('ALTER TABLE payments ADD COLUMN overridden_by_payment_id INTEGER'); } db.exec(` CREATE INDEX IF NOT EXISTS idx_payments_accounting_active ON payments(bill_id, paid_date, deleted_at, accounting_excluded); CREATE INDEX IF NOT EXISTS idx_payments_overridden_by ON payments(overridden_by_payment_id) WHERE overridden_by_payment_id IS NOT NULL; `); console.log('[v0.98] payment accounting override columns ensured'); } }, { version: 'v0.99', description: 'bills: autopay trust indicators + lifecycle fields; payments: autopay failure flag', check() { const billCols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name); return billCols.includes('autopay_verified_at') && billCols.includes('inactive_reason'); }, run() { const billCols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name); const paymentCols = db.prepare('PRAGMA table_info(payments)').all().map(c => c.name); if (!billCols.includes('autopay_verified_at')) { db.exec('ALTER TABLE bills ADD COLUMN autopay_verified_at TEXT'); } if (!billCols.includes('inactive_reason')) { db.exec('ALTER TABLE bills ADD COLUMN inactive_reason TEXT'); } if (!billCols.includes('inactivated_at')) { db.exec('ALTER TABLE bills ADD COLUMN inactivated_at TEXT'); } if (!paymentCols.includes('autopay_failure')) { db.exec('ALTER TABLE payments ADD COLUMN autopay_failure INTEGER NOT NULL DEFAULT 0'); } console.log('[v0.99] autopay trust indicators + lifecycle fields added'); } }, { version: 'v1.00', description: 'calendar feed subscription tokens', run() { db.exec(` CREATE TABLE IF NOT EXISTS calendar_tokens ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, token TEXT NOT NULL UNIQUE, label TEXT, active INTEGER NOT NULL DEFAULT 1, last_used_at TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), revoked_at TEXT ); CREATE INDEX IF NOT EXISTS idx_calendar_tokens_token ON calendar_tokens(token) WHERE active = 1; CREATE INDEX IF NOT EXISTS idx_calendar_tokens_user_active ON calendar_tokens(user_id, active); `); console.log('[v1.00] calendar feed token table ensured'); } }, { version: 'v1.01', description: 'transactions: pending flag for SimpleFIN pending transactions', run() { const cols = db.prepare('PRAGMA table_info(transactions)').all().map(c => c.name); if (!cols.includes('pending')) { db.exec('ALTER TABLE transactions ADD COLUMN pending INTEGER NOT NULL DEFAULT 0'); } // Partial index speeds the per-account orphan prune that clears pending rows // which never posted (e.g. a pending charge that re-posted under a new id). db.exec(`CREATE INDEX IF NOT EXISTS idx_transactions_pending ON transactions(account_id) WHERE pending = 1`); console.log('[v1.01] transactions.pending flag + partial index added'); } }, { version: 'v1.02', description: 'users: per-user geolocation opt-in (was global admin setting)', run() { const cols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name); if (!cols.includes('geolocation_enabled')) { db.exec('ALTER TABLE users ADD COLUMN geolocation_enabled INTEGER NOT NULL DEFAULT 0'); } console.log('[v1.02] users.geolocation_enabled added'); } }, { version: 'v1.03', description: 'money columns: dollars (REAL) -> integer cents', run() { const conv = [ ['bills', ['expected_amount', 'current_balance', 'minimum_payment']], ['payments', ['amount', 'balance_delta', 'interest_delta']], ['monthly_bill_state', ['actual_amount']], ['monthly_starting_amounts', ['first_amount', 'fifteenth_amount', 'other_amount']], ['monthly_income', ['amount']], ['spending_budgets', ['amount']], ['snowball_plans', ['extra_payment']], ['users', ['snowball_extra_payment']], ]; for (const [table, cols] of conv) { for (const col of cols) { db.exec(`UPDATE ${table} SET ${col} = CAST(ROUND(${col} * 100) AS INTEGER) WHERE ${col} IS NOT NULL`); } } console.log('[v1.03] money columns converted to integer cents'); } }, { version: 'v1.04', description: 'bill_templates.data JSON: money fields dollars -> integer cents', run() { // v1.03 converted table columns but not money values embedded in the // bill_templates.data JSON blob. Templates saved before v1.03 hold // dollars; the template code now reads cents (serializeTemplateData). for (const field of ['expected_amount', 'current_balance', 'minimum_payment']) { db.exec(` UPDATE bill_templates SET data = json_set(data, '$.${field}', CAST(ROUND(json_extract(data, '$.${field}') * 100) AS INTEGER)) WHERE json_extract(data, '$.${field}') IS NOT NULL `); } console.log('[v1.04] bill_templates.data money fields converted to integer cents'); } }, { version: 'v1.05', description: 'merchant_store_matches: 5k merchant/store matching pack for bank transaction categorization', dependsOn: ['v1.04'], run: function() { runMerchantStoreMatchMigration(db); } }, { version: 'v1.06', description: 'category_groups: organize spending categories into named groups', dependsOn: ['v1.05'], run: function() { db.exec(` CREATE TABLE IF NOT EXISTS category_groups ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, name TEXT NOT NULL, sort_order INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), UNIQUE(user_id, name) ) `); const cols = db.prepare('PRAGMA table_info(categories)').all().map(c => c.name); if (!cols.includes('group_id')) db.exec('ALTER TABLE categories ADD COLUMN group_id INTEGER REFERENCES category_groups(id) ON DELETE SET NULL'); console.log('[v1.06] category_groups table + categories.group_id added'); } }, ]; };