From 8ed4bf5254453a170218f674ac280952d06cdb44 Mon Sep 17 00:00:00 2001 From: Guido Date: Wed, 19 Aug 2026 19:16:27 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=202=20=E2=80=93=20Budget-Engine?= =?UTF-8?q?=20+=20Direktzugriff=20(http://IP:Port)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Budget: - Modelle + Migration 0003: budget_template_lines, planned_income_lines, budget_periods, budget_period_lines. - services/budget: Perioden-Grenzen (einstellbarer Starttag, Label nach Endmonat), Standard-Vorlage vs. Monats-Override (Snapshot-on-Customize), Soll/Ist inkl. budgetierter Sparen-Transfers, geplante Einnahmen (Soll/Ist). - routers/budgets: Vorlage, geplante Einnahmen (CRUD), Monats-Soll/Ist, Monats-Override, Zurücksetzen. pytest: Perioden, Soll/Ist, Override/Reset, Einnahmen. - Frontend: Budget-Seite (Monat + Standard-Budget), Fortschrittsbalken grün/rot, Monats-Navigation, editierbare Soll-Beträge; Sparen-Option im Transfer-Formular. Deployment-Fix (Nutzer-Anforderung: http://IP:Port muss funktionieren): - Dienst bindet an APP_BIND_HOST=0.0.0.0 (direkt im LAN erreichbar). - APP_COOKIE_SECURE=false als Standard (sonst kein Login über reines HTTP); für reinen HTTPS-Betrieb umstellbar. Doku + install.sh entsprechend angepasst. Co-Authored-By: Claude Opus 4.8 --- backend/app/main.py | 2 + backend/app/models.py | 88 ++++++ backend/app/routers/budgets.py | 256 ++++++++++++++++++ backend/app/schemas.py | 28 ++ backend/app/services/budget.py | 198 ++++++++++++++ backend/migrations/versions/0003_budget.py | 69 +++++ backend/tests/test_budget.py | 104 +++++++ deploy/README.md | 18 +- deploy/certain-finance.service | 7 +- deploy/env.example | 14 +- deploy/install.sh | 21 +- docs/ROADMAP.md | 2 +- web/src/App.jsx | 3 + web/src/api.js | 13 + web/src/components/EditableAmount.jsx | 32 +++ web/src/finance.css | 110 ++++++++ web/src/format.js | 11 + web/src/locales/de.json | 25 +- web/src/locales/en.json | 25 +- web/src/pages/Budget.jsx | 299 +++++++++++++++++++++ web/src/pages/Transactions.jsx | 49 ++++ 21 files changed, 1352 insertions(+), 22 deletions(-) create mode 100644 backend/app/routers/budgets.py create mode 100644 backend/app/services/budget.py create mode 100644 backend/migrations/versions/0003_budget.py create mode 100644 backend/tests/test_budget.py create mode 100644 web/src/components/EditableAmount.jsx create mode 100644 web/src/pages/Budget.jsx diff --git a/backend/app/main.py b/backend/app/main.py index 4bfa0b0..19a7cc9 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -20,6 +20,7 @@ from .migrate import upgrade_to_head from .routers import ( accounts, auth, + budgets, categories, rates, settings as settings_router, @@ -87,6 +88,7 @@ api.include_router(settings_router.router) api.include_router(accounts.router) api.include_router(categories.router) api.include_router(transactions.router) +api.include_router(budgets.router) api.include_router(rates.router) app.include_router(api) diff --git a/backend/app/models.py b/backend/app/models.py index 46fbf7a..20744c1 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -289,3 +289,91 @@ class ExchangeRate(Base): currency: Mapped[str] = mapped_column(String(3), primary_key=True) rate_per_eur: Mapped[float] = mapped_column(Numeric(18, 8), nullable=False) source: Mapped[str] = mapped_column(String(16), nullable=False, default="ecb") + + +# --------------------------------------------------------------------------- +# Phase 2: Budget (ein Standard-Budget als Vorlage + Monats-Overrides) +# Planned-Beträge sind in der Hauptwährung des Nutzers. +# --------------------------------------------------------------------------- + + +class BudgetTemplateLine(Base): + """Standard-Budget je Kategorie (die editierbare Vorlage).""" + + __tablename__ = "budget_template_lines" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + owner_id: Mapped[int] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) + category_id: Mapped[int] = mapped_column( + ForeignKey("categories.id", ondelete="CASCADE"), nullable=False + ) + planned_cents: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + + __table_args__ = ( + UniqueConstraint("owner_id", "category_id", name="uq_tpl_owner_category"), + ) + + +class PlannedIncomeLine(Base): + """Geplante Einnahme (z. B. Gehalt) mit optionaler Kategorie und Zielkonto.""" + + __tablename__ = "planned_income_lines" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + owner_id: Mapped[int] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) + label: Mapped[str] = mapped_column(String(120), nullable=False) + category_id: Mapped[int | None] = mapped_column( + ForeignKey("categories.id", ondelete="SET NULL"), nullable=True + ) + target_account_id: Mapped[int | None] = mapped_column( + ForeignKey("accounts.id", ondelete="SET NULL"), nullable=True + ) + planned_cents: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + position: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + + +class BudgetPeriod(Base): + """Ein Budget-Monat. Existiert erst, sobald der Monat angepasst wurde.""" + + __tablename__ = "budget_periods" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + owner_id: Mapped[int] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) + label: Mapped[str] = mapped_column(String(7), nullable=False) # YYYY-MM (End-/Hauptmonat) + customized: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) + + lines: Mapped[list[BudgetPeriodLine]] = relationship( + back_populates="period", cascade="all, delete-orphan" + ) + + __table_args__ = ( + UniqueConstraint("owner_id", "label", name="uq_period_owner_label"), + ) + + +class BudgetPeriodLine(Base): + """Monatsspezifischer Budget-Wert (Snapshot-on-Customize).""" + + __tablename__ = "budget_period_lines" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + budget_period_id: Mapped[int] = mapped_column( + ForeignKey("budget_periods.id", ondelete="CASCADE"), nullable=False, index=True + ) + category_id: Mapped[int] = mapped_column( + ForeignKey("categories.id", ondelete="CASCADE"), nullable=False + ) + planned_cents: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + + period: Mapped[BudgetPeriod] = relationship(back_populates="lines") + + __table_args__ = ( + UniqueConstraint("budget_period_id", "category_id", name="uq_periodline_period_category"), + ) diff --git a/backend/app/routers/budgets.py b/backend/app/routers/budgets.py new file mode 100644 index 0000000..5ea6f3c --- /dev/null +++ b/backend/app/routers/budgets.py @@ -0,0 +1,256 @@ +"""Budget: Standard-Vorlage, geplante Einnahmen und die Monats-Soll/Ist-Ansicht.""" + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from ..database import get_db +from ..deps import get_current_user, get_owned +from ..models import ( + Account, + BudgetTemplateLine, + Category, + CategoryGroup, + CategoryKind, + PlannedIncomeLine, + User, +) +from ..schemas import PlannedIncomeCreate, PlannedIncomeOut, PlannedIncomeUpdate +from ..services import budget +from .accounts import base_currency +from .settings import BUDGET_START_DAY_KEY, get_user_setting + +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 + + +def _require_expense_category(db: Session, user: User, category_id: int) -> Category: + cat = get_owned(db, Category, category_id, user) + if cat.type != CategoryKind.expense: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "Nur Ausgabe-Kategorien sind budgetierbar") + return cat + + +# --- Standard-Vorlage ------------------------------------------------------ + + +@router.get("/template") +def get_template(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict: + lines = db.query(BudgetTemplateLine).filter(BudgetTemplateLine.owner_id == user.id).all() + income = ( + db.query(PlannedIncomeLine) + .filter(PlannedIncomeLine.owner_id == user.id) + .order_by(PlannedIncomeLine.position, PlannedIncomeLine.id) + .all() + ) + return { + "lines": [{"category_id": ln.category_id, "planned_cents": ln.planned_cents} for ln in lines], + "income": [PlannedIncomeOut.model_validate(i).model_dump() for i in income], + } + + +@router.put("/template/category/{category_id}") +def set_template_category( + category_id: int, + planned_cents: int, + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +) -> dict: + _require_expense_category(db, user, category_id) + line = ( + db.query(BudgetTemplateLine) + .filter(BudgetTemplateLine.owner_id == user.id, BudgetTemplateLine.category_id == category_id) + .first() + ) + if planned_cents <= 0: + if line is not None: + db.delete(line) + db.commit() + return {"category_id": category_id, "planned_cents": 0} + if line is None: + line = BudgetTemplateLine(owner_id=user.id, category_id=category_id, planned_cents=planned_cents) + db.add(line) + else: + line.planned_cents = planned_cents + db.commit() + return {"category_id": category_id, "planned_cents": planned_cents} + + +# --- Geplante Einnahmen ---------------------------------------------------- + + +@router.post("/income", response_model=PlannedIncomeOut, status_code=status.HTTP_201_CREATED) +def create_income( + payload: PlannedIncomeCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user) +): + if payload.category_id is not None: + get_owned(db, Category, payload.category_id, user) + if payload.target_account_id is not None: + get_owned(db, Account, payload.target_account_id, user) + max_pos = ( + db.query(PlannedIncomeLine.position) + .filter(PlannedIncomeLine.owner_id == user.id) + .order_by(PlannedIncomeLine.position.desc()) + .first() + ) + line = PlannedIncomeLine( + owner_id=user.id, + label=payload.label.strip(), + category_id=payload.category_id, + target_account_id=payload.target_account_id, + planned_cents=payload.planned_cents, + position=(max_pos[0] + 1) if max_pos else 0, + ) + db.add(line) + db.commit() + db.refresh(line) + return line + + +@router.patch("/income/{income_id}", response_model=PlannedIncomeOut) +def update_income( + income_id: int, + payload: PlannedIncomeUpdate, + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +): + line = get_owned(db, PlannedIncomeLine, income_id, user) + data = payload.model_dump(exclude_unset=True) + if "category_id" in data and data["category_id"] is not None: + get_owned(db, Category, data["category_id"], user) + if "target_account_id" in data and data["target_account_id"] is not None: + get_owned(db, Account, data["target_account_id"], user) + for key, value in data.items(): + setattr(line, key, value) + db.commit() + db.refresh(line) + return line + + +@router.delete("/income/{income_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_income( + income_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user) +) -> None: + line = get_owned(db, PlannedIncomeLine, income_id, user) + db.delete(line) + db.commit() + + +# --- Soll/Ist-Ansicht ------------------------------------------------------ + + +def _build_view(db: Session, user: User, label: str) -> dict: + base = base_currency(db, user) + 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) + + groups_out = [] + total_planned = total_actual = 0 + groups = ( + db.query(CategoryGroup) + .filter(CategoryGroup.owner_id == user.id, CategoryGroup.kind == CategoryKind.expense) + .order_by(CategoryGroup.position, CategoryGroup.name) + .all() + ) + for g in groups: + cats = [] + g_planned = g_actual = 0 + for c in sorted(g.categories, key=lambda x: (x.position, x.name.lower())): + if c.is_archived: + continue + p = planned.get(c.id, 0) + a = actual.get(c.id, 0) + g_planned += p + g_actual += a + cats.append({ + "category_id": c.id, "name": c.name, "icon": c.icon, "color": c.color, + "planned_cents": p, "actual_cents": a, "delta_cents": p - a, "over": a > p, + }) + total_planned += g_planned + total_actual += g_actual + groups_out.append({ + "id": g.id, "name": g.name, "icon": g.icon, "color": g.color, + "planned_cents": g_planned, "actual_cents": g_actual, "over": g_actual > g_planned, + "categories": cats, + }) + + income_lines = ( + db.query(PlannedIncomeLine) + .filter(PlannedIncomeLine.owner_id == user.id) + .order_by(PlannedIncomeLine.position, PlannedIncomeLine.id) + .all() + ) + income_out = [] + total_planned_income = total_received_income = 0 + for ln in income_lines: + received = income_actual.get(ln.category_id, 0) if ln.category_id else 0 + total_planned_income += ln.planned_cents + total_received_income += received + income_out.append({ + "id": ln.id, "label": ln.label, "category_id": ln.category_id, + "target_account_id": ln.target_account_id, + "planned_cents": ln.planned_cents, "received_cents": received, + }) + + return { + "label": label, + "start": start.isoformat(), + "end": end.isoformat(), + "customized": budget.is_customized(db, user, label), + "base_currency": base, + "prev": budget.shift_label(label, -1), + "next": budget.shift_label(label, 1), + "groups": groups_out, + "income": income_out, + "totals": { + "planned_expense": total_planned, + "actual_expense": total_actual, + "planned_income": total_planned_income, + "received_income": total_received_income, + }, + } + + +@router.get("/current") +def current(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict: + return _build_view(db, user, budget.current_label(_start_day(db, user))) + + +@router.get("/{label}") +def view(label: str, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict: + try: + budget.parse_label(label) + except (ValueError, IndexError): + raise HTTPException(status.HTTP_400_BAD_REQUEST, "Ungültiges Monats-Label (YYYY-MM)") + return _build_view(db, user, label) + + +@router.put("/{label}/category/{category_id}") +def set_month_category( + label: str, + category_id: int, + planned_cents: int, + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +) -> dict: + budget.parse_label(label) + _require_expense_category(db, user, category_id) + budget.set_month_category(db, user, label, category_id, max(0, planned_cents)) + return _build_view(db, user, label) + + +@router.delete("/{label}") +def reset_month( + label: str, db: Session = Depends(get_db), user: User = Depends(get_current_user) +) -> dict: + budget.parse_label(label) + budget.reset_month(db, user, label) + return _build_view(db, user, label) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 0cacb6b..6b3614a 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -255,3 +255,31 @@ class ConversionOut(BaseModel): date: date_t converted_cents: int | None rate: float | None + + +# --- Budget: geplante Einnahmen -------------------------------------------- + + +class PlannedIncomeCreate(BaseModel): + label: str = Field(min_length=1, max_length=120) + category_id: int | None = None + target_account_id: int | None = None + planned_cents: int = Field(ge=0) + + +class PlannedIncomeUpdate(BaseModel): + label: str | None = Field(default=None, min_length=1, max_length=120) + category_id: int | None = None + target_account_id: int | None = None + planned_cents: int | None = Field(default=None, ge=0) + + +class PlannedIncomeOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + label: str + category_id: int | None + target_account_id: int | None + planned_cents: int + position: int diff --git a/backend/app/services/budget.py b/backend/app/services/budget.py new file mode 100644 index 0000000..1f38d23 --- /dev/null +++ b/backend/app/services/budget.py @@ -0,0 +1,198 @@ +"""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, + 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() + + +# --- 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 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).""" + result: dict[int, int] = defaultdict(int) + 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: + 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]: + result: dict[int | None, int] = defaultdict(int) + 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: + result[t.category_id] += _to_base(db, t.amount_eur_cents, base, t.date) + return result diff --git a/backend/migrations/versions/0003_budget.py b/backend/migrations/versions/0003_budget.py new file mode 100644 index 0000000..d0895be --- /dev/null +++ b/backend/migrations/versions/0003_budget.py @@ -0,0 +1,69 @@ +"""Budget: Standard-Vorlage, geplante Einnahmen, Monats-Perioden mit Overrides. + +Revision ID: 0003 +Revises: 0002 +Create Date: 2026-08-19 +""" +from alembic import op +import sqlalchemy as sa + +revision = "0003" +down_revision = "0002" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "budget_template_lines", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("owner_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("category_id", sa.Integer(), sa.ForeignKey("categories.id", ondelete="CASCADE"), nullable=False), + sa.Column("planned_cents", sa.Integer(), nullable=False, server_default=sa.text("0")), + sa.UniqueConstraint("owner_id", "category_id", name="uq_tpl_owner_category"), + ) + op.create_index("ix_budget_template_lines_owner_id", "budget_template_lines", ["owner_id"]) + + op.create_table( + "planned_income_lines", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("owner_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("label", sa.String(length=120), nullable=False), + sa.Column("category_id", sa.Integer(), sa.ForeignKey("categories.id", ondelete="SET NULL"), nullable=True), + sa.Column("target_account_id", sa.Integer(), sa.ForeignKey("accounts.id", ondelete="SET NULL"), nullable=True), + sa.Column("planned_cents", sa.Integer(), nullable=False, server_default=sa.text("0")), + sa.Column("position", sa.Integer(), nullable=False, server_default=sa.text("0")), + ) + op.create_index("ix_planned_income_lines_owner_id", "planned_income_lines", ["owner_id"]) + + op.create_table( + "budget_periods", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("owner_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("label", sa.String(length=7), nullable=False), + sa.Column("customized", sa.Boolean(), nullable=False, server_default=sa.text("0")), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), + sa.UniqueConstraint("owner_id", "label", name="uq_period_owner_label"), + ) + op.create_index("ix_budget_periods_owner_id", "budget_periods", ["owner_id"]) + + op.create_table( + "budget_period_lines", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("budget_period_id", sa.Integer(), sa.ForeignKey("budget_periods.id", ondelete="CASCADE"), nullable=False), + sa.Column("category_id", sa.Integer(), sa.ForeignKey("categories.id", ondelete="CASCADE"), nullable=False), + sa.Column("planned_cents", sa.Integer(), nullable=False, server_default=sa.text("0")), + sa.UniqueConstraint("budget_period_id", "category_id", name="uq_periodline_period_category"), + ) + op.create_index("ix_budget_period_lines_period_id", "budget_period_lines", ["budget_period_id"]) + + +def downgrade() -> None: + op.drop_index("ix_budget_period_lines_period_id", table_name="budget_period_lines") + op.drop_table("budget_period_lines") + op.drop_index("ix_budget_periods_owner_id", table_name="budget_periods") + op.drop_table("budget_periods") + op.drop_index("ix_planned_income_lines_owner_id", table_name="planned_income_lines") + op.drop_table("planned_income_lines") + op.drop_index("ix_budget_template_lines_owner_id", table_name="budget_template_lines") + op.drop_table("budget_template_lines") diff --git a/backend/tests/test_budget.py b/backend/tests/test_budget.py new file mode 100644 index 0000000..65f2b96 --- /dev/null +++ b/backend/tests/test_budget.py @@ -0,0 +1,104 @@ +from datetime import date + + +def test_period_bounds_and_labels(): + from app.services import budget + + # Gehalt am 25.: September-Budget läuft 25.08.–24.09. + assert budget.period_bounds("2026-09", 25) == (date(2026, 8, 25), date(2026, 9, 24)) + # Starttag 1: Kalendermonat. + assert budget.period_bounds("2026-09", 1) == (date(2026, 9, 1), date(2026, 9, 30)) + # Label-Zuordnung + assert budget.period_of(date(2026, 8, 27), 25) == "2026-09" + assert budget.period_of(date(2026, 9, 10), 25) == "2026-09" + assert budget.period_of(date(2026, 9, 26), 25) == "2026-10" + assert budget.period_of(date(2026, 9, 15), 1) == "2026-09" + assert budget.shift_label("2026-09", 1) == "2026-10" + assert budget.shift_label("2026-01", -1) == "2025-12" + + +def _lebensmittel(admin): + groups = admin.get("/api/categories").json() + for g in groups: + for c in g["categories"]: + if c["name"] == "Lebensmittel": + return c["id"] + raise AssertionError("Lebensmittel-Kategorie fehlt") + + +def _gehalt(admin): + groups = admin.get("/api/categories").json() + for g in groups: + for c in g["categories"]: + if c["name"] == "Gehalt": + return c["id"] + raise AssertionError("Gehalt-Kategorie fehlt") + + +def test_budget_soll_ist_and_override(admin): + # Starttag 1 -> aktueller Zeitraum = Kalendermonat, heutige Buchungen zählen. + admin.put("/api/settings/budget_start_day", params={"value": "1"}) + today = date.today() + label = f"{today.year:04d}-{today.month:02d}" + + acc = admin.post("/api/accounts", json={"name": "Budgetkonto", "currency": "EUR"}).json() + leb = _lebensmittel(admin) + + # Standard-Budget für Lebensmittel: 500,00 EUR. + admin.put(f"/api/budget/template/category/{leb}", params={"planned_cents": 50000}) + + # Ausgabe 300,00 EUR heute. + admin.post("/api/transactions", json={"type": "expense", "date": str(today), "account_id": acc["id"], "amount_cents": 30000, "category_id": leb}) + + v = admin.get("/api/budget/current").json() + row = next(c for g in v["groups"] for c in g["categories"] if c["category_id"] == leb) + assert row["planned_cents"] == 50000 + assert row["actual_cents"] == 30000 + assert row["over"] is False + assert v["customized"] is False + + # Noch 300 EUR -> insgesamt 600 > 500 -> über Budget. + admin.post("/api/transactions", json={"type": "expense", "date": str(today), "account_id": acc["id"], "amount_cents": 30000, "category_id": leb}) + v = admin.get(f"/api/budget/{label}").json() + row = next(c for g in v["groups"] for c in g["categories"] if c["category_id"] == leb) + assert row["actual_cents"] == 60000 + assert row["over"] is True + + # Monats-Override auf 700 EUR -> nicht mehr über Budget, Monat individualisiert. + v = admin.put(f"/api/budget/{label}/category/{leb}", params={"planned_cents": 70000}).json() + row = next(c for g in v["groups"] for c in g["categories"] if c["category_id"] == leb) + assert row["planned_cents"] == 70000 + assert row["over"] is False + assert v["customized"] is True + + # Vorlage bleibt unverändert. + tpl = admin.get("/api/budget/template").json() + tpl_leb = next(l for l in tpl["lines"] if l["category_id"] == leb) + assert tpl_leb["planned_cents"] == 50000 + + # Zurücksetzen -> wieder Vorlagenwert, nicht mehr individualisiert. + v = admin.delete(f"/api/budget/{label}").json() + row = next(c for g in v["groups"] for c in g["categories"] if c["category_id"] == leb) + assert row["planned_cents"] == 50000 + assert v["customized"] is False + + +def test_planned_income(admin): + admin.put("/api/settings/budget_start_day", params={"value": "1"}) + today = date.today() + acc = admin.post("/api/accounts", json={"name": "Lohnkonto", "currency": "EUR"}).json() + gehalt = _gehalt(admin) + + r = admin.post("/api/budget/income", json={ + "label": "Gehalt", "category_id": gehalt, "target_account_id": acc["id"], "planned_cents": 300000, + }) + assert r.status_code == 201 + + # Tatsächliche Einnahme 3000 EUR. + admin.post("/api/transactions", json={"type": "income", "date": str(today), "account_id": acc["id"], "amount_cents": 300000, "category_id": gehalt}) + + v = admin.get("/api/budget/current").json() + line = next(i for i in v["income"] if i["label"] == "Gehalt") + assert line["planned_cents"] == 300000 + assert line["received_cents"] == 300000 + assert v["totals"]["planned_income"] >= 300000 diff --git a/deploy/README.md b/deploy/README.md index 737836b..ac558fe 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -15,9 +15,17 @@ erzeugt `/etc/certain-finance/env` (mit Zufallsgeheimnissen), legt den systemd- Dienst `certain-finance` an und startet ihn. Node wird **nur zum Bauen** gebraucht; der laufende Dienst ist reines Python. -## Nginx Proxy Manager +## Direktzugriff (http://IP:Port) -Neuen **Proxy Host** anlegen: +Standardmäßig lauscht der Dienst auf `0.0.0.0:` (Standard-Port `8071`) und +ist damit direkt im LAN erreichbar: `http://:8071`. Dafür ist +`APP_COOKIE_SECURE=false` gesetzt (sonst speichert der Browser die Sitzung über +reines HTTP nicht). Möchtest du den Zugriff auf einen Reverse-Proxy **auf demselben +Host** beschränken, setze `APP_BIND_HOST=127.0.0.1` in `/etc/certain-finance/env`. + +## Optional: HTTPS über Nginx Proxy Manager + +Wenn du von außen per HTTPS zugreifen willst, einen **Proxy Host** anlegen: - **Domain Names:** deine (Sub-)Domain - **Scheme:** `http` @@ -30,8 +38,10 @@ Neuen **Proxy Host** anlegen: Für spätere CSV-/PDF-Uploads in den *Advanced*-Einstellungen des Hosts `client_max_body_size 20m;` setzen. -Da HTTPS extern terminiert, sind die Sitzungs-Cookies `Secure` -(`APP_COOKIE_SECURE=true`). Der Dienst selbst lauscht nur auf `127.0.0.1`. +Bei ausschließlichem HTTPS-Zugriff kannst du `APP_COOKIE_SECURE=true` in +`/etc/certain-finance/env` setzen (danach `systemctl restart certain-finance`) – +dann werden die Sitzungs-Cookies nur über HTTPS gesendet. Für gemischten Zugriff +(HTTP im LAN **und** HTTPS von außen) belasse es bei `false`. ## Betrieb diff --git a/deploy/certain-finance.service b/deploy/certain-finance.service index bd9ba86..53c45b1 100644 --- a/deploy/certain-finance.service +++ b/deploy/certain-finance.service @@ -14,9 +14,10 @@ User=certain-finance Group=certain-finance WorkingDirectory=/opt/certain-finance/backend EnvironmentFile=/etc/certain-finance/env -# Der Port kommt aus der EnvironmentFile (APP_PORT). Nur an 127.0.0.1 gebunden – -# von außen ist der Dienst ausschließlich über den Nginx Proxy Manager erreichbar. -ExecStart=/opt/certain-finance/backend/.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port ${APP_PORT} +# Adresse/Port kommen aus der EnvironmentFile. APP_BIND_HOST=0.0.0.0 macht den +# Dienst direkt über http://: erreichbar; 127.0.0.1 beschränkt +# ihn auf einen Reverse-Proxy auf demselben Host. +ExecStart=/opt/certain-finance/backend/.venv/bin/uvicorn app.main:app --host ${APP_BIND_HOST} --port ${APP_PORT} Restart=on-failure RestartSec=3 diff --git a/deploy/env.example b/deploy/env.example index 2f9d60e..8f3c841 100644 --- a/deploy/env.example +++ b/deploy/env.example @@ -5,15 +5,21 @@ # Ablageort für SQLite-Datenbank und Backups (muss dem Dienst-Benutzer gehören). APP_DATA_DIR=/var/lib/certain-finance -# Interner Port, auf dem der Dienst lauscht. Der Nginx Proxy Manager leitet -# von außen (HTTPS) hierher weiter. Nur an 127.0.0.1 gebunden (siehe Unit). +# Adresse + Port, auf denen der Dienst lauscht. +# APP_BIND_HOST=0.0.0.0 macht die Web-UI direkt über http://: +# im LAN erreichbar. Willst du NUR über einen Reverse-Proxy auf DEMSELBEN Host +# zugreifen, setze 127.0.0.1. +APP_BIND_HOST=0.0.0.0 APP_PORT=8071 # Signiert/HMACt Sitzungen. UNBEDINGT zufällig setzen (install.sh macht das). APP_SESSION_SECRET=change-me-please -# Cookies nur über HTTPS senden. Hinter dem Nginx Proxy Manager: true. -APP_COOKIE_SECURE=true +# Cookies nur über HTTPS senden. Bei direktem HTTP-Zugriff (http://IP:Port) MUSS +# das false sein, sonst speichert der Browser die Sitzung nicht (Login klappt dann +# scheinbar, springt aber sofort zurück). Nur wenn du ausschließlich über HTTPS +# zugreifst (z. B. Nginx Proxy Manager mit erzwungenem SSL), kannst du true setzen. +APP_COOKIE_SECURE=false # Erster Admin – wird NUR beim allerersten Start angelegt (wenn noch kein Nutzer da ist). APP_ADMIN_USERNAME=admin diff --git a/deploy/install.sh b/deploy/install.sh index b8515e3..f9a5ee8 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -95,6 +95,8 @@ if [[ -f "$ENV_FILE" ]]; then else echo "APP_GIT_SHA=$GIT_SHA" >> "$ENV_FILE" fi + # Direktzugriff (http://IP:Port) für ältere Konfigurationen nachrüsten. + grep -q '^APP_BIND_HOST=' "$ENV_FILE" || echo "APP_BIND_HOST=0.0.0.0" >> "$ENV_FILE" ADMIN_USER="$(grep -E '^APP_ADMIN_USERNAME=' "$ENV_FILE" | cut -d= -f2-)" GENERATED_PASS="" else @@ -110,9 +112,10 @@ else info "Erzeuge Konfiguration: $ENV_FILE" cat > "$ENV_FILE" <}:$PORT" echo " Admin-Login: ${ADMIN_USER:-admin}" if [[ -n "${GENERATED_PASS:-}" ]]; then echo " Admin-Passwort: ${GENERATED_PASS}" warn "Bitte dieses Passwort notieren – es wird nicht erneut angezeigt." fi echo -echo " Nginx Proxy Manager: neuen Proxy-Host anlegen ->" -echo " Forward Hostname/IP: " -echo " Forward Port: $PORT" -echo " SSL: Let's Encrypt aktivieren; 'Block Common Exploits' ok." -echo " (Websockets nicht nötig. Für spätere Uploads client_max_body_size >= 20M.)" +echo " Optional über HTTPS (Nginx Proxy Manager): neuen Proxy-Host ->" +echo " Forward Hostname/IP: ${LAN_IP:-}" +echo " Forward Port: $PORT (Scheme: http)" +echo " SSL: Let's Encrypt aktivieren. Dann in $ENV_FILE APP_COOKIE_SECURE=true" +echo " setzen und 'systemctl restart $APP_SLUG'. (Uploads: client_max_body_size >= 20M.)" echo echo " Logs: journalctl -u $APP_SLUG -f" echo " Neustart: systemctl restart $APP_SLUG" diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 82f6a4f..4e1d64e 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -14,7 +14,7 @@ Kategorien (Gruppen→Kategorien, Einnahme/Ausgabe, SVG-Icons), Transaktionen (Ausgabe/Einnahme/Transfer), EZB-Wechselkurse zum Buchungsdatum (lokal gecacht, pro Buchung überschreibbar), Kontostände. -## Phase 2 – Budget-Engine +## Phase 2 – Budget-Engine ✅ Einstellbarer Budget-Monat-Starttag, Standard-Budget als Vorlage (Snapshot-on- Customize), geplante Einnahmen mit Zielkonto, Soll/Ist-Tabelle (grün/rot), kein Rollover in v1. diff --git a/web/src/App.jsx b/web/src/App.jsx index 9f9fada..6b67602 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -10,6 +10,7 @@ import Dashboard from "./pages/Dashboard"; import Accounts from "./pages/Accounts"; import Categories from "./pages/Categories"; import Transactions from "./pages/Transactions"; +import Budget from "./pages/Budget"; import Users from "./pages/Users"; import Settings from "./pages/Settings"; @@ -44,6 +45,7 @@ function Sidebar() { + {isAdmin && ( <> @@ -128,6 +130,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/web/src/api.js b/web/src/api.js index ed86824..6ab875e 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -162,4 +162,17 @@ export const api = { if (on) qs.set("on", on); return request(`/rates/convert?${qs.toString()}`); }, + + // Budget + budgetCurrent: () => request("/budget/current"), + budget: (label) => request(`/budget/${label}`), + budgetTemplate: () => request("/budget/template"), + setTemplateCategory: (catId, cents) => + request(`/budget/template/category/${catId}?planned_cents=${cents}`, { method: "PUT" }), + setMonthCategory: (label, catId, cents) => + request(`/budget/${label}/category/${catId}?planned_cents=${cents}`, { method: "PUT" }), + resetMonth: (label) => request(`/budget/${label}`, { method: "DELETE" }), + createIncome: (body) => request("/budget/income", { method: "POST", body }), + updateIncome: (id, body) => request(`/budget/income/${id}`, { method: "PATCH", body }), + deleteIncome: (id) => request(`/budget/income/${id}`, { method: "DELETE" }), }; diff --git a/web/src/components/EditableAmount.jsx b/web/src/components/EditableAmount.jsx new file mode 100644 index 0000000..a4d3385 --- /dev/null +++ b/web/src/components/EditableAmount.jsx @@ -0,0 +1,32 @@ +import { useEffect, useState } from "react"; +import { centsToInput, parseAmountToCents } from "../format"; + +// Kleines, editierbares Betragsfeld (Cent). Speichert bei Verlassen/Enter, +// aber nur bei Änderung. +export default function EditableAmount({ cents, onSave, placeholder = "0,00" }) { + const [val, setVal] = useState(centsToInput(cents)); + useEffect(() => { setVal(centsToInput(cents)); }, [cents]); + + function commit() { + const c = parseAmountToCents(val) ?? 0; + if (c !== cents) onSave(c); + } + + return ( + setVal(e.target.value)} + onBlur={commit} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + commit(); + e.target.blur(); + } + }} + /> + ); +} diff --git a/web/src/finance.css b/web/src/finance.css index ae1e845..29523ba 100644 --- a/web/src/finance.css +++ b/web/src/finance.css @@ -261,3 +261,113 @@ .nowrap { white-space: nowrap; } + +/* Budget. */ +.budget-navbar { + display: flex; + align-items: center; + gap: var(--sp-3); +} +.budget-month { + font-size: 1.05rem; + min-width: 170px; + text-align: center; +} +.flip { + transform: rotate(180deg); +} +.budget-group + .budget-group { + margin-top: var(--sp-4); +} +.budget-group-head { + display: flex; + align-items: center; + gap: var(--sp-2); + padding: var(--sp-2) 0; + margin-bottom: 4px; + border-bottom: 2px solid var(--border-strong); +} +.budget-cat { + display: flex; + align-items: center; + gap: var(--sp-3); + padding: 7px 0; + border-bottom: 1px solid var(--border); +} +.budget-cat:last-child { + border-bottom: none; +} +.budget-cat-name { + display: flex; + align-items: center; + gap: var(--sp-2); + min-width: 0; +} +.budget-cat-bar { + flex: 1; +} +.amount-input { + width: 96px; + text-align: right; + margin-top: 0; + padding: 5px 8px; + font-variant-numeric: tabular-nums; +} +.budget-actual { + width: 96px; + text-align: right; + font-variant-numeric: tabular-nums; +} +.budget-slash { + margin: 0 -4px; +} +.budget-bar { + height: 7px; + background: var(--surface-2); + border-radius: 4px; + overflow: hidden; +} +.budget-bar-fill { + height: 100%; + background: var(--ok); + border-radius: 4px; + transition: width 0.2s; +} +.budget-bar-fill.over { + background: var(--danger); +} +.budget-total-row.card-total { + display: flex; + align-items: center; + gap: var(--sp-2); + padding-top: var(--sp-3); + margin-top: var(--sp-2); + border-top: 2px solid var(--border-strong); +} +tr.budget-total-row td { + border-top: 2px solid var(--border-strong); + padding-top: var(--sp-2); +} + +/* Sparen-Option im Transfer-Formular. */ +.savings-opt { + padding: var(--sp-3); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface-2); + margin-bottom: var(--sp-4); +} +.checkline { + display: flex; + align-items: center; + gap: var(--sp-2); + margin-bottom: var(--sp-2); +} +.checkline input[type="checkbox"] { + width: auto; + margin-top: 0; +} +.checkline span { + color: var(--text); + font-weight: 560; +} diff --git a/web/src/format.js b/web/src/format.js index 329193b..e5152bc 100644 --- a/web/src/format.js +++ b/web/src/format.js @@ -54,3 +54,14 @@ export function todayIso() { const z = (n) => String(n).padStart(2, "0"); return `${d.getFullYear()}-${z(d.getMonth() + 1)}-${z(d.getDate())}`; } + +/** Monats-Label "2026-09" -> "September 2026". */ +export function formatMonthLabel(label, locale = "de") { + if (!label) return ""; + const [y, m] = label.split("-").map(Number); + const d = new Date(y, (m || 1) - 1, 1); + return new Intl.DateTimeFormat(locale === "en" ? "en-GB" : "de-DE", { + month: "long", + year: "numeric", + }).format(d); +} diff --git a/web/src/locales/de.json b/web/src/locales/de.json index bb50fcc..198d528 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -150,6 +150,29 @@ "allTypes": "Alle Typen", "deleteConfirm": "Diese Buchung löschen?", "amountRequired": "Bitte einen Betrag eingeben.", - "accountRequired": "Bitte ein Konto wählen." + "accountRequired": "Bitte ein Konto wählen.", + "budgetAsSavings": "Als Sparen ins Budget zählen", + "budgetCategory": "Budget-Kategorie" + }, + "budget": { + "title": "Budget", + "monthMode": "Monat", + "templateMode": "Standard-Budget", + "income": "Einnahmen", + "expenses": "Ausgaben", + "planned": "Geplant", + "totalIncome": "Einnahmen gesamt", + "totalExpense": "Ausgaben gesamt", + "reset": "Auf Standard zurücksetzen", + "resetTitle": "Monat zurücksetzen", + "resetConfirm": "Diesen Monat auf das Standard-Budget zurücksetzen? Die Anpassungen dieses Monats gehen verloren.", + "templateHint": "Das Standard-Budget gilt für jeden Monat, solange du ihn nicht einzeln anpasst.", + "addIncome": "Geplante Einnahme", + "editIncome": "Einnahme bearbeiten", + "noIncome": "Noch keine geplanten Einnahmen.", + "deleteIncomeConfirm": "Diese geplante Einnahme löschen?", + "incomeLabel": "Bezeichnung", + "incomeCategory": "Kategorie", + "targetAccount": "Zielkonto" } } diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 0d7bd57..ede4da9 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -150,6 +150,29 @@ "allTypes": "All types", "deleteConfirm": "Delete this transaction?", "amountRequired": "Please enter an amount.", - "accountRequired": "Please choose an account." + "accountRequired": "Please choose an account.", + "budgetAsSavings": "Count as savings in the budget", + "budgetCategory": "Budget category" + }, + "budget": { + "title": "Budget", + "monthMode": "Month", + "templateMode": "Standard budget", + "income": "Income", + "expenses": "Expenses", + "planned": "Planned", + "totalIncome": "Total income", + "totalExpense": "Total expenses", + "reset": "Reset to standard", + "resetTitle": "Reset month", + "resetConfirm": "Reset this month to the standard budget? This month's adjustments will be lost.", + "templateHint": "The standard budget applies to every month until you adjust a month individually.", + "addIncome": "Planned income", + "editIncome": "Edit income", + "noIncome": "No planned income yet.", + "deleteIncomeConfirm": "Delete this planned income?", + "incomeLabel": "Label", + "incomeCategory": "Category", + "targetAccount": "Target account" } } diff --git a/web/src/pages/Budget.jsx b/web/src/pages/Budget.jsx new file mode 100644 index 0000000..59aa061 --- /dev/null +++ b/web/src/pages/Budget.jsx @@ -0,0 +1,299 @@ +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { api } from "../api"; +import { useSettings } from "../settings"; +import { useConfirm } from "../confirm"; +import { useToast } from "../toast"; +import Icon from "../components/Icon"; +import IconChip from "../components/IconChip"; +import Modal from "../components/Modal"; +import EditableAmount from "../components/EditableAmount"; +import { formatMoney, formatMonthLabel } from "../format"; + +function ProgressBar({ actual, planned }) { + const pct = planned > 0 ? Math.min(actual / planned, 1) * 100 : actual > 0 ? 100 : 0; + const over = actual > planned; + return ( +
+
+
+ ); +} + +export default function Budget() { + const { t } = useTranslation(); + const { locale } = useSettings(); + const confirm = useConfirm(); + const toast = useToast(); + + const [mode, setMode] = useState("month"); // "month" | "template" + const [view, setView] = useState(null); + const [label, setLabel] = useState(null); + const [groups, setGroups] = useState([]); + const [accounts, setAccounts] = useState([]); + const [template, setTemplate] = useState({ lines: [], income: [] }); + const [incomeModal, setIncomeModal] = useState(null); + + const base = view?.base_currency || "EUR"; + const fmt = (c) => formatMoney(c, base); + + async function loadView(lbl) { + try { + const v = lbl ? await api.budget(lbl) : await api.budgetCurrent(); + setView(v); + setLabel(v.label); + } catch (err) { toast(err.message, "error"); } + } + async function loadTemplate() { + try { setTemplate(await api.budgetTemplate()); } catch (err) { toast(err.message, "error"); } + } + async function loadStamm() { + try { + const [g, a] = await Promise.all([api.listCategories(), api.listAccounts()]); + setGroups(g); setAccounts(a); + } catch (err) { toast(err.message, "error"); } + } + + useEffect(() => { loadStamm(); loadView(); }, []); + useEffect(() => { if (mode === "template") loadTemplate(); }, [mode]); + + const tplMap = useMemo( + () => Object.fromEntries(template.lines.map((l) => [l.category_id, l.planned_cents])), + [template] + ); + const expenseGroups = useMemo(() => groups.filter((g) => g.kind === "expense"), [groups]); + const incomeCategories = useMemo( + () => groups.filter((g) => g.kind === "income").flatMap((g) => g.categories), + [groups] + ); + + async function saveMonth(catId, cents) { + try { setView(await api.setMonthCategory(label, catId, cents)); } catch (err) { toast(err.message, "error"); } + } + async function saveTemplate(catId, cents) { + try { await api.setTemplateCategory(catId, cents); loadTemplate(); } catch (err) { toast(err.message, "error"); } + } + async function resetMonth() { + if (!(await confirm({ title: t("budget.resetTitle"), message: t("budget.resetConfirm"), confirmLabel: t("budget.reset") }))) return; + try { setView(await api.resetMonth(label)); toast(t("settings.saved")); } catch (err) { toast(err.message, "error"); } + } + + async function saveIncome(e) { + e.preventDefault(); + const f = incomeModal; + const body = { + label: f.label.trim(), + category_id: f.category_id ? Number(f.category_id) : null, + target_account_id: f.target_account_id ? Number(f.target_account_id) : null, + planned_cents: f.planned_cents, + }; + try { + if (f.id) await api.updateIncome(f.id, body); + else await api.createIncome(body); + setIncomeModal(null); + loadTemplate(); + loadView(label); + } catch (err) { toast(err.message, "error"); } + } + async function deleteIncome(id) { + if (!(await confirm({ title: t("common.delete"), message: t("budget.deleteIncomeConfirm"), danger: true, confirmLabel: t("common.delete") }))) return; + try { await api.deleteIncome(id); loadTemplate(); loadView(label); } catch (err) { toast(err.message, "error"); } + } + + if (!view) return
{t("common.loading")}
; + + const tot = view.totals; + + return ( +
+
+

{t("budget.title")}

+
+ + +
+
+ + {mode === "month" ? ( + <> +
+ + {formatMonthLabel(view.label, locale)} + +
+ {view.customized && ( + + )} +
+ + {view.income.length > 0 && ( +
+

{t("budget.income")}

+ + + {view.income.map((i) => ( + + + + + + ))} + + + + + + +
{i.label}{t("budget.planned")}: {fmt(i.planned_cents)} + = i.planned_cents ? "pos" : "neg"}`}>{fmt(i.received_cents)} +
{t("budget.totalIncome")}{fmt(tot.planned_income)}{fmt(tot.received_income)}
+
+ )} + +
+

{t("budget.expenses")}

+ {view.groups.map((g) => ( +
+
+ + {g.name} + {fmt(g.actual_cents)} + / {fmt(g.planned_cents)} +
+ {g.categories.map((c) => ( +
+
+ + {c.name} +
+
+ +
+ {fmt(c.actual_cents)} + / + saveMonth(c.category_id, cents)} /> +
+ ))} +
+ ))} +
+ {t("budget.totalExpense")} + tot.planned_expense ? "neg" : ""}`}>{fmt(tot.actual_expense)} + / {fmt(tot.planned_expense)} +
+
+ + ) : ( + <> +
+

{t("budget.templateHint")}

+

{t("budget.expenses")}

+ {expenseGroups.map((g) => ( +
+
+ + {g.name} +
+ {g.categories.map((c) => ( +
+
+ + {c.name} +
+ saveTemplate(c.id, cents)} /> +
+ ))} +
+ ))} +
+ +
+
+

{t("budget.income")}

+ +
+ {template.income.length === 0 ? ( +
{t("budget.noIncome")}
+ ) : ( + + + {template.income.map((i) => ( + + + + + + ))} + +
{i.label}{formatMoney(i.planned_cents, base)} + + +
+ )} +
+ + )} + + {incomeModal && ( + setIncomeModal(null)} + footer={ + <> + + + + } + > +
+ +
+ + +
+ +
+
+ )} +
+ ); +} diff --git a/web/src/pages/Transactions.jsx b/web/src/pages/Transactions.jsx index d44f574..e066299 100644 --- a/web/src/pages/Transactions.jsx +++ b/web/src/pages/Transactions.jsx @@ -12,6 +12,8 @@ import { centsToInput, parseAmountToCents, todayIso } from "../format"; const TYPES = ["expense", "income", "transfer"]; +const SAVINGS_TYPES = ["sparkonto", "depot"]; + function emptyForm() { return { type: "expense", @@ -23,6 +25,8 @@ function emptyForm() { to_amount: "", payee: "", description: "", + budget_as_expense: true, + budget_category_id: "", }; } @@ -69,6 +73,22 @@ export default function Transactions() { const fromAcc = form && accountById[Number(form.account_id)]; const toAcc = form && accountById[Number(form.to_account_id)]; const crossCurrency = form?.type === "transfer" && fromAcc && toAcc && fromAcc.currency !== toAcc.currency; + const savingsDest = form?.type === "transfer" && toAcc && SAVINGS_TYPES.includes(toAcc.type); + + // Standard-Kategorie für budgetierte Sparen-Transfers (falls vorhanden). + const expenseGroups = useMemo(() => groups.filter((g) => g.kind === "expense"), [groups]); + const sparenCatId = useMemo(() => { + for (const g of expenseGroups) + for (const c of g.categories) if (c.name === "Sparen & Investments") return String(c.id); + return expenseGroups[0]?.categories?.[0] ? String(expenseGroups[0].categories[0].id) : ""; + }, [expenseGroups]); + + // Bei Spar-/Depot-Ziel eine Budget-Kategorie vorbelegen. + useEffect(() => { + if (savingsDest && form && !form.budget_category_id) { + setForm((f) => (f && !f.budget_category_id ? { ...f, budget_category_id: sparenCatId } : f)); + } + }, [savingsDest, sparenCatId]); // Bei währungsübergreifenden Transfers den Zielbetrag per EZB-Kurs vorschlagen. useEffect(() => { @@ -102,6 +122,8 @@ export default function Transactions() { to_amount: tx.to_amount_cents ? centsToInput(tx.to_amount_cents) : "", payee: tx.payee || "", description: tx.description || "", + budget_as_expense: tx.budget_treatment !== "neutral", + budget_category_id: tx.budget_category_id ? String(tx.budget_category_id) : "", }); } @@ -129,6 +151,11 @@ export default function Transactions() { body.to_account_id = Number(form.to_account_id); const toCents = parseAmountToCents(form.to_amount); if (toCents) body.to_amount_cents = toCents; + if (savingsDest) { + body.budget_treatment = form.budget_as_expense ? "budgeted" : "neutral"; + body.budget_category_id = + form.budget_as_expense && form.budget_category_id ? Number(form.budget_category_id) : null; + } } else { body.category_id = form.category_id ? Number(form.category_id) : null; } @@ -320,6 +347,28 @@ export default function Transactions() { )}
+ {savingsDest && ( +
+ + {form.budget_as_expense && ( + + )} +
+ )} + {form.type !== "transfer" && (