feat: Zu welchem Budget-Monat eine Buchung gehoert

Bisher entschied allein das Datum. Bei einem Budget-Monat, der nicht am
Ersten beginnt, faellt das auf: Die Zahlung geht am 24. ab und gehoert
doch zum naechsten Monat, die Rechnung wird verspaetet beglichen und
gehoert zum letzten.

Neu traegt jede Buchung einen Budget-Monat. Leer heisst weiterhin "aus dem
Datum" - der Normalfall, und er bleibt es. Wird einer von Hand gesetzt,
zaehlt die Buchung nur noch dort und in ihrem eigenen Monat NICHT mehr,
sonst stuende sie zweimal da.

Das Aendern liegt bewusst hinter einem Klick und hinter Pfeilen statt
hinter einer Auswahlliste: Es ist der Ausnahmefall, nicht der Normalfall,
und soll sich auch so anfuehlen. Waehlt man genau den Monat, der ohnehin
gilt, bleibt die Buchung automatisch - verschiebt sich das Datum spaeter,
wandert sie mit. In der Liste zeigt eine Marke den Monat, aber nur, wenn
jemand eingegriffen hat.

Der Server nimmt drei Monate in jede Richtung an. Ein Vertipper wie
2019-03 wird abgelehnt: Er liesse die Buchung aus jeder Auswertung
verschwinden, ohne dass irgendwo etwas fehlt.

Geprueft im Browser gegen eine Wegwerf-Datenbank mit Stichtag 25: Die
Karte summiert 25.08.-24.09., eine verschobene Buchung verlaesst diesen
Zeitraum und kehrt beim Zuruecknehmen zurueck.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-30 14:57:16 +02:00
co-authored by Claude Opus 5
parent bd79b83339
commit 0b19e07ce0
15 changed files with 413 additions and 35 deletions
+32 -8
View File
@@ -13,6 +13,7 @@ from collections import defaultdict
from datetime import date, timedelta
from dateutil.relativedelta import relativedelta
from sqlalchemy import and_, or_
from sqlalchemy.orm import Session
from ..models import (
@@ -71,6 +72,30 @@ def period_of(d: date, start_day: int) -> str:
return f"{d.year:04d}-{d.month:02d}"
def zugeordnet(label: str, start: date, end: date):
"""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.
"""
return or_(
Transaction.budget_period == label,
and_(Transaction.budget_period.is_(None),
Transaction.date >= start, Transaction.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)
@@ -193,7 +218,8 @@ def _neutrale_kategorien(db: Session, user: User) -> set[int]:
}
def actual_expense_by_category(db: Session, user: User, start: date, end: date, base: str) -> dict[int, int]:
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)."""
from .uebertraege import verknuepfte_ids
@@ -209,8 +235,7 @@ def actual_expense_by_category(db: Session, user: User, start: date, end: date,
.filter(
Transaction.owner_id == user.id,
Transaction.type == TxnType.expense,
Transaction.date >= start,
Transaction.date <= end,
zugeordnet(label, start, end),
Transaction.category_id.isnot(None),
)
.all()
@@ -227,8 +252,7 @@ def actual_expense_by_category(db: Session, user: User, start: date, end: date,
.filter(
Transaction.owner_id == user.id,
Transaction.type == TxnType.transfer,
Transaction.date >= start,
Transaction.date <= end,
zugeordnet(label, start, end),
Transaction.budget_category_id.isnot(None),
)
.all()
@@ -242,7 +266,8 @@ def actual_expense_by_category(db: Session, user: User, start: date, end: date,
return result
def income_by_category(db: Session, user: User, start: date, end: date, base: str) -> dict[int | None, int]:
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)
@@ -254,8 +279,7 @@ def income_by_category(db: Session, user: User, start: date, end: date, base: st
.filter(
Transaction.owner_id == user.id,
Transaction.type == TxnType.income,
Transaction.date >= start,
Transaction.date <= end,
zugeordnet(label, start, end),
)
.all()
)