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:
@@ -353,6 +353,12 @@ class Transaction(Base):
|
||||
budget_category_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("categories.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
# Budget-Monat (YYYY-MM), wenn er NICHT der aus dem Datum abgeleitete ist.
|
||||
# Leer heisst: aus dem Datum. Das ist der Normalfall; gesetzt wird das Feld
|
||||
# nur fuer den Ausnahmefall, dass eine Zahlung sichtbar in den einen Monat
|
||||
# faellt, aber zum anderen gehoert – die Miete, die am 24. abgeht, oder die
|
||||
# Rechnung, die man verspaetet begleicht.
|
||||
budget_period: Mapped[str | None] = mapped_column(String(7), nullable=True, index=True)
|
||||
|
||||
payee: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
description: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
|
||||
@@ -23,10 +23,7 @@ router = APIRouter(prefix="/budget", tags=["budget"])
|
||||
|
||||
|
||||
def _start_day(db: Session, user: User) -> int:
|
||||
try:
|
||||
return max(1, min(31, int(get_user_setting(db, user, BUDGET_START_DAY_KEY) or "1")))
|
||||
except ValueError:
|
||||
return 1
|
||||
return budget.start_tag(db, user)
|
||||
|
||||
|
||||
def _require_expense_category(db: Session, user: User, category_id: int) -> Category:
|
||||
@@ -149,8 +146,8 @@ def _build_view(db: Session, user: User, label: str) -> dict:
|
||||
start_day = _start_day(db, user)
|
||||
start, end = budget.period_bounds(label, start_day)
|
||||
planned = budget.effective_planned(db, user, label)
|
||||
actual = budget.actual_expense_by_category(db, user, start, end, base)
|
||||
income_actual = budget.income_by_category(db, user, start, end, base)
|
||||
actual = budget.actual_expense_by_category(db, user, label, start, end, base)
|
||||
income_actual = budget.income_by_category(db, user, label, start, end, base)
|
||||
|
||||
groups_out = []
|
||||
total_planned = total_actual = 0
|
||||
|
||||
@@ -37,10 +37,7 @@ DEFAULT_LAYOUT = [
|
||||
|
||||
|
||||
def _start_day(db: Session, user: User) -> int:
|
||||
try:
|
||||
return max(1, min(31, int(get_user_setting(db, user, BUDGET_START_DAY_KEY) or "1")))
|
||||
except ValueError:
|
||||
return 1
|
||||
return budget.start_tag(db, user)
|
||||
|
||||
|
||||
def _label(db: Session, user: User) -> str:
|
||||
@@ -136,7 +133,7 @@ def budget_donut(
|
||||
segments, total = collect(planned)
|
||||
mode = "planned"
|
||||
if total == 0:
|
||||
segments, total = collect(budget.actual_expense_by_category(db, user, start, end, base))
|
||||
segments, total = collect(budget.actual_expense_by_category(db, user, label, start, end, base))
|
||||
mode = "actual"
|
||||
return {"segments": segments, "total": total, "currency": base, "mode": mode,
|
||||
"label": label, "group_by": "category" if by_category else "group"}
|
||||
@@ -157,7 +154,7 @@ def expense_share(
|
||||
base = base_currency(db, user)
|
||||
label = _label(db, user)
|
||||
start, end = budget.period_bounds(label, _start_day(db, user))
|
||||
actual = budget.actual_expense_by_category(db, user, start, end, base)
|
||||
actual = budget.actual_expense_by_category(db, user, label, start, end, base)
|
||||
# Das Geplante kommt mit, damit die Karte zeigen kann, ob ein Anteil noch
|
||||
# im Rahmen liegt. Ohne Plan wird nichts behauptet: „ueber Budget" waere
|
||||
# sonst die Aussage ueber jede Kategorie, fuer die nie eine gemacht wurde.
|
||||
@@ -207,7 +204,7 @@ def budget_progress(
|
||||
label = _label(db, user)
|
||||
start, end = budget.period_bounds(label, _start_day(db, user))
|
||||
planned = budget.effective_planned(db, user, label)
|
||||
actual = budget.actual_expense_by_category(db, user, start, end, base)
|
||||
actual = budget.actual_expense_by_category(db, user, label, start, end, base)
|
||||
by_category = group_by == "category"
|
||||
rows = []
|
||||
for name, color, ids in _units(_expense_groups(db, user), by_category):
|
||||
@@ -224,8 +221,8 @@ def income_expense(db: Session = Depends(get_db), user: User = Depends(get_curre
|
||||
base = base_currency(db, user)
|
||||
label = _label(db, user)
|
||||
start, end = budget.period_bounds(label, _start_day(db, user))
|
||||
expense = sum(budget.actual_expense_by_category(db, user, start, end, base).values())
|
||||
income = sum(budget.income_by_category(db, user, start, end, base).values())
|
||||
expense = sum(budget.actual_expense_by_category(db, user, label, start, end, base).values())
|
||||
income = sum(budget.income_by_category(db, user, label, start, end, base).values())
|
||||
return {"income": income, "expense": expense, "net": income - expense, "currency": base, "label": label}
|
||||
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ from ..models import (
|
||||
User,
|
||||
)
|
||||
from ..schemas import TransactionCreate, TransactionOut, TransactionUpdate
|
||||
from ..services import buchung, currency, uebertrag_buchen
|
||||
from ..services import buchung, budget, currency, uebertrag_buchen
|
||||
from ..services.uebertrag_buchen import UebertragFehler
|
||||
from .accounts import base_currency
|
||||
|
||||
@@ -57,8 +57,32 @@ def _herkunft(db: Session, user: User, txns: list[Transaction]) -> dict[int, str
|
||||
}
|
||||
|
||||
|
||||
def _pruefe_zeitraum(db: Session, user: User, label: str | None, tag: date) -> str | None:
|
||||
"""Einen von Hand gewaehlten Budget-Monat pruefen.
|
||||
|
||||
``None`` nimmt die Wahl zurueck – dann entscheidet wieder das Datum. Sonst
|
||||
muss der Monat in Reichweite des Datums liegen: Ein Vertipper wie 2019-03
|
||||
liesse die Buchung aus jeder Auswertung verschwinden, ohne dass irgendwo
|
||||
etwas fehlt. Drei Monate in jede Richtung decken den echten Fall ab (eine
|
||||
Zahlung kurz vor oder nach dem Stichtag, eine verspaetete Rechnung).
|
||||
"""
|
||||
if label is None:
|
||||
return None
|
||||
eigen = budget.period_of(tag, budget.start_tag(db, user))
|
||||
erlaubt = {budget.shift_label(eigen, n) for n in range(-3, 4)}
|
||||
if label not in erlaubt:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
f"Budget-Monat {label} liegt zu weit von der Buchung entfernt "
|
||||
f"(erlaubt: {min(erlaubt)} bis {max(erlaubt)})",
|
||||
)
|
||||
# Der abgeleitete Monat wird nicht festgeschrieben: Verschiebt sich das
|
||||
# Datum spaeter, soll die Buchung wieder von selbst mitwandern.
|
||||
return None if label == eigen else label
|
||||
|
||||
|
||||
def to_out(db: Session, txn: Transaction, base: str, ccy_by_account: dict[int, str],
|
||||
herkunft: dict[int, str] | None = None) -> TransactionOut:
|
||||
herkunft: dict[int, str] | None = None, start_tag: int = 1) -> TransactionOut:
|
||||
acc_ccy = ccy_by_account.get(txn.account_id, "EUR")
|
||||
if base == "EUR":
|
||||
amount_base = txn.amount_eur_cents
|
||||
@@ -81,6 +105,8 @@ def to_out(db: Session, txn: Transaction, base: str, ccy_by_account: dict[int, s
|
||||
category_id=txn.category_id,
|
||||
budget_treatment=txn.budget_treatment,
|
||||
budget_category_id=txn.budget_category_id,
|
||||
budget_period=txn.budget_period or budget.period_of(txn.date, start_tag),
|
||||
budget_period_manuell=txn.budget_period is not None,
|
||||
import_batch_id=txn.import_batch_id,
|
||||
herkunft=(herkunft or {}).get(txn.id, "manuell"),
|
||||
payee=txn.payee,
|
||||
@@ -156,7 +182,8 @@ def list_transactions(
|
||||
base = base_currency(db, user)
|
||||
ccy = _currency_map(db, user)
|
||||
woher = _herkunft(db, user, rows)
|
||||
return [to_out(db, t, base, ccy, woher) for t in rows]
|
||||
tag = budget.start_tag(db, user)
|
||||
return [to_out(db, t, base, ccy, woher, tag) for t in rows]
|
||||
|
||||
|
||||
@router.get("/dubletten")
|
||||
@@ -271,6 +298,7 @@ def create_transaction(
|
||||
category_id=category_id,
|
||||
budget_treatment=payload.budget_treatment,
|
||||
budget_category_id=payload.budget_category_id,
|
||||
budget_period=_pruefe_zeitraum(db, user, payload.budget_period, payload.date),
|
||||
payee=(payload.payee or None),
|
||||
description=(payload.description or None),
|
||||
notes=(payload.notes or None),
|
||||
@@ -280,7 +308,7 @@ def create_transaction(
|
||||
db.commit()
|
||||
db.refresh(txn)
|
||||
return to_out(db, txn, base_currency(db, user), _currency_map(db, user),
|
||||
_herkunft(db, user, [txn]))
|
||||
_herkunft(db, user, [txn]), budget.start_tag(db, user))
|
||||
|
||||
|
||||
@router.get("/{txn_id}", response_model=TransactionOut)
|
||||
@@ -289,7 +317,7 @@ def get_transaction(
|
||||
):
|
||||
txn = get_owned(db, Transaction, txn_id, user)
|
||||
return to_out(db, txn, base_currency(db, user), _currency_map(db, user),
|
||||
_herkunft(db, user, [txn]))
|
||||
_herkunft(db, user, [txn]), budget.start_tag(db, user))
|
||||
|
||||
|
||||
@router.patch("/{txn_id}", response_model=TransactionOut)
|
||||
@@ -320,6 +348,9 @@ def update_transaction(
|
||||
if "budget_category_id" in data:
|
||||
_validate_category(db, user, data["budget_category_id"])
|
||||
txn.budget_category_id = data["budget_category_id"]
|
||||
if "budget_period" in data:
|
||||
txn.budget_period = _pruefe_zeitraum(db, user, data["budget_period"],
|
||||
data.get("date", txn.date))
|
||||
|
||||
# Konsistenz je nach Typ + Neuberechnung der abgeleiteten Werte.
|
||||
from_acc = get_owned(db, Account, txn.account_id, user)
|
||||
@@ -336,7 +367,7 @@ def update_transaction(
|
||||
db.commit()
|
||||
db.refresh(txn)
|
||||
return to_out(db, txn, base_currency(db, user), _currency_map(db, user),
|
||||
_herkunft(db, user, [txn]))
|
||||
_herkunft(db, user, [txn]), budget.start_tag(db, user))
|
||||
|
||||
|
||||
class TransactionTransferIn(BaseModel):
|
||||
@@ -368,7 +399,7 @@ def make_transfer(txn_id: int, payload: TransactionTransferIn,
|
||||
db.commit()
|
||||
db.refresh(txn)
|
||||
return to_out(db, txn, base_currency(db, user), _currency_map(db, user),
|
||||
_herkunft(db, user, [txn]))
|
||||
_herkunft(db, user, [txn]), budget.start_tag(db, user))
|
||||
|
||||
|
||||
@router.delete("/{txn_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@@ -288,6 +288,8 @@ class TransactionCreate(BaseModel):
|
||||
category_id: int | None = None
|
||||
budget_treatment: BudgetTreatment | None = None
|
||||
budget_category_id: int | None = None
|
||||
# Budget-Monat (YYYY-MM). Leer heisst: aus dem Datum ableiten.
|
||||
budget_period: str | None = Field(default=None, pattern=r"^\d{4}-\d{2}$")
|
||||
payee: str | None = Field(default=None, max_length=255)
|
||||
description: str | None = Field(default=None, max_length=255)
|
||||
notes: str | None = None
|
||||
@@ -305,6 +307,8 @@ class TransactionUpdate(BaseModel):
|
||||
category_id: int | None = None
|
||||
budget_treatment: BudgetTreatment | None = None
|
||||
budget_category_id: int | None = None
|
||||
# Budget-Monat (YYYY-MM). Leer heisst: aus dem Datum ableiten.
|
||||
budget_period: str | None = Field(default=None, pattern=r"^\d{4}-\d{2}$")
|
||||
payee: str | None = Field(default=None, max_length=255)
|
||||
description: str | None = Field(default=None, max_length=255)
|
||||
notes: str | None = None
|
||||
@@ -326,6 +330,10 @@ class TransactionOut(BaseModel):
|
||||
category_id: int | None
|
||||
budget_treatment: BudgetTreatment | None
|
||||
budget_category_id: int | None
|
||||
# Der Budget-Monat, in dem die Buchung zaehlt – immer gesetzt. Ob er von
|
||||
# Hand gewaehlt wurde oder aus dem Datum stammt, sagt das zweite Feld.
|
||||
budget_period: str
|
||||
budget_period_manuell: bool = False
|
||||
# Woher die Buchung stammt, fuer die Arbeitsliste: Ist sie gesetzt,
|
||||
# kam die Buchung aus einem Datei-Import.
|
||||
import_batch_id: int | None = None
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user