1770 lines
78 KiB
JavaScript
1770 lines
78 KiB
JavaScript
|
|
'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');
|
||
|
|
}
|
||
|
|
},
|
||
|
|
];
|
||
|
|
};
|