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")} + )} openCategory(g.id, c)}> @@ -325,6 +331,16 @@ export default function Categories() { setModal({ ...modal, form: { ...modal.form, icon: name } })} /> + {modal.type === "category" && ( + <> + + setModal({ ...modal, form: { ...modal.form, excluded_from_stats: e.target.checked } })} /> + {t("categories.excludedFromStats")} + + {t("categories.excludedHint")} + > + )} {t("accounts.color")}
{t("categories.excludedHint")}