refactor(jarca-ledger): implement BAT-010 backend core layering
This commit is contained in:
+1
-331
@@ -1,333 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Iterator
|
||||
|
||||
DEFAULT_DB_PATH = Path.home() / ".local" / "share" / "jarca" / "ledger.sqlite3"
|
||||
SCHEMA_VERSION = "10"
|
||||
|
||||
SCHEMA_SQL = """
|
||||
PRAGMA journal_mode=WAL;
|
||||
PRAGMA foreign_keys=ON;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS schema_meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS accounts (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL DEFAULT 'bank',
|
||||
institution TEXT NOT NULL DEFAULT '',
|
||||
currency TEXT NOT NULL DEFAULT 'KRW',
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
memo TEXT NOT NULL DEFAULT '',
|
||||
account_number_masked TEXT NOT NULL DEFAULT '',
|
||||
holder_name TEXT NOT NULL DEFAULT '',
|
||||
last_balance INTEGER,
|
||||
last_synced_at TEXT NOT NULL DEFAULT '',
|
||||
statement_status TEXT NOT NULL DEFAULT 'unknown',
|
||||
liquidity_bucket TEXT NOT NULL DEFAULT 'other',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS transactions (
|
||||
id TEXT PRIMARY KEY,
|
||||
transaction_date TEXT NOT NULL,
|
||||
posted_date TEXT NOT NULL DEFAULT '',
|
||||
type TEXT NOT NULL,
|
||||
amount INTEGER NOT NULL,
|
||||
currency TEXT NOT NULL DEFAULT 'KRW',
|
||||
category TEXT NOT NULL DEFAULT '',
|
||||
subcategory TEXT NOT NULL DEFAULT '',
|
||||
merchant TEXT NOT NULL DEFAULT '',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
account_id TEXT NOT NULL DEFAULT '',
|
||||
payment_method TEXT NOT NULL DEFAULT '',
|
||||
person TEXT NOT NULL DEFAULT '',
|
||||
project TEXT NOT NULL DEFAULT '',
|
||||
tags TEXT NOT NULL DEFAULT '[]',
|
||||
is_fixed INTEGER NOT NULL DEFAULT 0,
|
||||
source TEXT NOT NULL DEFAULT 'manual',
|
||||
import_batch_id TEXT NOT NULL DEFAULT '',
|
||||
external_id TEXT NOT NULL DEFAULT '',
|
||||
dedupe_key TEXT NOT NULL DEFAULT '',
|
||||
memo TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
transaction_datetime TEXT NOT NULL DEFAULT '',
|
||||
counterparty TEXT NOT NULL DEFAULT '',
|
||||
balance_after INTEGER,
|
||||
branch TEXT NOT NULL DEFAULT '',
|
||||
raw_summary TEXT NOT NULL DEFAULT '',
|
||||
raw_payload TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_date ON transactions(transaction_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_category ON transactions(category, subcategory);
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_person ON transactions(person);
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_account ON transactions(account_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_transactions_dedupe ON transactions(dedupe_key) WHERE dedupe_key <> '';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS budgets (
|
||||
id TEXT PRIMARY KEY,
|
||||
month TEXT NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
subcategory TEXT NOT NULL DEFAULT '',
|
||||
person TEXT NOT NULL DEFAULT '',
|
||||
amount INTEGER NOT NULL,
|
||||
currency TEXT NOT NULL DEFAULT 'KRW',
|
||||
memo TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_budgets_month ON budgets(month);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_budgets_unique ON budgets(month, category, subcategory, person);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS import_batches (
|
||||
id TEXT PRIMARY KEY,
|
||||
source TEXT NOT NULL,
|
||||
filename TEXT NOT NULL DEFAULT '',
|
||||
imported_at TEXT NOT NULL,
|
||||
row_count INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
memo TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
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
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_category_rules_active_priority ON category_rules(active, priority);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_category_rules_unique ON category_rules(match_field, match_type, pattern, transaction_type, category, subcategory, person);
|
||||
|
||||
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
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_ledger_categories_active_sort ON ledger_categories(active, sort_order, category, subcategory);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_ledger_categories_unique ON ledger_categories(category, subcategory);
|
||||
|
||||
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
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_assets_active_bucket ON manual_assets(active, liquidity_bucket, name);
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_assets_valuation_date ON manual_assets(valuation_date);
|
||||
|
||||
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
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_asset_snapshots_basis_date ON asset_snapshots(basis_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_asset_snapshots_source ON asset_snapshots(source_type, source_id);
|
||||
|
||||
INSERT OR REPLACE INTO schema_meta(key, value) VALUES ('schema_version', '10');
|
||||
"""
|
||||
|
||||
ACCOUNT_ADDITIVE_COLUMNS: dict[str, str] = {
|
||||
"account_number_masked": "TEXT NOT NULL DEFAULT ''",
|
||||
"holder_name": "TEXT NOT NULL DEFAULT ''",
|
||||
"last_balance": "INTEGER",
|
||||
"last_synced_at": "TEXT NOT NULL DEFAULT ''",
|
||||
"statement_status": "TEXT NOT NULL DEFAULT 'unknown'",
|
||||
"liquidity_bucket": "TEXT NOT NULL DEFAULT 'other'",
|
||||
}
|
||||
|
||||
TRANSACTION_ADDITIVE_COLUMNS: dict[str, str] = {
|
||||
"transaction_datetime": "TEXT NOT NULL DEFAULT ''",
|
||||
"counterparty": "TEXT NOT NULL DEFAULT ''",
|
||||
"balance_after": "INTEGER",
|
||||
"branch": "TEXT NOT NULL DEFAULT ''",
|
||||
"raw_summary": "TEXT NOT NULL DEFAULT ''",
|
||||
"raw_payload": "TEXT NOT NULL DEFAULT ''",
|
||||
}
|
||||
|
||||
|
||||
def resolve_db_path(db_path: str | None = None) -> Path:
|
||||
raw = db_path or os.getenv("JARCA_LEDGER_DB_PATH") or str(DEFAULT_DB_PATH)
|
||||
return Path(raw).expanduser().resolve()
|
||||
|
||||
|
||||
def connect(db_path: str | None = None) -> sqlite3.Connection:
|
||||
path = resolve_db_path(db_path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
return conn
|
||||
|
||||
|
||||
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 ''",
|
||||
})
|
||||
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("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)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def db(db_path: str | None = None) -> Iterator[sqlite3.Connection]:
|
||||
init_db(db_path)
|
||||
conn = connect(db_path)
|
||||
try:
|
||||
yield conn
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
from .infrastructure.sqlite import *
|
||||
|
||||
Reference in New Issue
Block a user