feat: Kategorien aus Auswertungen ausnehmen (z. B. „Kontoübertrag")

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 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-21 10:17:20 +02:00
co-authored by Claude Opus 4.8
parent ec0aa62017
commit 5c8b53a995
9 changed files with 142 additions and 7 deletions
+4
View File
@@ -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")
+4 -1
View File
@@ -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)
+3
View File
@@ -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 ---------------------------------------------------------
+21
View File
@@ -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
@@ -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")
+54
View File
@@ -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
+4 -1
View File
@@ -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",
+4 -1
View File
@@ -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",
+20 -4
View File
@@ -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() {
<Icon name="tags" size={14} />
{c.keyword_count ? <span className="kw-btn-zahl">{c.keyword_count}</span> : null}
</button>
{c.excluded_from_stats && (
<span className="badge" title={t("categories.excludedHint")}>{t("categories.excludedShort")}</span>
)}
<button className="btn sm ghost" title={t("common.edit")} onClick={() => openCategory(g.id, c)}>
<Icon name="edit" size={14} />
</button>
@@ -325,6 +331,16 @@ export default function Categories() {
<IconPicker icons={CATEGORY_ICONS} value={modal.form.icon} color={modal.form.color}
onChange={(name) => setModal({ ...modal, form: { ...modal.form, icon: name } })} />
</label>
{modal.type === "category" && (
<>
<label className="checkline">
<input type="checkbox" checked={!!modal.form.excluded_from_stats}
onChange={(e) => setModal({ ...modal, form: { ...modal.form, excluded_from_stats: e.target.checked } })} />
<span>{t("categories.excludedFromStats")}</span>
</label>
<p className="muted small" style={{ marginTop: -6 }}>{t("categories.excludedHint")}</p>
</>
)}
<label>
<span>{t("accounts.color")}</span>
<div className="color-row">