Files
ScarriffleandClaude Opus 5 f99be47587 feat: Abos & Vertraege – was regelmaessig abgeht, an einer Stelle
Netflix, Handyvertrag, Krankenkasse, Halbtax. Bisher sah man sie nur als
einzelne Buchungen im Auszug; die Frage "welche Abos habe ich eigentlich, und
was kosten die zusammen im Monat" liess sich nicht beantworten. Genau die
steht jetzt oben auf der Seite.

Der Bereich heisst "Abos & Vertraege", nicht "Abos": Ein Handyvertrag ist kein
Abo und die Versicherung erst recht nicht – niemand soll ueberlegen muessen,
wo sie hingehoert.

Der Monatswert wird gerechnet, nicht gespeichert (Betrag durch Intervall).
Gespeichert veraltete er beim naechsten Preisaufschlag stillschweigend.

Bei laengerem Intervall klappt "Ich lege monatlich zurueck auf" auf, mit dem
gerechneten Monatsbetrag daneben. Bei monatlicher Abrechnung gibt es nichts
anzusparen, also steht es da auch nicht.

DAZU: Waehrungen sind offen.

Die Rechen-Maschinerie konnte immer schon beliebige Waehrungen – sie rechnet
ueber den Euro als Drehpunkt. Beschraenkt haben es zwei Konstanten: eine
Erlaubnisliste mit EUR und CHF, und ein Filter, der aus dem EZB-Feed alles
ausser CHF wegwarf, obwohl er dreissig Waehrungen liefert.

Massgeblich ist jetzt die Liste der EZB. Frei erfundene Waehrungen bleiben
draussen, und das ist kein Geiz: Ohne Kurs liesse sich der Betrag nie
umrechnen – er stuende in keiner Summe und in keinem Budget, und das faellt
erst spaet auf. Ein Vertrag in einer Waehrung ohne Kurs wird deshalb genannt,
statt die Summe stillschweigend zu klein anzuzeigen.

Erkannt und gebucht wird noch nichts; der Erkennungstext wird nur erfasst.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 15:45:59 +02:00

87 lines
2.9 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 .. import waehrungen
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. Alle, die der Feed hergibt: Sie werden ohnehin
# schon heruntergeladen und eingelesen: sie wegzuwerfen sparte nichts und hiesse
# nur, dass ein Abo in Dollar später nicht anlegbar wäre. EUR ist der Pivot und
# hat keine eigene Zeile.
CURRENCIES = waehrungen.EZB_WAEHRUNGEN
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