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.""" conn.execute("""CREATE TABLE IF NOT EXISTS settlement_drafts ( id TEXT PRIMARY KEY, month TEXT NOT NULL, basis_date TEXT NOT NULL, status TEXT NOT NULL, version INTEGER NOT NULL, created_by TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, memo TEXT NOT NULL DEFAULT '', create_key TEXT NOT NULL UNIQUE, correction_of_run_id TEXT NOT NULL DEFAULT '', correction_reason TEXT NOT NULL DEFAULT '', final_run_id TEXT NOT NULL DEFAULT '', final_response TEXT NOT NULL DEFAULT '', cancelled_by TEXT NOT NULL DEFAULT '', cancellation_reason TEXT NOT NULL DEFAULT '', cancelled_at TEXT NOT NULL DEFAULT '', approval_fingerprint TEXT NOT NULL DEFAULT '', approval_version INTEGER NOT NULL DEFAULT 0, approved_by TEXT NOT NULL DEFAULT '', approved_at TEXT NOT NULL DEFAULT '', approval_reason TEXT NOT NULL DEFAULT '', approval_reference TEXT NOT NULL DEFAULT '', approval_flags TEXT NOT NULL DEFAULT '{}' )""") conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_settlement_drafts_active_month ON settlement_drafts(month) WHERE status='active'") conn.execute("CREATE INDEX IF NOT EXISTS idx_settlement_drafts_month ON settlement_drafts(month,created_at)") _add_missing_columns(conn, "settlement_drafts", {"original_request_hash": "TEXT NOT NULL DEFAULT ''", "cashflow_policy_json": "TEXT NOT NULL DEFAULT '{}'", "aggregation_version": "TEXT NOT NULL DEFAULT 'comprehensive-v1'"}) conn.execute("""CREATE TABLE IF NOT EXISTS settlement_draft_items ( id TEXT PRIMARY KEY, draft_id TEXT NOT NULL REFERENCES settlement_drafts(id), source_type TEXT NOT NULL, source_id TEXT NOT NULL, valuation_id TEXT NOT NULL DEFAULT '', selection_mode TEXT NOT NULL DEFAULT 'exact', selection_reason TEXT NOT NULL DEFAULT '', included INTEGER NOT NULL DEFAULT 1, exclusion_reason TEXT NOT NULL DEFAULT '', version INTEGER NOT NULL DEFAULT 1, selected_by TEXT NOT NULL DEFAULT '', selected_at TEXT NOT NULL DEFAULT '', evidence_json TEXT NOT NULL DEFAULT '{}', UNIQUE(draft_id,source_type,source_id) )""") _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 ''", "draft_id": "TEXT NOT NULL DEFAULT ''", "draft_version": "INTEGER NOT NULL DEFAULT 0", "review_fingerprint": "TEXT NOT NULL DEFAULT ''"}) _add_missing_columns(conn, "asset_snapshot_runs", { "settlement_month": "TEXT", "currency": "TEXT", "income_total": "INTEGER", "expense_total": "INTEGER", "net_cashflow": "INTEGER", "total_assets": "INTEGER", "total_liabilities": "INTEGER", "net_worth": "INTEGER", "cashflow_policy_json": "TEXT", "aggregation_version": "TEXT", "snapshot_schema_version": "INTEGER", "source_snapshot_json": "TEXT", }) conn.execute("""CREATE TABLE IF NOT EXISTS settlement_cashflow_snapshots ( id TEXT PRIMARY KEY, settlement_run_id TEXT NOT NULL REFERENCES asset_snapshot_runs(id) ON DELETE RESTRICT, cashflow_kind TEXT NOT NULL CHECK (cashflow_kind IN ('income', 'expense', 'excluded')), category TEXT NOT NULL DEFAULT '', subcategory TEXT NOT NULL DEFAULT '', amount INTEGER NOT NULL CHECK (typeof(amount) = 'integer'), transaction_count INTEGER NOT NULL CHECK (typeof(transaction_count) = 'integer' AND transaction_count > 0), currency TEXT NOT NULL, UNIQUE (settlement_run_id, cashflow_kind, category, subcategory, currency) )""") conn.execute("CREATE INDEX IF NOT EXISTS idx_settlement_cashflow_snapshots_run ON settlement_cashflow_snapshots(settlement_run_id)") 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 _execute_script_transactional(conn: sqlite3.Connection, script: str) -> None: """Execute a schema script without executescript's implicit COMMIT.""" statement = "" for line in script.splitlines(): statement += line + "\n" if sqlite3.complete_statement(statement): _execute_one_schema_statement(conn, statement) statement = "" if statement.strip(): _execute_one_schema_statement(conn, statement) def _execute_one_schema_statement(conn: sqlite3.Connection, statement: str) -> None: if statement.lstrip().upper().startswith("PRAGMA JOURNAL_MODE=WAL"): return if statement.lstrip().upper().startswith("CREATE INDEX") or statement.lstrip().upper().startswith("CREATE UNIQUE INDEX"): return conn.execute(statement) def _stored_version(conn: sqlite3.Connection) -> str | None: try: row = conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone() except sqlite3.OperationalError as exc: if "no such table" in str(exc): return None raise return row["value"] if row else None def _compatible_known_v16(conn: sqlite3.Connection) -> bool: """Allow only the already-approved v16 development shape to normalize to v15.""" required = { "accounts": {"id", "name", "type", "liquidity_bucket"}, "transactions": {"id", "transaction_date", "amount", "raw_payload"}, "account_snapshot": {"id", "monthly_settlement_id", "amount", "verification_status"}, "monthly_settlement": {"id", "month", "status"}, "settlement_cashflow_snapshots": {"id", "monthly_settlement_id", "amount"}, } for table, columns in required.items(): if not conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,)).fetchone(): return False actual = _existing_columns(conn, table) if not columns <= actual: return False return not conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name IN ('asset_snapshot_runs','manual_assets','asset_snapshots','account_valuations')").fetchone() def _upgrade_cashflow_storage(conn): """Preserve run identity: corrections cannot be merged into a single month. Each frozen group belongs to exactly one legacy run or canonical month. No inferred dates or fabricated approvals are needed to retain legacy rows. """ columns = _existing_columns(conn, "settlement_cashflow_snapshots") if {"settlement_run_id", "monthly_settlement_id"} <= columns: return objects = conn.execute( "SELECT sql FROM sqlite_master WHERE tbl_name='settlement_cashflow_snapshots' AND type IN ('index','trigger') AND sql IS NOT NULL" ).fetchall() if columns: conn.execute("ALTER TABLE settlement_cashflow_snapshots RENAME TO cashflow_upgrade_source") start = SCHEMA_SQL.index("CREATE TABLE IF NOT EXISTS settlement_cashflow_snapshots") end = SCHEMA_SQL.index(";", start) + 1 conn.execute(SCHEMA_SQL[start:end]) if columns: names = ",".join(sorted(columns)) conn.execute(f"INSERT INTO settlement_cashflow_snapshots ({names}) SELECT {names} FROM cashflow_upgrade_source") conn.execute("DROP TABLE cashflow_upgrade_source") for row in objects: conn.execute(row["sql"]) def _normalize_monthly_settlement_schema_15(conn: sqlite3.Connection) -> None: """Remove the development-only schema-16 run pointer from schema 15.""" if "latest_run_id" in _existing_columns(conn, "monthly_settlement"): conn.execute("ALTER TABLE monthly_settlement DROP COLUMN latest_run_id") def init_db(db_path: str | None = None) -> str: path = resolve_db_path(db_path) with connect(str(path)) as conn: stored = _stored_version(conn) if stored not in (None, "11", "12", "13", "14", "15"): if stored != "16" or not _compatible_known_v16(conn): raise sqlite3.DatabaseError(f"unsupported schema version {stored}; refusing implicit downgrade") conn.execute("BEGIN IMMEDIATE") # SQLite DDL and all row copies participate in this transaction. _execute_script_transactional(conn, SCHEMA_SQL) _upgrade_cashflow_storage(conn) migrate_db(conn) _add_missing_columns(conn, "monthly_settlement", {"approved_cashflow_fingerprint": "TEXT"}) _normalize_monthly_settlement_schema_15(conn) # Re-run indexes only after all older-version columns have been added. for statement in SCHEMA_SQL.split(";"): if statement.strip().upper().startswith(("CREATE INDEX", "CREATE UNIQUE INDEX")): conn.execute(statement) if conn.execute("PRAGMA foreign_key_check").fetchone(): raise sqlite3.IntegrityError("migration foreign key check failed") conn.commit() return str(path)