feat: Eine Ausgabe, die kein Verlust ist

Sparen zaehlt im Budget wie eine Ausgabe – zu Recht, das Geld ist diesen Monat
gebunden. Es stand danach aber in Rot da, und wer tausend zuruecklegte statt
achthundert, bekam eine Warnung fuer etwas Gutes.

Ein Haken an der Kategorie stellt das ab. Zwei Folgen:

Der Betrag steht neutral. NICHT gruen: Gruen heisst in Certain ueberall "Geld
kommt rein", und CHF 1'010 in Gruen liest sich als Einnahme. Grau sagt genau
das Richtige – kein Verlust, kein Gewinn.

Und mehr als geplant ist keine Ueberschreitung mehr. Das gilt auch eine Ebene
hoeher: Eine Gruppe misst sich an dem, was WIRKLICH zu viel war. Sonst faerbt
sich "Finanzen" rot, weil jemand gespart hat, und die echte Ueberschreitung
daneben faellt nicht mehr auf. Angezeigt werden weiter die vollen Summen; nur
das Urteil laesst das Sparen aus.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-07 15:28:36 +02:00
co-authored by Claude Opus 5
parent 15c2a164ec
commit ff621984dc
12 changed files with 210 additions and 21 deletions
+12
View File
@@ -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")
+15 -1
View File
@@ -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,
})
+3 -1
View File
@@ -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)
+26 -4
View File
@@ -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"}
+3
View File
@@ -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 ---------------------------------------------------------
@@ -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")
+76
View File
@@ -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})