"""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, 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.""" if start_day <= 1: return f"{d.year:04d}-{d.month:02d}" if d.day >= 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() # --- 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 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).""" result: dict[int, int] = defaultdict(int) 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: 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]: result: dict[int | None, int] = defaultdict(int) 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: result[t.category_id] += _to_base(db, t.amount_eur_cents, base, t.date) return result