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 (
|
||||
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)
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user