Die Vertraege standen bisher nur auf ihrer eigenen Seite. Im Budget sah man
nichts davon – man musste selbst nachrechnen, was fest gebunden ist, und beim
naechsten Preisaufschlag noch einmal.
Jetzt hat jede Kategorie zwei Betriebsarten, umschaltbar ueber ein Sigma neben
dem Eingabefeld:
fest eine Zahl, die man hinschreibt. Wie bisher.
berechnet Summe der Vertraege dieser Kategorie plus einem Zuschlag.
Der Zuschlag ist der Punkt. Wer fuer Streaming 62 plant, weil 42 gebunden sind
und 20 Luft sein sollen, muss beim naechsten Preisaufschlag nicht nachrechnen
– und verliert seine 20 dabei auch nicht. Unter der Zeile steht die Rechnung
offen, sonst stuende im Feld eine Zahl, die nicht der Soll-Betrag ist.
Das gilt fuer JEDE Kategorie, nicht nur fuer eine Abo-Kategorie: Ein
Handyvertrag liegt in "Telefon & Internet", und die rechnet sich genauso.
Auch auf "fest" steht der Hinweis da ("davon 16.84 fest gebunden") – sonst
sieht man nie, was einen schon bindet.
Zwei Dinge, die sonst still danebengingen, melden sich jetzt oben auf der
Seite: Vertraege OHNE Kategorie (ihr Geld fehlt im Budget, ohne dass irgendwo
steht, dass es fehlt) und Waehrungen ohne Kurs.
Gerechnet wird in effective_planned – der einen Stelle, aus der Budget-Seite
UND Dashboard ihr Soll beziehen. Damit koennen die beiden gar nicht erst
auseinanderlaufen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
420 lines
16 KiB
Python
420 lines
16 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 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 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
|