Files
certain/backend/app/services/budget.py
T
GuidoandClaude Opus 5 c9a598a056 feat: Ueberträge als Verknuepfung erkennen, ohne Buchungen anzufassen
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>
2026-08-23 22:41:34 +02:00

261 lines
9.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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