Files
certain/backend/app/services/budget.py
T
GuidoandClaude Opus 5 4c61a02c53 fix: Eine berechnete Budget-Zeile ueberlebt jetzt das Kopieren
Beide Kopierwege uebernahmen nur planned_cents. Ein Eintrag der Betriebsart
"berechnet" fiel dabei still auf "fest" zurueck - mit der Zahl, die zufaellig
zuletzt in planned_cents stand. Der Bezug zu den Vertraegen war danach weg,
ohne dass irgendwo stand, dass er weg ist.

Vorlage -> Monat (copy_template_to_month) zieht jetzt nach, was
ensure_customized nebenan laengst richtig macht. Monat -> Vorlage
(copy_month_to_template) nimmt die Betriebsart mit, statt die gerade
herauskommende Zahl einzufrieren: Eine berechnete Zeile soll auch in der
Vorlage berechnet bleiben.

Dabei aufgefallen: Eine berechnete Zeile gehoert auch dann in die Vorlage,
wenn planned_cents null ist - ihre Zahl steht in den Vertraegen. Eine feste
Null bleibt dagegen draussen, sonst fuellte sich die Vorlage mit Nullzeilen
fuer jede je beruehrte Kategorie.

Das war Backend-Ergaenzung D und damit die letzte offene.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-21 21:04:46 +02:00

440 lines
17 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 hauptwaehrung(db: Session, user: User) -> str:
"""Waehrung, in der Soll-Betraege gefuehrt werden."""
from ..routers.settings import BASE_CURRENCY_KEY, get_user_setting
return get_user_setting(db, user, BASE_CURRENCY_KEY) or "EUR"
def soll_einer_zeile(zeile, aus_vertraegen: dict[int, int]) -> int:
"""Was eine Budget-Zeile als Soll bedeutet.
Bei "berechnet" ist die hingeschriebene Zahl nicht der Soll-Betrag, sondern
nur der Zuschlag – dazu kommt, was die Vertraege dieser Kategorie kosten.
"""
if zeile.modus == "berechnet":
return aus_vertraegen.get(zeile.category_id, 0) + zeile.zuschlag_cents
return zeile.planned_cents
def _zeilen(db: Session, user: User, label: str):
"""Die wirksamen Budget-Zeilen: Monats-Override falls vorhanden, sonst Vorlage."""
period = _period(db, user, label)
if period is not None:
return period.lines
return db.query(BudgetTemplateLine).filter(
BudgetTemplateLine.owner_id == user.id).all()
def effective_planned(db: Session, user: User, label: str) -> dict[int, int]:
"""Wirksame Soll-Beträge je Kategorie: Monats-Override falls vorhanden, sonst Vorlage."""
from . import abos
aus_vertraegen = abos.monatlich_je_kategorie(db, user, hauptwaehrung(db, user))
return {
ln.category_id: soll_einer_zeile(ln, aus_vertraegen)
for ln in _zeilen(db, user, label)
}
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,
# Ohne diese beiden fiele ein angepasster Monat auf die Zahl
# zurueck, die zufaellig zuletzt in ``planned_cents`` stand.
modus=tl.modus, zuschlag_cents=tl.zuschlag_cents,
))
db.flush()
return period
def set_month_category(db: Session, user: User, label: str, category_id: int,
planned_cents: int | None = None, modus: str | None = None,
zuschlag_cents: int | None = None) -> 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:
line = BudgetPeriodLine(category_id=category_id, planned_cents=0)
period.lines.append(line)
uebernimm(line, planned_cents, modus, zuschlag_cents)
db.commit()
def uebernimm(zeile, planned_cents: int | None, modus: str | None,
zuschlag_cents: int | None) -> None:
"""Setzt an einer Budget-Zeile, was mitgegeben wurde – und nur das.
Getrennt gehalten, weil Vorlage und Monat dieselbe Regel brauchen und zwei
Kopien davon genau die Stelle waeren, an der sie auseinanderlaufen.
"""
if planned_cents is not None:
zeile.planned_cents = max(0, planned_cents)
if zuschlag_cents is not None:
zeile.zuschlag_cents = max(0, zuschlag_cents)
if modus is not None:
zeile.modus = modus
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 Soll-Werte dieses Monats
(„diesen Monat als Standard festlegen"). Einnahmen bleiben unberührt.
Übernommen wird die **Betriebsart**, nicht die gerade herauskommende Zahl:
Eine berechnete Zeile soll auch in der Vorlage berechnet bleiben. Vorher
fror dieser Weg den Monatswert als festen Betrag ein – der Bezug zu den
Verträgen war danach still weg, und niemand sah, wo er geblieben war.
"""
zeilen = list(_zeilen(db, user, label))
db.query(BudgetTemplateLine).filter(BudgetTemplateLine.owner_id == user.id).delete()
for zeile in zeilen:
# Eine feste Null ist nichts Geplantes und gehört nicht in die Vorlage.
# Eine berechnete Zeile dagegen schon: Ihre Zahl steht in den
# Verträgen, nicht in planned_cents.
if zeile.modus != "berechnet" and zeile.planned_cents <= 0:
continue
db.add(BudgetTemplateLine(
owner_id=user.id, category_id=zeile.category_id,
planned_cents=zeile.planned_cents, modus=zeile.modus,
zuschlag_cents=zeile.zuschlag_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,
# Wie in ``ensure_customized``: Ohne diese beiden fiele eine
# berechnete Zeile auf „fest" zurueck, mit der Zahl, die zufaellig
# zuletzt in planned_cents stand.
modus=tl.modus, zuschlag_cents=tl.zuschlag_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