feat: Phase 2 – Budget-Engine + Direktzugriff (http://IP:Port)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,7 @@ from .migrate import upgrade_to_head
|
|||||||
from .routers import (
|
from .routers import (
|
||||||
accounts,
|
accounts,
|
||||||
auth,
|
auth,
|
||||||
|
budgets,
|
||||||
categories,
|
categories,
|
||||||
rates,
|
rates,
|
||||||
settings as settings_router,
|
settings as settings_router,
|
||||||
@@ -87,6 +88,7 @@ api.include_router(settings_router.router)
|
|||||||
api.include_router(accounts.router)
|
api.include_router(accounts.router)
|
||||||
api.include_router(categories.router)
|
api.include_router(categories.router)
|
||||||
api.include_router(transactions.router)
|
api.include_router(transactions.router)
|
||||||
|
api.include_router(budgets.router)
|
||||||
api.include_router(rates.router)
|
api.include_router(rates.router)
|
||||||
|
|
||||||
app.include_router(api)
|
app.include_router(api)
|
||||||
|
|||||||
@@ -289,3 +289,91 @@ class ExchangeRate(Base):
|
|||||||
currency: Mapped[str] = mapped_column(String(3), primary_key=True)
|
currency: Mapped[str] = mapped_column(String(3), primary_key=True)
|
||||||
rate_per_eur: Mapped[float] = mapped_column(Numeric(18, 8), nullable=False)
|
rate_per_eur: Mapped[float] = mapped_column(Numeric(18, 8), nullable=False)
|
||||||
source: Mapped[str] = mapped_column(String(16), nullable=False, default="ecb")
|
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"),
|
||||||
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -255,3 +255,31 @@ class ConversionOut(BaseModel):
|
|||||||
date: date_t
|
date: date_t
|
||||||
converted_cents: int | None
|
converted_cents: int | None
|
||||||
rate: float | 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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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")
|
||||||
@@ -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
|
||||||
+14
-4
@@ -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;
|
Dienst `certain-finance` an und startet ihn. Node wird **nur zum Bauen** gebraucht;
|
||||||
der laufende Dienst ist reines Python.
|
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:<APP_PORT>` (Standard-Port `8071`) und
|
||||||
|
ist damit direkt im LAN erreichbar: `http://<LXC-IP>: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
|
- **Domain Names:** deine (Sub-)Domain
|
||||||
- **Scheme:** `http`
|
- **Scheme:** `http`
|
||||||
@@ -30,8 +38,10 @@ Neuen **Proxy Host** anlegen:
|
|||||||
Für spätere CSV-/PDF-Uploads in den *Advanced*-Einstellungen des Hosts
|
Für spätere CSV-/PDF-Uploads in den *Advanced*-Einstellungen des Hosts
|
||||||
`client_max_body_size 20m;` setzen.
|
`client_max_body_size 20m;` setzen.
|
||||||
|
|
||||||
Da HTTPS extern terminiert, sind die Sitzungs-Cookies `Secure`
|
Bei ausschließlichem HTTPS-Zugriff kannst du `APP_COOKIE_SECURE=true` in
|
||||||
(`APP_COOKIE_SECURE=true`). Der Dienst selbst lauscht nur auf `127.0.0.1`.
|
`/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
|
## Betrieb
|
||||||
|
|
||||||
|
|||||||
@@ -14,9 +14,10 @@ User=certain-finance
|
|||||||
Group=certain-finance
|
Group=certain-finance
|
||||||
WorkingDirectory=/opt/certain-finance/backend
|
WorkingDirectory=/opt/certain-finance/backend
|
||||||
EnvironmentFile=/etc/certain-finance/env
|
EnvironmentFile=/etc/certain-finance/env
|
||||||
# Der Port kommt aus der EnvironmentFile (APP_PORT). Nur an 127.0.0.1 gebunden –
|
# Adresse/Port kommen aus der EnvironmentFile. APP_BIND_HOST=0.0.0.0 macht den
|
||||||
# von außen ist der Dienst ausschließlich über den Nginx Proxy Manager erreichbar.
|
# Dienst direkt über http://<LXC-IP>:<APP_PORT> erreichbar; 127.0.0.1 beschränkt
|
||||||
ExecStart=/opt/certain-finance/backend/.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port ${APP_PORT}
|
# 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
|
Restart=on-failure
|
||||||
RestartSec=3
|
RestartSec=3
|
||||||
|
|
||||||
|
|||||||
+10
-4
@@ -5,15 +5,21 @@
|
|||||||
# Ablageort für SQLite-Datenbank und Backups (muss dem Dienst-Benutzer gehören).
|
# Ablageort für SQLite-Datenbank und Backups (muss dem Dienst-Benutzer gehören).
|
||||||
APP_DATA_DIR=/var/lib/certain-finance
|
APP_DATA_DIR=/var/lib/certain-finance
|
||||||
|
|
||||||
# Interner Port, auf dem der Dienst lauscht. Der Nginx Proxy Manager leitet
|
# Adresse + Port, auf denen der Dienst lauscht.
|
||||||
# von außen (HTTPS) hierher weiter. Nur an 127.0.0.1 gebunden (siehe Unit).
|
# APP_BIND_HOST=0.0.0.0 macht die Web-UI direkt über http://<LXC-IP>:<APP_PORT>
|
||||||
|
# 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
|
APP_PORT=8071
|
||||||
|
|
||||||
# Signiert/HMACt Sitzungen. UNBEDINGT zufällig setzen (install.sh macht das).
|
# Signiert/HMACt Sitzungen. UNBEDINGT zufällig setzen (install.sh macht das).
|
||||||
APP_SESSION_SECRET=change-me-please
|
APP_SESSION_SECRET=change-me-please
|
||||||
|
|
||||||
# Cookies nur über HTTPS senden. Hinter dem Nginx Proxy Manager: true.
|
# Cookies nur über HTTPS senden. Bei direktem HTTP-Zugriff (http://IP:Port) MUSS
|
||||||
APP_COOKIE_SECURE=true
|
# 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).
|
# Erster Admin – wird NUR beim allerersten Start angelegt (wenn noch kein Nutzer da ist).
|
||||||
APP_ADMIN_USERNAME=admin
|
APP_ADMIN_USERNAME=admin
|
||||||
|
|||||||
+13
-8
@@ -95,6 +95,8 @@ if [[ -f "$ENV_FILE" ]]; then
|
|||||||
else
|
else
|
||||||
echo "APP_GIT_SHA=$GIT_SHA" >> "$ENV_FILE"
|
echo "APP_GIT_SHA=$GIT_SHA" >> "$ENV_FILE"
|
||||||
fi
|
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-)"
|
ADMIN_USER="$(grep -E '^APP_ADMIN_USERNAME=' "$ENV_FILE" | cut -d= -f2-)"
|
||||||
GENERATED_PASS=""
|
GENERATED_PASS=""
|
||||||
else
|
else
|
||||||
@@ -110,9 +112,10 @@ else
|
|||||||
info "Erzeuge Konfiguration: $ENV_FILE"
|
info "Erzeuge Konfiguration: $ENV_FILE"
|
||||||
cat > "$ENV_FILE" <<EOF
|
cat > "$ENV_FILE" <<EOF
|
||||||
APP_DATA_DIR=$DATA_DIR
|
APP_DATA_DIR=$DATA_DIR
|
||||||
|
APP_BIND_HOST=0.0.0.0
|
||||||
APP_PORT=$local_port
|
APP_PORT=$local_port
|
||||||
APP_SESSION_SECRET=$(rand 48)
|
APP_SESSION_SECRET=$(rand 48)
|
||||||
APP_COOKIE_SECURE=true
|
APP_COOKIE_SECURE=false
|
||||||
APP_ADMIN_USERNAME=$admin_user
|
APP_ADMIN_USERNAME=$admin_user
|
||||||
APP_ADMIN_PASSWORD=$admin_pass
|
APP_ADMIN_PASSWORD=$admin_pass
|
||||||
APP_DEFAULT_BASE_CURRENCY=EUR
|
APP_DEFAULT_BASE_CURRENCY=EUR
|
||||||
@@ -139,7 +142,7 @@ User=$SERVICE_USER
|
|||||||
Group=$SERVICE_USER
|
Group=$SERVICE_USER
|
||||||
WorkingDirectory=$REPO_DIR/backend
|
WorkingDirectory=$REPO_DIR/backend
|
||||||
EnvironmentFile=$ENV_FILE
|
EnvironmentFile=$ENV_FILE
|
||||||
ExecStart=$REPO_DIR/backend/.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port \${APP_PORT}
|
ExecStart=$REPO_DIR/backend/.venv/bin/uvicorn app.main:app --host \${APP_BIND_HOST} --port \${APP_PORT}
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=3
|
RestartSec=3
|
||||||
NoNewPrivileges=true
|
NoNewPrivileges=true
|
||||||
@@ -167,24 +170,26 @@ for _ in $(seq 1 30); do
|
|||||||
sleep 2
|
sleep 2
|
||||||
done
|
done
|
||||||
|
|
||||||
|
LAN_IP="$(hostname -I 2>/dev/null | awk '{print $1}')"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
if [[ "$ok" -eq 1 ]]; then
|
if [[ "$ok" -eq 1 ]]; then
|
||||||
info "Fertig – Certain Finance läuft. 🎉"
|
info "Fertig – Certain Finance läuft. 🎉"
|
||||||
else
|
else
|
||||||
warn "Backend antwortet noch nicht. Logs: journalctl -u $APP_SLUG -e"
|
warn "Backend antwortet noch nicht. Logs: journalctl -u $APP_SLUG -e"
|
||||||
fi
|
fi
|
||||||
echo " Interner Dienst: http://127.0.0.1:$PORT (nur lokal)"
|
echo " Web-UI (direkt): http://${LAN_IP:-<LXC-IP>}:$PORT"
|
||||||
echo " Admin-Login: ${ADMIN_USER:-admin}"
|
echo " Admin-Login: ${ADMIN_USER:-admin}"
|
||||||
if [[ -n "${GENERATED_PASS:-}" ]]; then
|
if [[ -n "${GENERATED_PASS:-}" ]]; then
|
||||||
echo " Admin-Passwort: ${GENERATED_PASS}"
|
echo " Admin-Passwort: ${GENERATED_PASS}"
|
||||||
warn "Bitte dieses Passwort notieren – es wird nicht erneut angezeigt."
|
warn "Bitte dieses Passwort notieren – es wird nicht erneut angezeigt."
|
||||||
fi
|
fi
|
||||||
echo
|
echo
|
||||||
echo " Nginx Proxy Manager: neuen Proxy-Host anlegen ->"
|
echo " Optional über HTTPS (Nginx Proxy Manager): neuen Proxy-Host ->"
|
||||||
echo " Forward Hostname/IP: <IP-dieses-LXC>"
|
echo " Forward Hostname/IP: ${LAN_IP:-<LXC-IP>}"
|
||||||
echo " Forward Port: $PORT"
|
echo " Forward Port: $PORT (Scheme: http)"
|
||||||
echo " SSL: Let's Encrypt aktivieren; 'Block Common Exploits' ok."
|
echo " SSL: Let's Encrypt aktivieren. Dann in $ENV_FILE APP_COOKIE_SECURE=true"
|
||||||
echo " (Websockets nicht nötig. Für spätere Uploads client_max_body_size >= 20M.)"
|
echo " setzen und 'systemctl restart $APP_SLUG'. (Uploads: client_max_body_size >= 20M.)"
|
||||||
echo
|
echo
|
||||||
echo " Logs: journalctl -u $APP_SLUG -f"
|
echo " Logs: journalctl -u $APP_SLUG -f"
|
||||||
echo " Neustart: systemctl restart $APP_SLUG"
|
echo " Neustart: systemctl restart $APP_SLUG"
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@ Kategorien (Gruppen→Kategorien, Einnahme/Ausgabe, SVG-Icons), Transaktionen
|
|||||||
(Ausgabe/Einnahme/Transfer), EZB-Wechselkurse zum Buchungsdatum (lokal gecacht,
|
(Ausgabe/Einnahme/Transfer), EZB-Wechselkurse zum Buchungsdatum (lokal gecacht,
|
||||||
pro Buchung überschreibbar), Kontostände.
|
pro Buchung überschreibbar), Kontostände.
|
||||||
|
|
||||||
## Phase 2 – Budget-Engine
|
## Phase 2 – Budget-Engine ✅
|
||||||
Einstellbarer Budget-Monat-Starttag, Standard-Budget als Vorlage (Snapshot-on-
|
Einstellbarer Budget-Monat-Starttag, Standard-Budget als Vorlage (Snapshot-on-
|
||||||
Customize), geplante Einnahmen mit Zielkonto, Soll/Ist-Tabelle (grün/rot),
|
Customize), geplante Einnahmen mit Zielkonto, Soll/Ist-Tabelle (grün/rot),
|
||||||
kein Rollover in v1.
|
kein Rollover in v1.
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import Dashboard from "./pages/Dashboard";
|
|||||||
import Accounts from "./pages/Accounts";
|
import Accounts from "./pages/Accounts";
|
||||||
import Categories from "./pages/Categories";
|
import Categories from "./pages/Categories";
|
||||||
import Transactions from "./pages/Transactions";
|
import Transactions from "./pages/Transactions";
|
||||||
|
import Budget from "./pages/Budget";
|
||||||
import Users from "./pages/Users";
|
import Users from "./pages/Users";
|
||||||
import Settings from "./pages/Settings";
|
import Settings from "./pages/Settings";
|
||||||
|
|
||||||
@@ -44,6 +45,7 @@ function Sidebar() {
|
|||||||
<NavItem to="/konten" icon="wallet" label={t("nav.accounts")} />
|
<NavItem to="/konten" icon="wallet" label={t("nav.accounts")} />
|
||||||
<NavItem to="/kategorien" icon="list" label={t("nav.categories")} />
|
<NavItem to="/kategorien" icon="list" label={t("nav.categories")} />
|
||||||
<NavItem to="/buchungen" icon="arrow-left-right" label={t("nav.transactions")} />
|
<NavItem to="/buchungen" icon="arrow-left-right" label={t("nav.transactions")} />
|
||||||
|
<NavItem to="/budget" icon="pie-chart" label={t("nav.budget")} />
|
||||||
<NavItem to="/settings" icon="settings" label={t("nav.settings")} />
|
<NavItem to="/settings" icon="settings" label={t("nav.settings")} />
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<>
|
<>
|
||||||
@@ -128,6 +130,7 @@ export default function App() {
|
|||||||
<Route path="/konten" element={<Protected><Accounts /></Protected>} />
|
<Route path="/konten" element={<Protected><Accounts /></Protected>} />
|
||||||
<Route path="/kategorien" element={<Protected><Categories /></Protected>} />
|
<Route path="/kategorien" element={<Protected><Categories /></Protected>} />
|
||||||
<Route path="/buchungen" element={<Protected wide><Transactions /></Protected>} />
|
<Route path="/buchungen" element={<Protected wide><Transactions /></Protected>} />
|
||||||
|
<Route path="/budget" element={<Protected wide><Budget /></Protected>} />
|
||||||
<Route path="/settings" element={<Protected><Settings /></Protected>} />
|
<Route path="/settings" element={<Protected><Settings /></Protected>} />
|
||||||
<Route path="/users" element={<Protected adminOnly><Users /></Protected>} />
|
<Route path="/users" element={<Protected adminOnly><Users /></Protected>} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
|||||||
@@ -162,4 +162,17 @@ export const api = {
|
|||||||
if (on) qs.set("on", on);
|
if (on) qs.set("on", on);
|
||||||
return request(`/rates/convert?${qs.toString()}`);
|
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" }),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<input
|
||||||
|
className="amount-input"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={val}
|
||||||
|
placeholder={placeholder}
|
||||||
|
onChange={(e) => setVal(e.target.value)}
|
||||||
|
onBlur={commit}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
commit();
|
||||||
|
e.target.blur();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -261,3 +261,113 @@
|
|||||||
.nowrap {
|
.nowrap {
|
||||||
white-space: 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -54,3 +54,14 @@ export function todayIso() {
|
|||||||
const z = (n) => String(n).padStart(2, "0");
|
const z = (n) => String(n).padStart(2, "0");
|
||||||
return `${d.getFullYear()}-${z(d.getMonth() + 1)}-${z(d.getDate())}`;
|
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);
|
||||||
|
}
|
||||||
|
|||||||
+24
-1
@@ -150,6 +150,29 @@
|
|||||||
"allTypes": "Alle Typen",
|
"allTypes": "Alle Typen",
|
||||||
"deleteConfirm": "Diese Buchung löschen?",
|
"deleteConfirm": "Diese Buchung löschen?",
|
||||||
"amountRequired": "Bitte einen Betrag eingeben.",
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-1
@@ -150,6 +150,29 @@
|
|||||||
"allTypes": "All types",
|
"allTypes": "All types",
|
||||||
"deleteConfirm": "Delete this transaction?",
|
"deleteConfirm": "Delete this transaction?",
|
||||||
"amountRequired": "Please enter an amount.",
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="budget-bar">
|
||||||
|
<div className={`budget-bar-fill${over ? " over" : ""}`} style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <div className="muted">{t("common.loading")}</div>;
|
||||||
|
|
||||||
|
const tot = view.totals;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="page-head">
|
||||||
|
<h1>{t("budget.title")}</h1>
|
||||||
|
<div className="seg" style={{ width: 260, marginBottom: 0 }}>
|
||||||
|
<button type="button" className={`seg-btn${mode === "month" ? " active" : ""}`} onClick={() => setMode("month")}>
|
||||||
|
{t("budget.monthMode")}
|
||||||
|
</button>
|
||||||
|
<button type="button" className={`seg-btn${mode === "template" ? " active" : ""}`} onClick={() => setMode("template")}>
|
||||||
|
{t("budget.templateMode")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mode === "month" ? (
|
||||||
|
<>
|
||||||
|
<div className="card budget-navbar">
|
||||||
|
<button className="btn ghost" onClick={() => loadView(view.prev)}>
|
||||||
|
<Icon name="chevron-right" size={16} className="flip" />
|
||||||
|
</button>
|
||||||
|
<strong className="budget-month">{formatMonthLabel(view.label, locale)}</strong>
|
||||||
|
<button className="btn ghost" onClick={() => loadView(view.next)}>
|
||||||
|
<Icon name="chevron-right" size={16} />
|
||||||
|
</button>
|
||||||
|
<div className="grow" />
|
||||||
|
{view.customized && (
|
||||||
|
<button className="btn sm" onClick={resetMonth}>
|
||||||
|
<Icon name="refresh-ccw" size={14} /> {t("budget.reset")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{view.income.length > 0 && (
|
||||||
|
<div className="card">
|
||||||
|
<h2>{t("budget.income")}</h2>
|
||||||
|
<table className="table">
|
||||||
|
<tbody>
|
||||||
|
{view.income.map((i) => (
|
||||||
|
<tr key={i.id}>
|
||||||
|
<td>{i.label}</td>
|
||||||
|
<td style={{ textAlign: "right" }} className="muted">{t("budget.planned")}: {fmt(i.planned_cents)}</td>
|
||||||
|
<td style={{ textAlign: "right" }}>
|
||||||
|
<span className={`amount ${i.received_cents >= i.planned_cents ? "pos" : "neg"}`}>{fmt(i.received_cents)}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
<tr className="budget-total-row">
|
||||||
|
<td><strong>{t("budget.totalIncome")}</strong></td>
|
||||||
|
<td style={{ textAlign: "right" }} className="muted">{fmt(tot.planned_income)}</td>
|
||||||
|
<td style={{ textAlign: "right" }}><strong>{fmt(tot.received_income)}</strong></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<h2>{t("budget.expenses")}</h2>
|
||||||
|
{view.groups.map((g) => (
|
||||||
|
<div className="budget-group" key={g.id}>
|
||||||
|
<div className="budget-group-head">
|
||||||
|
<IconChip name={g.icon} color={g.color} size={26} />
|
||||||
|
<span className="grow"><strong>{g.name}</strong></span>
|
||||||
|
<span className={`amount ${g.over ? "neg" : "muted"}`}>{fmt(g.actual_cents)}</span>
|
||||||
|
<span className="muted"> / {fmt(g.planned_cents)}</span>
|
||||||
|
</div>
|
||||||
|
{g.categories.map((c) => (
|
||||||
|
<div className="budget-cat" key={c.category_id}>
|
||||||
|
<div className="budget-cat-name">
|
||||||
|
<IconChip name={c.icon} color={c.color} size={24} />
|
||||||
|
<span>{c.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="budget-cat-bar">
|
||||||
|
<ProgressBar actual={c.actual_cents} planned={c.planned_cents} />
|
||||||
|
</div>
|
||||||
|
<span className={`amount budget-actual ${c.over ? "neg" : "muted"}`}>{fmt(c.actual_cents)}</span>
|
||||||
|
<span className="muted budget-slash">/</span>
|
||||||
|
<EditableAmount cents={c.planned_cents} onSave={(cents) => saveMonth(c.category_id, cents)} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="budget-total-row card-total">
|
||||||
|
<span className="grow"><strong>{t("budget.totalExpense")}</strong></span>
|
||||||
|
<span className={`amount ${tot.actual_expense > tot.planned_expense ? "neg" : ""}`}>{fmt(tot.actual_expense)}</span>
|
||||||
|
<span className="muted"> / {fmt(tot.planned_expense)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="card">
|
||||||
|
<p className="muted small">{t("budget.templateHint")}</p>
|
||||||
|
<h2>{t("budget.expenses")}</h2>
|
||||||
|
{expenseGroups.map((g) => (
|
||||||
|
<div className="budget-group" key={g.id}>
|
||||||
|
<div className="budget-group-head">
|
||||||
|
<IconChip name={g.icon} color={g.color} size={26} />
|
||||||
|
<strong>{g.name}</strong>
|
||||||
|
</div>
|
||||||
|
{g.categories.map((c) => (
|
||||||
|
<div className="budget-cat" key={c.id}>
|
||||||
|
<div className="budget-cat-name grow">
|
||||||
|
<IconChip name={c.icon} color={c.color} size={24} />
|
||||||
|
<span>{c.name}</span>
|
||||||
|
</div>
|
||||||
|
<EditableAmount cents={tplMap[c.id] || 0} onSave={(cents) => saveTemplate(c.id, cents)} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div className="page-head" style={{ marginBottom: "var(--sp-3)" }}>
|
||||||
|
<h2 style={{ margin: 0 }}>{t("budget.income")}</h2>
|
||||||
|
<button className="btn sm primary" onClick={() => setIncomeModal({ label: "", category_id: "", target_account_id: "", planned_cents: 0 })}>
|
||||||
|
<Icon name="plus" size={15} /> {t("budget.addIncome")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{template.income.length === 0 ? (
|
||||||
|
<div className="muted small">{t("budget.noIncome")}</div>
|
||||||
|
) : (
|
||||||
|
<table className="table">
|
||||||
|
<tbody>
|
||||||
|
{template.income.map((i) => (
|
||||||
|
<tr key={i.id}>
|
||||||
|
<td><strong>{i.label}</strong></td>
|
||||||
|
<td style={{ textAlign: "right" }}>{formatMoney(i.planned_cents, base)}</td>
|
||||||
|
<td className="nowrap" style={{ textAlign: "right", width: 80 }}>
|
||||||
|
<button className="btn sm ghost" onClick={() => setIncomeModal({ ...i, category_id: i.category_id || "", target_account_id: i.target_account_id || "" })}>
|
||||||
|
<Icon name="edit" size={14} />
|
||||||
|
</button>
|
||||||
|
<button className="btn sm danger" onClick={() => deleteIncome(i.id)}>
|
||||||
|
<Icon name="trash" size={14} />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{incomeModal && (
|
||||||
|
<Modal
|
||||||
|
title={incomeModal.id ? t("budget.editIncome") : t("budget.addIncome")}
|
||||||
|
onClose={() => setIncomeModal(null)}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<button className="btn" onClick={() => setIncomeModal(null)}>{t("common.cancel")}</button>
|
||||||
|
<button className="btn primary" form="income-form" type="submit">{t("common.save")}</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<form id="income-form" onSubmit={saveIncome}>
|
||||||
|
<label>
|
||||||
|
<span>{t("budget.incomeLabel")}</span>
|
||||||
|
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
|
||||||
|
<input autoFocus value={incomeModal.label} onChange={(e) => setIncomeModal({ ...incomeModal, label: e.target.value })} required />
|
||||||
|
</label>
|
||||||
|
<div className="row">
|
||||||
|
<label className="grow">
|
||||||
|
<span>{t("budget.incomeCategory")}</span>
|
||||||
|
<select value={incomeModal.category_id} onChange={(e) => setIncomeModal({ ...incomeModal, category_id: e.target.value })}>
|
||||||
|
<option value="">—</option>
|
||||||
|
{incomeCategories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="grow">
|
||||||
|
<span>{t("budget.targetAccount")}</span>
|
||||||
|
<select value={incomeModal.target_account_id} onChange={(e) => setIncomeModal({ ...incomeModal, target_account_id: e.target.value })}>
|
||||||
|
<option value="">—</option>
|
||||||
|
{accounts.map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
<span>{t("budget.planned")} ({base})</span>
|
||||||
|
<input inputMode="decimal" defaultValue={(incomeModal.planned_cents / 100).toFixed(2).replace(".", ",")}
|
||||||
|
onChange={(e) => {
|
||||||
|
const cleaned = e.target.value.replace(",", ".");
|
||||||
|
setIncomeModal((m) => ({ ...m, planned_cents: Math.round((Number.parseFloat(cleaned) || 0) * 100) }));
|
||||||
|
}} />
|
||||||
|
</label>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ import { centsToInput, parseAmountToCents, todayIso } from "../format";
|
|||||||
|
|
||||||
const TYPES = ["expense", "income", "transfer"];
|
const TYPES = ["expense", "income", "transfer"];
|
||||||
|
|
||||||
|
const SAVINGS_TYPES = ["sparkonto", "depot"];
|
||||||
|
|
||||||
function emptyForm() {
|
function emptyForm() {
|
||||||
return {
|
return {
|
||||||
type: "expense",
|
type: "expense",
|
||||||
@@ -23,6 +25,8 @@ function emptyForm() {
|
|||||||
to_amount: "",
|
to_amount: "",
|
||||||
payee: "",
|
payee: "",
|
||||||
description: "",
|
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 fromAcc = form && accountById[Number(form.account_id)];
|
||||||
const toAcc = form && accountById[Number(form.to_account_id)];
|
const toAcc = form && accountById[Number(form.to_account_id)];
|
||||||
const crossCurrency = form?.type === "transfer" && fromAcc && toAcc && fromAcc.currency !== toAcc.currency;
|
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.
|
// Bei währungsübergreifenden Transfers den Zielbetrag per EZB-Kurs vorschlagen.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -102,6 +122,8 @@ export default function Transactions() {
|
|||||||
to_amount: tx.to_amount_cents ? centsToInput(tx.to_amount_cents) : "",
|
to_amount: tx.to_amount_cents ? centsToInput(tx.to_amount_cents) : "",
|
||||||
payee: tx.payee || "",
|
payee: tx.payee || "",
|
||||||
description: tx.description || "",
|
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);
|
body.to_account_id = Number(form.to_account_id);
|
||||||
const toCents = parseAmountToCents(form.to_amount);
|
const toCents = parseAmountToCents(form.to_amount);
|
||||||
if (toCents) body.to_amount_cents = toCents;
|
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 {
|
} else {
|
||||||
body.category_id = form.category_id ? Number(form.category_id) : null;
|
body.category_id = form.category_id ? Number(form.category_id) : null;
|
||||||
}
|
}
|
||||||
@@ -320,6 +347,28 @@ export default function Transactions() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{savingsDest && (
|
||||||
|
<div className="savings-opt">
|
||||||
|
<label className="checkline">
|
||||||
|
<input type="checkbox" checked={form.budget_as_expense}
|
||||||
|
onChange={(e) => setForm({ ...form, budget_as_expense: e.target.checked })} />
|
||||||
|
<span>{t("transactions.budgetAsSavings")}</span>
|
||||||
|
</label>
|
||||||
|
{form.budget_as_expense && (
|
||||||
|
<label>
|
||||||
|
<span>{t("transactions.budgetCategory")}</span>
|
||||||
|
<select value={form.budget_category_id} onChange={(e) => setForm({ ...form, budget_category_id: e.target.value })}>
|
||||||
|
{expenseGroups.map((g) => (
|
||||||
|
<optgroup key={g.id} label={g.name}>
|
||||||
|
{g.categories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||||
|
</optgroup>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{form.type !== "transfer" && (
|
{form.type !== "transfer" && (
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<label className="grow">
|
<label className="grow">
|
||||||
|
|||||||
Reference in New Issue
Block a user