'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');
      }
    },
  ];
};
