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("cards.total")} - {formatMoney(data.total, data.currency)} + {/* Mit einer Kreditkartenschuld kann diese Summe negativ werden. + formatMoney allein faerbt nicht – das Minus stuende still da. */} +
); diff --git a/web/src/finance.css b/web/src/finance.css index 1ca19e4..cc20e60 100644 --- a/web/src/finance.css +++ b/web/src/finance.css @@ -1164,6 +1164,31 @@ tr.row-dup { .saldo-ok { color: var(--ok); } .saldo-abweichung { color: var(--warn); font-weight: 560; } +/* ---------- Kreditrahmen ---------- + Wie viel von der Karte noch uebrig ist. Der Balken faellt bewusst schmal + aus: Er begleitet den Saldo, er ersetzt ihn nicht. Neutral gefaerbt, denn + einen Rahmen auszunutzen ist nicht falsch – erst ihn zu ueberziehen. */ +.kreditrahmen { + display: flex; + align-items: center; + gap: var(--sp-2); + margin-top: 3px; +} +.rahmen-balken { + flex: 0 0 64px; + height: 5px; + background: var(--surface-2); + border-radius: 3px; + overflow: hidden; +} +.rahmen-fuellung { + height: 100%; + background: var(--muted); + border-radius: 3px; + transition: width 0.2s; +} +.rahmen-balken.ueber .rahmen-fuellung { background: var(--warn); } + /* ---------- Übertrags-Vorschläge ---------- */ .uebertrag-vorschlag { padding: var(--sp-3) 0; diff --git a/web/src/locales/de.json b/web/src/locales/de.json index 0970802..3266703 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -180,6 +180,11 @@ "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.", + "creditLimit": "Kreditrahmen", + "creditLimitNone": "kein Rahmen hinterlegt", + "creditLimitHint": "Was die Karte ausgeben darf – kein Guthaben. Der Kontostand gehört nicht hierher: Eine abbezahlte Karte steht auf 0, jede Ausgabe drückt sie ins Minus, die Rechnung holt sie zurück.", + "creditLimitLeft": "noch {{left}} von {{limit}} frei", + "creditLimitExceeded": "{{over}} über dem Rahmen von {{limit}}", "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.", diff --git a/web/src/locales/en.json b/web/src/locales/en.json index ea79f19..f3791d4 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -180,6 +180,11 @@ "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.", + "creditLimit": "Credit limit", + "creditLimitNone": "no limit set", + "creditLimitHint": "What the card may spend — not money you have. The balance does not belong here: a paid-off card sits at 0, every purchase pushes it negative, and the bill brings it back.", + "creditLimitLeft": "{{left}} of {{limit}} left", + "creditLimitExceeded": "{{over}} over the {{limit}} limit", "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.", diff --git a/web/src/pages/Accounts.jsx b/web/src/pages/Accounts.jsx index 0f9a78b..29b3761 100644 --- a/web/src/pages/Accounts.jsx +++ b/web/src/pages/Accounts.jsx @@ -33,6 +33,7 @@ function emptyForm() { color: "#3f51b5", opening_balance: "", opening_date: "", + credit_limit: "", savings_category_id: "", identifiers: [], }; @@ -257,6 +258,8 @@ export default function Accounts() { color: acc.color, opening_balance: centsToInput(acc.opening_balance_cents), opening_date: acc.opening_date || "", + credit_limit: acc.credit_limit_cents != null + ? centsToInput(acc.credit_limit_cents) : "", savings_category_id: acc.savings_category_id ? String(acc.savings_category_id) : "", identifiers: acc.identifiers.map((i) => ({ kind: i.kind, value: i.value })), }); @@ -282,6 +285,10 @@ export default function Accounts() { color: form.color, opening_balance_cents: parseAmountToCents(form.opening_balance) ?? 0, opening_date: form.opening_date || null, + // Ausdruecklich null bei jedem anderen Typ: Wer eine Kreditkarte zum + // Girokonto macht, soll den Rahmen nicht unsichtbar mitschleppen. + credit_limit_cents: form.type === "kreditkarte" + ? (parseAmountToCents(form.credit_limit) ?? null) : null, savings_category_id: form.savings_category_id ? Number(form.savings_category_id) : null, identifiers: form.identifiers .filter((i) => i.value.trim()) @@ -326,7 +333,12 @@ export default function Accounts() { } } - const total = accounts.reduce((s, a) => s + a.balance_base_cents, 0); + // Archivierte bleiben draussen – das Backend rechnet ebenso + // (balances.net_worth_cents, dashboard.py). Zwei Zahlen fuer dieselbe Sache + // sind eine Zahl zu viel. + const total = accounts + .filter((a) => !a.is_archived) + .reduce((s, a) => s + a.balance_base_cents, 0); return (
@@ -343,7 +355,7 @@ export default function Accounts() {
{t("accounts.total")} - {formatMoney(total, baseCurrency)} +
{accounts.map((a, i) => ( @@ -359,6 +371,7 @@ export default function Accounts() { {a.identifiers.length > 0 && ` · ${t("accounts.identShort", { count: a.identifiers.length })}`}
+ {/* Bei angebundenen Konten der Saldo, den die Bank fuehrt. Er ersetzt die eigene Rechnung nicht, er prueft sie: Eine Abweichung heisst, dass eine Buchung fehlt oder doppelt @@ -488,6 +501,20 @@ export default function Accounts() {

{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")}

+ + )} +