feat: Ein Konto kann sagen, dass Ueberweisungen darauf Sparen sind

Wer auf ein Pocket spart, budgetiert dafuer eine Ausgabe – im Budget stand
davon aber nichts. Zwei Gruende:

Erstens gibt es zwei Formen von Uebertrag. Die eine ist EINE Buchung mit
Zielkonto, wie sie das Formular anlegt; nur fuer die gab es die Sparen-Logik.
Die andere sind ZWEI verknuepfte Buchungen, und genau so kommt ein Uebertrag
ueber die Bankanbindung herein. Beide Seiten uebersprang die Auswertung
ausnahmslos.

Zweitens hing das Sparen am Kontotyp. Ein Revolut-Pocket ist bei der Bank ein
ganz normales Konto und kommt als "girokonto" herein – am Typ ist nicht
abzulesen, dass man darauf spart.

Jetzt sagt man es dem Konto einmal ("Sparen gegen Kategorie X"), und es gilt
fuer beide Formen. Wer eine einzelne Bewegung anders sieht, nimmt sie an der
Buchung heraus oder gibt ihr eine eigene Kategorie – dieselben zwei Felder wie
bisher.

Der Normalfall aendert sich nicht: Ein Uebertrag auf ein Konto ohne Sparziel
ist weiterhin bloss verschobenes Geld.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-07 13:53:26 +02:00
co-authored by Claude Opus 5
parent 991a8e4fc2
commit 25392b9d08
11 changed files with 407 additions and 16 deletions
+17
View File
@@ -243,6 +243,23 @@ class Account(Base):
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#3f51b5") color: Mapped[str] = mapped_column(String(9), nullable=False, default="#3f51b5")
opening_balance_cents: Mapped[int] = mapped_column(Integer, nullable=False, default=0) opening_balance_cents: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
opening_date: Mapped[date_t | None] = mapped_column(Date, nullable=True) 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) is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0) position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
+28 -1
View File
@@ -11,6 +11,8 @@ from ..models import (
Account, Account,
AccountIdentifier, AccountIdentifier,
BankAccountLink, BankAccountLink,
Category,
CategoryKind,
IdentifierKind, IdentifierKind,
Transaction, Transaction,
TxnType, TxnType,
@@ -67,6 +69,7 @@ def to_out(db: Session, acc: Account, base: str, on: date,
opening_date=acc.opening_date, opening_date=acc.opening_date,
is_archived=acc.is_archived, is_archived=acc.is_archived,
position=acc.position, position=acc.position,
savings_category_id=acc.savings_category_id,
identifiers=[ identifiers=[
AccountIdentifierOut(id=i.id, kind=i.kind, value=i.value) for i in acc.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] 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) @router.post("", response_model=AccountOut, status_code=status.HTTP_201_CREATED)
def create_account( def create_account(
payload: AccountCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user) payload: AccountCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)
): ):
_pruefe_sparziel(db, user, payload.savings_category_id)
max_pos = ( max_pos = (
db.query(Account.position) db.query(Account.position)
.filter(Account.owner_id == user.id) .filter(Account.owner_id == user.id)
@@ -140,6 +162,7 @@ def create_account(
color=payload.color, color=payload.color,
opening_balance_cents=payload.opening_balance_cents, opening_balance_cents=payload.opening_balance_cents,
opening_date=payload.opening_date, opening_date=payload.opening_date,
savings_category_id=payload.savings_category_id,
position=(max_pos[0] + 1) if max_pos else 0, position=(max_pos[0] + 1) if max_pos else 0,
) )
_set_identifiers(acc, payload.identifiers) _set_identifiers(acc, payload.identifiers)
@@ -169,8 +192,12 @@ def update_account(
acc = get_owned(db, Account, account_id, user) acc = get_owned(db, Account, account_id, user)
data = payload.model_dump(exclude_unset=True) data = payload.model_dump(exclude_unset=True)
identifiers = data.pop("identifiers", None) 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(): 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) setattr(acc, key, value)
if identifiers is not None: if identifiers is not None:
_set_identifiers(acc, payload.identifiers) _set_identifiers(acc, payload.identifiers)
+4
View File
@@ -153,6 +153,8 @@ class AccountCreate(BaseModel):
color: str = "#3f51b5" color: str = "#3f51b5"
opening_balance_cents: int = 0 opening_balance_cents: int = 0
opening_date: date_t | None = None 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) identifiers: list[AccountIdentifierIn] = Field(default_factory=list)
@field_validator("currency") @field_validator("currency")
@@ -177,6 +179,7 @@ class AccountUpdate(BaseModel):
opening_date: date_t | None = None opening_date: date_t | None = None
is_archived: bool | None = None is_archived: bool | None = None
position: int | None = None position: int | None = None
savings_category_id: int | None = None
# Wenn gesetzt, ersetzt die komplette Identifikator-Liste. # Wenn gesetzt, ersetzt die komplette Identifikator-Liste.
identifiers: list[AccountIdentifierIn] | None = None identifiers: list[AccountIdentifierIn] | None = None
@@ -197,6 +200,7 @@ class AccountOut(BaseModel):
opening_date: date_t | None opening_date: date_t | None
is_archived: bool is_archived: bool
position: int position: int
savings_category_id: int | None = None
identifiers: list[AccountIdentifierOut] identifiers: list[AccountIdentifierOut]
balance_cents: int balance_cents: int
balance_base_cents: int balance_base_cents: int
+7 -3
View File
@@ -86,12 +86,16 @@ class TableSpec:
# Reihenfolge = Einfügereihenfolge. Verweise dürfen nur auf bereits # Reihenfolge = Einfügereihenfolge. Verweise dürfen nur auf bereits
# eingefügte Tabellen zeigen; gelöscht wird in umgekehrter Reihenfolge. # eingefügte Tabellen zeigen; gelöscht wird in umgekehrter Reihenfolge.
USER_TABLES: list[TableSpec] = [ USER_TABLES: list[TableSpec] = [
TableSpec("accounts", Account, "owner", label="Konten"), # Kategorien VOR den Konten: Ein Konto kann auf eine Spar-Kategorie zeigen,
TableSpec("account_identifiers", AccountIdentifier, "account", # und ein Verweis darf nur auf eine schon eingefuegte Tabelle gehen.
{"account_id": "accounts"}, label="Konto-Merkmale"), # Umgekehrt zeigt keine Kategorie je auf ein Konto.
TableSpec("category_groups", CategoryGroup, "owner", label="Kategoriegruppen"), TableSpec("category_groups", CategoryGroup, "owner", label="Kategoriegruppen"),
TableSpec("categories", Category, "owner", TableSpec("categories", Category, "owner",
{"group_id": "category_groups"}, label="Kategorien"), {"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_templates", ImportTemplate, "owner", label="Import-Vorlagen"),
TableSpec("import_batches", ImportBatch, "owner", TableSpec("import_batches", ImportBatch, "owner",
{"account_id": "accounts", "template_id": "import_templates"}, {"account_id": "accounts", "template_id": "import_templates"},
+72 -9
View File
@@ -14,7 +14,7 @@ from datetime import date, timedelta
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from sqlalchemy import and_, or_ from sqlalchemy import and_, or_
from sqlalchemy.orm import Session from sqlalchemy.orm import Session, aliased
from ..models import ( from ..models import (
Account, Account,
@@ -26,6 +26,7 @@ from ..models import (
CategoryKind, CategoryKind,
SAVINGS_ACCOUNT_TYPES, SAVINGS_ACCOUNT_TYPES,
Transaction, Transaction,
TransferLink,
TxnType, TxnType,
User, User,
) )
@@ -72,17 +73,21 @@ def period_of(d: date, start_day: int) -> str:
return f"{d.year:04d}-{d.month:02d}" 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. """Filter: Buchungen, die zu diesem Budget-Monat gehoeren.
Normalfall ist das Datum. Traegt eine Buchung aber einen ausdruecklich Normalfall ist das Datum. Traegt eine Buchung aber einen ausdruecklich
gesetzten Budget-Monat, gilt dieser – und ihr Datum zaehlt dann in KEINEM gesetzten Budget-Monat, gilt dieser – und ihr Datum zaehlt dann in KEINEM
Zeitraum mehr mit, sonst waere sie zweimal gezaehlt. 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_( return or_(
Transaction.budget_period == label, spalten.budget_period == label,
and_(Transaction.budget_period.is_(None), and_(spalten.budget_period.is_(None),
Transaction.date >= start, Transaction.date <= end), 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, def expense_rows(db: Session, user: User, label: str, start: date, end: date,
base: str) -> list[tuple[int, Transaction, int]]: base: str) -> list[tuple[int, Transaction, int]]:
"""Die einzelnen Buchungen hinter den Ist-Ausgaben: (Kategorie, Buchung, Betrag). """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))) 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 # 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 = ( transfers = (
db.query(Transaction) db.query(Transaction)
.filter( .filter(
Transaction.owner_id == user.id, Transaction.owner_id == user.id,
Transaction.type == TxnType.transfer, Transaction.type == TxnType.transfer,
zugeordnet(label, start, end), zugeordnet(label, start, end),
Transaction.budget_category_id.isnot(None),
) )
.all() .all()
) )
@@ -267,8 +313,25 @@ def expense_rows(db: Session, user: User, label: str, start: date, end: date,
if t.budget_treatment == BudgetTreatment.neutral: if t.budget_treatment == BudgetTreatment.neutral:
continue continue
to_acc = db.get(Account, t.to_account_id) if t.to_account_id else None 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: if to_acc is None:
zeilen.append((t.budget_category_id, t, _to_base(db, t.amount_eur_cents, base, t.date))) 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 return zeilen
@@ -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")
+197
View File
@@ -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
+3
View File
@@ -168,6 +168,9 @@
"empty": "Noch keine Konten. Lege dein erstes Konto an.", "empty": "Noch keine Konten. Lege dein erstes Konto an.",
"deleteConfirm": "Dieses Konto und alle zugehörigen Buchungen löschen?", "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.", "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*“.", "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", "moveUp": "Nach oben",
"moveDown": "Nach unten", "moveDown": "Nach unten",
+3
View File
@@ -168,6 +168,9 @@
"empty": "No accounts yet. Create your first account.", "empty": "No accounts yet. Create your first account.",
"deleteConfirm": "Delete this account and all its transactions?", "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.", "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.", "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", "moveUp": "Move up",
"moveDown": "Move down", "moveDown": "Move down",
+27 -1
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { api } from "../api"; import { api } from "../api";
import { useSettings } from "../settings"; import { useSettings } from "../settings";
@@ -9,6 +9,8 @@ import IconChip from "../components/IconChip";
import Money from "../components/Money"; import Money from "../components/Money";
import Modal from "../components/Modal"; import Modal from "../components/Modal";
import IconPicker, { ACCOUNT_ICONS } from "../components/IconPicker"; import IconPicker, { ACCOUNT_ICONS } from "../components/IconPicker";
import CategorySelect from "../components/CategorySelect";
import { gruppenFuer } from "../kategorien";
import { centsToInput, formatDate, formatMoney, parseAmountToCents } from "../format"; import { centsToInput, formatDate, formatMoney, parseAmountToCents } from "../format";
const ACCOUNT_TYPES = ["girokonto", "sparkonto", "bargeld", "kreditkarte", "depot", "sonstiges"]; const ACCOUNT_TYPES = ["girokonto", "sparkonto", "bargeld", "kreditkarte", "depot", "sonstiges"];
@@ -30,6 +32,7 @@ function emptyForm() {
color: "#3f51b5", color: "#3f51b5",
opening_balance: "", opening_balance: "",
opening_date: "", opening_date: "",
savings_category_id: "",
identifiers: [], identifiers: [],
}; };
} }
@@ -88,6 +91,10 @@ function TransferKeywords({ accounts }) {
catch (err) { toast(err.message, "error"); } catch (err) { toast(err.message, "error"); }
} }
useEffect(() => { laden(); }, []); useEffect(() => { laden(); }, []);
useEffect(() => {
api.listCategories().then(setGroups).catch(() => setGroups([]));
}, []);
const expenseGroups = useMemo(() => gruppenFuer(groups, "expense"), [groups]);
async function hinzufuegen() { async function hinzufuegen() {
const w = input.trim(); const w = input.trim();
@@ -178,6 +185,9 @@ export default function Accounts() {
const toast = useToast(); const toast = useToast();
const [accounts, setAccounts] = useState([]); 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 [editing, setEditing] = useState(null); // Account-Objekt oder null
const [form, setForm] = useState(null); // Formularzustand oder null (geschlossen) const [form, setForm] = useState(null); // Formularzustand oder null (geschlossen)
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@@ -236,6 +246,7 @@ export default function Accounts() {
color: acc.color, color: acc.color,
opening_balance: centsToInput(acc.opening_balance_cents), opening_balance: centsToInput(acc.opening_balance_cents),
opening_date: acc.opening_date || "", 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 })), identifiers: acc.identifiers.map((i) => ({ kind: i.kind, value: i.value })),
}); });
} }
@@ -260,6 +271,7 @@ export default function Accounts() {
color: form.color, color: form.color,
opening_balance_cents: parseAmountToCents(form.opening_balance) ?? 0, opening_balance_cents: parseAmountToCents(form.opening_balance) ?? 0,
opening_date: form.opening_date || null, opening_date: form.opening_date || null,
savings_category_id: form.savings_category_id ? Number(form.savings_category_id) : null,
identifiers: form.identifiers identifiers: form.identifiers
.filter((i) => i.value.trim()) .filter((i) => i.value.trim())
.map((i) => ({ kind: i.kind, value: i.value.trim() })), .map((i) => ({ kind: i.kind, value: i.value.trim() })),
@@ -466,6 +478,20 @@ export default function Accounts() {
</label> </label>
</div> </div>
<p className="muted small">{t("accounts.openingDateHint")}</p> <p className="muted small">{t("accounts.openingDateHint")}</p>
<label>
<span>{t("accounts.savingsCategory")}</span>
<CategorySelect
groups={expenseGroups}
value={form.savings_category_id}
onChange={(id) => setForm({ ...form, savings_category_id: id != null ? String(id) : "" })}
allowNone
noneLabel={t("accounts.savingsCategoryNone")}
placeholder={t("accounts.savingsCategoryNone")}
/>
</label>
<p className="muted small">{t("accounts.savingsCategoryHint")}</p>
<label> <label>
<span>{t("accounts.icon")}</span> <span>{t("accounts.icon")}</span>
<IconPicker icons={ACCOUNT_ICONS} value={form.icon} color={form.color} <IconPicker icons={ACCOUNT_ICONS} value={form.icon} color={form.color}
+9 -2
View File
@@ -154,16 +154,23 @@ export default function Transactions() {
const fromAcc = form && accountById[Number(form.account_id)]; const fromAcc = form && accountById[Number(form.account_id)];
const toAcc = form && accountById[Number(form.to_account_id)]; const toAcc = form && accountById[Number(form.to_account_id)];
const crossCurrency = form?.type === "transfer" && fromAcc && toAcc && fromAcc.currency !== toAcc.currency; const crossCurrency = form?.type === "transfer" && fromAcc && toAcc && fromAcc.currency !== toAcc.currency;
const savingsDest = form?.type === "transfer" && toAcc && SAVINGS_TYPES.includes(toAcc.type); // Sparziel ist das Konto, wenn es eine Spar-Kategorie traegt – oder, als
// Vorgabe, wenn sein Typ dafuer steht. Am Typ allein laesst es sich nicht
// ablesen: Ein Revolut-Pocket kommt als ganz normales Girokonto herein.
const savingsDest = form?.type === "transfer" && toAcc
&& (toAcc.savings_category_id != null || SAVINGS_TYPES.includes(toAcc.type));
// Standard-Kategorie für budgetierte Sparen-Transfers (falls vorhanden). // Standard-Kategorie für budgetierte Sparen-Transfers (falls vorhanden).
const expenseGroups = useMemo(() => gruppenFuer(groups, "expense"), [groups]); const expenseGroups = useMemo(() => gruppenFuer(groups, "expense"), [groups]);
const incomeGroups = useMemo(() => gruppenFuer(groups, "income"), [groups]); const incomeGroups = useMemo(() => gruppenFuer(groups, "income"), [groups]);
const sparenCatId = useMemo(() => { const sparenCatId = useMemo(() => {
// Was am Zielkonto steht, schlaegt jede Vermutung: Dort hat es jemand
// ausdruecklich gesagt.
if (toAcc?.savings_category_id != null) return String(toAcc.savings_category_id);
for (const g of expenseGroups) for (const g of expenseGroups)
for (const c of g.categories) if (c.name === "Sparen & Investments") return String(c.id); for (const c of g.categories) if (c.name === "Sparen & Investments") return String(c.id);
return expenseGroups[0]?.categories?.[0] ? String(expenseGroups[0].categories[0].id) : ""; return expenseGroups[0]?.categories?.[0] ? String(expenseGroups[0].categories[0].id) : "";
}, [expenseGroups]); }, [expenseGroups, toAcc]);
// Bei Spar-/Depot-Ziel eine Budget-Kategorie vorbelegen. // Bei Spar-/Depot-Ziel eine Budget-Kategorie vorbelegen.
useEffect(() => { useEffect(() => {