fix: "Nicht in Auswertungen einrechnen" galt fuer Sparen nicht
Wer den Haken an einer Kategorie setzt, sagt damit ausdruecklich: nicht ins Budget, nicht ins Dashboard, nicht in die Statistik. Die Sparen-Auswertung las ihn nicht – das Geld stand danach im Ringdiagramm, obwohl der Nutzer genau das abbestellt hatte. Beide Uebertrags-Wege halten sich jetzt daran, der am Stueck wie der aus zwei verknuepften Buchungen. Der Haken an der Kategorie gewinnt; ein Sparziel am Konto darf ihn nicht stillschweigend aushebeln. Und weil beides zusammen keinen Sinn ergibt, sagt es das Konto-Formular jetzt auch: Waehlt man dort eine Kategorie mit Haken, steht darunter, dass so nichts im Budget erscheinen wird. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
e0688ab95e
commit
b69c383a72
@@ -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
|
# Transfers auf Sparen/Depot zählen (sofern nicht auf "neutral" gesetzt) als
|
||||||
# budgetierte Ausgabe. Gegen welche Kategorie, sagt die Buchung selbst –
|
# budgetierte Ausgabe. Gegen welche Kategorie, sagt die Buchung selbst –
|
||||||
# und wenn sie schweigt, das Zielkonto.
|
# 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 = (
|
transfers = (
|
||||||
db.query(Transaction)
|
db.query(Transaction)
|
||||||
.filter(
|
.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:
|
if to_acc.savings_category_id is None and to_acc.type not in SAVINGS_ACCOUNT_TYPES:
|
||||||
continue
|
continue
|
||||||
kategorie = t.budget_category_id or to_acc.savings_category_id
|
kategorie = t.budget_category_id or to_acc.savings_category_id
|
||||||
if kategorie is None:
|
if kategorie is None or kategorie in neutral:
|
||||||
continue
|
continue
|
||||||
zeilen.append((kategorie, t, _to_base(db, t.amount_eur_cents, base, t.date)))
|
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
|
# Buchungen, wie sie der Bankabruf liefert. Ohne diesen Block bliebe Sparen
|
||||||
# ueber ein angebundenes Konto fuer das Budget unsichtbar.
|
# ueber ein angebundenes Konto fuer das Budget unsichtbar.
|
||||||
for kategorie, t in _spar_uebertraege(db, user, label, start, end):
|
for kategorie, t in _spar_uebertraege(db, user, label, start, end):
|
||||||
if kategorie is None:
|
if kategorie is None or kategorie in neutral:
|
||||||
continue
|
continue
|
||||||
zeilen.append((kategorie, t, _to_base(db, t.amount_eur_cents, base, t.date)))
|
zeilen.append((kategorie, t, _to_base(db, t.amount_eur_cents, base, t.date)))
|
||||||
return zeilen
|
return zeilen
|
||||||
|
|||||||
@@ -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})
|
r = admin.patch(f"/api/accounts/{konto['id']}", json={"savings_category_id": None})
|
||||||
assert r.status_code == 200, r.text
|
assert r.status_code == 200, r.text
|
||||||
assert r.json()["savings_category_id"] is None
|
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)
|
||||||
|
|||||||
@@ -171,6 +171,7 @@
|
|||||||
"savingsCategory": "Sparen gegen Kategorie",
|
"savingsCategory": "Sparen gegen Kategorie",
|
||||||
"savingsCategoryNone": "Kein Sparziel",
|
"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.",
|
"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*“.",
|
"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",
|
"moveUp": "Nach oben",
|
||||||
"moveDown": "Nach unten",
|
"moveDown": "Nach unten",
|
||||||
|
|||||||
@@ -171,6 +171,7 @@
|
|||||||
"savingsCategory": "Count savings towards",
|
"savingsCategory": "Count savings towards",
|
||||||
"savingsCategoryNone": "Not a savings account",
|
"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.",
|
"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.",
|
"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",
|
"moveUp": "Move up",
|
||||||
"moveDown": "Move down",
|
"moveDown": "Move down",
|
||||||
|
|||||||
@@ -231,6 +231,16 @@ export default function Accounts() {
|
|||||||
api.listCategories().then(setGroups).catch(() => setGroups([]));
|
api.listCategories().then(setGroups).catch(() => setGroups([]));
|
||||||
}, []);
|
}, []);
|
||||||
const expenseGroups = useMemo(() => gruppenFuer(groups, "expense"), [groups]);
|
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() {
|
function openNew() {
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
@@ -491,6 +501,9 @@ export default function Accounts() {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<p className="muted small">{t("accounts.savingsCategoryHint")}</p>
|
<p className="muted small">{t("accounts.savingsCategoryHint")}</p>
|
||||||
|
{sparzielNeutral && (
|
||||||
|
<p className="muted warn small">{t("accounts.savingsCategoryNeutral")}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<label>
|
<label>
|
||||||
<span>{t("accounts.icon")}</span>
|
<span>{t("accounts.icon")}</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user