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:
co-authored by
Claude Opus 5
parent
991a8e4fc2
commit
25392b9d08
@@ -86,12 +86,16 @@ class TableSpec:
|
||||
# Reihenfolge = Einfügereihenfolge. Verweise dürfen nur auf bereits
|
||||
# eingefügte Tabellen zeigen; gelöscht wird in umgekehrter Reihenfolge.
|
||||
USER_TABLES: list[TableSpec] = [
|
||||
TableSpec("accounts", Account, "owner", label="Konten"),
|
||||
TableSpec("account_identifiers", AccountIdentifier, "account",
|
||||
{"account_id": "accounts"}, label="Konto-Merkmale"),
|
||||
# Kategorien VOR den Konten: Ein Konto kann auf eine Spar-Kategorie zeigen,
|
||||
# und ein Verweis darf nur auf eine schon eingefuegte Tabelle gehen.
|
||||
# Umgekehrt zeigt keine Kategorie je auf ein Konto.
|
||||
TableSpec("category_groups", CategoryGroup, "owner", label="Kategoriegruppen"),
|
||||
TableSpec("categories", Category, "owner",
|
||||
{"group_id": "category_groups"}, label="Kategorien"),
|
||||
TableSpec("accounts", Account, "owner",
|
||||
{"savings_category_id": "categories"}, label="Konten"),
|
||||
TableSpec("account_identifiers", AccountIdentifier, "account",
|
||||
{"account_id": "accounts"}, label="Konto-Merkmale"),
|
||||
TableSpec("import_templates", ImportTemplate, "owner", label="Import-Vorlagen"),
|
||||
TableSpec("import_batches", ImportBatch, "owner",
|
||||
{"account_id": "accounts", "template_id": "import_templates"},
|
||||
|
||||
@@ -14,7 +14,7 @@ from datetime import date, timedelta
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from sqlalchemy import and_, or_
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.orm import Session, aliased
|
||||
|
||||
from ..models import (
|
||||
Account,
|
||||
@@ -26,6 +26,7 @@ from ..models import (
|
||||
CategoryKind,
|
||||
SAVINGS_ACCOUNT_TYPES,
|
||||
Transaction,
|
||||
TransferLink,
|
||||
TxnType,
|
||||
User,
|
||||
)
|
||||
@@ -72,17 +73,21 @@ def period_of(d: date, start_day: int) -> str:
|
||||
return f"{d.year:04d}-{d.month:02d}"
|
||||
|
||||
|
||||
def zugeordnet(label: str, start: date, end: date):
|
||||
def zugeordnet(label: str, start: date, end: date, spalten=Transaction):
|
||||
"""Filter: Buchungen, die zu diesem Budget-Monat gehoeren.
|
||||
|
||||
Normalfall ist das Datum. Traegt eine Buchung aber einen ausdruecklich
|
||||
gesetzten Budget-Monat, gilt dieser – und ihr Datum zaehlt dann in KEINEM
|
||||
Zeitraum mehr mit, sonst waere sie zweimal gezaehlt.
|
||||
|
||||
``spalten`` nimmt einen Alias entgegen. Noetig, sobald dieselbe Tabelle
|
||||
zweimal in einer Abfrage steht – bei einem Uebertrag sind Abgang und Zugang
|
||||
beide Buchungen, und gemeint ist nur der Abgang.
|
||||
"""
|
||||
return or_(
|
||||
Transaction.budget_period == label,
|
||||
and_(Transaction.budget_period.is_(None),
|
||||
Transaction.date >= start, Transaction.date <= end),
|
||||
spalten.budget_period == label,
|
||||
and_(spalten.budget_period.is_(None),
|
||||
spalten.date >= start, spalten.date <= end),
|
||||
)
|
||||
|
||||
|
||||
@@ -218,6 +223,47 @@ def _neutrale_kategorien(db: Session, user: User) -> set[int]:
|
||||
}
|
||||
|
||||
|
||||
def _spar_uebertraege(db: Session, user: User, label: str, start: date,
|
||||
end: date) -> list[tuple[int, Transaction]]:
|
||||
"""Verknuepfte Uebertraege, deren ZIELKONTO als Sparen gilt.
|
||||
|
||||
Rueckgabe je Treffer: (Kategorie, Abgangs-Buchung).
|
||||
|
||||
Der Bankabruf legt einen Uebertrag NICHT als eine Buchung mit Zielkonto an,
|
||||
sondern als zwei verknuepfte Buchungen – Abgang hier, Zugang dort. Beide
|
||||
ueberspringt die Auswertung sonst, und das ist bei einem Uebertrag zwischen
|
||||
Girokonto und Tagesgeld auch richtig: Das Geld ist nur woanders.
|
||||
|
||||
Beim Sparen ist es das eben nicht. Wer 200 auf sein Sparkonto legt, hat
|
||||
diese 200 in dem Monat nicht mehr zum Ausgeben – genau das soll ein Budget
|
||||
abbilden. Traegt das Zielkonto eine Spar-Kategorie, zaehlt der Abgang
|
||||
deshalb wie eine Ausgabe gegen sie.
|
||||
|
||||
Gezaehlt wird immer der ABGANG: Sein Betrag steht in der Waehrung des
|
||||
Kontos, von dem das Geld wirklich verschwindet.
|
||||
"""
|
||||
abgang = aliased(Transaction)
|
||||
zugang = aliased(Transaction)
|
||||
treffer: list[tuple[int, Transaction]] = []
|
||||
for buchung, konto_kategorie in (
|
||||
db.query(abgang, Account.savings_category_id)
|
||||
.join(TransferLink, TransferLink.from_txn_id == abgang.id)
|
||||
.join(zugang, TransferLink.to_txn_id == zugang.id)
|
||||
.join(Account, Account.id == zugang.account_id)
|
||||
.filter(TransferLink.owner_id == user.id,
|
||||
Account.savings_category_id.isnot(None),
|
||||
zugeordnet(label, start, end, abgang))
|
||||
):
|
||||
# Dieselben zwei Felder wie beim Uebertrag am Stueck – wer eine einzelne
|
||||
# Bewegung anders behandelt haben will, sagt es an der Buchung, nicht am
|
||||
# Konto: "neutral" nimmt sie ganz heraus, eine eigene Budget-Kategorie
|
||||
# schlaegt die des Kontos.
|
||||
if buchung.budget_treatment == BudgetTreatment.neutral:
|
||||
continue
|
||||
treffer.append((buchung.budget_category_id or konto_kategorie, buchung))
|
||||
return treffer
|
||||
|
||||
|
||||
def expense_rows(db: Session, user: User, label: str, start: date, end: date,
|
||||
base: str) -> list[tuple[int, Transaction, int]]:
|
||||
"""Die einzelnen Buchungen hinter den Ist-Ausgaben: (Kategorie, Buchung, Betrag).
|
||||
@@ -252,14 +298,14 @@ def expense_rows(db: Session, user: User, label: str, start: date, end: date,
|
||||
zeilen.append((t.category_id, t, _to_base(db, t.amount_eur_cents, base, t.date)))
|
||||
|
||||
# Transfers auf Sparen/Depot zählen (sofern nicht auf "neutral" gesetzt) als
|
||||
# budgetierte Ausgabe gegen budget_category_id.
|
||||
# budgetierte Ausgabe. Gegen welche Kategorie, sagt die Buchung selbst –
|
||||
# und wenn sie schweigt, das Zielkonto.
|
||||
transfers = (
|
||||
db.query(Transaction)
|
||||
.filter(
|
||||
Transaction.owner_id == user.id,
|
||||
Transaction.type == TxnType.transfer,
|
||||
zugeordnet(label, start, end),
|
||||
Transaction.budget_category_id.isnot(None),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
@@ -267,8 +313,25 @@ def expense_rows(db: Session, user: User, label: str, start: date, end: date,
|
||||
if t.budget_treatment == BudgetTreatment.neutral:
|
||||
continue
|
||||
to_acc = db.get(Account, t.to_account_id) if t.to_account_id else None
|
||||
if to_acc is not None and to_acc.type in SAVINGS_ACCOUNT_TYPES:
|
||||
zeilen.append((t.budget_category_id, t, _to_base(db, t.amount_eur_cents, base, t.date)))
|
||||
if to_acc is None:
|
||||
continue
|
||||
# Das Zielkonto sagt es ausdruecklich – oder sein Typ sagt es als
|
||||
# Vorgabe. Ohne beides ist ein Uebertrag kein Sparen, sondern bloss
|
||||
# verschobenes Geld.
|
||||
if to_acc.savings_category_id is None and to_acc.type not in SAVINGS_ACCOUNT_TYPES:
|
||||
continue
|
||||
kategorie = t.budget_category_id or to_acc.savings_category_id
|
||||
if kategorie is None:
|
||||
continue
|
||||
zeilen.append((kategorie, t, _to_base(db, t.amount_eur_cents, base, t.date)))
|
||||
|
||||
# Und dasselbe fuer die andere Form des Uebertrags: zwei verknuepfte
|
||||
# Buchungen, wie sie der Bankabruf liefert. Ohne diesen Block bliebe Sparen
|
||||
# ueber ein angebundenes Konto fuer das Budget unsichtbar.
|
||||
for kategorie, t in _spar_uebertraege(db, user, label, start, end):
|
||||
if kategorie is None:
|
||||
continue
|
||||
zeilen.append((kategorie, t, _to_base(db, t.amount_eur_cents, base, t.date)))
|
||||
return zeilen
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user