from __future__ import annotations import sqlite3 from .connection import connect, resolve_db_path from .schema import ACCOUNT_ADDITIVE_COLUMNS, SCHEMA_SQL, SCHEMA_VERSION, TRANSACTION_ADDITIVE_COLUMNS def _existing_columns(conn: sqlite3.Connection, table: str) -> set[str]: return {row["name"] for row in conn.execute(f"PRAGMA table_info({table})")} def _add_missing_columns(conn: sqlite3.Connection, table: str, columns: dict[str, str]) -> None: existing = _existing_columns(conn, table) for name, definition in columns.items(): if name not in existing: conn.execute(f"ALTER TABLE {table} ADD COLUMN {name} {definition}") def migrate_db(conn: sqlite3.Connection) -> None: """Apply additive, idempotent migrations only.""" _add_missing_columns(conn, "accounts", ACCOUNT_ADDITIVE_COLUMNS) _add_missing_columns(conn, "transactions", TRANSACTION_ADDITIVE_COLUMNS) conn.execute("CREATE INDEX IF NOT EXISTS idx_transactions_datetime ON transactions(transaction_datetime)") conn.execute("""CREATE TABLE IF NOT EXISTS category_rules ( id TEXT PRIMARY KEY, priority INTEGER NOT NULL DEFAULT 100, match_field TEXT NOT NULL, match_type TEXT NOT NULL DEFAULT 'contains', pattern TEXT NOT NULL, amount_min INTEGER, amount_max INTEGER, day_of_month INTEGER, transaction_type TEXT NOT NULL DEFAULT '', category TEXT NOT NULL, subcategory TEXT NOT NULL DEFAULT '', person TEXT NOT NULL DEFAULT '', is_fixed INTEGER, confidence REAL NOT NULL DEFAULT 1.0, active INTEGER NOT NULL DEFAULT 1, memo TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, updated_at TEXT NOT NULL )""") _add_missing_columns(conn, "category_rules", {"amount_min": "INTEGER", "amount_max": "INTEGER", "day_of_month": "INTEGER"}) conn.execute("CREATE INDEX IF NOT EXISTS idx_category_rules_active_priority ON category_rules(active, priority)") conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_category_rules_unique ON category_rules(match_field, match_type, pattern, transaction_type, category, subcategory, person)") conn.execute("""CREATE TABLE IF NOT EXISTS ledger_categories ( id TEXT PRIMARY KEY, category TEXT NOT NULL, subcategory TEXT NOT NULL DEFAULT '', cashflow_kind TEXT NOT NULL DEFAULT 'expense', active INTEGER NOT NULL DEFAULT 1, sort_order INTEGER NOT NULL DEFAULT 100, memo TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, updated_at TEXT NOT NULL )""") _add_missing_columns(conn, "ledger_categories", {"cashflow_kind": "TEXT NOT NULL DEFAULT 'expense'"}) conn.execute("CREATE INDEX IF NOT EXISTS idx_ledger_categories_active_sort ON ledger_categories(active, sort_order, category, subcategory)") conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_ledger_categories_unique ON ledger_categories(category, subcategory)") conn.execute("""CREATE TABLE IF NOT EXISTS manual_assets ( id TEXT PRIMARY KEY, name TEXT NOT NULL, liquidity_bucket TEXT NOT NULL DEFAULT 'other', valuation_amount INTEGER, valuation_date TEXT NOT NULL DEFAULT '', currency TEXT NOT NULL DEFAULT 'KRW', source_note TEXT NOT NULL DEFAULT '', memo TEXT NOT NULL DEFAULT '', active INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL, updated_at TEXT NOT NULL )""") conn.execute("CREATE INDEX IF NOT EXISTS idx_manual_assets_active_bucket ON manual_assets(active, liquidity_bucket, name)") conn.execute("CREATE INDEX IF NOT EXISTS idx_manual_assets_valuation_date ON manual_assets(valuation_date)") conn.execute("""CREATE TABLE IF NOT EXISTS asset_snapshots ( id TEXT PRIMARY KEY, basis_date TEXT NOT NULL, source_type TEXT NOT NULL, source_id TEXT NOT NULL DEFAULT '', name TEXT NOT NULL, amount INTEGER, currency TEXT NOT NULL DEFAULT 'KRW', liquidity_bucket TEXT NOT NULL DEFAULT 'other', valuation_source TEXT NOT NULL DEFAULT '', source_note TEXT NOT NULL DEFAULT '', memo TEXT NOT NULL DEFAULT '', snapshot_run_id TEXT NOT NULL DEFAULT '', correction_of_run_id TEXT NOT NULL DEFAULT '', correction_reason TEXT NOT NULL DEFAULT '', is_correction INTEGER NOT NULL DEFAULT 0, closed_at TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL )""") _add_missing_columns(conn, "asset_snapshots", { "snapshot_run_id": "TEXT NOT NULL DEFAULT ''", "correction_of_run_id": "TEXT NOT NULL DEFAULT ''", "correction_reason": "TEXT NOT NULL DEFAULT ''", "is_correction": "INTEGER NOT NULL DEFAULT 0", "closed_at": "TEXT NOT NULL DEFAULT ''", "valuation_id": "TEXT NOT NULL DEFAULT ''", "selection_policy": "TEXT NOT NULL DEFAULT 'exact'", "verification_status": "TEXT NOT NULL DEFAULT 'unverified'", "evidence_kind": "TEXT NOT NULL DEFAULT 'applied'", "exclusion_reason": "TEXT NOT NULL DEFAULT ''", }) conn.execute("CREATE INDEX IF NOT EXISTS idx_asset_snapshots_basis_date ON asset_snapshots(basis_date)") conn.execute("CREATE INDEX IF NOT EXISTS idx_asset_snapshots_source ON asset_snapshots(source_type, source_id)") conn.execute("CREATE INDEX IF NOT EXISTS idx_asset_snapshots_run ON asset_snapshots(basis_date, snapshot_run_id, created_at)") conn.execute("""CREATE TABLE IF NOT EXISTS asset_snapshot_runs ( id TEXT PRIMARY KEY, basis_date TEXT NOT NULL, close_status TEXT NOT NULL, selection_policy TEXT NOT NULL DEFAULT 'exact', idempotency_key TEXT NOT NULL DEFAULT '', approved_by TEXT NOT NULL DEFAULT '', approved_at TEXT NOT NULL DEFAULT '', approval_reason TEXT NOT NULL DEFAULT '', approval_reference TEXT NOT NULL DEFAULT '', correction_of_run_id TEXT NOT NULL DEFAULT '', correction_reason TEXT NOT NULL DEFAULT '', applied_evidence TEXT NOT NULL DEFAULT '[]', excluded_evidence TEXT NOT NULL DEFAULT '[]', carried_evidence TEXT NOT NULL DEFAULT '[]', request_fingerprint TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL )""") _add_missing_columns(conn, "asset_snapshot_runs", {"request_fingerprint": "TEXT NOT NULL DEFAULT ''"}) conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_asset_snapshot_runs_idempotency ON asset_snapshot_runs(idempotency_key) WHERE idempotency_key <> ''") conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_asset_snapshot_runs_initial_basis ON asset_snapshot_runs(basis_date) WHERE correction_of_run_id = ''") conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_asset_snapshot_runs_correction_parent ON asset_snapshot_runs(correction_of_run_id) WHERE correction_of_run_id <> ''") conn.execute("""CREATE TABLE IF NOT EXISTS review_decisions ( id TEXT PRIMARY KEY, target_type TEXT NOT NULL, target_id TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', decision_type TEXT NOT NULL DEFAULT 'unknown', reason_code TEXT NOT NULL DEFAULT '', memo TEXT NOT NULL DEFAULT '', operator TEXT NOT NULL DEFAULT 'operator', decided_at TEXT NOT NULL DEFAULT '', review_after TEXT NOT NULL DEFAULT '', target_payload TEXT NOT NULL DEFAULT '{}', created_at TEXT NOT NULL, updated_at TEXT NOT NULL )""") conn.execute("CREATE INDEX IF NOT EXISTS idx_review_decisions_target ON review_decisions(target_type, target_id, updated_at)") conn.execute("CREATE INDEX IF NOT EXISTS idx_review_decisions_status ON review_decisions(status, review_after, updated_at)") conn.execute("""CREATE TABLE IF NOT EXISTS reconciliation_sessions ( id TEXT PRIMARY KEY, account_id TEXT NOT NULL, basis_date TEXT NOT NULL, expected_balance INTEGER, actual_balance INTEGER, difference INTEGER, status TEXT NOT NULL DEFAULT 'open', closed_at TEXT NOT NULL DEFAULT '', source_note TEXT NOT NULL DEFAULT '', memo TEXT NOT NULL DEFAULT '', operator TEXT NOT NULL DEFAULT 'operator', created_at TEXT NOT NULL, updated_at TEXT NOT NULL )""") conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_reconciliation_sessions_account_basis ON reconciliation_sessions(account_id, basis_date)") conn.execute("CREATE INDEX IF NOT EXISTS idx_reconciliation_sessions_status ON reconciliation_sessions(status, closed_at)") conn.execute("""CREATE TABLE IF NOT EXISTS account_valuations ( id TEXT PRIMARY KEY, account_id TEXT NOT NULL REFERENCES accounts(id), basis_date TEXT NOT NULL, amount INTEGER NOT NULL, currency TEXT NOT NULL DEFAULT 'KRW', valuation_kind TEXT NOT NULL DEFAULT 'balance', source_type TEXT NOT NULL DEFAULT 'manual', source_ref TEXT NOT NULL DEFAULT '', verification_status TEXT NOT NULL DEFAULT 'unverified', recorded_at TEXT NOT NULL, memo TEXT NOT NULL DEFAULT '', idempotency_key TEXT NOT NULL DEFAULT '', supersedes_id TEXT REFERENCES account_valuations(id), correction_reason TEXT NOT NULL DEFAULT '', as_of_at TEXT, timezone TEXT, cost_basis INTEGER, net_contributed INTEGER, reported_principal_kind TEXT, reported_principal_label TEXT, profit INTEGER, return_rate REAL, return_basis TEXT, principal INTEGER, interest INTEGER, fee INTEGER, representative_liability_definition TEXT )""") conn.execute("CREATE INDEX IF NOT EXISTS idx_account_valuations_account_date ON account_valuations(account_id, basis_date, recorded_at)") conn.execute("CREATE INDEX IF NOT EXISTS idx_account_valuations_status_date ON account_valuations(verification_status, basis_date)") conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_account_valuations_idempotency ON account_valuations(account_id, idempotency_key) WHERE idempotency_key <> ''") conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_account_valuations_single_correction ON account_valuations(supersedes_id) WHERE supersedes_id IS NOT NULL") conn.execute("INSERT OR REPLACE INTO schema_meta(key, value) VALUES ('schema_version', ?)", (SCHEMA_VERSION,)) def init_db(db_path: str | None = None) -> str: path = resolve_db_path(db_path) with connect(str(path)) as conn: conn.executescript(SCHEMA_SQL) migrate_db(conn) return str(path)