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 }) {
● {r.name} - {formatMoney(r.actual, data.currency)} + {formatMoney(r.actual, data.currency)} / {formatMoney(r.planned, data.currency)}
-
+
); @@ -239,7 +253,7 @@ function ExpenseShare({ props }) { segmente={data.rows.map((r) => ({ name: r.name, value: r.amount, color: r.color, // Ohne Plan keine Aussage – dort gibt es nichts zu überschreiten. - budget: r.planned > 0 ? (r.over ? "over" : "ok") : null, + budget: r.planned > 0 && !r.good ? (r.over ? "over" : "ok") : null, }))} gesamtLabel={t("cards.spent")} gesamtBudget={data.total_planned > 0 ? (data.total_over ? "over" : "ok") : null} @@ -271,8 +285,7 @@ function ExpenseShare({ props }) { {/* Grün im geplanten Rahmen, rot darüber – dieselben Farben wie überall sonst für Einnahme und Ausgabe. Ohne Plan bleibt der Betrag gedeckt: Da gibt es nichts zu überschreiten. */} - 0 ? (r.over ? "neg" : "pos") : "muted"}`}> + {formatMoney(r.amount, data.currency)}
@@ -280,7 +293,7 @@ function ExpenseShare({ props }) { {/* Der Balken IST die Ausgabe – er trägt die Aussage mit, damit man sie sieht, ohne die Zahl zu lesen. Ohne Plan bleibt die Kategoriefarbe: Da gibt es nichts zu überschreiten. */} -
0 && r.over ? " over" : ""}`} +
0 && warnt(r) ? " over" : ""}`} style={{ width: `${pct}%`, ...(r.planned > 0 ? {} : { background: r.color }) }} />
diff --git a/web/src/locales/de.json b/web/src/locales/de.json index 468aa07..da8c9c8 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -246,6 +246,8 @@ "searchPlaceholder": "Kategorie suchen …", "excludedFromStats": "Nicht in Auswertungen einrechnen", "excludedHint": "Für Umbuchungen zwischen eigenen Konten: Der Kontostand ändert sich, aber Budget, Dashboard und Statistik lassen diese Buchungen außen vor – das Geld ist ja nicht ausgegeben.", + "spendingIsGood": "Diese Ausgabe ist kein Verlust", + "spendingIsGoodHint": "Für Sparen, Rücklagen und Vorsorge: Der Betrag zählt weiter im Budget – das Geld ist diesen Monat gebunden –, steht aber neutral statt rot. Und mehr zurückzulegen als geplant gilt nicht als Überschreitung.", "excludedShort": "neutral", "applyRules": "Regeln nachträglich anwenden", "applyRulesHint": "Regeln greifen beim Import. Alles, was vorher hereinkam – oder bevor es die Regel gab –, liegt noch ohne Kategorie da. Hier holst du das nach.", diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 3148495..296eafd 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -246,6 +246,8 @@ "searchPlaceholder": "Search category …", "excludedFromStats": "Exclude from reports", "excludedHint": "For transfers between your own accounts: the balance still changes, but budget, dashboard and statistics leave these out – the money was not spent.", + "spendingIsGood": "This spending is not a loss", + "spendingIsGoodHint": "For savings and reserves: the amount still counts towards the budget – the money is committed this month – but it is shown neutrally instead of red, and putting aside more than planned is not treated as overspending.", "excludedShort": "neutral", "applyRules": "Apply rules to existing transactions", "applyRulesHint": "Rules apply on import. Anything that came in earlier – or before the rule existed – still sits there without a category. This catches up.", diff --git a/web/src/pages/Budget.jsx b/web/src/pages/Budget.jsx index 9897040..b5346dc 100644 --- a/web/src/pages/Budget.jsx +++ b/web/src/pages/Budget.jsx @@ -10,12 +10,15 @@ import Modal from "../components/Modal"; import EditableAmount from "../components/EditableAmount"; import { formatMoney, formatMonthLabel } from "../format"; -function ProgressBar({ actual, planned }) { +function ProgressBar({ actual, planned, over }) { const pct = planned > 0 ? Math.min(actual / planned, 1) * 100 : actual > 0 ? 100 : 0; - const over = actual > planned; + // Ob ueberschritten wurde, sagt der Server – er laesst dabei das Sparen aus, + // und das ist eine Rechnung, die hier niemand nachbauen sollte. Ohne Angabe + // (andere Aufrufer) bleibt es beim schlichten Vergleich. + const rot = over ?? actual > planned; return (
-
+
); } @@ -158,15 +161,15 @@ export default function Budget() {
{g.name} - {fmt(g.actual_cents)} + {fmt(g.actual_cents)} / {fmt(g.planned_cents)}
- + {g.categories.map((c) => (
{c.name} - {fmt(c.actual_cents)} + {fmt(c.actual_cents)} saveMonth(v.label, c.category_id, cents)} />
))} diff --git a/web/src/pages/Categories.jsx b/web/src/pages/Categories.jsx index 2ef9129..db69897 100644 --- a/web/src/pages/Categories.jsx +++ b/web/src/pages/Categories.jsx @@ -197,8 +197,10 @@ export default function Categories() { groupId, form: editing ? { name: editing.name, icon: editing.icon, color: editing.color, - excluded_from_stats: !!editing.excluded_from_stats } - : { name: "", icon: "more-horizontal", color: "#6b7480", excluded_from_stats: false }, + excluded_from_stats: !!editing.excluded_from_stats, + spending_is_good: !!editing.spending_is_good } + : { name: "", icon: "more-horizontal", color: "#6b7480", + excluded_from_stats: false, spending_is_good: false }, }); } @@ -211,7 +213,8 @@ export default function Categories() { else await api.createGroup({ name: form.name.trim(), kind: form.kind, icon: form.icon, color: form.color }); } else { const rumpf = { name: form.name.trim(), icon: form.icon, color: form.color, - excluded_from_stats: !!form.excluded_from_stats }; + excluded_from_stats: !!form.excluded_from_stats, + spending_is_good: !!form.spending_is_good }; if (editing) await api.updateCategory(editing.id, rumpf); else await api.createCategory({ group_id: groupId, ...rumpf }); } @@ -339,6 +342,12 @@ export default function Categories() { {t("categories.excludedFromStats")}

{t("categories.excludedHint")}

+ +

{t("categories.spendingIsGoodHint")}

)}