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>
440 lines
17 KiB
Python
440 lines
17 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 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
|