from sqlalchemy import text from sqlalchemy.engine import Engine # Per-account offset for the Konto-Saldo-Anker (Ausbaustufe 3 Task 1): the # same math as app.services.balances.account_balance, expressed as a single # constant "base" per account so it can be added to a plain (anchor-agnostic) # running sum of transaction amounts: # # balance(day) = anchor_balance + Sum(tx: anchor_date < booking_date <= day) # - Sum(tx: day < booking_date <= anchor_date) # = anchor_balance - cumsum(<=anchor_date) + cumsum(<=day) # = base + cumsum(<=day) # # ... which holds for `day` both before and after anchor_date, since the # subtracted/added transaction windows collapse into the same telescoping # sum. Accounts without an anchor get base=0 (via COALESCE below), matching # account_balance's "Ohne Anker: Summe ab 0" fallback. _ACCOUNT_OFFSET = """ SELECT a.id AS account_id, a.anchor_balance - COALESCE(( SELECT SUM(t2.amount) FROM transactions t2 WHERE t2.account_id = a.id AND t2.status = 'confirmed' AND t2.booking_date <= a.anchor_date ), 0) AS base FROM accounts a WHERE a.anchor_date IS NOT NULL AND a.anchor_balance IS NOT NULL """ VIEWS: dict[str, str] = { "v_balance_history": f""" SELECT t.booking_date AS day, a.name AS account, COALESCE(b.base, 0) + SUM(SUM(t.amount)) OVER (PARTITION BY a.id ORDER BY t.booking_date) AS balance FROM transactions t JOIN accounts a ON a.id = t.account_id LEFT JOIN ({_ACCOUNT_OFFSET}) b ON b.account_id = a.id WHERE t.status = 'confirmed' GROUP BY a.id, a.name, t.booking_date, b.base""", "v_balance_total": f""" SELECT booking_date AS day, (SELECT COALESCE(SUM(base), 0) FROM ({_ACCOUNT_OFFSET}) s) + SUM(SUM(amount)) OVER (ORDER BY booking_date) AS balance FROM transactions WHERE status = 'confirmed' GROUP BY booking_date""", "v_monthly_by_category": """ SELECT date_trunc('month', t.booking_date) AS month, COALESCE(c.name, 'unkategorisiert') AS category, SUM(t.amount) AS total FROM transactions t LEFT JOIN categories c ON c.id = t.category_id WHERE t.status = 'confirmed' GROUP BY 1, 2""", "v_projection": """ SELECT s.name AS scenario, p.day, p.balance FROM projection_points p JOIN scenarios s ON s.id = p.scenario_id""", } def create_views(engine: Engine) -> None: if engine.dialect.name != "postgresql": return with engine.begin() as conn: for name, body in VIEWS.items(): # CREATE OR REPLACE VIEW fails if the output column set changed # (name/type/order). Fall back to DROP + CREATE in that case so a # redeploy always applies the current definition. try: with conn.begin_nested(): conn.execute( text(f"CREATE OR REPLACE VIEW {name} AS {body}")) except Exception: conn.execute(text(f"DROP VIEW IF EXISTS {name} CASCADE")) conn.execute(text(f"CREATE VIEW {name} AS {body}"))