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
+82
View File
@@ -0,0 +1,82 @@
"""EZB-Referenzkurse laden und lokal cachen.
Die EZB veröffentlicht die Euro-Referenzkurse werktäglich als XML: ein Tages-Feed,
ein 90-Tage-Feed und die volle Historie seit 1999. Wir brauchen nur CHF. Historische
Kurse ändern sich nicht – vorhandene Zeilen werden daher nicht überschrieben.
"""
import xml.etree.ElementTree as ET
from datetime import date
from decimal import Decimal
import httpx
from sqlalchemy.orm import Session
from ..models import ExchangeRate
FEED_90D = "https://www.ecb.europa.eu/stats/eurofxref/eurofxref-hist-90d.xml"
FEED_FULL = "https://www.ecb.europa.eu/stats/eurofxref/eurofxref-hist.xml"
# Welche Währungen wir speichern (Hauptwährungen des Nutzers: EUR ist Pivot).
CURRENCIES = {"CHF"}
def _parse(xml_bytes: bytes) -> list[tuple[date, str, str]]:
"""Extrahiert (Datum, Währung, Kurs) aus dem EZB-XML (namespace-tolerant)."""
root = ET.fromstring(xml_bytes)
rows: list[tuple[date, str, str]] = []
for elem in root.iter():
t = elem.attrib.get("time")
if not t:
continue
try:
d = date.fromisoformat(t)
except ValueError:
continue
for child in elem:
ccy = child.attrib.get("currency")
rate = child.attrib.get("rate")
if ccy in CURRENCIES and rate:
rows.append((d, ccy, rate))
return rows
def _fetch(url: str, timeout: float = 15.0) -> bytes:
with httpx.Client(timeout=timeout, follow_redirects=True) as client:
resp = client.get(url)
resp.raise_for_status()
return resp.content
def _upsert(db: Session, rows: list[tuple[date, str, str]]) -> int:
added = 0
for d, ccy, rate in rows:
if db.get(ExchangeRate, (d, ccy)) is None:
db.add(ExchangeRate(date=d, currency=ccy, rate_per_eur=Decimal(rate), source="ecb"))
added += 1
if added:
db.commit()
return added
def refresh_recent(db: Session) -> int:
"""Lädt die letzten ~90 Tage (schnell) und ergänzt fehlende Tage."""
return _upsert(db, _parse(_fetch(FEED_90D)))
def backfill_full(db: Session) -> int:
"""Lädt die komplette Historie (einmalig, größer)."""
return _upsert(db, _parse(_fetch(FEED_FULL)))
def ensure_rates_on_startup(db: Session, enabled: bool) -> None:
"""Beim App-Start best-effort die aktuellen Kurse nachladen.
Blockiert nie hart: Ohne Internet läuft die App weiter, nur ohne frische Kurse.
Die volle Historie holt das Install-Skript / der systemd-Timer separat."""
if not enabled:
return
try:
refresh_recent(db)
except Exception: # noqa: BLE001 – bewusst tolerant (Netz weg etc.)
pass