Files
certain/backend/app/services/budget.py
T
ScarriffleandClaude Opus 5 b69c383a72 fix: "Nicht in Auswertungen einrechnen" galt fuer Sparen nicht
Wer den Haken an einer Kategorie setzt, sagt damit ausdruecklich: nicht ins
Budget, nicht ins Dashboard, nicht in die Statistik. Die Sparen-Auswertung
las ihn nicht – das Geld stand danach im Ringdiagramm, obwohl der Nutzer
genau das abbestellt hatte.

Beide Uebertrags-Wege halten sich jetzt daran, der am Stueck wie der aus zwei
verknuepften Buchungen. Der Haken an der Kategorie gewinnt; ein Sparziel am
Konto darf ihn nicht stillschweigend aushebeln.

Und weil beides zusammen keinen Sinn ergibt, sagt es das Konto-Formular jetzt
auch: Waehlt man dort eine Kategorie mit Haken, steht darunter, dass so nichts
im Budget erscheinen wird.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 14:13:22 +02:00

373 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Budget-Engine: Perioden-Grenzen, Standard-Vorlage vs. Monats-Override, Soll/Ist.
Der Budget-Monat hat einen einstellbaren Starttag D. Ein Zeitraum ist nach seinem
End-/Hauptmonat benannt (YYYY-MM):
- D <= 1: der Kalendermonat.
- D >= 2: [Tag D des Vormonats … Tag D-1 des Hauptmonats].
Planned-Beträge liegen in der Hauptwährung des Nutzers; Ist-Werte werden über den
eingefrorenen EUR-Wert je Buchung in die Hauptwährung umgerechnet.
"""
import calendar
from collections import defaultdict
from datetime import date, timedelta
from dateutil.relativedelta import relativedelta
from sqlalchemy import and_, or_
from sqlalchemy.orm import Session, aliased
from ..models import (
Account,
BudgetPeriod,
BudgetPeriodLine,
BudgetTemplateLine,
BudgetTreatment,
Category,
CategoryKind,
SAVINGS_ACCOUNT_TYPES,
Transaction,
TransferLink,
TxnType,
User,
)
from . import currency
# --- Perioden --------------------------------------------------------------
def _clamp_day(year: int, month: int, day: int) -> date:
last = calendar.monthrange(year, month)[1]
return date(year, month, min(day, last))
def parse_label(label: str) -> tuple[int, int]:
y, m = label.split("-")
return int(y), int(m)
def period_bounds(label: str, start_day: int) -> tuple[date, date]:
"""(start, end) inklusive für einen Budget-Monat."""
y, m = parse_label(label)
if start_day <= 1:
return date(y, m, 1), date(y, m, calendar.monthrange(y, m)[1])
prev = date(y, m, 1) - relativedelta(months=1)
start = _clamp_day(prev.year, prev.month, start_day)
end = _clamp_day(y, m, start_day) - timedelta(days=1)
return start, end
def period_of(d: date, start_day: int) -> str:
"""Label des Budget-Monats, in den ein Datum fällt.
Der Starttag wird auf den letzten Tag des Monats gekappt – einen 31.
Februar gibt es nicht. Ohne dieselbe Kappung wie in ``period_bounds`` fiele
der 28. Februar bei Starttag 31 in einen Monat, der ihn gar nicht enthält:
Die Ausgabe waere in keiner Auswertung aufgetaucht.
"""
if start_day <= 1:
return f"{d.year:04d}-{d.month:02d}"
if d >= _clamp_day(d.year, d.month, start_day):
nxt = date(d.year, d.month, 1) + relativedelta(months=1)
return f"{nxt.year:04d}-{nxt.month:02d}"
return f"{d.year:04d}-{d.month:02d}"
def zugeordnet(label: str, start: date, end: date, spalten=Transaction):
"""Filter: Buchungen, die zu diesem Budget-Monat gehoeren.
Normalfall ist das Datum. Traegt eine Buchung aber einen ausdruecklich
gesetzten Budget-Monat, gilt dieser – und ihr Datum zaehlt dann in KEINEM
Zeitraum mehr mit, sonst waere sie zweimal gezaehlt.
``spalten`` nimmt einen Alias entgegen. Noetig, sobald dieselbe Tabelle
zweimal in einer Abfrage steht – bei einem Uebertrag sind Abgang und Zugang
beide Buchungen, und gemeint ist nur der Abgang.
"""
return or_(
spalten.budget_period == label,
and_(spalten.budget_period.is_(None),
spalten.date >= start, spalten.date <= end),
)
def start_tag(db: Session, user: User) -> int:
"""Eingestellter Starttag des Budget-Monats, auf 1..31 begrenzt."""
from ..routers.settings import BUDGET_START_DAY_KEY, get_user_setting
try:
return max(1, min(31, int(get_user_setting(db, user, BUDGET_START_DAY_KEY) or "1")))
except (TypeError, ValueError):
return 1
def shift_label(label: str, months: int) -> str:
y, m = parse_label(label)
d = date(y, m, 1) + relativedelta(months=months)
return f"{d.year:04d}-{d.month:02d}"
def current_label(start_day: int) -> str:
return period_of(date.today(), start_day)
# --- Vorlage vs. Monats-Override ------------------------------------------
def _period(db: Session, user: User, label: str) -> BudgetPeriod | None:
return (
db.query(BudgetPeriod)
.filter(BudgetPeriod.owner_id == user.id, BudgetPeriod.label == label)
.first()
)
def effective_planned(db: Session, user: User, label: str) -> dict[int, int]:
"""Wirksame Soll-Beträge je Kategorie: Monats-Override falls vorhanden, sonst Vorlage."""
period = _period(db, user, label)
if period is not None:
return {ln.category_id: ln.planned_cents for ln in period.lines}
return {
tl.category_id: tl.planned_cents
for tl in db.query(BudgetTemplateLine).filter(BudgetTemplateLine.owner_id == user.id)
}
def is_customized(db: Session, user: User, label: str) -> bool:
return _period(db, user, label) is not None
def ensure_customized(db: Session, user: User, label: str) -> BudgetPeriod:
"""Legt (falls nötig) die Monats-Periode an und übernimmt einmalig die Vorlage."""
period = _period(db, user, label)
if period is None:
period = BudgetPeriod(owner_id=user.id, label=label, customized=True)
db.add(period)
db.flush()
for tl in db.query(BudgetTemplateLine).filter(BudgetTemplateLine.owner_id == user.id):
period.lines.append(
BudgetPeriodLine(category_id=tl.category_id, planned_cents=tl.planned_cents)
)
db.flush()
return period
def set_month_category(db: Session, user: User, label: str, category_id: int, planned_cents: int) -> None:
period = ensure_customized(db, user, label)
line = next((ln for ln in period.lines if ln.category_id == category_id), None)
if line is None:
period.lines.append(BudgetPeriodLine(category_id=category_id, planned_cents=planned_cents))
else:
line.planned_cents = planned_cents
db.commit()
def reset_month(db: Session, user: User, label: str) -> None:
period = _period(db, user, label)
if period is not None:
db.delete(period)
db.commit()
def copy_month_to_template(db: Session, user: User, label: str) -> None:
"""Setzt die Standard-Vorlage auf die wirksamen Soll-Werte dieses Monats
(„diesen Monat als Standard festlegen"). Einnahmen bleiben unberührt."""
planned = effective_planned(db, user, label) # {category_id: cents}
db.query(BudgetTemplateLine).filter(BudgetTemplateLine.owner_id == user.id).delete()
for category_id, cents in planned.items():
if cents > 0:
db.add(BudgetTemplateLine(owner_id=user.id, category_id=category_id, planned_cents=cents))
db.commit()
def copy_template_to_month(db: Session, user: User, label: str) -> BudgetPeriod:
"""Überschreibt den Monat mit der aktuellen Vorlage (auch wenn er schon
angepasst war): vorhandene Monatszeilen weg, Vorlage frisch übernehmen."""
period = _period(db, user, label)
if period is None:
period = BudgetPeriod(owner_id=user.id, label=label, customized=True)
db.add(period)
db.flush()
else:
for ln in list(period.lines):
db.delete(ln)
db.flush()
period.customized = True
for tl in db.query(BudgetTemplateLine).filter(BudgetTemplateLine.owner_id == user.id):
period.lines.append(BudgetPeriodLine(category_id=tl.category_id, planned_cents=tl.planned_cents))
db.commit()
return period
# --- Ist-Werte -------------------------------------------------------------
def _to_base(db: Session, eur_cents: int, base: str, on: date) -> int:
if base == "EUR":
return eur_cents
conv = currency.from_eur_cents(db, eur_cents, base, on)
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 _spar_uebertraege(db: Session, user: User, label: str, start: date,
end: date) -> list[tuple[int, Transaction]]:
"""Verknuepfte Uebertraege, deren ZIELKONTO als Sparen gilt.
Rueckgabe je Treffer: (Kategorie, Abgangs-Buchung).
Der Bankabruf legt einen Uebertrag NICHT als eine Buchung mit Zielkonto an,
sondern als zwei verknuepfte Buchungen – Abgang hier, Zugang dort. Beide
ueberspringt die Auswertung sonst, und das ist bei einem Uebertrag zwischen
Girokonto und Tagesgeld auch richtig: Das Geld ist nur woanders.
Beim Sparen ist es das eben nicht. Wer 200 auf sein Sparkonto legt, hat
diese 200 in dem Monat nicht mehr zum Ausgeben – genau das soll ein Budget
abbilden. Traegt das Zielkonto eine Spar-Kategorie, zaehlt der Abgang
deshalb wie eine Ausgabe gegen sie.
Gezaehlt wird immer der ABGANG: Sein Betrag steht in der Waehrung des
Kontos, von dem das Geld wirklich verschwindet.
"""
abgang = aliased(Transaction)
zugang = aliased(Transaction)
treffer: list[tuple[int, Transaction]] = []
for buchung, konto_kategorie in (
db.query(abgang, Account.savings_category_id)
.join(TransferLink, TransferLink.from_txn_id == abgang.id)
.join(zugang, TransferLink.to_txn_id == zugang.id)
.join(Account, Account.id == zugang.account_id)
.filter(TransferLink.owner_id == user.id,
Account.savings_category_id.isnot(None),
zugeordnet(label, start, end, abgang))
):
# Dieselben zwei Felder wie beim Uebertrag am Stueck – wer eine einzelne
# Bewegung anders behandelt haben will, sagt es an der Buchung, nicht am
# Konto: "neutral" nimmt sie ganz heraus, eine eigene Budget-Kategorie
# schlaegt die des Kontos.
if buchung.budget_treatment == BudgetTreatment.neutral:
continue
treffer.append((buchung.budget_category_id or konto_kategorie, buchung))
return treffer
def expense_rows(db: Session, user: User, label: str, start: date, end: date,
base: str) -> list[tuple[int, Transaction, int]]:
"""Die einzelnen Buchungen hinter den Ist-Ausgaben: (Kategorie, Buchung, Betrag).
Die Summe darueber IST der Ist-Wert – beides kommt aus dieser einen Stelle,
damit eine Aufstellung nie etwas anderes ergeben kann als die Zahl, die sie
erklaeren soll.
"""
from .uebertraege import verknuepfte_ids
neutral = _neutrale_kategorien(db, user)
# Verknüpfte Buchungen sind zwei Seiten derselben Bewegung zwischen eigenen
# Konten – Geld wurde verschoben, nicht ausgegeben. Übersprungen, nicht
# umkategorisiert: Löst man die Verknüpfung, zählt die Buchung mit ihrer
# alten Kategorie sofort wieder mit.
verknuepft = verknuepfte_ids(db, user)
zeilen: list[tuple[int, Transaction, int]] = []
expenses = (
db.query(Transaction)
.filter(
Transaction.owner_id == user.id,
Transaction.type == TxnType.expense,
zugeordnet(label, start, end),
Transaction.category_id.isnot(None),
)
.all()
)
for t in expenses:
if t.category_id in neutral or t.id in verknuepft:
continue
zeilen.append((t.category_id, t, _to_base(db, t.amount_eur_cents, base, t.date)))
# Transfers auf Sparen/Depot zählen (sofern nicht auf "neutral" gesetzt) als
# budgetierte Ausgabe. Gegen welche Kategorie, sagt die Buchung selbst –
# und wenn sie schweigt, das Zielkonto.
#
# Eine Kategorie mit "Nicht in Auswertungen einrechnen" bleibt auch hier
# aussen vor. Der Haken an der Kategorie ist eine ausdrueckliche Ansage des
# Nutzers; ein Sparziel am Konto darf sie nicht stillschweigend aushebeln.
transfers = (
db.query(Transaction)
.filter(
Transaction.owner_id == user.id,
Transaction.type == TxnType.transfer,
zugeordnet(label, start, end),
)
.all()
)
for t in transfers:
if t.budget_treatment == BudgetTreatment.neutral:
continue
to_acc = db.get(Account, t.to_account_id) if t.to_account_id else None
if to_acc is None:
continue
# Das Zielkonto sagt es ausdruecklich – oder sein Typ sagt es als
# Vorgabe. Ohne beides ist ein Uebertrag kein Sparen, sondern bloss
# verschobenes Geld.
if to_acc.savings_category_id is None and to_acc.type not in SAVINGS_ACCOUNT_TYPES:
continue
kategorie = t.budget_category_id or to_acc.savings_category_id
if kategorie is None or kategorie in neutral:
continue
zeilen.append((kategorie, t, _to_base(db, t.amount_eur_cents, base, t.date)))
# Und dasselbe fuer die andere Form des Uebertrags: zwei verknuepfte
# Buchungen, wie sie der Bankabruf liefert. Ohne diesen Block bliebe Sparen
# ueber ein angebundenes Konto fuer das Budget unsichtbar.
for kategorie, t in _spar_uebertraege(db, user, label, start, end):
if kategorie is None or kategorie in neutral:
continue
zeilen.append((kategorie, t, _to_base(db, t.amount_eur_cents, base, t.date)))
return zeilen
def actual_expense_by_category(db: Session, user: User, label: str, 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)
for cat_id, _txn, cents in expense_rows(db, user, label, start, end, base):
result[cat_id] += cents
return result
def income_by_category(db: Session, user: User, label: str, start: date, end: date,
base: str) -> dict[int | None, int]:
from .uebertraege import verknuepfte_ids
result: dict[int | None, int] = defaultdict(int)
neutral = _neutrale_kategorien(db, user)
# Die Gegenseite eines Übertrags ist keine Einnahme (siehe oben).
verknuepft = verknuepfte_ids(db, user)
incomes = (
db.query(Transaction)
.filter(
Transaction.owner_id == user.id,
Transaction.type == TxnType.income,
zugeordnet(label, start, end),
)
.all()
)
for t in incomes:
if t.category_id in neutral or t.id in verknuepft:
continue
result[t.category_id] += _to_base(db, t.amount_eur_cents, base, t.date)
return result