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:
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user