Files
certain/backend/app/services/budget.py
T
GuidoandClaude Opus 5 ee36dae34a feat: Zeigen, welche Buchungen in einem Ausgaben-Anteil stecken
Eine Zahl, die man nicht aufschluesseln kann, muss man glauben. Genau das
war der Streitpunkt: Die Karte nannte fuer Lebensmittel einen Betrag, und
es gab keine Moeglichkeit nachzusehen, welche Zahlungen darin einbezogen
sind und welche nicht.

Jetzt oeffnet ein Klick auf einen Anteil - in der Liste wie in der
Legende des Kreises - die Buchungen dahinter: Datum, Empfaenger, Konto,
Betrag. Die Liste kommt aus derselben Stelle, die auch die Summe bildet
(`budget.expense_rows`), sie kann also gar nicht etwas anderes zeigen als
das, was gezaehlt wurde. Ein Test haelt das fest.

Steht eine Buchung darin, obwohl ihr Datum ausserhalb des Zeitraums
liegt, traegt sie die Kalendermarke: Dann wurde ihr Budget-Monat von Hand
gesetzt, und das ist der Grund.

Nebenbei: Dialoge haengen jetzt am Seitenkoerper. Kacheln der Uebersicht
werden per `transform` positioniert - damit war die Kachel der
Bezugsrahmen fuer `position: fixed`, und der Dialog sass eingeklemmt
darin statt ueber der Seite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 15:10:19 +02:00

306 lines
11 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
from ..models import (
Account,
BudgetPeriod,
BudgetPeriodLine,
BudgetTemplateLine,
BudgetTreatment,
Category,
CategoryKind,
SAVINGS_ACCOUNT_TYPES,
Transaction,
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):
"""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.
"""
return or_(
Transaction.budget_period == label,
and_(Transaction.budget_period.is_(None),
Transaction.date >= start, Transaction.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 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 budget_category_id.
transfers = (
db.query(Transaction)
.filter(
Transaction.owner_id == user.id,
Transaction.type == TxnType.transfer,
zugeordnet(label, start, end),
Transaction.budget_category_id.isnot(None),
)
.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 not None and to_acc.type in SAVINGS_ACCOUNT_TYPES:
zeilen.append((t.budget_category_id, 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