101 lines
4.7 KiB
Python
101 lines
4.7 KiB
Python
"""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
|
|
);
|
|
CREATE TABLE IF NOT EXISTS data_migrations (
|
|
name TEXT PRIMARY KEY,
|
|
applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
''')
|
|
db.execute('BEGIN IMMEDIATE')
|
|
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)
|
|
_update_interest_positions(db)
|
|
if db.execute('PRAGMA user_version').fetchone()[0] < 2:
|
|
db.execute('PRAGMA user_version = 2')
|
|
|
|
|
|
def _update_interest_positions(db):
|
|
"""Apply the requested position changes once, preserving all income history."""
|
|
migration = '2026-09-09-interest-positions'
|
|
if db.execute('SELECT 1 FROM data_migrations WHERE name=?', (migration,)).fetchone():
|
|
return
|
|
for name, kind in [('Anleihezinsen', 'bond'), ('Zinsen NG', 'interest')]:
|
|
asset_id = ensure_asset(db, name, kind)
|
|
db.execute('UPDATE assets SET active=1 WHERE id=?', (asset_id,))
|
|
db.execute('UPDATE assets SET active=0 WHERE normalized_name IN (?,?)',
|
|
(name_key('Zinsen'), name_key('Steuerrückzahlung')))
|
|
db.execute('INSERT INTO data_migrations (name) VALUES (?)', (migration,))
|