diff --git a/backend/app/models.py b/backend/app/models.py index 07ef95b..820dc86 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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) diff --git a/backend/app/routers/budgets.py b/backend/app/routers/budgets.py index 6472c20..e677bc6 100644 --- a/backend/app/routers/budgets.py +++ b/backend/app/routers/budgets.py @@ -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 diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py index 00aada2..f805e4a 100644 --- a/backend/app/routers/dashboard.py +++ b/backend/app/routers/dashboard.py @@ -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} diff --git a/backend/app/routers/transactions.py b/backend/app/routers/transactions.py index 9e07a15..0704a4e 100644 --- a/backend/app/routers/transactions.py +++ b/backend/app/routers/transactions.py @@ -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) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index c6112b4..d6df995 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -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 diff --git a/backend/app/services/budget.py b/backend/app/services/budget.py index 8c35f2f..afe1755 100644 --- a/backend/app/services/budget.py +++ b/backend/app/services/budget.py @@ -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() ) diff --git a/backend/migrations/versions/0026_budgetzeitraum.py b/backend/migrations/versions/0026_budgetzeitraum.py new file mode 100644 index 0000000..ec0a81d --- /dev/null +++ b/backend/migrations/versions/0026_budgetzeitraum.py @@ -0,0 +1,33 @@ +"""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 Miete geht am 24. ab und gehoert doch zum naechsten +Monat, die Rechnung wird verspaetet bezahlt und gehoert zum letzten. + +Leer heisst weiterhin "aus dem Datum" – das ist der Normalfall und bleibt es. +Deshalb genuegt eine Spalte ohne Vorbelegung: Alles Bestehende rechnet danach +genau wie vorher. + +Revision ID: 0026 +Revises: 0025 +""" + +import sqlalchemy as sa +from alembic import op + +revision = "0026" +down_revision = "0025" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + with op.batch_alter_table("transactions") as batch: + batch.add_column(sa.Column("budget_period", sa.String(length=7), nullable=True)) + batch.create_index("ix_transactions_budget_period", ["budget_period"]) + + +def downgrade() -> None: + with op.batch_alter_table("transactions") as batch: + batch.drop_index("ix_transactions_budget_period") + batch.drop_column("budget_period") diff --git a/backend/tests/test_budget.py b/backend/tests/test_budget.py index 46ae966..9abe731 100644 --- a/backend/tests/test_budget.py +++ b/backend/tests/test_budget.py @@ -215,3 +215,97 @@ def test_ausgabenanteile_nennen_das_geplante(admin): ganz = admin.get("/api/dashboard/expense-share").json() assert ganz["total_planned"] > 0 assert ganz["total_over"] == (ganz["total"] > ganz["total_planned"]) + + +def test_budgetzeitraum_von_hand_verschiebt_die_buchung(admin): + """Eine Zahlung faellt sichtbar in den einen Monat und gehoert zum anderen. + + Der Ausnahmefall, fuer den es das Feld gibt: Die Buchung wandert vollstaendig + – sie zaehlt im gewaehlten Monat und in ihrem eigenen dann NICHT mehr, sonst + stuende sie zweimal da. + """ + admin.put("/api/settings/budget_start_day", params={"value": "1"}) + today = date.today() + hier = f"{today.year:04d}-{today.month:02d}" + + from app.services import budget as b + dort = b.shift_label(hier, 1) + + acc = admin.post("/api/accounts", json={"name": "Zeitraumkonto", "currency": "EUR"}).json() + leb = _lebensmittel(admin) + vorher = _ist(admin, hier, leb) + vorher_dort = _ist(admin, dort, leb) + + tx = admin.post("/api/transactions", json={ + "type": "expense", "date": str(today), "account_id": acc["id"], + "amount_cents": 12345, "category_id": leb}).json() + assert tx["budget_period"] == hier + assert tx["budget_period_manuell"] is False + assert _ist(admin, hier, leb) == vorher + 12345 + + # In den naechsten Monat schieben. + tx = admin.patch(f"/api/transactions/{tx['id']}", json={"budget_period": dort}).json() + assert tx["budget_period"] == dort + assert tx["budget_period_manuell"] is True + assert _ist(admin, hier, leb) == vorher, "im eigenen Monat darf sie nicht mehr zaehlen" + assert _ist(admin, dort, leb) == vorher_dort + 12345 + + # Zuruecknehmen -> wieder das Datum. + tx = admin.patch(f"/api/transactions/{tx['id']}", json={"budget_period": None}).json() + assert tx["budget_period"] == hier + assert tx["budget_period_manuell"] is False + assert _ist(admin, hier, leb) == vorher + 12345 + + +def _ist(admin, label, cat_id): + v = admin.get(f"/api/budget/{label}").json() + for g in v["groups"]: + for c in g["categories"]: + if c["category_id"] == cat_id: + return c["actual_cents"] + return 0 + + +def test_ein_abwegiger_budgetmonat_wird_abgelehnt(admin): + """Ein Vertipper wie 2019-03 liesse die Buchung aus jeder Auswertung + verschwinden, ohne dass irgendwo etwas fehlt. Das muss auffallen.""" + admin.put("/api/settings/budget_start_day", params={"value": "1"}) + today = date.today() + acc = admin.post("/api/accounts", json={"name": "Abwegkonto", "currency": "EUR"}).json() + tx = admin.post("/api/transactions", json={ + "type": "expense", "date": str(today), "account_id": acc["id"], + "amount_cents": 500, "category_id": _lebensmittel(admin)}).json() + + r = admin.patch(f"/api/transactions/{tx['id']}", json={"budget_period": "2019-03"}) + assert r.status_code == 400 + assert "2019-03" in r.json()["detail"] + + # Drei Monate in jede Richtung decken den echten Fall ab. + from app.services import budget as b + eigen = b.period_of(today, 1) + assert admin.patch(f"/api/transactions/{tx['id']}", + json={"budget_period": b.shift_label(eigen, 3)}).status_code == 200 + assert admin.patch(f"/api/transactions/{tx['id']}", + json={"budget_period": b.shift_label(eigen, 4)}).status_code == 400 + + +def test_der_eigene_monat_wird_nicht_festgeschrieben(admin): + """Waehlt man genau den Monat, der ohnehin gilt, bleibt die Buchung + automatisch – verschiebt sich das Datum spaeter, wandert sie mit.""" + admin.put("/api/settings/budget_start_day", params={"value": "25"}) + from app.services import budget as b + + acc = admin.post("/api/accounts", json={"name": "Mitwanderkonto", "currency": "EUR"}).json() + tx = admin.post("/api/transactions", json={ + "type": "expense", "date": "2026-09-10", "account_id": acc["id"], + "amount_cents": 700, "category_id": _lebensmittel(admin)}).json() + assert tx["budget_period"] == "2026-09" + + tx = admin.patch(f"/api/transactions/{tx['id']}", json={"budget_period": "2026-09"}).json() + assert tx["budget_period_manuell"] is False + + # Datum ueber den Stichtag hinaus -> der Monat wandert von selbst mit. + tx = admin.patch(f"/api/transactions/{tx['id']}", json={"date": "2026-09-26"}).json() + assert tx["budget_period"] == "2026-10" + assert b.period_of(date(2026, 9, 26), 25) == "2026-10" + admin.put("/api/settings/budget_start_day", params={"value": "1"}) diff --git a/web/src/components/BudgetZeitraum.jsx b/web/src/components/BudgetZeitraum.jsx new file mode 100644 index 0000000..2e90c84 --- /dev/null +++ b/web/src/components/BudgetZeitraum.jsx @@ -0,0 +1,96 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import Icon from "./Icon"; +import { formatMonthLabel } from "../format"; + +/** Auf welchen Tag der Monat gekappt fällt – einen 31. Februar gibt es nicht. + * Dieselbe Kappung wie `_clamp_day` im Server, sonst fiele der 28. Februar bei + * Starttag 31 in einen Monat, der ihn gar nicht enthält. */ +function gekappt(jahr, monat, tag) { + return Math.min(tag, new Date(jahr, monat, 0).getDate()); +} + +/** Der Budget-Monat, in den ein Datum von selbst fällt – die Entsprechung zu + * `budget.period_of` im Server. Hier gerechnet und nicht erfragt, damit die + * Anzeige mitläuft, während man im Formular noch am Datum dreht. */ +export function budgetMonat(iso, startTag) { + const [j, m, d] = String(iso).split("-").map(Number); + if (!j || !m) return ""; + const zwei = (n) => String(n).padStart(2, "0"); + if (startTag <= 1 || d < gekappt(j, m, startTag)) return `${j}-${zwei(m)}`; + return m === 12 ? `${j + 1}-01` : `${j}-${zwei(m + 1)}`; +} + +export function verschoben(label, monate) { + const [j, m] = label.split("-").map(Number); + const d = new Date(j, m - 1 + monate, 1); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; +} + +// Drei Monate in jede Richtung – so weit wie der Server es annimmt, und weit +// genug für den echten Fall (eine Zahlung kurz vor dem Stichtag, eine +// verspätet beglichene Rechnung). +const REICHWEITE = 3; + +/** + * Zu welchem Budget-Monat eine Buchung zählt. + * + * Normalerweise entscheidet das Datum, und dann ist hier nichts zu tun – das + * Feld sagt nur, was gilt. Das Ändern liegt bewusst hinter einem Klick und + * hinter Pfeilen statt hinter einer Auswahlliste: Es ist der Ausnahmefall, + * nicht der Normalfall, und soll sich auch so anfühlen. + */ +export default function BudgetZeitraum({ wert, datum, startTag, onChange }) { + const { t, i18n } = useTranslation(); + const [offen, setOffen] = useState(false); + const eigen = budgetMonat(datum, startTag); + if (!eigen) return null; + const gilt = wert || eigen; + const abstand = Math.round( + (Number(gilt.split("-")[0]) - Number(eigen.split("-")[0])) * 12 + + Number(gilt.split("-")[1]) - Number(eigen.split("-")[1])); + + function setze(schritt) { + const neu = verschoben(gilt, schritt); + onChange(neu === eigen ? null : neu); + } + + return ( +
{t("transactions.budgetPeriodHint")}
+