feat: Konto-Saldo-Anker mit ankerbasierter Saldenrechnung

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 21:25:04 +02:00
parent 41d556bcde
commit f6a53b7e56
9 changed files with 297 additions and 47 deletions

View File

@@ -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 ###

View File

@@ -16,6 +16,12 @@ class Account(Base):
iban: Mapped[str] = mapped_column(String(34), unique=True) iban: Mapped[str] = mapped_column(String(34), unique=True)
name: Mapped[str] = mapped_column(String(100)) name: Mapped[str] = mapped_column(String(100))
type: Mapped[str] = mapped_column(String(20), default="giro") 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): class Category(Base):

View File

@@ -1,32 +1,45 @@
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.engine import Engine from sqlalchemy.engine import Engine
# Per-account starting balance: the opening_balance of each account's earliest # Per-account offset for the Konto-Saldo-Anker (Ausbaustufe 3 Task 1): the
# confirmed statement (if any). Used to seed the cumulative sums below so the # same math as app.services.balances.account_balance, expressed as a single
# Grafana balance charts show the real account balance, not just the running # constant "base" per account so it can be added to a plain (anchor-agnostic)
# sum of imported transactions (which would start at zero and ignore the # running sum of transaction amounts:
# balance that existed before the first imported statement). #
_ACCOUNT_BASE = """ # balance(day) = anchor_balance + Sum(tx: anchor_date < booking_date <= day)
SELECT DISTINCT ON (account_id) account_id, opening_balance # - Sum(tx: day < booking_date <= anchor_date)
FROM statements # = anchor_balance - cumsum(<=anchor_date) + cumsum(<=day)
WHERE status = 'confirmed' AND opening_balance IS NOT NULL # = base + cumsum(<=day)
ORDER BY account_id, period_start NULLS LAST, id #
# ... 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] = { VIEWS: dict[str, str] = {
"v_balance_history": f""" "v_balance_history": f"""
SELECT t.booking_date AS day, a.name AS account, 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 + SUM(SUM(t.amount)) OVER (PARTITION BY a.id
ORDER BY t.booking_date) AS balance ORDER BY t.booking_date) AS balance
FROM transactions t JOIN accounts a ON a.id = t.account_id 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' 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""" "v_balance_total": f"""
SELECT booking_date AS day, SELECT booking_date AS day,
(SELECT COALESCE(SUM(opening_balance), 0) (SELECT COALESCE(SUM(base), 0)
FROM ({_ACCOUNT_BASE}) s) FROM ({_ACCOUNT_OFFSET}) s)
+ SUM(SUM(amount)) OVER (ORDER BY booking_date) AS balance + SUM(SUM(amount)) OVER (ORDER BY booking_date) AS balance
FROM transactions WHERE status = 'confirmed' GROUP BY booking_date""", FROM transactions WHERE status = 'confirmed' GROUP BY booking_date""",
"v_monthly_by_category": """ "v_monthly_by_category": """

View File

@@ -1,3 +1,4 @@
from datetime import date
from decimal import Decimal from decimal import Decimal
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
@@ -25,14 +26,23 @@ class AccountOut(AccountIn):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
id: int id: int
balance: Decimal = Decimal("0") balance: Decimal = Decimal("0")
anchor_date: date | None = None
anchor_balance: Decimal | None = None
class AccountPatch(BaseModel): 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") @field_validator("name")
@classmethod @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() v = v.strip()
if not v or len(v) > 100: if not v or len(v) > 100:
raise ValueError("Name muss 1100 Zeichen lang sein") raise ValueError("Name muss 1100 Zeichen lang sein")
@@ -75,7 +85,25 @@ def patch_account(account_id: int, data: AccountPatch, session: Session = Depend
acc = session.get(Account, account_id) acc = session.get(Account, account_id)
if acc is None: if acc is None:
raise HTTPException(404, "Konto nicht gefunden") raise HTTPException(404, "Konto nicht gefunden")
# 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 acc.name = data.name
if anchor_fields:
acc.anchor_date = data.anchor_date
acc.anchor_balance = data.anchor_balance
session.commit() session.commit()
session.refresh(acc) session.refresh(acc)
item = AccountOut.model_validate(acc) item = AccountOut.model_validate(acc)

View File

@@ -1,26 +1,53 @@
from datetime import date
from decimal import Decimal from decimal import Decimal
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.orm import Session 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: def _confirmed_sum(session: Session, account_id: int,
stmt = session.execute( after: date | None, upto: date | None) -> Decimal:
select(Statement).where(Statement.account_id == account.id, """Summe bestaetigter Buchungen fuer `account_id` mit `after < booking_date
Statement.status == "confirmed") <= upto` (Raender werden nur gesetzt, wenn nicht None)."""
.order_by(Statement.period_end.desc())).scalars().first()
q = select(func.coalesce(func.sum(Transaction.amount), 0)).where( q = select(func.coalesce(func.sum(Transaction.amount), 0)).where(
Transaction.account_id == account.id, Transaction.status == "confirmed") Transaction.account_id == account_id, Transaction.status == "confirmed")
if stmt is not None: if after is not None:
q = q.where(Transaction.booking_date > stmt.period_end) q = q.where(Transaction.booking_date > after)
base = stmt.closing_balance if upto is not None:
else: q = q.where(Transaction.booking_date <= upto)
base = Decimal("0") return Decimal(session.execute(q).scalar_one())
return Decimal(base) + 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() 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"))

View File

@@ -16,7 +16,10 @@
<table> <table>
<thead> <thead>
<tr><th>Konto</th><th>Bank</th><th>Saldo</th><th>Umbenennen</th></tr> <tr>
<th>Konto</th><th>Bank</th><th>Saldo</th><th>Anker</th>
<th>Umbenennen</th><th>Anker pflegen</th>
</tr>
</thead> </thead>
<tbody> <tbody>
{% for account, balance in balances %} {% for account, balance in balances %}
@@ -24,6 +27,13 @@
<td class="account">{{ account.name }}</td> <td class="account">{{ account.name }}</td>
<td>{{ account.bank }}</td> <td>{{ account.bank }}</td>
<td class="{{ 'neg' if balance < 0 else '' }}">{{ '%.2f'|format(balance) }} €</td> <td class="{{ 'neg' if balance < 0 else '' }}">{{ '%.2f'|format(balance) }} €</td>
<td>
{% 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 %}
</td>
<td> <td>
<form class="inline-form" hx-ext="json-form" hx-patch="/api/accounts/{{ account.id }}" <form class="inline-form" hx-ext="json-form" hx-patch="/api/accounts/{{ account.id }}"
hx-swap="none" hx-on::after-request="if(event.detail.successful){window.location.reload()}"> hx-swap="none" hx-on::after-request="if(event.detail.successful){window.location.reload()}">
@@ -31,9 +41,19 @@
<button type="submit">Umbenennen</button> <button type="submit">Umbenennen</button>
</form> </form>
</td> </td>
<td>
<form class="inline-form" hx-ext="json-form" hx-patch="/api/accounts/{{ account.id }}"
hx-swap="none" hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<input type="date" name="anchor_date"
value="{{ account.anchor_date.isoformat() if account.anchor_date is not none else '' }}" required>
<input type="text" name="anchor_balance" placeholder="500.00"
value="{{ account.anchor_balance if account.anchor_balance is not none else '' }}" required>
<button type="submit">Anker setzen</button>
</form>
</td>
</tr> </tr>
{% else %} {% else %}
<tr><td colspan="4">Keine Konten angelegt.</td></tr> <tr><td colspan="6">Keine Konten angelegt.</td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>

View File

@@ -1,28 +1,91 @@
from datetime import date from datetime import date, timedelta
from decimal import Decimal 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 from app.services.balances import account_balance
H = {"Authorization": "Bearer test-key"} H = {"Authorization": "Bearer test-key"}
def test_account_crud_and_balance(client, db): 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={ r = client.post("/api/accounts", headers=H, json={
"bank": "DKB", "iban": "DE02120300000000202051", "bank": "DKB", "iban": "DE02120300000000202051",
"name": "Giro", "type": "giro"}) "name": "Giro", "type": "giro"})
assert r.status_code == 201 assert r.status_code == 201
acc_id = r.json()["id"] acc_id = r.json()["id"]
db.add(Statement(filename="a.pdf", bank="dkb", account_id=acc_id, acc = db.get(Account, acc_id)
period_start=date(2026, 6, 1), period_end=date(2026, 6, 30), acc.anchor_date = date(2026, 6, 30)
opening_balance=Decimal("0"), closing_balance=Decimal("100.00"), acc.anchor_balance = Decimal("100.00")
status="confirmed"))
db.add(Transaction(account_id=acc_id, booking_date=date(2026, 7, 2), db.add(Transaction(account_id=acc_id, booking_date=date(2026, 7, 2),
amount=Decimal("-30.00"), purpose="Bar", status="confirmed", amount=Decimal("-30.00"), purpose="Bar", status="confirmed",
dedup_hash="x1")) dedup_hash="x1"))
db.commit() db.commit()
acc = db.get(Account, acc_id) 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): def test_manual_transaction_dedup(client):
@@ -62,6 +125,40 @@ def test_patch_account_name(client):
json={"name": " "}).status_code == 422 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): def test_patch_transaction_category(client):
cat = client.post("/api/categories", headers=H, json={"name": "Freizeit"}).json() cat = client.post("/api/categories", headers=H, json={"name": "Freizeit"}).json()
acc = client.post("/api/accounts", headers=H, json={ acc = client.post("/api/accounts", headers=H, json={

View File

@@ -1,6 +1,10 @@
from datetime import date, timedelta from datetime import date, timedelta
from decimal import Decimal from decimal import Decimal
from app.models.tables import Account
H = {"Authorization": "Bearer test-key"}
def test_pages_require_login(client): def test_pages_require_login(client):
for path in ("/", "/buchungen", "/import", "/planung", "/hilfe"): 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 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): def test_import_page_has_dropzone_and_list(client):
client.post("/login", data={"username": "admin", "password": "geheim"}) client.post("/login", data={"username": "admin", "password": "geheim"})
r = client.get("/import") r = client.get("/import")

View File

@@ -1,20 +1,21 @@
from datetime import date from datetime import date
from decimal import Decimal 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 from app.services.suggestions import suggest_recurring
H = {"Authorization": "Bearer test-key"} H = {"Authorization": "Bearer test-key"}
def _seed_balance(db, amount="1000.00"): 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") acc = Account(bank="dkb", iban="DE01", name="G", type="giro")
db.add(acc) db.add(acc)
db.flush() db.flush()
db.add(Statement(filename="s.pdf", bank="dkb", account_id=acc.id, acc.anchor_date = date(2026, 6, 30)
period_start=date(2026, 6, 1), period_end=date(2026, 6, 30), acc.anchor_balance = Decimal(amount)
opening_balance=Decimal("0"), closing_balance=Decimal(amount),
status="confirmed"))
db.commit() db.commit()