PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON; CREATE TABLE IF NOT EXISTS categories ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, name TEXT NOT NULL, sort_order INTEGER, deleted_at TEXT, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS bills ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, name TEXT NOT NULL, category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL, due_day INTEGER NOT NULL CHECK(due_day BETWEEN 1 AND 31), override_due_date TEXT, bucket TEXT CHECK(bucket IN ('1st', '15th')), expected_amount INTEGER NOT NULL DEFAULT 0, -- cents interest_rate REAL CHECK(interest_rate IS NULL OR (interest_rate >= 0 AND interest_rate <= 100)), billing_cycle TEXT DEFAULT 'monthly' CHECK(billing_cycle IN ('monthly', 'quarterly', 'annually', 'irregular')), cycle_type TEXT NOT NULL DEFAULT 'monthly' CHECK(cycle_type IN ('monthly', 'weekly', 'biweekly', 'quarterly', 'annual')), cycle_day TEXT, autopay_enabled INTEGER NOT NULL DEFAULT 0, autodraft_status TEXT NOT NULL DEFAULT 'none' CHECK(autodraft_status IN ('none', 'pending', 'assumed_paid', 'confirmed')), auto_mark_paid INTEGER NOT NULL DEFAULT 0, website TEXT, username TEXT, account_info TEXT, has_2fa INTEGER NOT NULL DEFAULT 0, active INTEGER NOT NULL DEFAULT 1, current_balance INTEGER, -- cents minimum_payment INTEGER, -- cents snowball_order INTEGER, sort_order INTEGER, snowball_include INTEGER NOT NULL DEFAULT 0, snowball_exempt INTEGER NOT NULL DEFAULT 0, is_subscription INTEGER NOT NULL DEFAULT 0, subscription_type TEXT, reminder_days_before INTEGER NOT NULL DEFAULT 3, subscription_source TEXT NOT NULL DEFAULT 'manual', subscription_detected_at TEXT, deleted_at TEXT, notes TEXT, drift_snoozed_until TEXT, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS payments ( id INTEGER PRIMARY KEY AUTOINCREMENT, bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE, amount INTEGER NOT NULL, -- cents paid_date TEXT NOT NULL, method TEXT, notes TEXT, balance_delta INTEGER, -- cents payment_source TEXT NOT NULL DEFAULT 'manual', transaction_id INTEGER, accounting_excluded INTEGER NOT NULL DEFAULT 0, exclusion_reason TEXT, excluded_at TEXT, overridden_by_payment_id INTEGER, deleted_at TEXT, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TEXT DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE COLLATE NOCASE, display_name TEXT, password_hash TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'user' CHECK(role IN ('admin', 'user')), active INTEGER NOT NULL DEFAULT 1, is_default_admin INTEGER NOT NULL DEFAULT 0, must_change_password INTEGER NOT NULL DEFAULT 0, first_login INTEGER NOT NULL DEFAULT 1, snowball_extra_payment INTEGER NOT NULL DEFAULT 0, -- cents notify_amount_change INTEGER NOT NULL DEFAULT 1, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')) ); 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) ); CREATE TABLE IF NOT EXISTS data_sources ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, type TEXT NOT NULL, provider TEXT, name TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active', config_json TEXT, encrypted_secret TEXT, last_sync_at TEXT, last_error TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS financial_accounts ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, data_source_id INTEGER, provider_account_id TEXT, name TEXT NOT NULL, org_name TEXT, account_type TEXT, currency TEXT, balance INTEGER, available_balance INTEGER, monitored INTEGER NOT NULL DEFAULT 1, raw_data TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (data_source_id) REFERENCES data_sources(id) ON DELETE SET NULL, UNIQUE(data_source_id, provider_account_id) ); CREATE TABLE IF NOT EXISTS transactions ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, data_source_id INTEGER, account_id INTEGER, provider_transaction_id TEXT, source_type TEXT NOT NULL, transaction_type TEXT, posted_date TEXT, transacted_at TEXT, amount INTEGER NOT NULL, currency TEXT, description TEXT, payee TEXT, memo TEXT, category TEXT, raw_data TEXT, matched_bill_id INTEGER, match_status TEXT NOT NULL DEFAULT 'unmatched', ignored INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (data_source_id) REFERENCES data_sources(id) ON DELETE SET NULL, FOREIGN KEY (account_id) REFERENCES financial_accounts(id) ON DELETE SET NULL, FOREIGN KEY (matched_bill_id) REFERENCES bills(id) ON DELETE SET NULL ); CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, expires_at TEXT NOT NULL, created_at TEXT DEFAULT (datetime('now')) ); 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 ); CREATE TABLE IF NOT EXISTS notifications ( id INTEGER PRIMARY KEY AUTOINCREMENT, bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, year INTEGER NOT NULL, month INTEGER NOT NULL, type TEXT NOT NULL, -- due_3d | due_1d | due_today | overdue sent_date TEXT NOT NULL DEFAULT (date('now')), UNIQUE(bill_id, user_id, year, month, type, sent_date) ); CREATE INDEX IF NOT EXISTS idx_notifications_lookup ON notifications(bill_id, user_id, year, month); CREATE INDEX IF NOT EXISTS idx_bills_active ON bills(active); CREATE INDEX IF NOT EXISTS idx_payments_bill_id ON payments(bill_id); CREATE INDEX IF NOT EXISTS idx_payments_paid_date ON payments(paid_date); CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id); CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at); CREATE INDEX IF NOT EXISTS idx_data_sources_user_type ON data_sources(user_id, type, status); CREATE UNIQUE INDEX IF NOT EXISTS idx_data_sources_user_manual ON data_sources(user_id, type, provider) WHERE type = 'manual' AND provider = 'manual'; CREATE INDEX IF NOT EXISTS idx_financial_accounts_user_source ON financial_accounts(user_id, data_source_id); CREATE INDEX IF NOT EXISTS idx_transactions_user_date ON transactions(user_id, posted_date, transacted_at); CREATE INDEX IF NOT EXISTS idx_transactions_user_match ON transactions(user_id, match_status, ignored); CREATE INDEX IF NOT EXISTS idx_transactions_account ON transactions(account_id); CREATE INDEX IF NOT EXISTS idx_transactions_matched_bill ON transactions(matched_bill_id); CREATE UNIQUE INDEX IF NOT EXISTS idx_transactions_provider_dedupe ON transactions (data_source_id, provider_transaction_id) WHERE provider_transaction_id IS NOT NULL; 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); 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 INTEGER, -- cents; NULL = use bill.expected_amount for this month notes TEXT, -- month-specific notes, NULL = no notes is_skipped INTEGER NOT NULL DEFAULT 0, -- 1 = hidden/removed for this month only snoozed_until TEXT, -- ISO date: hide from overdue command center until this date created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')), UNIQUE(bill_id, year, month) ); CREATE INDEX IF NOT EXISTS idx_monthly_bill_state_lookup ON monthly_bill_state(bill_id, year, month); 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); 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); CREATE TABLE IF NOT EXISTS advisory_non_bill_filters ( id TEXT PRIMARY KEY, pattern TEXT NOT NULL, confidence TEXT NOT NULL CHECK(confidence IN ('high', 'medium')), category TEXT NOT NULL, rationale TEXT ); CREATE INDEX IF NOT EXISTS idx_advisory_filters_confidence ON advisory_non_bill_filters(confidence); CREATE TABLE IF NOT EXISTS advisory_bill_like_overrides ( id INTEGER PRIMARY KEY AUTOINCREMENT, term TEXT NOT NULL UNIQUE ); 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 INTEGER NOT NULL DEFAULT 0, -- cents plan_snapshot TEXT NOT NULL, notes TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_snowball_plans_user ON snowball_plans(user_id, status, created_at); 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);