diff --git a/finance/alembic/versions/1ef6a356f028_konto_anker.py b/finance/alembic/versions/1ef6a356f028_konto_anker.py new file mode 100644 index 0000000..650ad27 --- /dev/null +++ b/finance/alembic/versions/1ef6a356f028_konto_anker.py @@ -0,0 +1,34 @@ +"""konto anker + +Revision ID: 1ef6a356f028 +Revises: b2b1f5a18a74 +Create Date: 2026-07-19 21:18:41.888568 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '1ef6a356f028' +down_revision: Union[str, Sequence[str], None] = 'b2b1f5a18a74' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('accounts', sa.Column('anchor_date', sa.Date(), nullable=True)) + op.add_column('accounts', sa.Column('anchor_balance', sa.Numeric(precision=12, scale=2), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('accounts', 'anchor_balance') + op.drop_column('accounts', 'anchor_date') + # ### end Alembic commands ### diff --git a/finance/app/models/tables.py b/finance/app/models/tables.py index 1b99ea5..26e1f7a 100644 --- a/finance/app/models/tables.py +++ b/finance/app/models/tables.py @@ -16,6 +16,12 @@ class Account(Base): iban: Mapped[str] = mapped_column(String(34), unique=True) name: Mapped[str] = mapped_column(String(100)) type: Mapped[str] = mapped_column(String(20), default="giro") + # Konto-Saldo-Anker (Ausbaustufe 3 Task 1): ersetzt die bisherige + # Statement-closing-Logik als Basis der Saldenrechnung. Beide Felder sind + # nur gemeinsam gesetzt oder gemeinsam NULL (durchgesetzt in + # app.routers.accounts.AccountPatch). + anchor_date: Mapped[date | None] = mapped_column(Date, nullable=True) + anchor_balance: Mapped[Decimal | None] = mapped_column(MONEY, nullable=True) class Category(Base): diff --git a/finance/app/models/views.py b/finance/app/models/views.py index ff47434..42c4aec 100644 --- a/finance/app/models/views.py +++ b/finance/app/models/views.py @@ -1,32 +1,45 @@ from sqlalchemy import text from sqlalchemy.engine import Engine -# Per-account starting balance: the opening_balance of each account's earliest -# confirmed statement (if any). Used to seed the cumulative sums below so the -# Grafana balance charts show the real account balance, not just the running -# sum of imported transactions (which would start at zero and ignore the -# balance that existed before the first imported statement). -_ACCOUNT_BASE = """ - SELECT DISTINCT ON (account_id) account_id, opening_balance - FROM statements - WHERE status = 'confirmed' AND opening_balance IS NOT NULL - ORDER BY account_id, period_start NULLS LAST, id +# 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.opening_balance, 0) + 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_BASE}) b ON b.account_id = a.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.opening_balance""", + GROUP BY a.id, a.name, t.booking_date, b.base""", "v_balance_total": f""" SELECT booking_date AS day, - (SELECT COALESCE(SUM(opening_balance), 0) - FROM ({_ACCOUNT_BASE}) s) + (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": """ diff --git a/finance/app/routers/accounts.py b/finance/app/routers/accounts.py index 948ae02..eb831ab 100644 --- a/finance/app/routers/accounts.py +++ b/finance/app/routers/accounts.py @@ -1,3 +1,4 @@ +from datetime import date from decimal import Decimal from fastapi import APIRouter, Depends, HTTPException @@ -25,14 +26,23 @@ class AccountOut(AccountIn): model_config = ConfigDict(from_attributes=True) id: int balance: Decimal = Decimal("0") + anchor_date: date | None = None + anchor_balance: Decimal | None = None class AccountPatch(BaseModel): - name: str + # Alle Felder optional; model_fields_set entscheidet, was tatsaechlich + # geaendert wird (Muster wie TransactionPatch.category_id). anchor_date + # und anchor_balance muessen gemeinsam gesetzt oder gemeinsam null sein. + name: str | None = None + anchor_date: date | None = None + anchor_balance: Decimal | None = None @field_validator("name") @classmethod - def name_not_blank(cls, v: str) -> str: + def name_not_blank(cls, v: str | None) -> str | None: + if v is None: + return v v = v.strip() if not v or len(v) > 100: raise ValueError("Name muss 1–100 Zeichen lang sein") @@ -75,7 +85,25 @@ def patch_account(account_id: int, data: AccountPatch, session: Session = Depend acc = session.get(Account, account_id) if acc is None: raise HTTPException(404, "Konto nicht gefunden") - acc.name = data.name + + # Validierung zuerst, VOR jeder Mutation - sonst haengt bei einem 422 auf + # dem Anker eine bereits geschriebene name-Aenderung im Session-State. + fields = data.model_fields_set + if "name" in fields and data.name is None: + raise HTTPException(422, "Name darf nicht leer sein") + + anchor_fields = {"anchor_date", "anchor_balance"} & fields + if anchor_fields and ( + anchor_fields != {"anchor_date", "anchor_balance"} + or (data.anchor_date is None) != (data.anchor_balance is None)): + raise HTTPException(422, "Anker braucht Datum und Betrag (oder beide leeren)") + + if "name" in fields: + acc.name = data.name + if anchor_fields: + acc.anchor_date = data.anchor_date + acc.anchor_balance = data.anchor_balance + session.commit() session.refresh(acc) item = AccountOut.model_validate(acc) diff --git a/finance/app/services/balances.py b/finance/app/services/balances.py index 09a6f91..f759107 100644 --- a/finance/app/services/balances.py +++ b/finance/app/services/balances.py @@ -1,26 +1,53 @@ +from datetime import date from decimal import Decimal from sqlalchemy import func, select from sqlalchemy.orm import Session -from app.models.tables import Account, Statement, Transaction +from app.models.tables import Account, Transaction -def account_balance(session: Session, account: Account) -> Decimal: - stmt = session.execute( - select(Statement).where(Statement.account_id == account.id, - Statement.status == "confirmed") - .order_by(Statement.period_end.desc())).scalars().first() +def _confirmed_sum(session: Session, account_id: int, + after: date | None, upto: date | None) -> Decimal: + """Summe bestaetigter Buchungen fuer `account_id` mit `after < booking_date + <= upto` (Raender werden nur gesetzt, wenn nicht None).""" q = select(func.coalesce(func.sum(Transaction.amount), 0)).where( - Transaction.account_id == account.id, Transaction.status == "confirmed") - if stmt is not None: - q = q.where(Transaction.booking_date > stmt.period_end) - base = stmt.closing_balance - else: - base = Decimal("0") - return Decimal(base) + Decimal(session.execute(q).scalar_one()) + Transaction.account_id == account_id, Transaction.status == "confirmed") + if after is not None: + q = q.where(Transaction.booking_date > after) + if upto is not None: + q = q.where(Transaction.booking_date <= upto) + return Decimal(session.execute(q).scalar_one()) -def total_balance(session: Session) -> Decimal: +def account_balance(session: Session, account: Account, at: date | None = None) -> Decimal: + """Kontostand am Ende des Tages `at` (Default: heute). + + Konto-Saldo-Anker (Ausbaustufe 3 Task 1) ersetzt die bisherige + Statement-closing-Logik ersatzlos. Konvention: der Anker gilt PER + TAGESENDE des Ankerdatums - er enthaelt bereits alle Buchungen bis + einschliesslich diesem Tag. + + Mit Anker: anchor_balance + Sum(tx: anchor_date < booking_date <= at) + - Sum(tx: at < booking_date <= anchor_date) + (jeweils nur eine der beiden Summen ist nicht-leer, je nachdem ob `at` + vor oder nach dem Anker liegt; bei at == anchor_date sind beide leer.) + + Ohne Anker: Sum(tx: booking_date <= at) - bisheriges Verhalten mit Basis 0. + """ + if at is None: + at = date.today() + + if account.anchor_date is not None and account.anchor_balance is not None: + anchor_date = account.anchor_date + anchor_balance = Decimal(account.anchor_balance) + if at >= anchor_date: + return anchor_balance + _confirmed_sum(session, account.id, anchor_date, at) + return anchor_balance - _confirmed_sum(session, account.id, at, anchor_date) + + return _confirmed_sum(session, account.id, None, at) + + +def total_balance(session: Session, at: date | None = None) -> Decimal: accounts = session.execute(select(Account)).scalars().all() - return sum((account_balance(session, a) for a in accounts), Decimal("0")) + return sum((account_balance(session, a, at) for a in accounts), Decimal("0")) diff --git a/finance/app/templates/index.html b/finance/app/templates/index.html index 8316891..bcd81c8 100644 --- a/finance/app/templates/index.html +++ b/finance/app/templates/index.html @@ -16,7 +16,10 @@
| Konto | Bank | Saldo | Umbenennen | ||
|---|---|---|---|---|---|
| Konto | Bank | Saldo | Anker | +Umbenennen | Anker pflegen | +{{ account.name }} | {{ account.bank }} | {{ '%.2f'|format(balance) }} € | ++ {% if account.anchor_date is not none and account.anchor_balance is not none %} + Anker: {{ '%.2f'|format(account.anchor_balance) }} € am {{ account.anchor_date.strftime('%d.%m.%Y') }} + {% else %} + kein Anker + {% endif %} + | + | + + | {% else %} -
| Keine Konten angelegt. | |||||
| Keine Konten angelegt. | |||||