diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..2bfc248 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,20 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "backend", + "runtimeExecutable": "cmd", + "runtimeArgs": [ + "/c", + "cd backend && .venv\\Scripts\\python.exe -m uvicorn app.main:app --port 8000" + ], + "port": 8000 + }, + { + "name": "web", + "runtimeExecutable": "cmd", + "runtimeArgs": ["/c", "cd web && npm run dev"], + "port": 5173 + } + ] +} diff --git a/backend/app/models.py b/backend/app/models.py index 7cd4c0e..d82f68d 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -313,6 +313,20 @@ 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) + # Was die Karte ausgeben DARF – nicht, was auf ihr liegt. + # + # Eine Kreditkarte ist kein Guthaben. Abbezahlt steht sie auf null, jede + # Ausgabe drueckt sie ins Minus, die Rechnungszahlung holt sie zurueck. + # Der Rahmen ist eine Eigenschaft des Vertrags mit der Bank und hat im + # Saldo nichts zu suchen – wer ihn dort eintraegt, sieht Geld, das er + # nicht hat. + # + # Deshalb ein eigenes Feld: Die Zahl ist es wert, aufgehoben zu werden + # („noch 1.900 von 2.500 frei"), nur eben nicht als Vermoegen. + # + # Optional, und nur bei Kreditkarten sinnvoll. Ohne Eintrag aendert sich + # nichts. + credit_limit_cents: Mapped[int | None] = mapped_column(Integer, nullable=True) # Wer hierher ueberweist, spart – und zwar gegen DIESE Kategorie. # # Sparen ist eine Entscheidung ueber ein KONTO, nicht ueber jede einzelne diff --git a/backend/app/routers/accounts.py b/backend/app/routers/accounts.py index 235b37c..7d87b7c 100644 --- a/backend/app/routers/accounts.py +++ b/backend/app/routers/accounts.py @@ -67,6 +67,7 @@ def to_out(db: Session, acc: Account, base: str, on: date, color=acc.color, opening_balance_cents=acc.opening_balance_cents, opening_date=acc.opening_date, + credit_limit_cents=acc.credit_limit_cents, is_archived=acc.is_archived, position=acc.position, savings_category_id=acc.savings_category_id, @@ -162,6 +163,7 @@ def create_account( color=payload.color, opening_balance_cents=payload.opening_balance_cents, opening_date=payload.opening_date, + credit_limit_cents=payload.credit_limit_cents, savings_category_id=payload.savings_category_id, position=(max_pos[0] + 1) if max_pos else 0, ) @@ -196,8 +198,12 @@ def update_account( _pruefe_sparziel(db, user, data["savings_category_id"]) for key, value in data.items(): # "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"): + # ein einmal gesetztes Sparziel nie wieder loesen. Fuer den + # Kreditrahmen gilt dasselbe – wer ein Konto vom Typ Kreditkarte auf + # Girokonto umstellt, muss den Rahmen wieder loswerden koennen. + if value is not None or key in ( + "opening_date", "savings_category_id", "credit_limit_cents" + ): 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 3bfc930..acdca38 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -162,6 +162,8 @@ class AccountCreate(BaseModel): color: str = "#3f51b5" opening_balance_cents: int = 0 opening_date: date_t | None = None + # Was die Karte ausgeben darf – kein Guthaben. Nur bei Kreditkarten sinnvoll. + credit_limit_cents: int | None = Field(default=None, ge=0) # Ueberweisungen hierher zaehlen als Sparen gegen diese Ausgabe-Kategorie. savings_category_id: int | None = None identifiers: list[AccountIdentifierIn] = Field(default_factory=list) @@ -186,6 +188,7 @@ class AccountUpdate(BaseModel): color: str | None = None opening_balance_cents: int | None = None opening_date: date_t | None = None + credit_limit_cents: int | None = Field(default=None, ge=0) is_archived: bool | None = None position: int | None = None savings_category_id: int | None = None @@ -207,6 +210,7 @@ class AccountOut(BaseModel): color: str opening_balance_cents: int opening_date: date_t | None + credit_limit_cents: int | None = None is_archived: bool position: int savings_category_id: int | None = None diff --git a/backend/migrations/versions/0035_kreditrahmen.py b/backend/migrations/versions/0035_kreditrahmen.py new file mode 100644 index 0000000..f18d3b3 --- /dev/null +++ b/backend/migrations/versions/0035_kreditrahmen.py @@ -0,0 +1,35 @@ +"""Der Kreditrahmen bekommt einen eigenen Platz. + +Bisher hatte niemand einen Ort fuer die Zahl, die eine Kreditkarte ausmacht: +wie viel sie ausgeben darf. Wer sie trotzdem festhalten wollte, trug sie als +Startsaldo ein – und sah fortan ein Guthaben, das es nicht gibt. + +Eine Karte ist kein Konto mit Geld darauf. Abbezahlt steht sie auf null, jede +Ausgabe drueckt sie ins Minus, die Rechnung holt sie zurueck. Der Rahmen ist +eine Zusage der Bank, kein Besitz – und gehoert deshalb neben den Saldo, nicht +hinein. + +Optional: Konten ohne Eintrag verhalten sich wie bisher. + +Revision ID: 0035 +Revises: 0034 +""" + +import sqlalchemy as sa +from alembic import op + +revision = "0035" +down_revision = "0034" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Ohne batch_alter_table, aus demselben Grund wie in 0034: Eine Spalte + # anhaengen kann SQLite direkt, der Tabellen-Umbau risse ueber ON DELETE + # CASCADE haengende Zeilen mit (tests/test_migrations.py). + op.execute(sa.text("ALTER TABLE accounts ADD COLUMN credit_limit_cents INTEGER")) + + +def downgrade() -> None: + op.execute(sa.text("ALTER TABLE accounts DROP COLUMN credit_limit_cents")) diff --git a/backend/tests/test_kreditkarte.py b/backend/tests/test_kreditkarte.py new file mode 100644 index 0000000..ed8c28b --- /dev/null +++ b/backend/tests/test_kreditkarte.py @@ -0,0 +1,134 @@ +"""Eine Kreditkarte ist eine Schuld, kein Guthaben. + +Abbezahlt steht sie auf null. Jede Ausgabe drueckt sie ins Minus, die Zahlung +der Rechnung holt sie zurueck. Der Kreditrahmen ist, was sie ausgeben darf – +nicht, was auf ihr liegt. + +Die Rechenkette war dafuer schon vorbereitet (balances.py rechnet +vorzeichenoffen). Diese Tests halten fest, dass sie es bleibt. +""" + +import json +from datetime import date + + +def _karte(c, **kw): + body = {"name": "Visa", "type": "kreditkarte", "currency": "EUR", + "opening_balance_cents": 0} + body.update(kw) + r = c.post("/api/accounts", json=body) + assert r.status_code == 201, r.text + return r.json() + + +def test_kreditrahmen_wird_gespeichert_und_zurueckgegeben(admin): + karte = _karte(admin, credit_limit_cents=250000) + assert karte["credit_limit_cents"] == 250000 + assert admin.get(f"/api/accounts/{karte['id']}").json()["credit_limit_cents"] == 250000 + + +def test_kreditrahmen_laesst_sich_aendern_und_wieder_loeschen(admin): + """Ohne die Loesch-Moeglichkeit haengt ein einmal gesetzter Rahmen fuer + immer am Konto – auch wenn daraus laengst ein Girokonto geworden ist.""" + karte = _karte(admin, credit_limit_cents=250000) + + r = admin.patch(f"/api/accounts/{karte['id']}", json={"credit_limit_cents": 300000}) + assert r.json()["credit_limit_cents"] == 300000 + + r = admin.patch(f"/api/accounts/{karte['id']}", json={"credit_limit_cents": None}) + assert r.json()["credit_limit_cents"] is None + + +def test_kontoname_aendern_laesst_den_rahmen_stehen(admin): + karte = _karte(admin, credit_limit_cents=250000) + r = admin.patch(f"/api/accounts/{karte['id']}", json={"name": "Visa Gold"}) + assert r.json()["credit_limit_cents"] == 250000 + + +def test_negativer_rahmen_wird_abgelehnt(admin): + r = admin.post("/api/accounts", json={ + "name": "Unsinn", "type": "kreditkarte", "credit_limit_cents": -100}) + assert r.status_code == 422, r.text + + +def test_karte_geht_ins_minus_und_die_rechnung_holt_sie_zurueck(admin): + """Der ganze Lebenslauf einer Karte in einem Monat.""" + giro = admin.post("/api/accounts", json={ + "name": "Giro", "currency": "EUR", "opening_balance_cents": 100000}).json() + karte = _karte(admin, credit_limit_cents=250000) + heute = str(date.today()) + + def saldo(acc): + return admin.get(f"/api/accounts/{acc['id']}").json()["balance_cents"] + + assert saldo(karte) == 0, "abbezahlt = null, nicht der Kreditrahmen" + + for betrag in (600, 2400): + admin.post("/api/transactions", json={ + "type": "expense", "date": heute, "account_id": karte["id"], + "amount_cents": betrag, "payee": "Laden"}) + assert saldo(karte) == -3000, "Ausgaben druecken die Karte ins Minus" + + # Die Rechnung: Geld geht vom Giro auf die Karte. + admin.post("/api/transactions", json={ + "type": "transfer", "date": heute, "account_id": giro["id"], + "to_account_id": karte["id"], "amount_cents": 3000, "payee": "Kartenrechnung"}) + + assert saldo(karte) == 0, "bezahlt = wieder null" + assert saldo(giro) == 97000, "und das Geld ist wirklich vom Giro weg" + + +def test_vermoegen_zieht_die_kartenschuld_ab(admin): + """Eine Schuld darf das Vermoegen nicht erhoehen – der Fehler, der + entsteht, wenn man den Kreditrahmen als Startsaldo eintraegt. + + Gemessen wird die Veraenderung, nicht der absolute Wert: Konten aus + anderen Tests dieser Datei zaehlen sonst mit. + """ + from app.database import SessionLocal + from app.models import Account, User + from app.services import balances + + def vermoegen() -> int: + db = SessionLocal() + try: + user = db.query(User).filter(User.username == "admin").one() + konten = db.query(Account).filter(Account.owner_id == user.id).all() + return balances.net_worth_cents(db, konten, "EUR", date.today()) + finally: + db.close() + + vorher = vermoegen() + + admin.post("/api/accounts", json={ + "name": "Giro Vermoegen", "currency": "EUR", "opening_balance_cents": 100000}) + assert vermoegen() == vorher + 100000 + + karte = _karte(admin, name="Visa Vermoegen", credit_limit_cents=250000) + assert vermoegen() == vorher + 100000, "ein Kreditrahmen ist kein Vermoegen" + + admin.post("/api/transactions", json={ + "type": "expense", "date": str(date.today()), "account_id": karte["id"], + "amount_cents": 3000, "payee": "Laden"}) + assert vermoegen() == vorher + 97000, "die Schuld wird abgezogen, nicht addiert" + + +def test_kreditrahmen_ueberlebt_das_backup(admin): + """Ein Feld, das die Rueckspielung nicht ueberlebt, ist kein Feld.""" + karte = _karte(admin, credit_limit_cents=250000) + + daten = json.loads(admin.get("/api/export/backup.json").content) + gesichert = [a for a in daten["data"]["accounts"] if a["name"] == "Visa"] + assert gesichert and gesichert[0]["credit_limit_cents"] == 250000 + + admin.delete(f"/api/accounts/{karte['id']}") + r = admin.post( + "/api/backup/restore", + files={"file": ("backup.json", json.dumps(daten).encode("utf-8"), + "application/json")}, + data={"mode": "replace"}, + ) + assert r.status_code == 200, r.text + + zurueck = [a for a in admin.get("/api/accounts").json() if a["name"] == "Visa"] + assert zurueck and zurueck[0]["credit_limit_cents"] == 250000 diff --git a/web/src/dashboard/cards.jsx b/web/src/dashboard/cards.jsx index 7aec026..9280c31 100644 --- a/web/src/dashboard/cards.jsx +++ b/web/src/dashboard/cards.jsx @@ -49,7 +49,9 @@ function AccountBalances() { ))}
{t("accounts.openingDateHint")}
+ {/* Nur bei Kreditkarten: An einem Girokonto gaebe es nichts zu + begrenzen, und das Feld waere nur ein weiteres Bedienelement. */} + {form.type === "kreditkarte" && ( + <> + +{t("accounts.creditLimitHint")}
+ > + )} +