"""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