diff --git a/backend/app/models.py b/backend/app/models.py index e11e2e0..ca0d9e2 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -243,6 +243,23 @@ class Account(Base): color: Mapped[str] = mapped_column(String(9), nullable=False, default="#3f51b5") opening_balance_cents: Mapped[int] = mapped_column(Integer, nullable=False, default=0) opening_date: Mapped[date_t | None] = mapped_column(Date, nullable=True) + # Wer hierher ueberweist, spart – und zwar gegen DIESE Kategorie. + # + # Sparen ist eine Entscheidung ueber ein KONTO, nicht ueber jede einzelne + # Buchung: Ein Sparkonto ist zum Sparen da, sonst waere es keins. Die + # Angabe gehoert deshalb hierher und nicht an jeden Uebertrag – einmal + # gesagt, gilt sie fuer alles, was danach kommt. + # + # Sie steht ueber der Vorgabe nach Kontotyp (siehe SAVINGS_ACCOUNT_TYPES). + # Das ist der Punkt: Ein Revolut-Pocket ist bei der Bank ein ganz normales + # Konto und kommt als „girokonto" herein. Am Typ laesst sich nicht ablesen, + # dass man darauf spart – am ausdruecklichen Eintrag hier schon. + # + # SET NULL: Verschwindet die Kategorie, faellt nur die Zuordnung weg. Ein + # Konto zu loeschen, weil man eine Kategorie aufraeumt, waere absurd. + savings_category_id: Mapped[int | None] = mapped_column( + ForeignKey("categories.id", ondelete="SET NULL"), nullable=True + ) is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) position: Mapped[int] = mapped_column(Integer, nullable=False, default=0) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) diff --git a/backend/app/routers/accounts.py b/backend/app/routers/accounts.py index ad3d43e..235b37c 100644 --- a/backend/app/routers/accounts.py +++ b/backend/app/routers/accounts.py @@ -11,6 +11,8 @@ from ..models import ( Account, AccountIdentifier, BankAccountLink, + Category, + CategoryKind, IdentifierKind, Transaction, TxnType, @@ -67,6 +69,7 @@ def to_out(db: Session, acc: Account, base: str, on: date, opening_date=acc.opening_date, is_archived=acc.is_archived, position=acc.position, + savings_category_id=acc.savings_category_id, identifiers=[ AccountIdentifierOut(id=i.id, kind=i.kind, value=i.value) for i in acc.identifiers ], @@ -121,10 +124,29 @@ def list_accounts(db: Session = Depends(get_db), user: User = Depends(get_curren return [to_out(db, a, base, today, saldi) for a in accounts] +def _pruefe_sparziel(db: Session, user: User, category_id: int | None) -> None: + """Die Spar-Kategorie muss dem Nutzer gehoeren und eine AUSGABE sein. + + Sparen zaehlt im Budget wie eine Ausgabe – gegen eine Einnahme-Kategorie + liesse es sich nicht verplanen, und die Zahl landete in einer Spalte, in + der sie niemand sucht. + """ + if category_id is None: + return + kategorie = get_owned(db, Category, category_id, user) + if kategorie.type != CategoryKind.expense: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=(f"'{kategorie.name}' ist keine Ausgabe-Kategorie. Sparen " + f"zaehlt im Budget wie eine Ausgabe."), + ) + + @router.post("", response_model=AccountOut, status_code=status.HTTP_201_CREATED) def create_account( payload: AccountCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user) ): + _pruefe_sparziel(db, user, payload.savings_category_id) max_pos = ( db.query(Account.position) .filter(Account.owner_id == user.id) @@ -140,6 +162,7 @@ def create_account( color=payload.color, opening_balance_cents=payload.opening_balance_cents, opening_date=payload.opening_date, + savings_category_id=payload.savings_category_id, position=(max_pos[0] + 1) if max_pos else 0, ) _set_identifiers(acc, payload.identifiers) @@ -169,8 +192,12 @@ def update_account( acc = get_owned(db, Account, account_id, user) data = payload.model_dump(exclude_unset=True) identifiers = data.pop("identifiers", None) + if "savings_category_id" in data: + _pruefe_sparziel(db, user, data["savings_category_id"]) for key, value in data.items(): - if value is not None or key in ("opening_date",): + # "savings_category_id" gehoert in die Ausnahme: Ohne sie liesse sich + # ein einmal gesetztes Sparziel nie wieder loesen. + if value is not None or key in ("opening_date", "savings_category_id"): setattr(acc, key, value) if identifiers is not None: _set_identifiers(acc, payload.identifiers) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index d6df995..83db063 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -153,6 +153,8 @@ class AccountCreate(BaseModel): color: str = "#3f51b5" opening_balance_cents: int = 0 opening_date: date_t | None = None + # Ueberweisungen hierher zaehlen als Sparen gegen diese Ausgabe-Kategorie. + savings_category_id: int | None = None identifiers: list[AccountIdentifierIn] = Field(default_factory=list) @field_validator("currency") @@ -177,6 +179,7 @@ class AccountUpdate(BaseModel): opening_date: date_t | None = None is_archived: bool | None = None position: int | None = None + savings_category_id: int | None = None # Wenn gesetzt, ersetzt die komplette Identifikator-Liste. identifiers: list[AccountIdentifierIn] | None = None @@ -197,6 +200,7 @@ class AccountOut(BaseModel): opening_date: date_t | None is_archived: bool position: int + savings_category_id: int | None = None identifiers: list[AccountIdentifierOut] balance_cents: int balance_base_cents: int diff --git a/backend/app/services/backup_data.py b/backend/app/services/backup_data.py index e97275e..9a6db19 100644 --- a/backend/app/services/backup_data.py +++ b/backend/app/services/backup_data.py @@ -86,12 +86,16 @@ class TableSpec: # Reihenfolge = Einfügereihenfolge. Verweise dürfen nur auf bereits # eingefügte Tabellen zeigen; gelöscht wird in umgekehrter Reihenfolge. USER_TABLES: list[TableSpec] = [ - TableSpec("accounts", Account, "owner", label="Konten"), - TableSpec("account_identifiers", AccountIdentifier, "account", - {"account_id": "accounts"}, label="Konto-Merkmale"), + # Kategorien VOR den Konten: Ein Konto kann auf eine Spar-Kategorie zeigen, + # und ein Verweis darf nur auf eine schon eingefuegte Tabelle gehen. + # Umgekehrt zeigt keine Kategorie je auf ein Konto. TableSpec("category_groups", CategoryGroup, "owner", label="Kategoriegruppen"), TableSpec("categories", Category, "owner", {"group_id": "category_groups"}, label="Kategorien"), + TableSpec("accounts", Account, "owner", + {"savings_category_id": "categories"}, label="Konten"), + TableSpec("account_identifiers", AccountIdentifier, "account", + {"account_id": "accounts"}, label="Konto-Merkmale"), TableSpec("import_templates", ImportTemplate, "owner", label="Import-Vorlagen"), TableSpec("import_batches", ImportBatch, "owner", {"account_id": "accounts", "template_id": "import_templates"}, diff --git a/backend/app/services/budget.py b/backend/app/services/budget.py index be889fc..6390c04 100644 --- a/backend/app/services/budget.py +++ b/backend/app/services/budget.py @@ -14,7 +14,7 @@ from datetime import date, timedelta from dateutil.relativedelta import relativedelta from sqlalchemy import and_, or_ -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, aliased from ..models import ( Account, @@ -26,6 +26,7 @@ from ..models import ( CategoryKind, SAVINGS_ACCOUNT_TYPES, Transaction, + TransferLink, TxnType, User, ) @@ -72,17 +73,21 @@ def period_of(d: date, start_day: int) -> str: return f"{d.year:04d}-{d.month:02d}" -def zugeordnet(label: str, start: date, end: date): +def zugeordnet(label: str, start: date, end: date, spalten=Transaction): """Filter: Buchungen, die zu diesem Budget-Monat gehoeren. Normalfall ist das Datum. Traegt eine Buchung aber einen ausdruecklich gesetzten Budget-Monat, gilt dieser – und ihr Datum zaehlt dann in KEINEM Zeitraum mehr mit, sonst waere sie zweimal gezaehlt. + + ``spalten`` nimmt einen Alias entgegen. Noetig, sobald dieselbe Tabelle + zweimal in einer Abfrage steht – bei einem Uebertrag sind Abgang und Zugang + beide Buchungen, und gemeint ist nur der Abgang. """ return or_( - Transaction.budget_period == label, - and_(Transaction.budget_period.is_(None), - Transaction.date >= start, Transaction.date <= end), + spalten.budget_period == label, + and_(spalten.budget_period.is_(None), + spalten.date >= start, spalten.date <= end), ) @@ -218,6 +223,47 @@ def _neutrale_kategorien(db: Session, user: User) -> set[int]: } +def _spar_uebertraege(db: Session, user: User, label: str, start: date, + end: date) -> list[tuple[int, Transaction]]: + """Verknuepfte Uebertraege, deren ZIELKONTO als Sparen gilt. + + Rueckgabe je Treffer: (Kategorie, Abgangs-Buchung). + + Der Bankabruf legt einen Uebertrag NICHT als eine Buchung mit Zielkonto an, + sondern als zwei verknuepfte Buchungen – Abgang hier, Zugang dort. Beide + ueberspringt die Auswertung sonst, und das ist bei einem Uebertrag zwischen + Girokonto und Tagesgeld auch richtig: Das Geld ist nur woanders. + + Beim Sparen ist es das eben nicht. Wer 200 auf sein Sparkonto legt, hat + diese 200 in dem Monat nicht mehr zum Ausgeben – genau das soll ein Budget + abbilden. Traegt das Zielkonto eine Spar-Kategorie, zaehlt der Abgang + deshalb wie eine Ausgabe gegen sie. + + Gezaehlt wird immer der ABGANG: Sein Betrag steht in der Waehrung des + Kontos, von dem das Geld wirklich verschwindet. + """ + abgang = aliased(Transaction) + zugang = aliased(Transaction) + treffer: list[tuple[int, Transaction]] = [] + for buchung, konto_kategorie in ( + db.query(abgang, Account.savings_category_id) + .join(TransferLink, TransferLink.from_txn_id == abgang.id) + .join(zugang, TransferLink.to_txn_id == zugang.id) + .join(Account, Account.id == zugang.account_id) + .filter(TransferLink.owner_id == user.id, + Account.savings_category_id.isnot(None), + zugeordnet(label, start, end, abgang)) + ): + # Dieselben zwei Felder wie beim Uebertrag am Stueck – wer eine einzelne + # Bewegung anders behandelt haben will, sagt es an der Buchung, nicht am + # Konto: "neutral" nimmt sie ganz heraus, eine eigene Budget-Kategorie + # schlaegt die des Kontos. + if buchung.budget_treatment == BudgetTreatment.neutral: + continue + treffer.append((buchung.budget_category_id or konto_kategorie, buchung)) + return treffer + + def expense_rows(db: Session, user: User, label: str, start: date, end: date, base: str) -> list[tuple[int, Transaction, int]]: """Die einzelnen Buchungen hinter den Ist-Ausgaben: (Kategorie, Buchung, Betrag). @@ -252,14 +298,14 @@ def expense_rows(db: Session, user: User, label: str, start: date, end: date, zeilen.append((t.category_id, t, _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. + # budgetierte Ausgabe. Gegen welche Kategorie, sagt die Buchung selbst – + # und wenn sie schweigt, das Zielkonto. transfers = ( db.query(Transaction) .filter( Transaction.owner_id == user.id, Transaction.type == TxnType.transfer, zugeordnet(label, start, end), - Transaction.budget_category_id.isnot(None), ) .all() ) @@ -267,8 +313,25 @@ def expense_rows(db: Session, user: User, label: str, start: date, end: date, 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: - zeilen.append((t.budget_category_id, t, _to_base(db, t.amount_eur_cents, base, t.date))) + if to_acc is None: + continue + # Das Zielkonto sagt es ausdruecklich – oder sein Typ sagt es als + # Vorgabe. Ohne beides ist ein Uebertrag kein Sparen, sondern bloss + # verschobenes Geld. + if to_acc.savings_category_id is None and to_acc.type not in SAVINGS_ACCOUNT_TYPES: + continue + kategorie = t.budget_category_id or to_acc.savings_category_id + if kategorie is None: + continue + zeilen.append((kategorie, t, _to_base(db, t.amount_eur_cents, base, t.date))) + + # Und dasselbe fuer die andere Form des Uebertrags: zwei verknuepfte + # Buchungen, wie sie der Bankabruf liefert. Ohne diesen Block bliebe Sparen + # ueber ein angebundenes Konto fuer das Budget unsichtbar. + for kategorie, t in _spar_uebertraege(db, user, label, start, end): + if kategorie is None: + continue + zeilen.append((kategorie, t, _to_base(db, t.amount_eur_cents, base, t.date))) return zeilen diff --git a/backend/migrations/versions/0028_sparziel_am_konto.py b/backend/migrations/versions/0028_sparziel_am_konto.py new file mode 100644 index 0000000..4761e51 --- /dev/null +++ b/backend/migrations/versions/0028_sparziel_am_konto.py @@ -0,0 +1,40 @@ +"""Ein Konto kann sagen, dass Ueberweisungen darauf Sparen sind. + +Bisher entschied das allein der Kontotyp (sparkonto/depot), und auch nur fuer +Uebertraege, die als EINE Buchung mit Zielkonto vorliegen. Ein Revolut-Pocket +faellt durch beide Raster: Es kommt ueber die Bank als ganz normales Konto +herein, und sein Uebertrag besteht aus ZWEI verknuepften Buchungen. Gespart +wurde trotzdem – im Budget stand davon nichts. + +Mit dieser Spalte sagt man es dem Konto einmal, und es gilt fuer beide Formen. + +Revision ID: 0028 +Revises: 0027 +""" + +import sqlalchemy as sa +from alembic import op + +revision = "0028" +down_revision = "0027" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Der Tabellen-Umbau ist hier sicher: migrations/env.py schaltet die + # Fremdschluessel fuer die Dauer der Migration ab, sonst risse das + # implizite DELETE beim DROP die Kontomerkmale, Buchungen und + # Eingangs-Eintraege mit. Siehe tests/test_migrations.py. + with op.batch_alter_table("accounts") as batch: + batch.add_column(sa.Column("savings_category_id", sa.Integer(), nullable=True)) + batch.create_foreign_key( + "fk_accounts_savings_category", "categories", + ["savings_category_id"], ["id"], ondelete="SET NULL", + ) + + +def downgrade() -> None: + with op.batch_alter_table("accounts") as batch: + batch.drop_constraint("fk_accounts_savings_category", type_="foreignkey") + batch.drop_column("savings_category_id") diff --git a/backend/tests/test_sparziel.py b/backend/tests/test_sparziel.py new file mode 100644 index 0000000..b7b78d4 --- /dev/null +++ b/backend/tests/test_sparziel.py @@ -0,0 +1,197 @@ +"""Ein Uebertrag auf ein Spar-Konto zaehlt im Budget als Sparen. + +Der Anlass ist ein Revolut-Pocket: Bei der Bank ist es ein ganz normales +Konto, sein Uebertrag kommt als ZWEI verknuepfte Buchungen herein (Abgang +hier, Zugang dort), und beide uebersprang die Auswertung. Wer 200 auf sein +Sparkonto legt, hat sie in dem Monat aber nicht mehr zum Ausgeben – genau das +soll ein Budget zeigen. + +Gesagt wird es EINMAL am Konto, nicht an jeder Buchung. +""" + +from datetime import date + + +def _konto(admin, name, **kw): + body = {"name": name, "type": "girokonto", "currency": "EUR", + "opening_balance_cents": 0} + body.update(kw) + r = admin.post("/api/accounts", json=body) + assert r.status_code == 201, r.text + return r.json() + + +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 _buchung(admin, konto, typ, betrag, datum, payee=""): + r = admin.post("/api/transactions", json={ + "type": typ, "date": datum, "account_id": konto["id"], + "amount_cents": betrag, "payee": payee}) + assert r.status_code == 201, r.text + return r.json() + + +def _ist(admin, kategorie_id): + """Ist-Wert dieser Kategorie im laufenden Budget-Monat.""" + v = admin.get("/api/budget/current").json() + for g in v["groups"]: + for c in g["categories"]: + if c["category_id"] == kategorie_id: + return c["actual_cents"] + raise AssertionError("Kategorie steht nicht im Budget") + + +def _verknuepfen(admin, abgang, zugang): + r = admin.post("/api/uebertraege/verbinden", + json={"a_id": abgang["id"], "b_id": zugang["id"]}) + assert r.status_code == 200, r.text + return r.json() + + +def _sparen_szenario(admin, betrag=20000): + """Giro -> Pocket als verknuepftes Paar, Pocket mit Spar-Kategorie.""" + admin.put("/api/settings/budget_start_day", params={"value": "1"}) + heute = str(date.today()) + sparen = _kategorie(admin, "Sparen & Investments") + giro = _konto(admin, "Giro fuer Sparen") + pocket = _konto(admin, "Revolut Pocket", savings_category_id=sparen) + + abgang = _buchung(admin, giro, "expense", betrag, heute, payee="Sparen") + zugang = _buchung(admin, pocket, "income", betrag, heute, payee="Sparen") + _verknuepfen(admin, abgang, zugang) + return {"sparen": sparen, "giro": giro, "pocket": pocket, + "abgang": abgang, "zugang": zugang} + + +def test_verknuepfter_uebertrag_auf_spar_konto_zaehlt_als_sparen(admin): + vorher = _ist(admin, _kategorie(admin, "Sparen & Investments")) + s = _sparen_szenario(admin) + assert _ist(admin, s["sparen"]) == vorher + 20000 + + +def test_ohne_spar_kategorie_am_konto_bleibt_der_uebertrag_neutral(admin): + """Der Normalfall aendert sich NICHT. Ein Uebertrag aufs Tagesgeld, das + niemand als Sparziel benannt hat, ist weiterhin bloss verschobenes Geld.""" + admin.put("/api/settings/budget_start_day", params={"value": "1"}) + heute = str(date.today()) + sparen = _kategorie(admin, "Sparen & Investments") + vorher = _ist(admin, sparen) + + giro = _konto(admin, "Giro ohne Sparziel") + anderes = _konto(admin, "Zweitkonto ohne Sparziel") + abgang = _buchung(admin, giro, "expense", 15000, heute) + zugang = _buchung(admin, anderes, "income", 15000, heute) + _verknuepfen(admin, abgang, zugang) + + assert _ist(admin, sparen) == vorher + + +def test_der_abgang_zaehlt_genau_einmal(admin): + """Traegt der Abgang schon eine eigene Kategorie, darf er nicht zweimal + gezaehlt werden – einmal dort und einmal als Sparen. Er gehoert zum + Sparen; seine alte Kategorie tritt zurueck.""" + admin.put("/api/settings/budget_start_day", params={"value": "1"}) + heute = str(date.today()) + sparen = _kategorie(admin, "Sparen & Investments") + lebensmittel = _kategorie(admin, "Lebensmittel") + vor_sparen = _ist(admin, sparen) + vor_leb = _ist(admin, lebensmittel) + + giro = _konto(admin, "Giro einmal zaehlen") + pocket = _konto(admin, "Pocket einmal zaehlen", savings_category_id=sparen) + r = admin.post("/api/transactions", json={ + "type": "expense", "date": heute, "account_id": giro["id"], + "amount_cents": 7500, "category_id": lebensmittel}) + assert r.status_code == 201, r.text + abgang = r.json() + # Solange sie unverknuepft ist, zaehlt sie ganz normal gegen ihre Kategorie. + assert _ist(admin, lebensmittel) == vor_leb + 7500 + + zugang = _buchung(admin, pocket, "income", 7500, heute) + _verknuepfen(admin, abgang, zugang) + + assert _ist(admin, sparen) == vor_sparen + 7500, "nicht als Sparen gezaehlt" + assert _ist(admin, lebensmittel) == vor_leb, "zusaetzlich in der alten Kategorie geblieben" + + +def test_einzelne_buchung_laesst_sich_wieder_herausnehmen(admin): + """Am Konto gesagt heisst nicht: fuer immer und fuer jede Buchung. Wer eine + einzelne Bewegung anders sieht, setzt sie auf neutral.""" + s = _sparen_szenario(admin) + mit = _ist(admin, s["sparen"]) + + r = admin.patch(f"/api/transactions/{s['abgang']['id']}", + json={"budget_treatment": "neutral"}) + assert r.status_code == 200, r.text + assert _ist(admin, s["sparen"]) == mit - 20000 + + +def test_eigene_budget_kategorie_schlaegt_die_des_kontos(admin): + s = _sparen_szenario(admin) + andere = _kategorie(admin, "Lebensmittel") + vorher_andere = _ist(admin, andere) + mit = _ist(admin, s["sparen"]) + + r = admin.patch(f"/api/transactions/{s['abgang']['id']}", + json={"budget_category_id": andere}) + assert r.status_code == 200, r.text + assert _ist(admin, s["sparen"]) == mit - 20000 + assert _ist(admin, andere) == vorher_andere + 20000 + + +def test_geloeste_verknuepfung_nimmt_das_sparen_zurueck(admin): + """Die Verknuepfung ist ein Etikett. Zieht man es ab, ist der Abgang wieder + eine gewoehnliche Buchung – und zaehlt nicht mehr als Sparen.""" + vorher = _ist(admin, _kategorie(admin, "Sparen & Investments")) + s = _sparen_szenario(admin) + assert _ist(admin, s["sparen"]) == vorher + 20000 + + link = next(l for l in admin.get("/api/uebertraege").json() + if l["from_txn_id"] == s["abgang"]["id"]) + assert admin.delete(f"/api/uebertraege/{link['id']}").status_code == 204 + assert _ist(admin, s["sparen"]) == vorher + + +def test_uebertrag_am_stueck_nimmt_die_kategorie_des_kontos(admin): + """Die andere Form des Uebertrags – EINE Buchung mit Zielkonto, wie sie das + Formular anlegt. Auch sie soll das Sparziel des Kontos von allein finden, + ohne dass man die Kategorie an der Buchung wiederholt.""" + admin.put("/api/settings/budget_start_day", params={"value": "1"}) + heute = str(date.today()) + sparen = _kategorie(admin, "Sparen & Investments") + vorher = _ist(admin, sparen) + + giro = _konto(admin, "Giro am Stueck") + pocket = _konto(admin, "Pocket am Stueck", savings_category_id=sparen) + r = admin.post("/api/transactions", json={ + "type": "transfer", "date": heute, "account_id": giro["id"], + "to_account_id": pocket["id"], "amount_cents": 5000}) + assert r.status_code == 201, r.text + + assert _ist(admin, sparen) == vorher + 5000 + + +def test_sparziel_muss_eine_ausgabe_kategorie_sein(admin): + """Gegen eine Einnahme-Kategorie liesse sich Sparen nicht verplanen.""" + gehalt = _kategorie(admin, "Gehalt") + r = admin.post("/api/accounts", json={ + "name": "Falsches Sparziel", "type": "sparkonto", "currency": "EUR", + "opening_balance_cents": 0, "savings_category_id": gehalt}) + assert r.status_code == 400, r.text + assert "Ausgabe" in r.json()["detail"] + + +def test_sparziel_laesst_sich_wieder_loesen(admin): + sparen = _kategorie(admin, "Sparen & Investments") + konto = _konto(admin, "Sparziel loesbar", savings_category_id=sparen) + assert konto["savings_category_id"] == sparen + + r = admin.patch(f"/api/accounts/{konto['id']}", json={"savings_category_id": None}) + assert r.status_code == 200, r.text + assert r.json()["savings_category_id"] is None diff --git a/web/src/locales/de.json b/web/src/locales/de.json index 71beb8b..0d65b3f 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -168,6 +168,9 @@ "empty": "Noch keine Konten. Lege dein erstes Konto an.", "deleteConfirm": "Dieses Konto und alle zugehörigen Buchungen löschen?", "openingDateHint": "Stand am Ende dieses Tages. Buchungen bis dahin zählen nicht zum Kontostand, bleiben aber in Liste und Auswertungen. Ohne Stichtag zählt alles.", + "savingsCategory": "Sparen gegen Kategorie", + "savingsCategoryNone": "Kein Sparziel", + "savingsCategoryHint": "Überweisungen auf dieses Konto zählen im Budget als Sparen gegen diese Kategorie – auch dann, wenn sie über die Bank als zwei verknüpfte Buchungen hereinkommen. Einzelne Buchungen lassen sich davon ausnehmen.", "shortIdentWarning": "Kurze Erkennungstexte treffen leicht Fremdes: „Euro“ steckt auch in „Europa-Park“ oder „PayPal Europe“. Solche Buchungen werden dann fälschlich zu Überträgen. Nimm lieber etwas Eindeutiges wie die IBAN oder „Revolut**5064*“.", "moveUp": "Nach oben", "moveDown": "Nach unten", diff --git a/web/src/locales/en.json b/web/src/locales/en.json index f1da5fa..258716a 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -168,6 +168,9 @@ "empty": "No accounts yet. Create your first account.", "deleteConfirm": "Delete this account and all its transactions?", "openingDateHint": "Balance at the end of that day. Bookings up to then don't count toward the balance but stay in the list and reports. Without a date, everything counts.", + "savingsCategory": "Count savings towards", + "savingsCategoryNone": "Not a savings account", + "savingsCategoryHint": "Transfers into this account count as savings against this category in the budget – including those that arrive from the bank as two linked transactions. Individual transactions can be excluded.", "shortIdentWarning": "Short match texts hit unrelated bookings: “Euro” also occurs in “Europa-Park” or “PayPal Europe”, turning those into transfers by mistake. Prefer something unique such as the IBAN.", "moveUp": "Move up", "moveDown": "Move down", diff --git a/web/src/pages/Accounts.jsx b/web/src/pages/Accounts.jsx index 677a796..a262e68 100644 --- a/web/src/pages/Accounts.jsx +++ b/web/src/pages/Accounts.jsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { api } from "../api"; import { useSettings } from "../settings"; @@ -9,6 +9,8 @@ import IconChip from "../components/IconChip"; import Money from "../components/Money"; import Modal from "../components/Modal"; import IconPicker, { ACCOUNT_ICONS } from "../components/IconPicker"; +import CategorySelect from "../components/CategorySelect"; +import { gruppenFuer } from "../kategorien"; import { centsToInput, formatDate, formatMoney, parseAmountToCents } from "../format"; const ACCOUNT_TYPES = ["girokonto", "sparkonto", "bargeld", "kreditkarte", "depot", "sonstiges"]; @@ -30,6 +32,7 @@ function emptyForm() { color: "#3f51b5", opening_balance: "", opening_date: "", + savings_category_id: "", identifiers: [], }; } @@ -88,6 +91,10 @@ function TransferKeywords({ accounts }) { catch (err) { toast(err.message, "error"); } } useEffect(() => { laden(); }, []); + useEffect(() => { + api.listCategories().then(setGroups).catch(() => setGroups([])); + }, []); + const expenseGroups = useMemo(() => gruppenFuer(groups, "expense"), [groups]); async function hinzufuegen() { const w = input.trim(); @@ -178,6 +185,9 @@ export default function Accounts() { const toast = useToast(); const [accounts, setAccounts] = useState([]); + // Nur fuer die Auswahl des Sparziels. Ausgaben-Gruppen, denn Sparen zaehlt im + // Budget wie eine Ausgabe. + const [groups, setGroups] = useState([]); const [editing, setEditing] = useState(null); // Account-Objekt oder null const [form, setForm] = useState(null); // Formularzustand oder null (geschlossen) const [busy, setBusy] = useState(false); @@ -236,6 +246,7 @@ export default function Accounts() { color: acc.color, opening_balance: centsToInput(acc.opening_balance_cents), opening_date: acc.opening_date || "", + savings_category_id: acc.savings_category_id ? String(acc.savings_category_id) : "", identifiers: acc.identifiers.map((i) => ({ kind: i.kind, value: i.value })), }); } @@ -260,6 +271,7 @@ export default function Accounts() { color: form.color, opening_balance_cents: parseAmountToCents(form.opening_balance) ?? 0, opening_date: form.opening_date || null, + savings_category_id: form.savings_category_id ? Number(form.savings_category_id) : null, identifiers: form.identifiers .filter((i) => i.value.trim()) .map((i) => ({ kind: i.kind, value: i.value.trim() })), @@ -466,6 +478,20 @@ export default function Accounts() {
{t("accounts.openingDateHint")}
+ + +{t("accounts.savingsCategoryHint")}
+