feat: Phase 1 – Konten, Kategorien, Transaktionen, EZB-Währungsumrechnung

Backend:
- Modelle + Migration 0002: accounts, account_identifiers, category_groups,
  categories, transactions, exchange_rates (alle owner-gescoped).
- EZB-Kursdienst (90-Tage/Historie) + Backfill-Skript; Umrechnung EUR<->CHF zum
  Buchungsdatum mit lokalem Cache und manueller Übersteuerung; EUR-Pivot
  (amount_eur_cents) eingefroren.
- Kontostände + Netto-Vermögen (Umrechnung in Hauptwährung).
- Router: accounts (inkl. IBAN-Identifikatoren), categories (Gruppen+Kategorien),
  transactions (Ausgabe/Einnahme/Transfer, cross-currency), rates.
- Gruppierte Standard-Kategorien je Nutzer beim Anlegen.
- pytest-Suite: Währung, Salden, Cross-Currency-Transfer, Nutzer-Isolation.

Frontend:
- Seiten Konten, Kategorien, Buchungen; Icon-Chip, Geldbetrag mit Umrechnung,
  Icon-/Farbwähler, Modal; erweitertes SVG-Icon-Set (Konto-/Kategorie-Icons).
- Währungs-/Datumsformatierung (EUR de-DE, CHF de-CH), i18n DE/EN erweitert.
- Live-Umrechnungs-Vorschau im Transfer-Formular.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-19 18:59:24 +02:00
co-authored by Claude Opus 4.8
parent 2c795a57c8
commit 1b7107b9bd
35 changed files with 3116 additions and 6 deletions
+39
View File
@@ -0,0 +1,39 @@
"""Test-Setup: frische SQLite-DB je Testlauf, kein EZB-Netzabruf."""
import os
import secrets
import tempfile
# Umgebung setzen, BEVOR app importiert wird (config wird beim Import gelesen).
os.environ.setdefault("APP_DATA_DIR", tempfile.mkdtemp(prefix="cf-test-"))
os.environ.setdefault("APP_ECB_FETCH_ON_START", "false")
os.environ.setdefault("APP_ADMIN_USERNAME", "admin")
os.environ.setdefault("APP_ADMIN_PASSWORD", "adminpass")
os.environ.setdefault("APP_SESSION_SECRET", "test-secret")
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def client():
from app.main import app
with TestClient(app) as c:
yield c
@pytest.fixture()
def admin(client):
r = client.post("/api/auth/login", data={"username": "admin", "password": "adminpass"})
assert r.status_code == 200
return client
def new_user(admin_client) -> tuple[str, str]:
"""Legt (als Admin) einen frischen Nutzer an und gibt (username, passwort) zurück."""
username = "u_" + secrets.token_hex(4)
password = "pw_" + secrets.token_hex(4)
r = admin_client.post("/api/users", json={"username": username, "password": password, "role": "user"})
assert r.status_code == 201, r.text
return username, password
+84
View File
@@ -0,0 +1,84 @@
from datetime import date, timedelta
from decimal import Decimal
def _seed_recent_rates(rate: str = "0.95") -> None:
from app.database import SessionLocal
from app.models import ExchangeRate
db = SessionLocal()
try:
today = date.today()
for delta in range(0, 7):
day = today - timedelta(days=delta)
if db.get(ExchangeRate, (day, "CHF")) is None:
db.add(ExchangeRate(date=day, currency="CHF", rate_per_eur=Decimal(rate)))
db.commit()
finally:
db.close()
def _mk_account(admin, **kw):
body = {"name": "A", "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 test_starter_categories_present(admin):
groups = admin.get("/api/categories").json()
assert len(groups) >= 6
names = {c["name"] for g in groups for c in g["categories"]}
assert {"Gehalt", "Lebensmittel", "Sparen & Investments"} <= names
def test_currency_validation(admin):
r = admin.post("/api/accounts", json={"name": "X", "currency": "USD"})
assert r.status_code == 422
def test_identifiers_stored_and_deduped(admin):
acc = _mk_account(
admin,
name="Mit IBAN",
identifiers=[
{"kind": "iban", "value": "DE12 3456"},
{"kind": "iban", "value": "de123456"}, # normiert identisch -> dedupliziert
{"kind": "card_no", "value": "1234"},
],
)
kinds = sorted((i["kind"], i["value"]) for i in acc["identifiers"])
assert len(acc["identifiers"]) == 2
assert ("card_no", "1234") in kinds
def test_balance_and_cross_currency_transfer(admin):
_seed_recent_rates("0.95")
d = date.today()
eur = _mk_account(admin, name="Giro", currency="EUR", opening_balance_cents=100000)
chf = _mk_account(admin, name="Spar", type="sparkonto", currency="CHF", opening_balance_cents=0)
admin.post("/api/transactions", json={"type": "income", "date": str(d), "account_id": eur["id"], "amount_cents": 50000})
admin.post("/api/transactions", json={"type": "expense", "date": str(d), "account_id": eur["id"], "amount_cents": 20000})
assert admin.get(f"/api/accounts/{eur['id']}").json()["balance_cents"] == 130000
# Transfer 100 EUR -> CHF (0.95) = 95 CHF gutgeschrieben.
r = admin.post("/api/transactions", json={
"type": "transfer", "date": str(d), "account_id": eur["id"],
"to_account_id": chf["id"], "amount_cents": 10000,
})
assert r.status_code == 201, r.text
assert r.json()["to_amount_cents"] == 9500
assert admin.get(f"/api/accounts/{eur['id']}").json()["balance_cents"] == 120000
assert admin.get(f"/api/accounts/{chf['id']}").json()["balance_cents"] == 9500
def test_transfer_requires_other_account(admin):
eur = _mk_account(admin, name="Solo", currency="EUR")
d = date.today()
r = admin.post("/api/transactions", json={
"type": "transfer", "date": str(d), "account_id": eur["id"],
"to_account_id": eur["id"], "amount_cents": 100,
})
assert r.status_code == 400
+59
View File
@@ -0,0 +1,59 @@
from datetime import date
from decimal import Decimal
def _ensure_rate(day: date, rate: str) -> None:
from app.database import SessionLocal
from app.models import ExchangeRate
db = SessionLocal()
try:
if db.get(ExchangeRate, (day, "CHF")) is None:
db.add(ExchangeRate(date=day, currency="CHF", rate_per_eur=Decimal(rate)))
db.commit()
finally:
db.close()
def test_conversion_and_override(admin):
from app.database import SessionLocal
from app.services import currency
d = date(2026, 1, 15)
_ensure_rate(d, "0.95")
db = SessionLocal()
try:
assert currency.to_eur_cents(db, 9500, "CHF", d) == 10000 # 9500 / 0.95
assert currency.from_eur_cents(db, 10000, "CHF", d) == 9500
assert currency.convert(db, 10000, "EUR", "CHF", d) == 9500
assert currency.to_eur_cents(db, 5000, "EUR", d) == 5000 # EUR bleibt EUR
# Manuell gesetzter Kurs hat Vorrang vor dem EZB-Kurs.
assert currency.to_eur_cents(db, 10000, "CHF", d, fx_rate=2.0) == 5000
finally:
db.close()
def test_weekend_fallback(admin):
from app.database import SessionLocal
from app.services import currency
friday = date(2026, 3, 6)
sunday = date(2026, 3, 8) # kein Kurs -> Freitag gilt
_ensure_rate(friday, "0.90")
db = SessionLocal()
try:
assert currency.rate_on(db, "CHF", sunday) == Decimal("0.90")
finally:
db.close()
def test_missing_rate_returns_none(admin):
from app.database import SessionLocal
from app.services import currency
db = SessionLocal()
try:
# Weit in der Vergangenheit gibt es keinen Kurs -> None.
assert currency.to_eur_cents(db, 1000, "CHF", date(1990, 1, 1)) is None
finally:
db.close()
+33
View File
@@ -0,0 +1,33 @@
import secrets
from datetime import date
def test_users_are_isolated(admin):
from fastapi.testclient import TestClient
from app.main import app
# Admin legt ein Konto an.
acc = admin.post("/api/accounts", json={"name": "AdminKonto", "currency": "EUR"}).json()
# Frischen Nutzer anlegen und getrennt einloggen.
username = "iso_" + secrets.token_hex(4)
password = "pw_" + secrets.token_hex(4)
assert admin.post("/api/users", json={"username": username, "password": password, "role": "user"}).status_code == 201
other = TestClient(app)
assert other.post("/api/auth/login", data={"username": username, "password": password}).status_code == 200
# Sieht keine fremden Konten/Buchungen.
assert other.get("/api/accounts").json() == []
assert other.get("/api/transactions").json() == []
# Zugriff auf fremdes Konto -> 404 (nicht 403: existiert für ihn schlicht nicht).
assert other.get(f"/api/accounts/{acc['id']}").status_code == 404
# Buchung auf fremdes Konto -> 404.
r = other.post("/api/transactions", json={
"type": "expense", "date": str(date.today()), "account_id": acc["id"], "amount_cents": 100,
})
assert r.status_code == 404
# Eigene Startkategorien sind vorhanden.
assert len(other.get("/api/categories").json()) >= 6
# Kein Admin: darf keine Nutzer listen.
assert other.get("/api/users").status_code == 403