BillTracker/db/migrations/versionedMigrations.js

1770 lines
78 KiB
JavaScript
Raw Normal View History

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