Wer auf ein Pocket spart, budgetiert dafuer eine Ausgabe – im Budget stand
davon aber nichts. Zwei Gruende:
Erstens gibt es zwei Formen von Uebertrag. Die eine ist EINE Buchung mit
Zielkonto, wie sie das Formular anlegt; nur fuer die gab es die Sparen-Logik.
Die andere sind ZWEI verknuepfte Buchungen, und genau so kommt ein Uebertrag
ueber die Bankanbindung herein. Beide Seiten uebersprang die Auswertung
ausnahmslos.
Zweitens hing das Sparen am Kontotyp. Ein Revolut-Pocket ist bei der Bank ein
ganz normales Konto und kommt als "girokonto" herein – am Typ ist nicht
abzulesen, dass man darauf spart.
Jetzt sagt man es dem Konto einmal ("Sparen gegen Kategorie X"), und es gilt
fuer beide Formen. Wer eine einzelne Bewegung anders sieht, nimmt sie an der
Buchung heraus oder gibt ihr eine eigene Kategorie – dieselben zwei Felder wie
bisher.
Der Normalfall aendert sich nicht: Ein Uebertrag auf ein Konto ohne Sparziel
ist weiterhin bloss verschobenes Geld.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
369 lines
14 KiB
Python
369 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.
|
||
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:
|
||
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:
|
||
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
|