From f6a53b7e56800e48939f8920721720074398988d38e40c418240e5da9caa4369 Mon Sep 17 00:00:00 2001 From: wlfb Date: Sun, 19 Jul 2026 21:25:04 +0200 Subject: [PATCH] feat: Konto-Saldo-Anker mit ankerbasierter Saldenrechnung Co-Authored-By: Claude Fable 5 --- .../versions/1ef6a356f028_konto_anker.py | 34 ++++++ finance/app/models/tables.py | 6 + finance/app/models/views.py | 43 ++++--- finance/app/routers/accounts.py | 34 +++++- finance/app/services/balances.py | 57 ++++++--- finance/app/templates/index.html | 24 +++- finance/tests/test_crud_api.py | 111 ++++++++++++++++-- finance/tests/test_gui.py | 24 ++++ finance/tests/test_planning_api.py | 11 +- 9 files changed, 297 insertions(+), 47 deletions(-) create mode 100644 finance/alembic/versions/1ef6a356f028_konto_anker.py 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 @@ - + + + + {% for account, balance in balances %} @@ -24,6 +27,13 @@ + + {% else %} - + {% endfor %}
KontoBankSaldoUmbenennen
KontoBankSaldoAnkerUmbenennenAnker pflegen
{{ 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 %} +
@@ -31,9 +41,19 @@
+
+ + + +
+
Keine Konten angelegt.
Keine Konten angelegt.
diff --git a/finance/tests/test_crud_api.py b/finance/tests/test_crud_api.py index 1017728..0e0b2aa 100644 --- a/finance/tests/test_crud_api.py +++ b/finance/tests/test_crud_api.py @@ -1,28 +1,91 @@ -from datetime import date +from datetime import date, timedelta from decimal import Decimal -from app.models.tables import Account, Statement, Transaction +from app.models.tables import Account, Transaction from app.services.balances import account_balance H = {"Authorization": "Bearer test-key"} def test_account_crud_and_balance(client, db): + # Portiert von Statement-closing-Logik auf Konto-Saldo-Anker (Ausbaustufe 3 + # Task 1): frueher lieferte die closing_balance des juengsten bestaetigten + # Statements die Basis, jetzt uebernimmt der Anker (Datum+Betrag am Konto) + # dieselbe Rolle. `at` wird explizit gesetzt statt auf date.today() zu + # vertrauen, damit der Test unabhaengig vom Ausfuehrungsdatum ist. r = client.post("/api/accounts", headers=H, json={ "bank": "DKB", "iban": "DE02120300000000202051", "name": "Giro", "type": "giro"}) assert r.status_code == 201 acc_id = r.json()["id"] - db.add(Statement(filename="a.pdf", bank="dkb", account_id=acc_id, - period_start=date(2026, 6, 1), period_end=date(2026, 6, 30), - opening_balance=Decimal("0"), closing_balance=Decimal("100.00"), - status="confirmed")) + acc = db.get(Account, acc_id) + acc.anchor_date = date(2026, 6, 30) + acc.anchor_balance = Decimal("100.00") db.add(Transaction(account_id=acc_id, booking_date=date(2026, 7, 2), amount=Decimal("-30.00"), purpose="Bar", status="confirmed", dedup_hash="x1")) db.commit() acc = db.get(Account, acc_id) - assert account_balance(db, acc) == Decimal("70.00") + assert account_balance(db, acc, at=date(2026, 7, 15)) == Decimal("70.00") + + +def test_account_balance_anchor_forward_and_backward(client, db): + # Spec-Interface (Plan Ausbaustufe 3 Task 1): anchor_balance + + # Sum(tx: anchor_date < booking_date <= at) - Sum(tx: at < booking_date + # <= anchor_date). Buchungen VOR und NACH dem Anker duerfen sich nicht + # gegenseitig beeinflussen. + acc = Account(bank="dkb", iban="DE-ANCHOR-1", name="Anker-Konto", type="giro") + db.add(acc) + db.flush() + anchor_date = date(2026, 6, 30) + acc.anchor_date = anchor_date + acc.anchor_balance = Decimal("500.00") + db.add(Transaction(account_id=acc.id, booking_date=date(2026, 6, 29), + amount=Decimal("-20.00"), purpose="vor Anker", + status="confirmed", dedup_hash="x2")) + db.add(Transaction(account_id=acc.id, booking_date=date(2026, 7, 1), + amount=Decimal("50.00"), purpose="nach Anker", + status="confirmed", dedup_hash="x3")) + db.commit() + acc = db.get(Account, acc.id) + + assert account_balance(db, acc, at=anchor_date) == Decimal("500.00") + assert account_balance(db, acc, at=anchor_date + timedelta(days=2)) == Decimal("550.00") + assert account_balance(db, acc, at=anchor_date - timedelta(days=2)) == Decimal("520.00") + + +def test_account_balance_anchor_day_transaction_convention(client, db): + # Konvention (Plan Step 5): der Anker gilt PER TAGESENDE des Ankerdatums. + # Eine Buchung AM Ankertag ist im Anker bereits enthalten -> zaehlt NICHT + # zur Vorwaertssumme, wohl aber zur Rueckwaertssumme. + acc = Account(bank="dkb", iban="DE-ANCHOR-2", name="Anker-Konto 2", type="giro") + db.add(acc) + db.flush() + anchor_date = date(2026, 6, 30) + acc.anchor_date = anchor_date + acc.anchor_balance = Decimal("200.00") + db.add(Transaction(account_id=acc.id, booking_date=anchor_date, + amount=Decimal("15.00"), purpose="am Ankertag", + status="confirmed", dedup_hash="x4")) + db.commit() + acc = db.get(Account, acc.id) + + assert account_balance(db, acc, at=anchor_date + timedelta(days=1)) == Decimal("200.00") + assert account_balance(db, acc, at=anchor_date - timedelta(days=1)) == Decimal("185.00") + + +def test_account_without_anchor_uses_zero_base(client, db): + # Ohne Anker: Summe aller bestaetigten Buchungen bis `at` (Basis 0) - + # bisheriges Verhalten bleibt fuer Konten ohne Anker erhalten. + acc = Account(bank="dkb", iban="DE-NOANCHOR", name="Kein Anker", type="giro") + db.add(acc) + db.flush() + db.add(Transaction(account_id=acc.id, booking_date=date(2026, 7, 1), + amount=Decimal("42.00"), purpose="ohne Anker", + status="confirmed", dedup_hash="x5")) + db.commit() + acc = db.get(Account, acc.id) + assert account_balance(db, acc, at=date(2026, 7, 15)) == Decimal("42.00") def test_manual_transaction_dedup(client): @@ -62,6 +125,40 @@ def test_patch_account_name(client): json={"name": " "}).status_code == 422 +def test_patch_account_anchor_set_and_clear(client): + acc = client.post("/api/accounts", headers=H, json={ + "bank": "DKB", "iban": "DE72", "name": "Anker-Test", "type": "giro"}).json() + assert acc["anchor_date"] is None + assert acc["anchor_balance"] is None + + r = client.patch(f"/api/accounts/{acc['id']}", headers=H, + json={"anchor_date": "2026-06-30", "anchor_balance": "500.00"}) + assert r.status_code == 200 + body = r.json() + assert body["anchor_date"] == "2026-06-30" + assert Decimal(body["anchor_balance"]) == Decimal("500.00") + + r = client.patch(f"/api/accounts/{acc['id']}", headers=H, + json={"anchor_date": None, "anchor_balance": None}) + assert r.status_code == 200 + body = r.json() + assert body["anchor_date"] is None + assert body["anchor_balance"] is None + + +def test_patch_account_anchor_requires_both_fields(client): + acc = client.post("/api/accounts", headers=H, json={ + "bank": "DKB", "iban": "DE73", "name": "Anker-422", "type": "giro"}).json() + + assert client.patch(f"/api/accounts/{acc['id']}", headers=H, + json={"anchor_date": "2026-06-30"}).status_code == 422 + assert client.patch(f"/api/accounts/{acc['id']}", headers=H, + json={"anchor_balance": "500.00"}).status_code == 422 + assert client.patch(f"/api/accounts/{acc['id']}", headers=H, + json={"anchor_date": "2026-06-30", + "anchor_balance": None}).status_code == 422 + + def test_patch_transaction_category(client): cat = client.post("/api/categories", headers=H, json={"name": "Freizeit"}).json() acc = client.post("/api/accounts", headers=H, json={ diff --git a/finance/tests/test_gui.py b/finance/tests/test_gui.py index 159ea8d..c4b2d33 100644 --- a/finance/tests/test_gui.py +++ b/finance/tests/test_gui.py @@ -1,6 +1,10 @@ from datetime import date, timedelta from decimal import Decimal +from app.models.tables import Account + +H = {"Authorization": "Bearer test-key"} + def test_pages_require_login(client): for path in ("/", "/buchungen", "/import", "/planung", "/hilfe"): @@ -18,6 +22,26 @@ def test_pages_render_after_login(client): assert "Gebrauchsanleitung" in client.get("/hilfe").text +def test_index_shows_account_anchor(client, db): + client.post("/login", data={"username": "admin", "password": "geheim"}) + with_anchor = Account(bank="dkb", iban="DE-GUI-1", name="Mit Anker", type="giro") + without_anchor = Account(bank="vr", iban="DE-GUI-2", name="Ohne Anker", type="giro") + db.add_all([with_anchor, without_anchor]) + db.flush() + with_anchor.anchor_date = date(2026, 6, 30) + with_anchor.anchor_balance = Decimal("321.00") + db.commit() + + r = client.get("/") + assert r.status_code == 200 + assert "Anker: 321.00" in r.text + assert "30.06.2026" in r.text + assert "kein Anker" in r.text + # Inline-Pflegeformular fuer den Anker muss je Konto vorhanden sein. + assert 'name="anchor_date"' in r.text + assert 'name="anchor_balance"' in r.text + + def test_import_page_has_dropzone_and_list(client): client.post("/login", data={"username": "admin", "password": "geheim"}) r = client.get("/import") diff --git a/finance/tests/test_planning_api.py b/finance/tests/test_planning_api.py index d4308f6..348f688 100644 --- a/finance/tests/test_planning_api.py +++ b/finance/tests/test_planning_api.py @@ -1,20 +1,21 @@ from datetime import date from decimal import Decimal -from app.models.tables import Account, Category, RecurringItem, Statement, Transaction +from app.models.tables import Account, Category, RecurringItem, Transaction from app.services.suggestions import suggest_recurring H = {"Authorization": "Bearer test-key"} def _seed_balance(db, amount="1000.00"): + # Portiert von Statement-closing-Logik auf Konto-Saldo-Anker (Ausbaustufe 3 + # Task 1): der Anker am Konto uebernimmt die Rolle der frueheren + # Statement.closing_balance als Saldo-Basis. acc = Account(bank="dkb", iban="DE01", name="G", type="giro") db.add(acc) db.flush() - db.add(Statement(filename="s.pdf", bank="dkb", account_id=acc.id, - period_start=date(2026, 6, 1), period_end=date(2026, 6, 30), - opening_balance=Decimal("0"), closing_balance=Decimal(amount), - status="confirmed")) + acc.anchor_date = date(2026, 6, 30) + acc.anchor_balance = Decimal(amount) db.commit()