"""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.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 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 actual_expense_by_category(db: Session, user: User, start: date, end: date, base: str) -> dict[int, int]: """Ist-Ausgaben je Kategorie in Hauptwährung (inkl. budgetierter Sparen-Transfers).""" from .uebertraege import verknuepfte_ids result: dict[int, int] = defaultdict(int) 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) expenses = ( db.query(Transaction) .filter( Transaction.owner_id == user.id, Transaction.type == TxnType.expense, Transaction.date >= start, Transaction.date <= end, Transaction.category_id.isnot(None), ) .all() ) for t in expenses: 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) # 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, Transaction.date >= start, Transaction.date <= 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: result[t.budget_category_id] += _to_base(db, t.amount_eur_cents, base, t.date) return result def income_by_category(db: Session, user: User, 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, Transaction.date >= start, Transaction.date <= 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