From 026c6a56b8b47e2860630705a38fb411b391c319 Mon Sep 17 00:00:00 2001 From: null Date: Fri, 3 Jul 2026 13:31:00 -0500 Subject: [PATCH] refactor(db): extract the versioned migrations array into its own module (IMP-CODE-02) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit db/database.js carried a ~1,740-line inline `const migrations = [...]` inside runMigrations(). Moved it to db/migrations/versionedMigrations.js as a factory, buildVersionedMigrations(deps), injected with the live `db` connection and the few schema helpers the migration bodies close over (isValidColumnName, isValidSqlDefinition, ensureTransactionFoundationSchema, and the four run*Migration helpers). Behavior is identical — the run/check closures resolve the same bindings, just passed in rather than captured. Fixed the two path references that broke by moving one directory deeper: the inline require('../services/...') calls and the __dirname docs JSON require now use ../../. database.js: 3,859 → 2,119 lines. Verified: full server suite 122 pass; a fresh DB applies all 79 migrations and is idempotent on a second boot; the real prod DB copy (v1.06) migrates as a clean no-op with data intact and no version-sync drift between the runMigrations and reconcileLegacyMigrations version lists. Co-Authored-By: Claude Opus 4.8 --- db/database.js | 1762 +------------------------ db/migrations/versionedMigrations.js | 1769 ++++++++++++++++++++++++++ 2 files changed, 1781 insertions(+), 1750 deletions(-) create mode 100644 db/migrations/versionedMigrations.js diff --git a/db/database.js b/db/database.js index 00af8ae..33499df 100644 --- a/db/database.js +++ b/db/database.js @@ -75,6 +75,8 @@ function isValidSqlDefinition(def) { // Subscription catalog seed rows live in their own module (pure data, no logic). const { SUBSCRIPTION_CATALOG_ROWS, SUBSCRIPTION_CATALOG_V2_ROWS } = require('./subscriptionCatalogSeed'); +// The versioned migration list lives in its own module (a factory injected with db + helpers). +const buildVersionedMigrations = require('./migrations/versionedMigrations'); function runSubscriptionCatalogV2Migration(database) { // Category fixes for existing rows @@ -1433,1756 +1435,16 @@ function runMigrations() { console.error(`[audit-error] Failed to log migration start to audit log: ${auditErr.message}`); } // Define all migrations with explicit version tracking and dependency chains - const migrations = [ - { - 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'); - } - }, - ]; + const migrations = buildVersionedMigrations({ + db, + isValidColumnName, + isValidSqlDefinition, + ensureTransactionFoundationSchema, + runSubscriptionCatalogMigration, + runSubscriptionCatalogV2Migration, + runAdvisoryFiltersMigration, + runMerchantStoreMatchMigration, + }); // ── users: notification columns ─────────────────────────────────────────── // This migration needs to run first since it's not versioned in the schema diff --git a/db/migrations/versionedMigrations.js b/db/migrations/versionedMigrations.js new file mode 100644 index 0000000..170ff68 --- /dev/null +++ b/db/migrations/versionedMigrations.js @@ -0,0 +1,1769 @@ +'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'); + } + }, + ]; +};