"""Small SQLite layer. Each operation owns its connection and transaction.""" import os import sqlite3 from contextlib import contextmanager from pathlib import Path from models import canonical_name, name_key, ASSET_TYPES def db_path(): return Path(os.environ.get('FINANCE_DB_PATH', '/data/finance.db')) @contextmanager def connect(path=None): db = sqlite3.connect(path or db_path(), timeout=5) db.row_factory = sqlite3.Row db.execute('PRAGMA foreign_keys = ON') db.execute('PRAGMA busy_timeout = 5000') try: with db: yield db finally: db.close() def validate_asset(name, asset_type='other', ticker=None): name = canonical_name(name) if asset_type not in ASSET_TYPES: raise ValueError('Ungültiger Positionstyp.') ticker = (ticker or '').strip() if len(ticker) > 30: raise ValueError('Ticker darf maximal 30 Zeichen enthalten.') return name, ticker or None def ensure_asset(db, name, asset_type='other', ticker=None): name, ticker = validate_asset(name, asset_type, ticker) db.execute('INSERT INTO assets (name, normalized_name, ticker, asset_type) VALUES (?, ?, ?, ?) ON CONFLICT(normalized_name) DO NOTHING', (name, name_key(name), ticker or None, asset_type)) return db.execute('SELECT id FROM assets WHERE normalized_name = ?', (name_key(name),)).fetchone()['id'] def initialize(path=None): target = Path(path or db_path()) target.parent.mkdir(parents=True, exist_ok=True) with connect(target) as db: first_setup = db.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='assets'").fetchone() is None db.execute('PRAGMA journal_mode = WAL') db.executescript(''' CREATE TABLE IF NOT EXISTS assets ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, normalized_name TEXT NOT NULL UNIQUE, ticker TEXT, asset_type TEXT NOT NULL CHECK(asset_type IN ('stock','etf','bond','crypto','interest','other')), active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1)), created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) ); CREATE TABLE IF NOT EXISTS income_entries ( id INTEGER PRIMARY KEY, date TEXT NOT NULL, asset_id INTEGER NOT NULL REFERENCES assets(id) ON DELETE RESTRICT, category TEXT NOT NULL CHECK(category IN ('dividend','interest','distribution','other')), amount INTEGER NOT NULL CHECK(typeof(amount) = 'integer' AND abs(amount) <= 99999999999), note TEXT, expected INTEGER NOT NULL DEFAULT 0 CHECK(expected IN (0,1)), received INTEGER NOT NULL DEFAULT 1 CHECK(received IN (0,1)), created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) ); CREATE INDEX IF NOT EXISTS income_date ON income_entries(date DESC, id DESC); CREATE INDEX IF NOT EXISTS income_asset ON income_entries(asset_id); CREATE TABLE IF NOT EXISTS import_records ( fingerprint TEXT PRIMARY KEY, entry_id INTEGER REFERENCES income_entries(id) ON DELETE SET NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); PRAGMA user_version = 1; ''') if first_setup: for name, kind in [('AGNC','stock'), ('Main Street Capital','stock'), ('Capital Southwest','stock'), ('Ares Capital','stock'), ('Realty Income','stock'), ('Enbridge','stock'), ('Bayer','stock'), ('STOXX Global Select Dividend 100','etf'), ('airBaltic','bond')]: ensure_asset(db, name, kind)