feat: Der Soll-Betrag kann sich aus den Vertraegen rechnen

Die Vertraege standen bisher nur auf ihrer eigenen Seite. Im Budget sah man
nichts davon – man musste selbst nachrechnen, was fest gebunden ist, und beim
naechsten Preisaufschlag noch einmal.

Jetzt hat jede Kategorie zwei Betriebsarten, umschaltbar ueber ein Sigma neben
dem Eingabefeld:

  fest        eine Zahl, die man hinschreibt. Wie bisher.
  berechnet   Summe der Vertraege dieser Kategorie plus einem Zuschlag.

Der Zuschlag ist der Punkt. Wer fuer Streaming 62 plant, weil 42 gebunden sind
und 20 Luft sein sollen, muss beim naechsten Preisaufschlag nicht nachrechnen
– und verliert seine 20 dabei auch nicht. Unter der Zeile steht die Rechnung
offen, sonst stuende im Feld eine Zahl, die nicht der Soll-Betrag ist.

Das gilt fuer JEDE Kategorie, nicht nur fuer eine Abo-Kategorie: Ein
Handyvertrag liegt in "Telefon & Internet", und die rechnet sich genauso.

Auch auf "fest" steht der Hinweis da ("davon 16.84 fest gebunden") – sonst
sieht man nie, was einen schon bindet.

Zwei Dinge, die sonst still danebengingen, melden sich jetzt oben auf der
Seite: Vertraege OHNE Kategorie (ihr Geld fehlt im Budget, ohne dass irgendwo
steht, dass es fehlt) und Waehrungen ohne Kurs.

