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