feat: Ein Konto kann sagen, dass Ueberweisungen darauf Sparen sind

Wer auf ein Pocket spart, budgetiert dafuer eine Ausgabe – im Budget stand
davon aber nichts. Zwei Gruende:

Erstens gibt es zwei Formen von Uebertrag. Die eine ist EINE Buchung mit
Zielkonto, wie sie das Formular anlegt; nur fuer die gab es die Sparen-Logik.
Die andere sind ZWEI verknuepfte Buchungen, und genau so kommt ein Uebertrag
ueber die Bankanbindung herein. Beide Seiten uebersprang die Auswertung
ausnahmslos.

Zweitens hing das Sparen am Kontotyp. Ein Revolut-Pocket ist bei der Bank ein
ganz normales Konto und kommt als "girokonto" herein – am Typ ist nicht
abzulesen, dass man darauf spart.

Jetzt sagt man es dem Konto einmal ("Sparen gegen Kategorie X"), und es gilt
fuer beide Formen. Wer eine einzelne Bewegung anders sieht, nimmt sie an der
Buchung heraus oder gibt ihr eine eigene Kategorie – dieselben zwei Felder wie
bisher.

Der Normalfall aendert sich nicht: Ein Uebertrag auf ein Konto ohne Sparziel
ist weiterhin bloss verschobenes Geld.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-07 13:53:26 +02:00
co-authored by Claude Opus 5
parent 991a8e4fc2
commit 25392b9d08
11 changed files with 407 additions and 16 deletions
+28 -1
View File
@@ -11,6 +11,8 @@ from ..models import (
Account,
AccountIdentifier,
BankAccountLink,
Category,
CategoryKind,
IdentifierKind,
Transaction,
TxnType,
@@ -67,6 +69,7 @@ def to_out(db: Session, acc: Account, base: str, on: date,
opening_date=acc.opening_date,
is_archived=acc.is_archived,
position=acc.position,
savings_category_id=acc.savings_category_id,
identifiers=[
AccountIdentifierOut(id=i.id, kind=i.kind, value=i.value) for i in acc.identifiers
],
@@ -121,10 +124,29 @@ def list_accounts(db: Session = Depends(get_db), user: User = Depends(get_curren
return [to_out(db, a, base, today, saldi) for a in accounts]
def _pruefe_sparziel(db: Session, user: User, category_id: int | None) -> None:
"""Die Spar-Kategorie muss dem Nutzer gehoeren und eine AUSGABE sein.
Sparen zaehlt im Budget wie eine Ausgabe – gegen eine Einnahme-Kategorie
liesse es sich nicht verplanen, und die Zahl landete in einer Spalte, in
der sie niemand sucht.
"""
if category_id is None:
return
kategorie = get_owned(db, Category, category_id, user)
if kategorie.type != CategoryKind.expense:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(f"'{kategorie.name}' ist keine Ausgabe-Kategorie. Sparen "
f"zaehlt im Budget wie eine Ausgabe."),
)
@router.post("", response_model=AccountOut, status_code=status.HTTP_201_CREATED)
def create_account(
payload: AccountCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)
):
_pruefe_sparziel(db, user, payload.savings_category_id)
max_pos = (
db.query(Account.position)
.filter(Account.owner_id == user.id)
@@ -140,6 +162,7 @@ def create_account(
color=payload.color,
opening_balance_cents=payload.opening_balance_cents,
opening_date=payload.opening_date,
savings_category_id=payload.savings_category_id,
position=(max_pos[0] + 1) if max_pos else 0,
)
_set_identifiers(acc, payload.identifiers)
@@ -169,8 +192,12 @@ def update_account(
acc = get_owned(db, Account, account_id, user)
data = payload.model_dump(exclude_unset=True)
identifiers = data.pop("identifiers", None)
if "savings_category_id" in data:
_pruefe_sparziel(db, user, data["savings_category_id"])
for key, value in data.items():
if value is not None or key in ("opening_date",):
# "savings_category_id" gehoert in die Ausnahme: Ohne sie liesse sich
# ein einmal gesetztes Sparziel nie wieder loesen.
if value is not None or key in ("opening_date", "savings_category_id"):
setattr(acc, key, value)
if identifiers is not None:
_set_identifiers(acc, payload.identifiers)