From 5c8b53a995589b8d8002aabe1f600f26191377af Mon Sep 17 00:00:00 2001 From: Guido Date: Fri, 21 Aug 2026 10:17:20 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Kategorien=20aus=20Auswertungen=20ausne?= =?UTF-8?q?hmen=20(z.=20B.=20=E2=80=9EKonto=C3=BCbertrag")?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Umbuchungen zwischen eigenen Konten sind keine Ausgabe – das Geld ist nicht weg, es liegt woanders. Bisher blieb nur die Wahl zwischen einer echten Transfer- Buchung (bildet die Wirklichkeit ab, braucht aber auf beiden Seiten passende Regeln und schätzt bei Fremdwährung den Gegenwert) oder einer gewöhnlichen Ausgabe, die Budget und Statistik verfälscht. Kategorien haben jetzt den Schalter „Nicht in Auswertungen einrechnen": Die Buchung wirkt weiter auf den Kontostand, bleibt aber aus Budget, Dashboard und Auswertungen heraus. Gefiltert wird zentral in actual_expense_by_category und income_by_category – damit greift es zugleich für Budget-Donut, Budget- Fortschritt, Einnahmen/Ausgaben, Ausgaben-Anteile und die Budget-Seite. Das ersetzt die Transfer-Paarung nicht, es ist der einfachere Weg: kein Gegenkonto, keine Kursschätzung, dafür ohne Verbindung zwischen den beiden Seiten. Migration 0013, Schalter im Kategorie-Dialog, Kennzeichnung in der Liste, Test. Co-Authored-By: Claude Opus 4.8 --- backend/app/models.py | 4 ++ backend/app/routers/categories.py | 5 +- backend/app/schemas.py | 3 ++ backend/app/services/budget.py | 21 ++++++++ .../0013_category_excluded_from_stats.py | 28 ++++++++++ backend/tests/test_neutral_category.py | 54 +++++++++++++++++++ web/src/locales/de.json | 5 +- web/src/locales/en.json | 5 +- web/src/pages/Categories.jsx | 24 +++++++-- 9 files changed, 142 insertions(+), 7 deletions(-) create mode 100644 backend/migrations/versions/0013_category_excluded_from_stats.py create mode 100644 backend/tests/test_neutral_category.py diff --git a/backend/app/models.py b/backend/app/models.py index d824c0d..dc44314 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -307,6 +307,10 @@ class Category(Base): position: Mapped[int] = mapped_column(Integer, nullable=False, default=0) is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + # Umbuchungen zwischen eigenen Konten sind keine Ausgabe: Das Geld ist nicht + # 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) group: Mapped[CategoryGroup] = relationship(back_populates="categories") diff --git a/backend/app/routers/categories.py b/backend/app/routers/categories.py index a47a0d5..cda7ff0 100644 --- a/backend/app/routers/categories.py +++ b/backend/app/routers/categories.py @@ -154,6 +154,7 @@ def create_category( type=group.kind, # erbt die Art der Gruppe icon=payload.icon, color=payload.color, + excluded_from_stats=payload.excluded_from_stats, position=_next_position(db, Category, owner_id=user.id, group_id=group.id), ) db.add(cat) @@ -177,7 +178,9 @@ def update_category( cat.group_id = group.id cat.type = group.kind # Art an die neue Gruppe angleichen for key, value in data.items(): - if value is not None or key == "is_archived": + # 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"): setattr(cat, key, value) db.commit() db.refresh(cat) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 158e28d..b59ea2b 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -209,6 +209,7 @@ class CategoryOut(BaseModel): position: int is_builtin: bool is_archived: bool + excluded_from_stats: 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 @@ -244,6 +245,7 @@ class CategoryCreate(BaseModel): name: str = Field(min_length=1, max_length=120) icon: str = "more-horizontal" color: str = "#6b7480" + excluded_from_stats: bool = False class CategoryUpdate(BaseModel): @@ -253,6 +255,7 @@ class CategoryUpdate(BaseModel): color: str | None = None position: int | None = None is_archived: bool | None = None + excluded_from_stats: bool | None = None # --- Transaktionen --------------------------------------------------------- diff --git a/backend/app/services/budget.py b/backend/app/services/budget.py index 48ad739..5bc10da 100644 --- a/backend/app/services/budget.py +++ b/backend/app/services/budget.py @@ -21,6 +21,7 @@ from ..models import ( BudgetPeriodLine, BudgetTemplateLine, BudgetTreatment, + Category, CategoryKind, SAVINGS_ACCOUNT_TYPES, Transaction, @@ -172,9 +173,24 @@ def _to_base(db: Session, eur_cents: int, base: str, on: date) -> int: return conv if conv is not None else eur_cents +def _neutrale_kategorien(db: Session, user: User) -> set[int]: + """Kategorien, die nicht in Auswertungen zählen (z. B. „Kontoübertrag"). + + Eine Umbuchung zwischen eigenen Konten ist keine Ausgabe – das Geld liegt nur + woanders. Auf den Kontostand wirkt sie weiterhin, nur Budget und Statistik + lassen sie außen vor.""" + return { + cid + for (cid,) in db.query(Category.id).filter( + Category.owner_id == user.id, Category.excluded_from_stats.is_(True) + ) + } + + def actual_expense_by_category(db: Session, user: User, start: date, end: date, base: str) -> dict[int, int]: """Ist-Ausgaben je Kategorie in Hauptwährung (inkl. budgetierter Sparen-Transfers).""" result: dict[int, int] = defaultdict(int) + neutral = _neutrale_kategorien(db, user) expenses = ( db.query(Transaction) .filter( @@ -187,6 +203,8 @@ def actual_expense_by_category(db: Session, user: User, start: date, end: date, .all() ) for t in expenses: + if t.category_id in neutral: + continue result[t.category_id] += _to_base(db, t.amount_eur_cents, base, t.date) # Transfers auf Sparen/Depot zählen (sofern nicht auf "neutral" gesetzt) als @@ -213,6 +231,7 @@ def actual_expense_by_category(db: Session, user: User, start: date, end: date, def income_by_category(db: Session, user: User, start: date, end: date, base: str) -> dict[int | None, int]: result: dict[int | None, int] = defaultdict(int) + neutral = _neutrale_kategorien(db, user) incomes = ( db.query(Transaction) .filter( @@ -224,5 +243,7 @@ def income_by_category(db: Session, user: User, start: date, end: date, base: st .all() ) for t in incomes: + if t.category_id in neutral: + continue result[t.category_id] += _to_base(db, t.amount_eur_cents, base, t.date) return result diff --git a/backend/migrations/versions/0013_category_excluded_from_stats.py b/backend/migrations/versions/0013_category_excluded_from_stats.py new file mode 100644 index 0000000..0f66b04 --- /dev/null +++ b/backend/migrations/versions/0013_category_excluded_from_stats.py @@ -0,0 +1,28 @@ +"""Kategorien: aus Auswertungen ausnehmen. + +Für Umbuchungen zwischen eigenen Konten („Kontoübertrag"). Das Geld ist nicht +ausgegeben, es liegt nur woanders – solche Buchungen sollen den Kontostand +verändern, aber Budget, Dashboard und Statistik nicht verfälschen. + +Revision ID: 0013 +Revises: 0012 +Create Date: 2026-08-21 +""" +from alembic import op +import sqlalchemy as sa + +revision = "0013" +down_revision = "0012" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + with op.batch_alter_table("categories") as batch: + batch.add_column(sa.Column("excluded_from_stats", sa.Boolean(), nullable=False, + server_default=sa.text("0"))) + + +def downgrade() -> None: + with op.batch_alter_table("categories") as batch: + batch.drop_column("excluded_from_stats") diff --git a/backend/tests/test_neutral_category.py b/backend/tests/test_neutral_category.py new file mode 100644 index 0000000..c8da132 --- /dev/null +++ b/backend/tests/test_neutral_category.py @@ -0,0 +1,54 @@ +"""Kategorien, die nicht in Auswertungen zählen (z. B. „Kontoübertrag"). + +Eine Umbuchung zwischen eigenen Konten ist keine Ausgabe – das Geld liegt nur +woanders. Der Kontostand muss sie trotzdem abbilden.""" + +import datetime + + +def _account(admin, **kw): + body = {"name": "Giro N", "type": "girokonto", "currency": "EUR", "opening_balance_cents": 100000} + body.update(kw) + return admin.post("/api/accounts", json=body).json() + + +def _expense_cats(admin, n=2): + out = [] + for g in admin.get("/api/categories").json(): + if g["kind"] == "expense": + out += g["categories"] + return out[:n] + + +def test_neutral_category_out_of_stats_but_in_balance(admin): + acc = _account(admin, name="Giro Neutral") + normal, uebertrag = _expense_cats(admin, 2) + + # „Kontoübertrag" aus den Auswertungen nehmen. + r = admin.patch(f"/api/categories/{uebertrag['id']}", json={"excluded_from_stats": True}) + assert r.status_code == 200, r.text + assert r.json()["excluded_from_stats"] is True + + heute = datetime.date.today().isoformat() + for cat, cents in ((normal, 5000), (uebertrag, 30000)): + admin.post("/api/transactions", json={ + "type": "expense", "date": heute, "account_id": acc["id"], + "amount_cents": cents, "category_id": cat["id"], + }) + + # Saldo bildet BEIDE Buchungen ab: 1000.00 - 50.00 - 300.00 = 650.00 + assert admin.get(f"/api/accounts/{acc['id']}").json()["balance_cents"] == 65000 + + # Auswertungen kennen nur die echte Ausgabe. + ie = admin.get("/api/dashboard/income-expense").json() + assert ie["expense"] == 5000, ie + + anteile = admin.get("/api/dashboard/expense-share?group_by=category").json() + namen = [r["name"] for r in anteile["rows"]] + assert normal["name"] in namen + assert uebertrag["name"] not in namen + assert anteile["total"] == 5000 + + # Häkchen wieder entfernen -> zählt sofort wieder mit. + admin.patch(f"/api/categories/{uebertrag['id']}", json={"excluded_from_stats": False}) + assert admin.get("/api/dashboard/income-expense").json()["expense"] == 35000 diff --git a/web/src/locales/de.json b/web/src/locales/de.json index 2ed8677..45782e8 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -212,7 +212,10 @@ "keywordAddBtn": "Hinzufügen", "keywordEditHint": "Doppelklick auf ein Schlagwort lädt es mit Betrag oben zum Bearbeiten.", "keywordEditing": "Vorhandenes Schlagwort wird bearbeitet – Escape bricht ab.", - "searchPlaceholder": "Kategorie suchen …" + "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.", + "excludedShort": "neutral" }, "transactions": { "title": "Buchungen", diff --git a/web/src/locales/en.json b/web/src/locales/en.json index b9d45f5..bbb9ac9 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -212,7 +212,10 @@ "keywordAddBtn": "Add", "keywordEditHint": "Double-click a keyword to load it, with its amount, into the row above.", "keywordEditing": "Editing an existing keyword – press Escape to cancel.", - "searchPlaceholder": "Search category …" + "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.", + "excludedShort": "neutral" }, "transactions": { "title": "Transactions", diff --git a/web/src/pages/Categories.jsx b/web/src/pages/Categories.jsx index f9e8c43..2ef9129 100644 --- a/web/src/pages/Categories.jsx +++ b/web/src/pages/Categories.jsx @@ -196,8 +196,9 @@ export default function Categories() { editing, groupId, form: editing - ? { name: editing.name, icon: editing.icon, color: editing.color } - : { name: "", icon: "more-horizontal", color: "#6b7480" }, + ? { 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 }, }); } @@ -209,8 +210,10 @@ export default function Categories() { if (editing) await api.updateGroup(editing.id, { name: form.name.trim(), icon: form.icon, color: form.color }); else await api.createGroup({ name: form.name.trim(), kind: form.kind, icon: form.icon, color: form.color }); } else { - if (editing) await api.updateCategory(editing.id, { name: form.name.trim(), icon: form.icon, color: form.color }); - else await api.createCategory({ group_id: groupId, name: form.name.trim(), icon: form.icon, color: form.color }); + const rumpf = { name: form.name.trim(), icon: form.icon, color: form.color, + excluded_from_stats: !!form.excluded_from_stats }; + if (editing) await api.updateCategory(editing.id, rumpf); + else await api.createCategory({ group_id: groupId, ...rumpf }); } setModal(null); laden(); @@ -272,6 +275,9 @@ export default function Categories() { {c.keyword_count ? {c.keyword_count} : null} + {c.excluded_from_stats && ( + {t("categories.excludedShort")} + )} @@ -325,6 +331,16 @@ export default function Categories() { setModal({ ...modal, form: { ...modal.form, icon: name } })} /> + {modal.type === "category" && ( + <> + +

{t("categories.excludedHint")}

+ + )}