diff --git a/backend/app/services/budget.py b/backend/app/services/budget.py index 6390c04..8fb3211 100644 --- a/backend/app/services/budget.py +++ b/backend/app/services/budget.py @@ -300,6 +300,10 @@ def expense_rows(db: Session, user: User, label: str, start: date, end: date, # Transfers auf Sparen/Depot zählen (sofern nicht auf "neutral" gesetzt) als # budgetierte Ausgabe. Gegen welche Kategorie, sagt die Buchung selbst – # und wenn sie schweigt, das Zielkonto. + # + # Eine Kategorie mit "Nicht in Auswertungen einrechnen" bleibt auch hier + # aussen vor. Der Haken an der Kategorie ist eine ausdrueckliche Ansage des + # Nutzers; ein Sparziel am Konto darf sie nicht stillschweigend aushebeln. transfers = ( db.query(Transaction) .filter( @@ -321,7 +325,7 @@ def expense_rows(db: Session, user: User, label: str, start: date, end: date, 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: + if kategorie is None or kategorie in neutral: continue zeilen.append((kategorie, t, _to_base(db, t.amount_eur_cents, base, t.date))) @@ -329,7 +333,7 @@ def expense_rows(db: Session, user: User, label: str, start: date, end: date, # 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: + if kategorie is None or kategorie in neutral: continue zeilen.append((kategorie, t, _to_base(db, t.amount_eur_cents, base, t.date))) return zeilen diff --git a/backend/tests/test_sparziel.py b/backend/tests/test_sparziel.py index b7b78d4..3bef841 100644 --- a/backend/tests/test_sparziel.py +++ b/backend/tests/test_sparziel.py @@ -195,3 +195,61 @@ def test_sparziel_laesst_sich_wieder_loesen(admin): r = admin.patch(f"/api/accounts/{konto['id']}", json={"savings_category_id": None}) assert r.status_code == 200, r.text assert r.json()["savings_category_id"] is None + + +def _neutral_setzen(admin, kategorie_id, wert=True): + r = admin.patch(f"/api/categories/{kategorie_id}", + json={"excluded_from_stats": wert}) + assert r.status_code == 200, r.text + return r.json() + + +def test_nicht_in_auswertungen_schlaegt_das_sparziel(admin): + """Der Haken an der Kategorie ist eine ausdrueckliche Ansage. Ein Sparziel + am Konto darf sie nicht stillschweigend aushebeln – sonst stuende das Geld + in Budget und Ringdiagramm, obwohl der Nutzer genau das abbestellt hat.""" + sparen = _kategorie(admin, "Sparen & Investments") + try: + # Der Haken MUSS vor dem Ablesen sitzen: Er nimmt auch das Sparen der + # Nachbartests aus der Auswertung, und die Testdatenbank lebt ueber den + # ganzen Lauf. + _neutral_setzen(admin, sparen, True) + vorher = _ist(admin, sparen) + s = _sparen_szenario(admin) + assert _ist(admin, s["sparen"]) == vorher, "trotz Haken in der Auswertung" + finally: + _neutral_setzen(admin, sparen, False) + + +def test_ohne_haken_zaehlt_dasselbe_konto_wieder(admin): + """Die Gegenprobe: Es liegt WIRKLICH am Haken und nicht daran, dass der + Uebertrag ohnehin nicht gefunden wurde.""" + sparen = _kategorie(admin, "Sparen & Investments") + _neutral_setzen(admin, sparen, False) + ohne_haken = _ist(admin, sparen) # Stand vor dem Szenario, ohne Haken + _neutral_setzen(admin, sparen, True) + mit_haken = _ist(admin, sparen) + + s = _sparen_szenario(admin, betrag=33300) + assert _ist(admin, s["sparen"]) == mit_haken, "mit Haken doch gezaehlt" + + _neutral_setzen(admin, sparen, False) + assert _ist(admin, s["sparen"]) == ohne_haken + 33300, "ohne Haken nicht gezaehlt" + + +def test_uebertrag_am_stueck_haelt_sich_auch_daran(admin): + """Dieselbe Regel fuer die andere Form des Uebertrags.""" + admin.put("/api/settings/budget_start_day", params={"value": "1"}) + sparen = _kategorie(admin, "Sparen & Investments") + try: + _neutral_setzen(admin, sparen, True) + vorher = _ist(admin, sparen) + giro = _konto(admin, "Giro Stueck neutral") + pocket = _konto(admin, "Pocket Stueck neutral", savings_category_id=sparen) + r = admin.post("/api/transactions", json={ + "type": "transfer", "date": str(date.today()), "account_id": giro["id"], + "to_account_id": pocket["id"], "amount_cents": 9900}) + assert r.status_code == 201, r.text + assert _ist(admin, sparen) == vorher + finally: + _neutral_setzen(admin, sparen, False) diff --git a/web/src/locales/de.json b/web/src/locales/de.json index 0d65b3f..468aa07 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -171,6 +171,7 @@ "savingsCategory": "Sparen gegen Kategorie", "savingsCategoryNone": "Kein Sparziel", "savingsCategoryHint": "Überweisungen auf dieses Konto zählen im Budget als Sparen gegen diese Kategorie – auch dann, wenn sie über die Bank als zwei verknüpfte Buchungen hereinkommen. Einzelne Buchungen lassen sich davon ausnehmen.", + "savingsCategoryNeutral": "Diese Kategorie ist als „Nicht in Auswertungen einrechnen“ markiert. Der Haken an der Kategorie gewinnt: Solange er sitzt, taucht das Sparen weder im Budget noch im Dashboard auf.", "shortIdentWarning": "Kurze Erkennungstexte treffen leicht Fremdes: „Euro“ steckt auch in „Europa-Park“ oder „PayPal Europe“. Solche Buchungen werden dann fälschlich zu Überträgen. Nimm lieber etwas Eindeutiges wie die IBAN oder „Revolut**5064*“.", "moveUp": "Nach oben", "moveDown": "Nach unten", diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 258716a..3148495 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -171,6 +171,7 @@ "savingsCategory": "Count savings towards", "savingsCategoryNone": "Not a savings account", "savingsCategoryHint": "Transfers into this account count as savings against this category in the budget – including those that arrive from the bank as two linked transactions. Individual transactions can be excluded.", + "savingsCategoryNeutral": "This category is marked “exclude from reports”. The category's setting wins: while it is set, savings will not show up in the budget or the dashboard.", "shortIdentWarning": "Short match texts hit unrelated bookings: “Euro” also occurs in “Europa-Park” or “PayPal Europe”, turning those into transfers by mistake. Prefer something unique such as the IBAN.", "moveUp": "Move up", "moveDown": "Move down", diff --git a/web/src/pages/Accounts.jsx b/web/src/pages/Accounts.jsx index 2d5d5ff..5c7a3b4 100644 --- a/web/src/pages/Accounts.jsx +++ b/web/src/pages/Accounts.jsx @@ -231,6 +231,16 @@ export default function Accounts() { api.listCategories().then(setGroups).catch(() => setGroups([])); }, []); const expenseGroups = useMemo(() => gruppenFuer(groups, "expense"), [groups]); + // Eine Kategorie mit "Nicht in Auswertungen einrechnen" kann im Budget + // nichts zeigen – der Haken an der Kategorie gewinnt. Das gehoert hierhin + // gesagt und nicht erst gemerkt, wenn die Zahl fehlt. + const sparzielNeutral = useMemo(() => { + const id = Number(form?.savings_category_id); + if (!id) return false; + for (const g of groups) + for (const c of g.categories || []) if (c.id === id) return !!c.excluded_from_stats; + return false; + }, [groups, form?.savings_category_id]); function openNew() { setEditing(null); @@ -491,6 +501,9 @@ export default function Accounts() { />
{t("accounts.savingsCategoryHint")}
+ {sparzielNeutral && ( +{t("accounts.savingsCategoryNeutral")}
+ )}