diff --git a/backend/app/models.py b/backend/app/models.py index eed4190..a02682b 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -472,6 +472,19 @@ class BudgetTemplateLine(Base): ForeignKey("categories.id", ondelete="CASCADE"), nullable=False ) 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__ = ( UniqueConstraint("owner_id", "category_id", name="uq_tpl_owner_category"), @@ -533,6 +546,10 @@ class BudgetPeriodLine(Base): ForeignKey("categories.id", ondelete="CASCADE"), nullable=False ) 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") diff --git a/backend/app/routers/budgets.py b/backend/app/routers/budgets.py index 1b854e0..65c0181 100644 --- a/backend/app/routers/budgets.py +++ b/backend/app/routers/budgets.py @@ -15,7 +15,7 @@ from ..models import ( User, ) from ..schemas import PlannedIncomeCreate, PlannedIncomeOut, PlannedIncomeUpdate -from ..services import budget +from ..services import abos, budget from .accounts import base_currency 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}") def set_template_category( 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), user: User = Depends(get_current_user), ) -> dict: _require_expense_category(db, user, category_id) + _pruefe_modus(modus) line = ( db.query(BudgetTemplateLine) .filter(BudgetTemplateLine.owner_id == user.id, BudgetTemplateLine.category_id == category_id) .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: db.delete(line) 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: - 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) - else: - line.planned_cents = planned_cents + budget.uebernimm(line, planned_cents, modus, zuschlag_cents) 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 ---------------------------------------------------- @@ -148,6 +168,10 @@ def _build_view(db: Session, user: User, label: str) -> dict: planned = budget.effective_planned(db, user, label) actual = budget.actual_expense_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 = [] 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) g_planned += p g_actual += a + zeile = zeilen.get(c.id) 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, + # 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, # entscheidet die Kategorie. Wer mehr zuruecklegt als geplant, # 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, "over": echt_ist > echt_geplant, "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, }) @@ -221,6 +253,13 @@ def _build_view(db: Session, user: User, label: str) -> dict: "next": budget.shift_label(label, 1), "groups": groups_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": { "planned_expense": total_planned, "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( label: str, 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), 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)) + _pruefe_modus(modus) + budget.set_month_category(db, user, label, category_id, planned_cents, modus, + zuschlag_cents) return _build_view(db, user, label) diff --git a/backend/app/services/abos.py b/backend/app/services/abos.py new file mode 100644 index 0000000..135f134 --- /dev/null +++ b/backend/app/services/abos.py @@ -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"] diff --git a/backend/app/services/budget.py b/backend/app/services/budget.py index 8fb3211..cab666b 100644 --- a/backend/app/services/budget.py +++ b/backend/app/services/budget.py @@ -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]: - """Wirksame Soll-Beträge je Kategorie: Monats-Override falls vorhanden, sonst Vorlage.""" +def hauptwaehrung(db: Session, user: User) -> str: + """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) 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 { - tl.category_id: tl.planned_cents - for tl in db.query(BudgetTemplateLine).filter(BudgetTemplateLine.owner_id == user.id) + ln.category_id: soll_einer_zeile(ln, aus_vertraegen) + 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.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) - ) + period.lines.append(BudgetPeriodLine( + 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() 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) 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 + line = BudgetPeriodLine(category_id=category_id, planned_cents=0) + period.lines.append(line) + uebernimm(line, planned_cents, modus, zuschlag_cents) 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: period = _period(db, user, label) if period is not None: diff --git a/backend/migrations/versions/0032_budget_aus_vertraegen.py b/backend/migrations/versions/0032_budget_aus_vertraegen.py new file mode 100644 index 0000000..d2b3988 --- /dev/null +++ b/backend/migrations/versions/0032_budget_aus_vertraegen.py @@ -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") diff --git a/backend/tests/test_budget_vertraege.py b/backend/tests/test_budget_vertraege.py new file mode 100644 index 0000000..417f160 --- /dev/null +++ b/backend/tests/test_budget_vertraege.py @@ -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 diff --git a/web/src/api.js b/web/src/api.js index e9b320e..202c721 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -329,6 +329,14 @@ export const api = { budgetTemplate: () => request("/budget/template"), setTemplateCategory: (catId, cents) => 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) => request(`/budget/${label}/category/${catId}?planned_cents=${cents}`, { method: "PUT" }), resetMonth: (label) => request(`/budget/${label}`, { method: "DELETE" }), diff --git a/web/src/finance.css b/web/src/finance.css index 0d36377..1913b8b 100644 --- a/web/src/finance.css +++ b/web/src/finance.css @@ -1859,3 +1859,36 @@ tr.row-dup { .griff-n, .griff-s { left: 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); +} diff --git a/web/src/locales/de.json b/web/src/locales/de.json index 81424b1..afbd298 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -321,7 +321,17 @@ "deleteIncomeConfirm": "Diese geplante Einnahme löschen?", "incomeLabel": "Bezeichnung", "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": { "title": "Abos & Verträge", diff --git a/web/src/locales/en.json b/web/src/locales/en.json index d01a3a2..44f7ca0 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -321,7 +321,17 @@ "deleteIncomeConfirm": "Delete this planned income?", "incomeLabel": "Label", "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": { "title": "Subscriptions & contracts", diff --git a/web/src/pages/Budget.jsx b/web/src/pages/Budget.jsx index b5346dc..5cd0d06 100644 --- a/web/src/pages/Budget.jsx +++ b/web/src/pages/Budget.jsx @@ -103,6 +103,21 @@ export default function Budget() { 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) { try { await api.setTemplateCategory(catId, cents); loadTemplate(); } catch (err) { toast(err.message, "error"); } } @@ -156,6 +171,24 @@ export default function Budget() { )} + {/* 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 && ( +
+ {t("budget.contractsWithoutCategory", { + count: v.vertraege.ohne_kategorie_anzahl, + betrag: fmt(v.vertraege.ohne_kategorie_cents), + })} +
+ )} + {v.vertraege?.ohne_kurs?.length > 0 && ( ++ {t("budget.contractsWithoutRate", { + waehrungen: v.vertraege.ohne_kurs.join(", "), + })} +
+ )} + {v.groups.map((g) => (