Budget: - Modelle + Migration 0003: budget_template_lines, planned_income_lines, budget_periods, budget_period_lines. - services/budget: Perioden-Grenzen (einstellbarer Starttag, Label nach Endmonat), Standard-Vorlage vs. Monats-Override (Snapshot-on-Customize), Soll/Ist inkl. budgetierter Sparen-Transfers, geplante Einnahmen (Soll/Ist). - routers/budgets: Vorlage, geplante Einnahmen (CRUD), Monats-Soll/Ist, Monats-Override, Zurücksetzen. pytest: Perioden, Soll/Ist, Override/Reset, Einnahmen. - Frontend: Budget-Seite (Monat + Standard-Budget), Fortschrittsbalken grün/rot, Monats-Navigation, editierbare Soll-Beträge; Sparen-Option im Transfer-Formular. Deployment-Fix (Nutzer-Anforderung: http://IP:Port muss funktionieren): - Dienst bindet an APP_BIND_HOST=0.0.0.0 (direkt im LAN erreichbar). - APP_COOKIE_SECURE=false als Standard (sonst kein Login über reines HTTP); für reinen HTTPS-Betrieb umstellbar. Doku + install.sh entsprechend angepasst. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
199 lines
6.6 KiB
Python
199 lines
6.6 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.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
|