From 0b19e07ce0373db7622ae33a2df1b0db8051fdd5 Mon Sep 17 00:00:00 2001 From: Guido Date: Sun, 30 Aug 2026 14:57:16 +0200 Subject: [PATCH] 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 --- backend/app/models.py | 6 ++ backend/app/routers/budgets.py | 9 +- backend/app/routers/dashboard.py | 15 ++- backend/app/routers/transactions.py | 45 +++++++-- backend/app/schemas.py | 8 ++ backend/app/services/budget.py | 40 ++++++-- .../versions/0026_budgetzeitraum.py | 33 +++++++ backend/tests/test_budget.py | 94 ++++++++++++++++++ web/src/components/BudgetZeitraum.jsx | 96 +++++++++++++++++++ web/src/components/Icon.jsx | 7 ++ web/src/finance.css | 36 +++++++ web/src/format.js | 9 ++ web/src/locales/de.json | 10 +- web/src/locales/en.json | 10 +- web/src/pages/Transactions.jsx | 30 +++++- 15 files changed, 413 insertions(+), 35 deletions(-) create mode 100644 backend/migrations/versions/0026_budgetzeitraum.py create mode 100644 web/src/components/BudgetZeitraum.jsx 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.budgetPeriod")} +
+ {formatMonthLabel(gilt, i18n.language)} + {wert + ? {t("transactions.budgetPeriodManual")} + : {t("transactions.budgetPeriodAuto")}} + + {!offen && ( + + )} +
+ {offen && ( +
+

{t("transactions.budgetPeriodHint")}

+
+ + {formatMonthLabel(gilt, i18n.language)} + + {wert && ( + + )} +
+
+ )} +
+ ); +} diff --git a/web/src/components/Icon.jsx b/web/src/components/Icon.jsx index cabfa10..d6cc4d0 100644 --- a/web/src/components/Icon.jsx +++ b/web/src/components/Icon.jsx @@ -72,6 +72,13 @@ const PATHS = { ), + calendar: ( + <> + + + + ), + "chevron-left": , "chevron-right": , "chevron-down": , "chevron-up": , diff --git a/web/src/finance.css b/web/src/finance.css index 5f8f8ea..99ad9fd 100644 --- a/web/src/finance.css +++ b/web/src/finance.css @@ -1487,3 +1487,39 @@ tr.row-dup { .dash-zeitraum { margin-bottom: var(--sp-2); } + +/* ---------- Budgetzeitraum einer Buchung ---------- + Normalerweise entscheidet das Datum, und dann steht hier nur, was gilt. Das + Ändern liegt hinter einem Klick und hinter Pfeilen statt hinter einer + Auswahlliste: Es ist der Ausnahmefall und soll sich auch so anfühlen. */ +.budgetzeitraum { + margin-bottom: var(--sp-4); +} +.budgetzeitraum-kopf { + display: flex; + align-items: center; + gap: var(--sp-2); + min-height: 32px; +} +.budgetzeitraum-wahl { + margin-top: var(--sp-2); + padding: var(--sp-3); + border: 1px dashed var(--border-strong); + border-radius: var(--radius-sm); +} +.budgetzeitraum-wahl p { + margin: 0 0 var(--sp-3); +} +.budgetzeitraum-schritte { + display: flex; + align-items: center; + gap: var(--sp-2); + flex-wrap: wrap; +} +/* Feste Breite: Sonst springen die Pfeile, sobald „Mai" auf „September" folgt. */ +.budgetzeitraum-monat { + min-width: 9.5em; + text-align: center; + font-weight: 580; + color: var(--text); +} diff --git a/web/src/format.js b/web/src/format.js index 72d757b..fb126bc 100644 --- a/web/src/format.js +++ b/web/src/format.js @@ -67,6 +67,15 @@ export function todayIso() { } /** Monats-Label "2026-09" -> "September 2026". */ +/** Kurzform für enge Stellen – "Okt. 2026" statt "Oktober 2026". */ +export function formatMonthShort(label, locale = "de") { + if (!label) return ""; + const [y, m] = label.split("-").map(Number); + return new Intl.DateTimeFormat(locale === "en" ? "en-GB" : "de-DE", { + month: "short", year: "numeric", + }).format(new Date(y, (m || 1) - 1, 1)); +} + export function formatMonthLabel(label, locale = "de") { if (!label) return ""; const [y, m] = label.split("-").map(Number); diff --git a/web/src/locales/de.json b/web/src/locales/de.json index 26c46a6..71beb8b 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -276,7 +276,15 @@ "accountRequired": "Bitte ein Konto wählen.", "budgetAsSavings": "Als Sparen ins Budget zählen", "budgetCategory": "Budget-Kategorie", - "subject": "Buchung" + "subject": "Buchung", + "budgetPeriod": "Budgetzeitraum", + "budgetPeriodAuto": "aus dem Datum", + "budgetPeriodManual": "von Hand gesetzt", + "budgetPeriodReset": "wieder aus dem Datum", + "budgetPeriodBack": "Ein Monat zurück", + "budgetPeriodForward": "Ein Monat vor", + "budgetPeriodHint": "Nur für Ausnahmefälle: eine Zahlung, die sichtbar in den einen Monat fällt und doch zum anderen gehört. Sie zählt dann nur noch dort – in ihrem eigenen Monat nicht mehr.", + "budgetPeriodChange": "ändern" }, "budget": { "title": "Budget", diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 5e81500..f1da5fa 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -276,7 +276,15 @@ "accountRequired": "Please choose an account.", "budgetAsSavings": "Count as savings in the budget", "budgetCategory": "Budget category", - "subject": "Subject" + "subject": "Subject", + "budgetPeriod": "Budget period", + "budgetPeriodAuto": "from the date", + "budgetPeriodManual": "set by hand", + "budgetPeriodReset": "back to the date", + "budgetPeriodBack": "One month back", + "budgetPeriodForward": "One month forward", + "budgetPeriodHint": "For exceptions only: a payment that visibly falls in one month but belongs to another. It then counts there alone – no longer in its own month.", + "budgetPeriodChange": "change" }, "budget": { "title": "Budget", diff --git a/web/src/pages/Transactions.jsx b/web/src/pages/Transactions.jsx index 5869602..7495dd5 100644 --- a/web/src/pages/Transactions.jsx +++ b/web/src/pages/Transactions.jsx @@ -8,12 +8,13 @@ import Icon from "../components/Icon"; import IconChip from "../components/IconChip"; import Money from "../components/Money"; import Modal from "../components/Modal"; +import BudgetZeitraum from "../components/BudgetZeitraum"; import AccountSelect from "../components/AccountSelect"; import CategorySelect from "../components/CategorySelect"; import UebertragDialog from "../components/UebertragDialog"; import DataTable from "../components/DataTable"; import { gruppenFuer } from "../kategorien"; -import { centsToInput, formatMoney, parseAmountToCents, todayIso } from "../format"; +import { centsToInput, formatMonthLabel, formatMonthShort, formatMoney, parseAmountToCents, todayIso } from "../format"; const TYPES = ["expense", "income", "transfer"]; @@ -32,12 +33,14 @@ function emptyForm() { description: "", budget_as_expense: true, budget_category_id: "", + // Leer heisst: aus dem Datum. Der Normalfall, und er bleibt es. + budget_period: null, }; } export default function Transactions() { - const { t } = useTranslation(); - const { baseCurrency, formatDate } = useSettings(); + const { t, i18n } = useTranslation(); + const { baseCurrency, formatDate, budget_start_day: startTag } = useSettings(); const confirm = useConfirm(); const toast = useToast(); @@ -203,6 +206,9 @@ export default function Transactions() { description: tx.description || "", budget_as_expense: tx.budget_treatment !== "neutral", budget_category_id: tx.budget_category_id ? String(tx.budget_category_id) : "", + // Nur der von Hand gesetzte Monat gehoert ins Formular. Leer heisst: + // aus dem Datum – und wandert mit, wenn man das Datum noch aendert. + budget_period: tx.budget_period_manuell ? tx.budget_period : null, }); } @@ -225,6 +231,7 @@ export default function Transactions() { amount_cents: cents, payee: form.payee.trim() || null, description: form.description.trim() || null, + budget_period: form.budget_period || null, }; if (form.type === "transfer") { body.to_account_id = Number(form.to_account_id); @@ -332,6 +339,16 @@ export default function Transactions() { {t(`todo.from.${tx.herkunft}`, { defaultValue: tx.herkunft, })} + {/* Nur der Ausnahmefall wird gezeigt: Steht hier nichts, gilt + das Datum – und das steht ohnehin in seiner eigenen + Spalte. */} + {tx.budget_period_manuell && ( + + + {formatMonthShort(tx.budget_period, i18n.language)} + + )} {l.sub &&
{l.sub}
} {/* Verknüpft heisst: zählt nicht als Ausgabe oder Einnahme. @@ -593,6 +610,13 @@ export default function Transactions() { )} + setForm({ ...form, budget_period: label })} + /> + {savingsDest && (