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>
373 lines
14 KiB
Python
373 lines
14 KiB
Python
"""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
|