diff --git a/backend/app/models.py b/backend/app/models.py index ca0d9e2..5ff37d4 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -331,6 +331,18 @@ class Category(Base): # weg, es liegt woanders. Solche Kategorien bleiben aus Budget, Dashboard und # Auswertungen heraus – die Buchung selbst zählt aber weiter auf den Saldo. excluded_from_stats: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + # Diese Ausgabe ist kein Verlust. Sparen, Rücklagen, Altersvorsorge: Das Geld + # ist ausgegeben im Sinne des Budgets – man kann es diesen Monat nicht mehr + # verbrauchen –, aber es ist nicht weg. + # + # Zwei Folgen, und die zweite ist die wichtigere: + # 1. Beträge stehen neutral statt rot. NICHT grün: Grün heisst in Certain + # überall "Geld kommt rein", und CHF 1'010 in Grün liest sich als + # Einnahme. Grau sagt genau das Richtige – kein Verlust, kein Gewinn. + # 2. Mehr als geplant ist keine Warnung. Wer 1'000 statt 800 zurücklegt, + # hat nichts überzogen; ihn deswegen rot anzuschreien ist schlicht + # falsch herum. + spending_is_good: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) group: Mapped[CategoryGroup] = relationship(back_populates="categories") diff --git a/backend/app/routers/budgets.py b/backend/app/routers/budgets.py index e677bc6..1b854e0 100644 --- a/backend/app/routers/budgets.py +++ b/backend/app/routers/budgets.py @@ -170,12 +170,26 @@ def _build_view(db: Session, user: User, label: str) -> dict: cats.append({ "category_id": c.id, "name": c.name, "icon": c.icon, "color": c.color, "planned_cents": p, "actual_cents": a, "delta_cents": p - a, "over": a > p, + # "over" bleibt die nackte Tatsache; ob sie eine Warnung IST, + # entscheidet die Kategorie. Wer mehr zuruecklegt als geplant, + # hat nichts ueberzogen. + "good": c.spending_is_good, }) total_planned += g_planned total_actual += g_actual + # Ueberschritten ist eine Gruppe an dem, was WIRKLICH zu viel war. Das + # Sparen bleibt draussen: Sonst faerbt sich "Finanzen" rot, weil jemand + # mehr zurueckgelegt hat als geplant – eine Warnung fuer etwas Gutes, + # und daneben stuende die echte Ueberschreitung ununterscheidbar. + # Angezeigt werden weiter die vollen Summen; nur das Urteil laesst das + # Sparen aus. + echt_geplant = sum(c["planned_cents"] for c in cats if not c["good"]) + echt_ist = sum(c["actual_cents"] for c in cats if not c["good"]) groups_out.append({ "id": g.id, "name": g.name, "icon": g.icon, "color": g.color, - "planned_cents": g_planned, "actual_cents": g_actual, "over": g_actual > g_planned, + "planned_cents": g_planned, "actual_cents": g_actual, + "over": echt_ist > echt_geplant, + "good": bool(cats) and all(c["good"] for c in cats), "categories": cats, }) diff --git a/backend/app/routers/categories.py b/backend/app/routers/categories.py index a1c1ee8..c1b18a7 100644 --- a/backend/app/routers/categories.py +++ b/backend/app/routers/categories.py @@ -155,6 +155,7 @@ def create_category( icon=payload.icon, color=payload.color, excluded_from_stats=payload.excluded_from_stats, + spending_is_good=payload.spending_is_good, position=_next_position(db, Category, owner_id=user.id, group_id=group.id), ) db.add(cat) @@ -180,7 +181,8 @@ def update_category( for key, value in data.items(): # Bei den Schaltern zählt auch False als gesetzter Wert – sonst ließe sich # ein einmal gesetztes Häkchen nie wieder entfernen. - if value is not None or key in ("is_archived", "excluded_from_stats"): + if value is not None or key in ("is_archived", "excluded_from_stats", + "spending_is_good"): setattr(cat, key, value) db.commit() db.refresh(cat) diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py index 236b75f..b5e5652 100644 --- a/backend/app/routers/dashboard.py +++ b/backend/app/routers/dashboard.py @@ -108,6 +108,15 @@ def _units(groups: list[CategoryGroup], by_category: bool): yield (g.name, g.color, [c.id for c in g.categories if not c.is_archived]) +def _kein_verlust(groups: list[CategoryGroup]) -> set[int]: + """Kategorien, deren Ausgabe kein Verlust ist (Sparen, Ruecklagen). + + Ihre Betraege stehen neutral statt rot, und mehr als geplant ist bei ihnen + keine Warnung. + """ + return {c.id for g in groups for c in g.categories if c.spending_is_good} + + @router.get("/budget-donut") def budget_donut( group_by: str = Query(default="group"), @@ -163,15 +172,22 @@ def expense_share( # Als Liste: ``_units`` ist ein Generator, und er wird hier zweimal # gebraucht – beim zweiten Mal waere er leer und das Geplante 0. - einheiten = list(_units(_expense_groups(db, user), by_category)) + gruppen = _expense_groups(db, user) + gut = _kein_verlust(gruppen) + einheiten = list(_units(gruppen, by_category)) rows = [] for name, color, ids in einheiten: val = sum(actual.get(i, 0) for i in ids) if val > 0: geplant = sum(planned.get(i, 0) for i in ids) + # Ueberschritten ist die Zeile an dem, was WIRKLICH zu viel war – + # das Sparen bleibt beim Urteil draussen, nicht beim Betrag. + echt_geplant = sum(planned.get(i, 0) for i in ids if i not in gut) + echt_ist = sum(actual.get(i, 0) for i in ids if i not in gut) rows.append({"name": name, "color": color, "amount": val, "planned": geplant, - "over": geplant > 0 and val > geplant, + "over": echt_geplant > 0 and echt_ist > echt_geplant, + "good": bool(ids) and all(i in gut for i in ids), # Womit sich die Zeile aufschluesseln laesst. "ids": list(ids)}) rows.sort(key=lambda r: r["amount"], reverse=True) @@ -251,12 +267,18 @@ def budget_progress( planned = budget.effective_planned(db, user, label) actual = budget.actual_expense_by_category(db, user, label, start, end, base) by_category = group_by == "category" + gruppen = _expense_groups(db, user) + gut = _kein_verlust(gruppen) rows = [] - for name, color, ids in _units(_expense_groups(db, user), by_category): + for name, color, ids in _units(gruppen, by_category): gp = sum(planned.get(i, 0) for i in ids) ga = sum(actual.get(i, 0) for i in ids) if gp > 0 or ga > 0: - rows.append({"name": name, "color": color, "planned": gp, "actual": ga, "over": ga > gp}) + echt_geplant = sum(planned.get(i, 0) for i in ids if i not in gut) + echt_ist = sum(actual.get(i, 0) for i in ids if i not in gut) + rows.append({"name": name, "color": color, "planned": gp, "actual": ga, + "over": echt_ist > echt_geplant, + "good": bool(ids) and all(i in gut for i in ids)}) return {"rows": rows, "currency": base, "label": label, "group_by": "category" if by_category else "group"} diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 83db063..a5a409c 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -229,6 +229,7 @@ class CategoryOut(BaseModel): is_builtin: bool is_archived: bool excluded_from_stats: bool = False + spending_is_good: bool = False # Anzahl hinterlegter Schlagwörter. Nur in der Liste gefüllt, damit die # Übersicht ohne einen Abruf je Kategorie auskommt. keyword_count: int = 0 @@ -265,6 +266,7 @@ class CategoryCreate(BaseModel): icon: str = "more-horizontal" color: str = "#6b7480" excluded_from_stats: bool = False + spending_is_good: bool = False class CategoryUpdate(BaseModel): @@ -275,6 +277,7 @@ class CategoryUpdate(BaseModel): position: int | None = None is_archived: bool | None = None excluded_from_stats: bool | None = None + spending_is_good: bool | None = None # --- Transaktionen --------------------------------------------------------- diff --git a/backend/migrations/versions/0029_ausgabe_ist_kein_verlust.py b/backend/migrations/versions/0029_ausgabe_ist_kein_verlust.py new file mode 100644 index 0000000..d1c4bd6 --- /dev/null +++ b/backend/migrations/versions/0029_ausgabe_ist_kein_verlust.py @@ -0,0 +1,31 @@ +"""Eine Ausgabe, die kein Verlust ist. + +Sparen zaehlt im Budget wie eine Ausgabe – zu Recht, denn das Geld ist diesen +Monat nicht mehr verfuegbar. Es stand danach aber in Rot da, und mehr gespart +als geplant galt als Budget-Ueberschreitung. Wer tausend zuruecklegt statt +achthundert, bekam eine Warnung fuer etwas Gutes. + +Mit diesem Haken steht der Betrag neutral, und ein Ueberschreiten ist keins. + +Revision ID: 0029 +Revises: 0028 +""" + +import sqlalchemy as sa +from alembic import op + +revision = "0029" +down_revision = "0028" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + with op.batch_alter_table("categories") as batch: + batch.add_column(sa.Column("spending_is_good", sa.Boolean(), nullable=False, + server_default=sa.false())) + + +def downgrade() -> None: + with op.batch_alter_table("categories") as batch: + batch.drop_column("spending_is_good") diff --git a/backend/tests/test_sparziel.py b/backend/tests/test_sparziel.py index 3bef841..28305e9 100644 --- a/backend/tests/test_sparziel.py +++ b/backend/tests/test_sparziel.py @@ -253,3 +253,79 @@ def test_uebertrag_am_stueck_haelt_sich_auch_daran(admin): assert _ist(admin, sparen) == vorher finally: _neutral_setzen(admin, sparen, False) + + +# --- Eine Ausgabe, die kein Verlust ist -------------------------------------- + + +def test_sparen_zaehlt_weiter_gilt_aber_nicht_als_ueberschreitung(admin): + """Der Betrag bleibt im Budget – das Geld ist diesen Monat gebunden. Nur + die Warnung faellt weg: Wer mehr zuruecklegt als geplant, hat nichts + ueberzogen, und Rot waere schlicht falsch herum.""" + admin.put("/api/settings/budget_start_day", params={"value": "1"}) + sparen = _kategorie(admin, "Sparen & Investments") + admin.put(f"/api/budget/template/category/{sparen}", params={"planned_cents": 10000}) + s = _sparen_szenario(admin, betrag=80000) # deutlich mehr als geplant + + def zeile(): + v = admin.get("/api/budget/current").json() + return next(c for g in v["groups"] for c in g["categories"] + if c["category_id"] == sparen) + + ohne = zeile() + assert ohne["actual_cents"] >= 80000, "der Betrag muss weiter zaehlen" + assert ohne["over"] is True, "die Tatsache bleibt die Tatsache" + assert ohne["good"] is False + + r = admin.patch(f"/api/categories/{sparen}", json={"spending_is_good": True}) + assert r.status_code == 200, r.text + try: + mit = zeile() + assert mit["actual_cents"] == ohne["actual_cents"], "Betrag darf sich nicht aendern" + assert mit["good"] is True, "die Kategorie meldet sich nicht als 'kein Verlust'" + finally: + admin.patch(f"/api/categories/{sparen}", json={"spending_is_good": False}) + admin.put(f"/api/budget/template/category/{sparen}", params={"planned_cents": 0}) + + +def test_gruppe_gilt_nur_dann_als_kein_verlust_wenn_alle_es_sind(admin): + """Sonst verschwindet eine echte Ueberschreitung hinter dem Sparen, das + zufaellig in derselben Gruppe liegt.""" + sparen = _kategorie(admin, "Sparen & Investments") + r = admin.patch(f"/api/categories/{sparen}", json={"spending_is_good": True}) + assert r.status_code == 200, r.text + try: + v = admin.get("/api/budget/current").json() + gruppe = next(g for g in v["groups"] + if any(c["category_id"] == sparen for c in g["categories"])) + andere = [c for c in gruppe["categories"] if c["category_id"] != sparen] + if andere: + assert gruppe["good"] is False, "eine gemischte Gruppe ist nicht 'kein Verlust'" + assert next(c for c in gruppe["categories"] + if c["category_id"] == sparen)["good"] is True + finally: + admin.patch(f"/api/categories/{sparen}", json={"spending_is_good": False}) + + +def test_gruppe_wird_nicht_rot_weil_jemand_zu_viel_gespart_hat(admin): + """Die Ueberschreitung einer Gruppe misst sich an dem, was wirklich zu viel + war. Sonst faerbt sich "Finanzen" rot, weil mehr zurueckgelegt wurde als + geplant – und die echte Ueberschreitung daneben faellt nicht mehr auf.""" + admin.put("/api/settings/budget_start_day", params={"value": "1"}) + sparen = _kategorie(admin, "Sparen & Investments") + admin.put(f"/api/budget/template/category/{sparen}", params={"planned_cents": 10000}) + admin.patch(f"/api/categories/{sparen}", json={"spending_is_good": True}) + try: + _sparen_szenario(admin, betrag=90000) # weit ueber dem Plan + v = admin.get("/api/budget/current").json() + gruppe = next(g for g in v["groups"] + if any(c["category_id"] == sparen for c in g["categories"])) + zeile = next(c for c in gruppe["categories"] if c["category_id"] == sparen) + + assert zeile["over"] is True, "die Tatsache bleibt stehen" + assert zeile["good"] is True + assert gruppe["actual_cents"] >= 90000, "die Summe zeigt weiter alles" + assert gruppe["over"] is False, "Gruppe rot wegen des Sparens" + finally: + admin.patch(f"/api/categories/{sparen}", json={"spending_is_good": False}) + admin.put(f"/api/budget/template/category/{sparen}", params={"planned_cents": 0}) diff --git a/web/src/dashboard/cards.jsx b/web/src/dashboard/cards.jsx index 5d86781..7aec026 100644 --- a/web/src/dashboard/cards.jsx +++ b/web/src/dashboard/cards.jsx @@ -55,6 +55,20 @@ function AccountBalances() { ); } +// Ist das Ueberschreiten dieser Zeile eine Warnung? Bei Sparen und Ruecklagen +// nicht: Wer mehr zurueckgelegt hat als geplant, hat nichts ueberzogen. +function warnt(r) { + return r.over && !r.good; +} + +// Farbe des Betrags. "good" schlaegt beides: nicht rot (keine Warnung), aber +// auch nicht gruen – gruen heisst hier ueberall "Geld kommt rein". +function tonFuer(r) { + if (r.good) return "muted"; + if (!(r.planned > 0)) return "muted"; + return r.over ? "neg" : "pos"; +} + function BudgetDonut({ props }) { const { t } = useTranslation(); const groupBy = props?.group_by || "group"; @@ -94,11 +108,11 @@ function BudgetProgress({ props }) {
{t("categories.excludedHint")}
+ +{t("categories.spendingIsGoodHint")}
> )}