Ein Uebertrag steht in zwei Auszuegen und wird zweimal gebucht – richtig fuer die Salden, aber er zaehlt als Ausgabe UND als Einnahme in der Auswertung. Statt beide zu einer Uebertrags-Buchung zusammenzufuehren (der urspruengliche Plan) legt Certain nur eine Schicht darueber: transfer_links haelt fest, dass zwei Buchungen zusammengehoeren. An den Buchungen selbst aendert sich nichts – Betrag, Konto, Kategorie, Import-Schluessel und Stapel bleiben. Das haelt alles heraus, was am Zusammenfuehren haette brechen koennen: der Duplikatschutz verliert keinen Schluessel, undo_batch reisst keine Gegenseite mit, und eine falsche Paarung ist ein Etikett, das man abzieht. Die Auswertung ueberspringt verknuepfte Buchungen – nicht umkategorisiert: Loest man die Verknuepfung, zaehlt die Buchung mit ihrer alten Kategorie sofort wieder mit. Automatisch nur, wenn die Zuordnung zwingend ist UND keine Seite eine Kategorie traegt UND der Text auf einen Uebertrag hindeutet. Sonst Vorschlag. Waehrungswechsel werden ueber den EZB-Kurs des Tages geprueft (2,5 % Toleranz fuer den Bankaufschlag) – der Kurs dient nur als Pruefung, gespeichert wird nichts davon. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
261 lines
9.2 KiB
Python
261 lines
9.2 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,
|
||
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."""
|
||
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()
|
||
|
||
|
||
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
|