Gerechnet wird in effective_planned – der einen Stelle, aus der Budget-Seite
UND Dashboard ihr Soll beziehen. Damit koennen die beiden gar nicht erst
auseinanderlaufen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-07 20:49:21 +02:00
co-authored by Claude Opus 5
parent 26718b1301
commit ab5d18e0d0
11 changed files with 613 additions and 31 deletions
+17
View File
@@ -472,6 +472,19 @@ class BudgetTemplateLine(Base):
ForeignKey("categories.id", ondelete="CASCADE"), nullable=False ForeignKey("categories.id", ondelete="CASCADE"), nullable=False
) )
planned_cents: Mapped[int] = mapped_column(Integer, nullable=False, default=0) planned_cents: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# Wie der Soll-Betrag zustande kommt.
#
# "fest" – die Zahl in ``planned_cents``, wie eh und je.
# "berechnet" – Summe der Vertraege dieser Kategorie PLUS ``zuschlag_cents``.
#
# Der Zuschlag ist der Punkt: Wer fuer Streaming 80 plant, weil 58 fest
# gebunden sind und 22 Luft sein sollen, will beim naechsten Preisaufschlag
# nicht nachrechnen muessen – und seine 22 auch nicht verlieren.
#
# ``planned_cents`` bleibt bei "berechnet" stehen, wird aber nicht gelesen.
# So findet man seinen alten Wert wieder, wenn man zurueckschaltet.
modus: Mapped[str] = mapped_column(String(16), nullable=False, default="fest")
zuschlag_cents: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
__table_args__ = ( __table_args__ = (
UniqueConstraint("owner_id", "category_id", name="uq_tpl_owner_category"), UniqueConstraint("owner_id", "category_id", name="uq_tpl_owner_category"),
@@ -533,6 +546,10 @@ class BudgetPeriodLine(Base):
ForeignKey("categories.id", ondelete="CASCADE"), nullable=False ForeignKey("categories.id", ondelete="CASCADE"), nullable=False
) )
planned_cents: Mapped[int] = mapped_column(Integer, nullable=False, default=0) planned_cents: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# Wie in der Vorlage – der angepasste Monat erbt die Betriebsart, sonst
# friert er beim Anpassen heimlich auf einer Zahl ein.
modus: Mapped[str] = mapped_column(String(16), nullable=False, default="fest")
zuschlag_cents: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
period: Mapped[BudgetPeriod] = relationship(back_populates="lines") period: Mapped[BudgetPeriod] = relationship(back_populates="lines")
+53 -10
View File
@@ -15,7 +15,7 @@ from ..models import (
User, User,
) )
from ..schemas import PlannedIncomeCreate, PlannedIncomeOut, PlannedIncomeUpdate from ..schemas import PlannedIncomeCreate, PlannedIncomeOut, PlannedIncomeUpdate
from ..services import budget from ..services import abos, budget
from .accounts import base_currency from .accounts import base_currency
from .settings import BUDGET_START_DAY_KEY, get_user_setting from .settings import BUDGET_START_DAY_KEY, get_user_setting
@@ -51,31 +51,51 @@ def get_template(db: Session = Depends(get_db), user: User = Depends(get_current
} }
MODI = ("fest", "berechnet")
def _pruefe_modus(modus: str | None) -> None:
if modus is not None and modus not in MODI:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Unbekannte Betriebsart '{modus}'. Moeglich: {', '.join(MODI)}.",
)
@router.put("/template/category/{category_id}") @router.put("/template/category/{category_id}")
def set_template_category( def set_template_category(
category_id: int, category_id: int,
planned_cents: int, planned_cents: int | None = None,
modus: str | None = None,
zuschlag_cents: int | None = None,
db: Session = Depends(get_db), db: Session = Depends(get_db),
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
) -> dict: ) -> dict:
_require_expense_category(db, user, category_id) _require_expense_category(db, user, category_id)
_pruefe_modus(modus)
line = ( line = (
db.query(BudgetTemplateLine) db.query(BudgetTemplateLine)
.filter(BudgetTemplateLine.owner_id == user.id, BudgetTemplateLine.category_id == category_id) .filter(BudgetTemplateLine.owner_id == user.id, BudgetTemplateLine.category_id == category_id)
.first() .first()
) )
if planned_cents <= 0: ziel_modus = modus or (line.modus if line is not None else "fest")
# Eine Zeile ohne Inhalt wird geloescht – sie ist dasselbe wie "kein
# Budget". Bei "berechnet" gilt das NICHT: Dort ist die Zeile selbst die
# Aussage ("rechne aus den Vertraegen"), auch wenn der Zuschlag null ist.
leer = ziel_modus == "fest" and (planned_cents is not None and planned_cents <= 0)
if leer:
if line is not None: if line is not None:
db.delete(line) db.delete(line)
db.commit() db.commit()
return {"category_id": category_id, "planned_cents": 0} return {"category_id": category_id, "planned_cents": 0, "modus": "fest",
"zuschlag_cents": 0}
if line is None: if line is None:
line = BudgetTemplateLine(owner_id=user.id, category_id=category_id, planned_cents=planned_cents) line = BudgetTemplateLine(owner_id=user.id, category_id=category_id, planned_cents=0)
db.add(line) db.add(line)
else: budget.uebernimm(line, planned_cents, modus, zuschlag_cents)
line.planned_cents = planned_cents
db.commit() db.commit()
return {"category_id": category_id, "planned_cents": planned_cents} return {"category_id": category_id, "planned_cents": line.planned_cents,
"modus": line.modus, "zuschlag_cents": line.zuschlag_cents}
# --- Geplante Einnahmen ---------------------------------------------------- # --- Geplante Einnahmen ----------------------------------------------------
@@ -148,6 +168,10 @@ def _build_view(db: Session, user: User, label: str) -> dict:
planned = budget.effective_planned(db, user, label) planned = budget.effective_planned(db, user, label)
actual = budget.actual_expense_by_category(db, user, label, start, end, base) actual = budget.actual_expense_by_category(db, user, label, start, end, base)
income_actual = budget.income_by_category(db, user, label, start, end, base) income_actual = budget.income_by_category(db, user, label, start, end, base)
# Was aus Vertraegen kommt – auch fuer Kategorien auf "fest". Dort ist es
# kein Rechenwert, sondern ein Hinweis: "hier sind 43.89 gebunden".
vertraege = abos.uebersicht(db, user, base)
zeilen = {ln.category_id: ln for ln in budget._zeilen(db, user, label)}
groups_out = [] groups_out = []
total_planned = total_actual = 0 total_planned = total_actual = 0
@@ -167,9 +191,16 @@ def _build_view(db: Session, user: User, label: str) -> dict:
a = actual.get(c.id, 0) a = actual.get(c.id, 0)
g_planned += p g_planned += p
g_actual += a g_actual += a
zeile = zeilen.get(c.id)
cats.append({ cats.append({
"category_id": c.id, "name": c.name, "icon": c.icon, "color": c.color, "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, "planned_cents": p, "actual_cents": a, "delta_cents": p - a, "over": a > p,
# Woraus sich der Soll-Betrag ergibt. "aus_vertraegen_cents"
# steht auch bei "fest" – dann als Hinweis, nicht als Summand.
"modus": zeile.modus if zeile is not None else "fest",
"zuschlag_cents": zeile.zuschlag_cents if zeile is not None else 0,
"aus_vertraegen_cents": vertraege["je_kategorie"].get(c.id, 0),
"vertraege_anzahl": vertraege["anzahl"].get(c.id, 0),
# "over" bleibt die nackte Tatsache; ob sie eine Warnung IST, # "over" bleibt die nackte Tatsache; ob sie eine Warnung IST,
# entscheidet die Kategorie. Wer mehr zuruecklegt als geplant, # entscheidet die Kategorie. Wer mehr zuruecklegt als geplant,
# hat nichts ueberzogen. # hat nichts ueberzogen.
@@ -190,6 +221,7 @@ def _build_view(db: Session, user: User, label: str) -> dict:
"planned_cents": g_planned, "actual_cents": g_actual, "planned_cents": g_planned, "actual_cents": g_actual,
"over": echt_ist > echt_geplant, "over": echt_ist > echt_geplant,
"good": bool(cats) and all(c["good"] for c in cats), "good": bool(cats) and all(c["good"] for c in cats),
"aus_vertraegen_cents": sum(c["aus_vertraegen_cents"] for c in cats),
"categories": cats, "categories": cats,
}) })
@@ -221,6 +253,13 @@ def _build_view(db: Session, user: User, label: str) -> dict:
"next": budget.shift_label(label, 1), "next": budget.shift_label(label, 1),
"groups": groups_out, "groups": groups_out,
"income": income_out, "income": income_out,
# Was sich NICHT zuordnen liess. Ohne diese zwei Angaben fehlt das Geld
# im Budget, ohne dass irgendwo steht, dass es fehlt.
"vertraege": {
"ohne_kategorie_cents": vertraege["ohne_kategorie_cents"],
"ohne_kategorie_anzahl": vertraege["ohne_kategorie_anzahl"],
"ohne_kurs": vertraege["ohne_kurs"],
},
"totals": { "totals": {
"planned_expense": total_planned, "planned_expense": total_planned,
"actual_expense": total_actual, "actual_expense": total_actual,
@@ -248,13 +287,17 @@ def view(label: str, db: Session = Depends(get_db), user: User = Depends(get_cur
def set_month_category( def set_month_category(
label: str, label: str,
category_id: int, category_id: int,
planned_cents: int, planned_cents: int | None = None,
modus: str | None = None,
zuschlag_cents: int | None = None,
db: Session = Depends(get_db), db: Session = Depends(get_db),
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
) -> dict: ) -> dict:
budget.parse_label(label) budget.parse_label(label)
_require_expense_category(db, user, category_id) _require_expense_category(db, user, category_id)
budget.set_month_category(db, user, label, category_id, max(0, planned_cents)) _pruefe_modus(modus)
budget.set_month_category(db, user, label, category_id, planned_cents, modus,
zuschlag_cents)
return _build_view(db, user, label) return _build_view(db, user, label)
+75
View File
@@ -0,0 +1,75 @@
"""Was die Verträge je Kategorie im Monat kosten.
Die eine Stelle, an der aus Verträgen eine Budget-Zahl wird. Budget-Seite und
Dashboard fragen beide hier – sonst könnten sie unterschiedliche Antworten
geben, und man müsste raten, welche stimmt.
Gerechnet wird in der Hauptwährung: Ein Vertrag in Euro und einer in Franken
lassen sich sonst nicht addieren. Was sich mangels Kurs nicht umrechnen lässt,
wird GEMELDET und nicht stillschweigend als Null verbucht – eine zu kleine
Summe im Budget ist schlimmer als gar keine.
"""
from __future__ import annotations
from collections import defaultdict
from datetime import date
from sqlalchemy.orm import Session
from ..models import Subscription, User
from . import currency
def _monatlich_in(db: Session, s: Subscription, base: str, heute: date) -> int | None:
"""Monatswert eines Vertrags in der Hauptwährung, oder None ohne Kurs."""
monatlich = s.monatlich_cents
if s.currency == base:
return monatlich
return currency.convert(db, monatlich, s.currency, base, heute)
def uebersicht(db: Session, user: User, base: str) -> dict:
"""Alles, was die Budget-Seite über die Verträge wissen muss.
``je_kategorie`` Monatswert je Kategorie, in Hauptwährung
``anzahl`` wie viele Verträge je Kategorie – „aus 2 Verträgen"
``ohne_kategorie`` Summe und Anzahl der Verträge, die keiner Kategorie
zugeordnet sind. Ohne diese Angabe fehlt ihr Geld im
Budget, ohne dass irgendwo steht, dass es fehlt.
``ohne_kurs`` Währungen, die sich nicht umrechnen liessen.
"""
heute = date.today()
je_kategorie: dict[int, int] = defaultdict(int)
anzahl: dict[int, int] = defaultdict(int)
ohne_kategorie_cents = 0
ohne_kategorie_anzahl = 0
ohne_kurs: set[str] = set()
for s in db.query(Subscription).filter(
Subscription.owner_id == user.id,
Subscription.is_active.is_(True),
):
cents = _monatlich_in(db, s, base, heute)
if cents is None:
ohne_kurs.add(s.currency)
continue
if s.category_id is None:
ohne_kategorie_cents += cents
ohne_kategorie_anzahl += 1
continue
je_kategorie[s.category_id] += cents
anzahl[s.category_id] += 1
return {
"je_kategorie": dict(je_kategorie),
"anzahl": dict(anzahl),
"ohne_kategorie_cents": ohne_kategorie_cents,
"ohne_kategorie_anzahl": ohne_kategorie_anzahl,
"ohne_kurs": sorted(ohne_kurs),
}
def monatlich_je_kategorie(db: Session, user: User, base: str) -> dict[int, int]:
"""Nur die Beträge – für die Soll-Berechnung."""
return uebersicht(db, user, base)["je_kategorie"]
+59 -12
View File
@@ -122,14 +122,41 @@ def _period(db: Session, user: User, label: str) -> BudgetPeriod | None:
) )
def effective_planned(db: Session, user: User, label: str) -> dict[int, int]: def hauptwaehrung(db: Session, user: User) -> str:
"""Wirksame Soll-Beträge je Kategorie: Monats-Override falls vorhanden, sonst Vorlage.""" """Waehrung, in der Soll-Betraege gefuehrt werden."""
from ..routers.settings import BASE_CURRENCY_KEY, get_user_setting
return get_user_setting(db, user, BASE_CURRENCY_KEY) or "EUR"
def soll_einer_zeile(zeile, aus_vertraegen: dict[int, int]) -> int:
"""Was eine Budget-Zeile als Soll bedeutet.
Bei "berechnet" ist die hingeschriebene Zahl nicht der Soll-Betrag, sondern
nur der Zuschlag – dazu kommt, was die Vertraege dieser Kategorie kosten.
"""
if zeile.modus == "berechnet":
return aus_vertraegen.get(zeile.category_id, 0) + zeile.zuschlag_cents
return zeile.planned_cents
def _zeilen(db: Session, user: User, label: str):
"""Die wirksamen Budget-Zeilen: Monats-Override falls vorhanden, sonst Vorlage."""
period = _period(db, user, label) period = _period(db, user, label)
if period is not None: if period is not None:
return {ln.category_id: ln.planned_cents for ln in period.lines} return period.lines
return db.query(BudgetTemplateLine).filter(
BudgetTemplateLine.owner_id == user.id).all()
def effective_planned(db: Session, user: User, label: str) -> dict[int, int]:
"""Wirksame Soll-Beträge je Kategorie: Monats-Override falls vorhanden, sonst Vorlage."""
from . import abos
aus_vertraegen = abos.monatlich_je_kategorie(db, user, hauptwaehrung(db, user))
return { return {
tl.category_id: tl.planned_cents ln.category_id: soll_einer_zeile(ln, aus_vertraegen)
for tl in db.query(BudgetTemplateLine).filter(BudgetTemplateLine.owner_id == user.id) for ln in _zeilen(db, user, label)
} }
@@ -145,23 +172,43 @@ def ensure_customized(db: Session, user: User, label: str) -> BudgetPeriod:
db.add(period) db.add(period)
db.flush() db.flush()
for tl in db.query(BudgetTemplateLine).filter(BudgetTemplateLine.owner_id == user.id): for tl in db.query(BudgetTemplateLine).filter(BudgetTemplateLine.owner_id == user.id):
period.lines.append( period.lines.append(BudgetPeriodLine(
BudgetPeriodLine(category_id=tl.category_id, planned_cents=tl.planned_cents) category_id=tl.category_id, planned_cents=tl.planned_cents,
) # Ohne diese beiden fiele ein angepasster Monat auf die Zahl
# zurueck, die zufaellig zuletzt in ``planned_cents`` stand.
modus=tl.modus, zuschlag_cents=tl.zuschlag_cents,
))
db.flush() db.flush()
return period return period
def set_month_category(db: Session, user: User, label: str, category_id: int, planned_cents: int) -> None: def set_month_category(db: Session, user: User, label: str, category_id: int,
planned_cents: int | None = None, modus: str | None = None,
zuschlag_cents: int | None = None) -> None:
period = ensure_customized(db, user, label) period = ensure_customized(db, user, label)
line = next((ln for ln in period.lines if ln.category_id == category_id), None) line = next((ln for ln in period.lines if ln.category_id == category_id), None)
if line is None: if line is None:
period.lines.append(BudgetPeriodLine(category_id=category_id, planned_cents=planned_cents)) line = BudgetPeriodLine(category_id=category_id, planned_cents=0)
else: period.lines.append(line)
line.planned_cents = planned_cents uebernimm(line, planned_cents, modus, zuschlag_cents)
db.commit() db.commit()
def uebernimm(zeile, planned_cents: int | None, modus: str | None,
zuschlag_cents: int | None) -> None:
"""Setzt an einer Budget-Zeile, was mitgegeben wurde – und nur das.
Getrennt gehalten, weil Vorlage und Monat dieselbe Regel brauchen und zwei
Kopien davon genau die Stelle waeren, an der sie auseinanderlaufen.
"""
if planned_cents is not None:
zeile.planned_cents = max(0, planned_cents)
if zuschlag_cents is not None:
zeile.zuschlag_cents = max(0, zuschlag_cents)
if modus is not None:
zeile.modus = modus
def reset_month(db: Session, user: User, label: str) -> None: def reset_month(db: Session, user: User, label: str) -> None:
period = _period(db, user, label) period = _period(db, user, label)
if period is not None: if period is not None:
@@ -0,0 +1,38 @@
"""Der Soll-Betrag kann sich aus den Vertraegen rechnen.
Bisher war er eine Zahl, die man hinschreibt. Wer fuer Streaming 80 plant,
weil 58 fest gebunden sind und 22 Luft sein sollen, musste beim naechsten
Preisaufschlag nachrechnen – und dabei aufpassen, seine 22 nicht zu verlieren.
Mit "berechnet" rechnet sich der Soll-Betrag selbst: Summe der Vertraege
dieser Kategorie plus einem Zuschlag, den man behaelt.
Revision ID: 0032
Revises: 0031
"""
import sqlalchemy as sa
from alembic import op
revision = "0032"
down_revision = "0031"
branch_labels = None
depends_on = None
TABELLEN = ("budget_template_lines", "budget_period_lines")
def upgrade() -> None:
for tabelle in TABELLEN:
with op.batch_alter_table(tabelle) as batch:
batch.add_column(sa.Column("modus", sa.String(length=16), nullable=False,
server_default="fest"))
batch.add_column(sa.Column("zuschlag_cents", sa.Integer(), nullable=False,
server_default="0"))
def downgrade() -> None:
for tabelle in TABELLEN:
with op.batch_alter_table(tabelle) as batch:
batch.drop_column("zuschlag_cents")
batch.drop_column("modus")
+230
View File
@@ -0,0 +1,230 @@
"""Der Soll-Betrag rechnet sich aus den Vertraegen – plus einem Zuschlag.
Der Zuschlag ist der Kern: Wer fuer Streaming 80 plant, weil 58 fest gebunden
sind und 22 Luft sein sollen, will beim naechsten Preisaufschlag nicht
nachrechnen muessen und seine 22 auch nicht verlieren.
"""
import secrets
from contextlib import contextmanager
from datetime import date
def _kategorie(admin, name):
for g in admin.get("/api/categories").json():
for c in g["categories"]:
if c["name"] == name:
return c["id"]
raise AssertionError(f"Kategorie '{name}' fehlt")
def _abo(admin, **kw):
body = {"name": "Vertrag " + secrets.token_hex(3), "amount_cents": 1000,
"currency": "EUR", "interval_months": 1}
body.update(kw)
r = admin.post("/api/subscriptions", json=body)
assert r.status_code == 201, r.text
return r.json()
@contextmanager
def vertraege(admin, *bausteine):
"""Legt Vertraege an und raeumt sie hinterher wieder weg.
Die Testdatenbank lebt ueber den ganzen Lauf, und die Summe je Kategorie
geht ueber ALLE Vertraege des Nutzers – ohne Aufraeumen zaehlt der naechste
Test die des vorigen mit.
"""
angelegt = [_abo(admin, **b) for b in bausteine]
try:
yield angelegt
finally:
for a in angelegt:
admin.delete("/api/subscriptions/" + str(a["id"]))
def _zeile(admin, kategorie_id):
v = admin.get("/api/budget/current").json()
return next(c for g in v["groups"] for c in g["categories"]
if c["category_id"] == kategorie_id)
def _setze(admin, kategorie_id, **params):
r = admin.put("/api/budget/template/category/" + str(kategorie_id), params=params)
assert r.status_code == 200, r.text
return r.json()
def _leere(admin, kategorie_id):
_setze(admin, kategorie_id, modus="fest", planned_cents=0)
def _nur_vorlage(admin):
"""Die Monats-Anpassung entfernen, damit die Vorlage wirkt.
Ohne das haengen diese Tests davon ab, ob ein frueherer Test den laufenden
Monat angepasst hat – der Override schlaegt die Vorlage, und alle Werte
stuenden auf null. Die Testdatenbank lebt ueber den ganzen Lauf.
"""
admin.put("/api/settings/budget_start_day", params={"value": "1"})
heute = date.today()
admin.delete(f"/api/budget/{heute.year:04d}-{heute.month:02d}")
def _preis(admin, abo_id, cents):
r = admin.patch("/api/subscriptions/" + str(abo_id), json={"amount_cents": cents})
assert r.status_code == 200, r.text
def test_fest_bleibt_wie_es_war(admin):
"""Der Normalfall aendert sich nicht: eine Zahl bleibt eine Zahl, auch
wenn in der Kategorie Vertraege liegen."""
_nur_vorlage(admin)
kat = _kategorie(admin, "Abos & Streaming")
_leere(admin, kat)
with vertraege(admin, {"amount_cents": 1990, "category_id": kat}):
_setze(admin, kat, planned_cents=8000)
try:
z = _zeile(admin, kat)
assert z["modus"] == "fest"
assert z["planned_cents"] == 8000
# Der Hinweis steht trotzdem da – sonst sieht man nie, was gebunden ist.
assert z["aus_vertraegen_cents"] == 1990
assert z["vertraege_anzahl"] == 1
finally:
_leere(admin, kat)
def test_berechnet_zaehlt_vertraege_und_zuschlag_zusammen(admin):
_nur_vorlage(admin)
kat = _kategorie(admin, "Abos & Streaming")
_leere(admin, kat)
with vertraege(admin, {"amount_cents": 1990, "category_id": kat},
{"amount_cents": 1200, "category_id": kat}):
_setze(admin, kat, modus="berechnet", zuschlag_cents=2000)
try:
z = _zeile(admin, kat)
assert z["aus_vertraegen_cents"] == 3190
assert z["zuschlag_cents"] == 2000
assert z["planned_cents"] == 5190, "Soll ist nicht Vertraege + Zuschlag"
finally:
_leere(admin, kat)
def test_preisaufschlag_zieht_das_budget_mit(admin):
"""Der Grund fuer das Ganze: nicht nachrechnen muessen – und den eigenen
Zuschlag dabei behalten."""
_nur_vorlage(admin)
kat = _kategorie(admin, "Abos & Streaming")
_leere(admin, kat)
with vertraege(admin, {"amount_cents": 1790, "category_id": kat}) as (abo,):
_setze(admin, kat, modus="berechnet", zuschlag_cents=2200)
try:
assert _zeile(admin, kat)["planned_cents"] == 1790 + 2200
_preis(admin, abo["id"], 1990)
z = _zeile(admin, kat)
assert z["planned_cents"] == 1990 + 2200, "Soll folgte dem Preis nicht"
assert z["zuschlag_cents"] == 2200, "der eigene Zuschlag ging verloren"
finally:
_leere(admin, kat)
def test_jahresvertrag_zaehlt_mit_seinem_monatswert(admin):
_nur_vorlage(admin)
kat = _kategorie(admin, "Mobilität")
_leere(admin, kat)
with vertraege(admin, {"amount_cents": 24000, "interval_months": 12,
"category_id": kat}):
_setze(admin, kat, modus="berechnet", zuschlag_cents=0)
try:
assert _zeile(admin, kat)["planned_cents"] == 2000, \
"240 im Jahr sind 20 im Monat"
finally:
_leere(admin, kat)
def test_gekuendigte_vertraege_zaehlen_nicht(admin):
_nur_vorlage(admin)
kat = _kategorie(admin, "Abos & Streaming")
_leere(admin, kat)
with vertraege(admin, {"amount_cents": 5000, "category_id": kat}) as (abo,):
_setze(admin, kat, modus="berechnet", zuschlag_cents=0)
try:
assert _zeile(admin, kat)["planned_cents"] == 5000
admin.patch("/api/subscriptions/" + str(abo["id"]),
json={"is_active": False})
assert _zeile(admin, kat)["planned_cents"] == 0
finally:
_leere(admin, kat)
def test_berechnet_mit_zuschlag_null_bleibt_bestehen(admin):
"""Eine Zeile auf "fest" mit 0 ist dasselbe wie "kein Budget" und wird
geloescht. Bei "berechnet" ist die Zeile SELBST die Aussage – sie darf
nicht verschwinden, nur weil der Zuschlag null ist."""
_nur_vorlage(admin)
kat = _kategorie(admin, "Gesundheit")
_leere(admin, kat)
_setze(admin, kat, modus="berechnet", zuschlag_cents=0)
try:
assert _zeile(admin, kat)["modus"] == "berechnet"
finally:
_leere(admin, kat)
def test_zurueckschalten_findet_den_alten_wert_wieder(admin):
_nur_vorlage(admin)
kat = _kategorie(admin, "Abos & Streaming")
_leere(admin, kat)
_setze(admin, kat, planned_cents=7700)
_setze(admin, kat, modus="berechnet", zuschlag_cents=500)
try:
_setze(admin, kat, modus="fest")
assert _zeile(admin, kat)["planned_cents"] == 7700, "alter Wert verloren"
finally:
_leere(admin, kat)
def test_angepasster_monat_erbt_die_betriebsart(admin):
"""Sonst friert er auf der Zahl ein, die zufaellig in planned_cents stand –
und der Preisaufschlag kaeme im angepassten Monat nicht mehr an."""
_nur_vorlage(admin)
kat = _kategorie(admin, "Abos & Streaming")
_leere(admin, kat)
heute = date.today()
label = f"{heute.year:04d}-{heute.month:02d}"
with vertraege(admin, {"amount_cents": 3000, "category_id": kat}) as (abo,):
_setze(admin, kat, modus="berechnet", zuschlag_cents=1000)
try:
# Irgendeine andere Kategorie anfassen – das macht den Monat
# "angepasst" und uebernimmt die Vorlage in den Monat.
andere = _kategorie(admin, "Kleidung")
r = admin.put("/api/budget/" + label + "/category/" + str(andere),
params={"planned_cents": 100})
assert r.status_code == 200, r.text
assert admin.get("/api/budget/current").json()["customized"] is True
z = _zeile(admin, kat)
assert z["modus"] == "berechnet", "Betriebsart beim Anpassen verloren"
_preis(admin, abo["id"], 4000)
assert _zeile(admin, kat)["planned_cents"] == 4000 + 1000
finally:
admin.delete("/api/budget/" + label)
_leere(admin, kat)
def test_vertraege_ohne_kategorie_werden_gemeldet(admin):
"""Ihr Geld fehlt sonst im Budget, ohne dass irgendwo steht, dass es fehlt."""
vorher = admin.get("/api/budget/current").json()["vertraege"]["ohne_kategorie_cents"]
with vertraege(admin, {"amount_cents": 2590, "category_id": None}):
v = admin.get("/api/budget/current").json()["vertraege"]
assert v["ohne_kategorie_cents"] == vorher + 2590
assert v["ohne_kategorie_anzahl"] >= 1
def test_unbekannte_betriebsart_wird_abgelehnt(admin):
kat = _kategorie(admin, "Gesundheit")
r = admin.put("/api/budget/template/category/" + str(kat),
params={"modus": "irgendwas"})
assert r.status_code == 400, r.text
+8
View File
@@ -329,6 +329,14 @@ export const api = {
budgetTemplate: () => request("/budget/template"), budgetTemplate: () => request("/budget/template"),
setTemplateCategory: (catId, cents) => setTemplateCategory: (catId, cents) =>
request(`/budget/template/category/${catId}?planned_cents=${cents}`, { method: "PUT" }), request(`/budget/template/category/${catId}?planned_cents=${cents}`, { method: "PUT" }),
// Betriebsart und Zuschlag einer Budget-Zeile. Getrennt vom Soll-Betrag,
// weil bei "berechnet" der Betrag gar nicht mehr gesetzt wird.
setTemplateModus: (catId, modus, zuschlagCents) =>
request(`/budget/template/category/${catId}?modus=${modus}&zuschlag_cents=${zuschlagCents}`,
{ method: "PUT" }),
setMonthModus: (label, catId, modus, zuschlagCents) =>
request(`/budget/${label}/category/${catId}?modus=${modus}&zuschlag_cents=${zuschlagCents}`,
{ method: "PUT" }),
setMonthCategory: (label, catId, cents) => setMonthCategory: (label, catId, cents) =>
request(`/budget/${label}/category/${catId}?planned_cents=${cents}`, { method: "PUT" }), request(`/budget/${label}/category/${catId}?planned_cents=${cents}`, { method: "PUT" }),
resetMonth: (label) => request(`/budget/${label}`, { method: "DELETE" }), resetMonth: (label) => request(`/budget/${label}`, { method: "DELETE" }),
+33
View File
@@ -1859,3 +1859,36 @@ tr.row-dup {
.griff-n, .griff-s { left: calc(50% - 10px); } .griff-n, .griff-s { left: calc(50% - 10px); }
.griff-w, .griff-e { top: calc(50% - 10px); } .griff-w, .griff-e { top: calc(50% - 10px); }
} }
/* --- Budget aus Verträgen ------------------------------------------------ */
/* Die Rechnung unter der Zeile. Eingerückt bis unter den Namen, damit sie zur
Zeile darüber gehört und nicht wie eine eigene Kategorie aussieht. */
.budget-rechnung {
padding: 0 0 6px 30px;
margin-top: -4px;
font-variant-numeric: tabular-nums;
}
.budget-hinweis { margin: 0 0 var(--sp-2); }
/* Der Umschalter. Schmal – er steht zwischen Ist-Betrag und Eingabefeld, und
dort ist wenig Platz. */
.btn-sigma {
flex: none;
width: 26px;
height: 26px;
padding: 0;
border: 1px solid var(--border);
border-radius: 6px;
background: transparent;
color: var(--muted);
font-size: 0.9rem;
line-height: 1;
cursor: pointer;
}
.btn-sigma:hover { border-color: var(--border-strong); color: var(--fg); }
.btn-sigma.an {
border-color: var(--accent, var(--border-strong));
color: var(--accent, var(--fg));
background: var(--accent-soft, transparent);
}
+11 -1
View File
@@ -321,7 +321,17 @@
"deleteIncomeConfirm": "Diese geplante Einnahme löschen?", "deleteIncomeConfirm": "Diese geplante Einnahme löschen?",
"incomeLabel": "Bezeichnung", "incomeLabel": "Bezeichnung",
"incomeCategory": "Kategorie", "incomeCategory": "Kategorie",
"targetAccount": "Zielkonto" "targetAccount": "Zielkonto",
"modusBerechnet": "Aus Verträgen rechnen",
"modusFest": "Festen Betrag setzen",
"surchargePlaceholder": "Zuschlag",
"contractsWithoutRate": "Für {{waehrungen}} liegt kein Kurs vor. Verträge in dieser Währung fehlen in der Rechnung.",
"fromContracts_one": "{{betrag}} aus einem Vertrag",
"fromContracts_other": "{{betrag}} aus {{count}} Verträgen",
"boundByContracts_one": "davon {{betrag}} fest gebunden (ein Vertrag)",
"boundByContracts_other": "davon {{betrag}} fest gebunden ({{count}} Verträge)",
"contractsWithoutCategory_one": "Ein Vertrag über {{betrag}} im Monat hat keine Kategorie und fehlt deshalb im Budget.",
"contractsWithoutCategory_other": "{{count}} Verträge über {{betrag}} im Monat haben keine Kategorie und fehlen deshalb im Budget."
}, },
"abos": { "abos": {
"title": "Abos & Verträge", "title": "Abos & Verträge",
+11 -1
View File
@@ -321,7 +321,17 @@
"deleteIncomeConfirm": "Delete this planned income?", "deleteIncomeConfirm": "Delete this planned income?",
"incomeLabel": "Label", "incomeLabel": "Label",
"incomeCategory": "Category", "incomeCategory": "Category",
"targetAccount": "Target account" "targetAccount": "Target account",
"modusBerechnet": "Compute from contracts",
"modusFest": "Set a fixed amount",
"surchargePlaceholder": "Extra",
"contractsWithoutRate": "No exchange rate for {{waehrungen}}. Contracts in that currency are missing from the calculation.",
"fromContracts_one": "{{betrag}} from one contract",
"fromContracts_other": "{{betrag}} from {{count}} contracts",
"boundByContracts_one": "{{betrag}} of it is committed (one contract)",
"boundByContracts_other": "{{betrag}} of it is committed ({{count}} contracts)",
"contractsWithoutCategory_one": "One contract worth {{betrag}} per month has no category and is missing from the budget.",
"contractsWithoutCategory_other": "{{count}} contracts worth {{betrag}} per month have no category and are missing from the budget."
}, },
"abos": { "abos": {
"title": "Subscriptions & contracts", "title": "Subscriptions & contracts",
+78 -7
View File
@@ -103,6 +103,21 @@ export default function Budget() {
catch (err) { toast(err.message, "error"); } catch (err) { toast(err.message, "error"); }
} }
// Betriebsart umschalten. Beim Wechsel auf "berechnet" wandert der bisherige
// Soll-Betrag NICHT in den Zuschlag: Er steckt ja groesstenteils schon in den
// Vertraegen, und ihn zu addieren verdoppelte sie stillschweigend.
async function saveModus(lbl, c, modus) {
const zuschlag = modus === "berechnet" ? c.zuschlag_cents : 0;
try {
replaceView(await api.setMonthModus(lbl, c.category_id, modus, zuschlag));
} catch (err) { toast(err.message, "error"); }
}
async function saveZuschlag(lbl, c, cents) {
try {
replaceView(await api.setMonthModus(lbl, c.category_id, "berechnet", cents));
} catch (err) { toast(err.message, "error"); }
}
async function saveTemplate(catId, cents) { async function saveTemplate(catId, cents) {
try { await api.setTemplateCategory(catId, cents); loadTemplate(); } catch (err) { toast(err.message, "error"); } try { await api.setTemplateCategory(catId, cents); loadTemplate(); } catch (err) { toast(err.message, "error"); }
} }
@@ -156,6 +171,24 @@ export default function Budget() {
</div> </div>
)} )}
{/* Was sich nicht zuordnen liess. Ohne diesen Hinweis fehlt das Geld
im Budget, ohne dass irgendwo steht, dass es fehlt. */}
{v.vertraege?.ohne_kategorie_anzahl > 0 && (
<p className="muted warn small budget-hinweis">
{t("budget.contractsWithoutCategory", {
count: v.vertraege.ohne_kategorie_anzahl,
betrag: fmt(v.vertraege.ohne_kategorie_cents),
})}
</p>
)}
{v.vertraege?.ohne_kurs?.length > 0 && (
<p className="muted warn small budget-hinweis">
{t("budget.contractsWithoutRate", {
waehrungen: v.vertraege.ohne_kurs.join(", "),
})}
</p>
)}
{v.groups.map((g) => ( {v.groups.map((g) => (
<div className="budget-group" key={g.id}> <div className="budget-group" key={g.id}>
<div className="budget-group-head"> <div className="budget-group-head">
@@ -165,14 +198,52 @@ export default function Budget() {
<span className="muted small"> / {fmt(g.planned_cents)}</span> <span className="muted small"> / {fmt(g.planned_cents)}</span>
</div> </div>
<ProgressBar actual={g.actual_cents} planned={g.planned_cents} over={g.over} /> <ProgressBar actual={g.actual_cents} planned={g.planned_cents} over={g.over} />
{g.categories.map((c) => ( {g.categories.map((c) => {
<div className="budget-crow" key={c.category_id}> const berechnet = c.modus === "berechnet";
<IconChip name={c.icon} color={c.color} size={20} /> return (
<span className="grow ellipsis">{c.name}</span> <div key={c.category_id}>
<span className={`amount small ${c.over && !c.good ? "neg" : "muted"}`}>{fmt(c.actual_cents)}</span> <div className="budget-crow">
<EditableAmount cents={c.planned_cents} onSave={(cents) => saveMonth(v.label, c.category_id, cents)} /> <IconChip name={c.icon} color={c.color} size={20} />
<span className="grow ellipsis">{c.name}</span>
<span className={`amount small ${c.over && !c.good ? "neg" : "muted"}`}>{fmt(c.actual_cents)}</span>
{/* Der Schalter steht nur da, wo es etwas zu rechnen gibt –
bei einer Kategorie ohne Vertraege waere er ein Angebot,
das nichts tut. */}
{(c.vertraege_anzahl > 0 || berechnet) && (
<button type="button"
className={`btn-sigma${berechnet ? " an" : ""}`}
title={t(berechnet ? "budget.modusFest" : "budget.modusBerechnet")}
onClick={() => saveModus(v.label, c, berechnet ? "fest" : "berechnet")}>
&#931;
</button>
)}
<EditableAmount
cents={berechnet ? c.zuschlag_cents : c.planned_cents}
placeholder={berechnet ? t("budget.surchargePlaceholder") : "0,00"}
onSave={(cents) => (berechnet
? saveZuschlag(v.label, c, cents)
: saveMonth(v.label, c.category_id, cents))} />
</div>
{/* Die Rechnung offenlegen. Ohne sie stuende im Feld eine Zahl,
die nicht der Soll-Betrag ist – und niemand wuesste, warum. */}
{berechnet ? (
<div className="budget-rechnung muted small">
{t("budget.fromContracts", {
betrag: fmt(c.aus_vertraegen_cents), count: c.vertraege_anzahl,
})}
{" + "}{fmt(c.zuschlag_cents)}{" = "}
<strong>{fmt(c.planned_cents)}</strong>
</div>
) : c.vertraege_anzahl > 0 ? (
<div className="budget-rechnung muted small">
{t("budget.boundByContracts", {
betrag: fmt(c.aus_vertraegen_cents), count: c.vertraege_anzahl,
})}
</div>
) : null}
</div> </div>
))} );
})}
</div> </div>
))} ))}