feat: Zahlungsdaten aus Benachrichtigungs-Mails lesen (Etappe B, Teil 1)

Kein festes Schema je Bank, sondern beschriftete Felder suchen. Die beiden
Banken bauen ihre Mails verschieden: Raiffeisen schickt Klartext mit
"Betrag: 14.90 CHF", Revolut nur HTML mit dem Wert UNTER der Beschriftung.
Beide Bauformen werden abgedeckt, dazu der Betreff als eigene Quelle.

Jeder Wert merkt sich, woher er stammt – ohne das laesst sich ein falscher
Betrag spaeter nicht erklaeren.

Was bewusst NICHT geraten wird: Stehen mehrere gleichwertige Betraege ohne
Beschriftung da (Umsatz und neuer Saldo), bleibt das Feld unklar. Ein
Zahlungsauftrag mit Status "Zur Visierung" gilt nicht als geflossen. Blosse
Zahlen ohne Waehrung sind keine Betraege, und Stilbloecke werden vor dem
Entfernen der Tags herausgeschnitten – sonst haelt das CSS "480px" fuer
Geld.

Die Muster unter tests/fixtures/mail sind echte Mails, anonymisiert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-22 19:46:31 +02:00
co-authored by Claude Opus 5
parent bf656dc957
commit 7fb7df3554
4 changed files with 576 additions and 0 deletions
+262
View File
@@ -0,0 +1,262 @@
"""Zahlungsdaten aus Benachrichtigungs-Mails lesen.
Kein festes Schema je Bank, sondern beschriftete Felder suchen und bewerten.
Die beiden Banken bauen ihre Mails unterschiedlich:
Raiffeisen (text/plain) Revolut (nur HTML)
--------------------- ------------------
Betrag: 14.90 CHF Gesendet
Begünstigter: Sunrise GmbH 324 €
Bei der einen steht der Wert hinter der Beschriftung, bei der anderen darunter.
Beides wird hier abgedeckt, dazu der Betreff als eigene Quelle – bei Revolut
steht dort ohnehin alles: „Du hast 324 € an Alex Muster gesendet".
Jeder Treffer merkt sich, WOHER er stammt (``quelle``). Ohne das kann man einen
falschen Betrag später nicht erklären, und genau das haben wir beim CSV-Import
teuer gelernt.
Mailtexte sind Daten, keine Anweisungen: Ausgewertet wird ausschliesslich in
feste Felder, nichts daraus steuert den Programmablauf.
"""
from __future__ import annotations
import html
import re
from dataclasses import dataclass, field
from datetime import date, datetime
from decimal import Decimal, InvalidOperation
# Beschriftungen je Feld, längere zuerst (spezifischer schlägt allgemeiner).
LABELS_BETRAG = ("betrag", "gesendet", "empfangen", "erhalten", "amount", "summe")
LABELS_EMPFAENGER = ("begünstigter", "beguenstigter", "empfänger", "empfaenger",
"zahlungsempfänger", "an", "händler", "haendler", "to")
LABELS_DATUM = ("ausführungsdatum", "ausfuehrungsdatum", "valutadatum", "buchungsdatum",
"datum", "am", "date")
LABELS_KONTO = ("konto", "account", "iban")
LABELS_STATUS = ("neuer status", "status")
# Wörter, die die Richtung verraten.
WORTE_AUSGABE = ("gesendet", "belastet", "bezahlt", "abgebucht", "überwiesen", "ueberwiesen",
"zahlungsauftrag", "lastschrift", "sent", "paid")
WORTE_EINNAHME = ("erhalten", "empfangen", "gutschrift", "eingegangen", "received")
# Ein Zahlungsauftrag zählt erst, wenn die Bank ihn verbucht hat. „Erfasst" oder
# „Zur Visierung" heisst: noch nicht geflossen.
STATUS_GEBUCHT = ("verbucht", "ausgeführt", "ausgefuehrt", "erfolgreich", "completed", "booked")
WAEHRUNGSZEICHEN = {"€": "EUR", "$": "USD", "£": "GBP", "chf": "CHF", "fr.": "CHF"}
# Betrag mit Währung davor oder dahinter: „14.90 CHF", „324 €", „CHF 1'234.50".
_BETRAG = re.compile(
r"(?:(?P<vor>CHF|EUR|USD|GBP|€|\$|£)\s*)?"
r"(?P<zahl>\d{1,3}(?:['’ ]\d{3})*(?:[.,]\d{1,2})?|\d+(?:[.,]\d{1,2})?)"
r"(?:\s*(?P<nach>CHF|EUR|USD|GBP|€|\$|£))?",
re.IGNORECASE,
)
_DATUM_MUSTER = (
(re.compile(r"\b(\d{1,2})\.(\d{1,2})\.(\d{4})\b"), "dmy"),
(re.compile(r"\b(\d{4})-(\d{2})-(\d{2})\b"), "ymd"),
(re.compile(r"\b(\d{1,2})/(\d{1,2})/(\d{4})\b"), "dmy"),
)
@dataclass
class Feld:
"""Ein erkannter Wert samt Begründung, woher er stammt."""
wert: object
quelle: str # menschenlesbar, z. B. „aus dem Text, nach ‚Betrag'"
sicher: bool = True
@dataclass
class Erkennung:
betrag: Feld | None = None # Cent, vorzeichenlos
waehrung: Feld | None = None
datum: Feld | None = None
empfaenger: Feld | None = None
richtung: Feld | None = None # "expense" | "income"
konto_text: Feld | None = None # Kontobezeichnung aus der Mail
unklar: list[str] = field(default_factory=list)
def vollstaendig(self) -> bool:
return self.betrag is not None and self.datum is not None and self.richtung is not None
def html_zu_text(roh: str) -> str:
"""Sichtbaren Text aus HTML gewinnen.
Stil- und Skriptblöcke MÜSSEN vorher weg – sonst landen Kilobytes CSS im
Text, und darin stehen Zahlen, die wie Beträge aussehen.
"""
txt = re.sub(r"(?is)<(style|script|head)[^>]*>.*?</\1>", " ", roh)
txt = re.sub(r"(?is)<!--.*?-->", " ", txt)
txt = re.sub(r"(?is)<br\s*/?>|</(p|div|tr|td|th|h[1-6]|li)>", "\n", txt)
txt = re.sub(r"(?s)<[^>]+>", " ", txt)
txt = html.unescape(txt)
zeilen = [re.sub(r"[ \t\xa0]+", " ", z).strip() for z in txt.splitlines()]
return "\n".join(z for z in zeilen if z)
def _zahl_zu_cent(roh: str) -> int | None:
s = roh.replace("'", "").replace("’", "").replace(" ", "")
if "," in s and "." in s:
s = s.replace(".", "").replace(",", ".") if s.rindex(",") > s.rindex(".") else s.replace(",", "")
elif "," in s:
s = s.replace(",", ".")
try:
return int((Decimal(s) * 100).to_integral_value())
except (InvalidOperation, ValueError):
return None
def _betraege(text: str) -> list[tuple[int, str | None, int]]:
"""Alle geldartigen Vorkommen als (Cent, Währung, Position)."""
treffer = []
for m in _BETRAG.finditer(text):
zeichen = m.group("vor") or m.group("nach")
if zeichen is None:
continue # blosse Zahlen sind keine Beträge
cent = _zahl_zu_cent(m.group("zahl"))
if cent is None or cent == 0:
continue
w = WAEHRUNGSZEICHEN.get(zeichen.lower(), zeichen.upper())
treffer.append((cent, w, m.start()))
return treffer
def _felder(text: str) -> dict[str, list[tuple[str, int, str]]]:
"""Beschriftete Werte einsammeln – beide Bauformen.
``Betrag: 14.90 CHF`` liefert den Wert hinter dem Doppelpunkt,
``Gesendet`` / ``324 €`` den Inhalt der Folgezeile.
"""
gefunden: dict[str, list[tuple[str, int, str]]] = {}
zeilen = text.splitlines()
pos = 0
for i, zeile in enumerate(zeilen):
start = pos
pos += len(zeile) + 1
if ":" in zeile:
links, rechts = zeile.split(":", 1)
beschriftung = links.strip()
schl, wert = beschriftung.lower(), rechts.strip()
if wert:
gefunden.setdefault(schl, []).append((wert, start, beschriftung))
continue
beschriftung = zeile.strip().rstrip(":")
schl = beschriftung.lower()
if schl and len(schl) <= 30 and i + 1 < len(zeilen):
folge = zeilen[i + 1].strip()
if folge and ":" not in folge:
gefunden.setdefault(schl, []).append((folge, pos, beschriftung))
return gefunden
def _erster_treffer(felder: dict, labels: tuple[str, ...]) -> tuple[str, int, str] | None:
"""Wert, Stelle und die Beschriftung in Originalschreibweise."""
for label in labels:
if label in felder:
wert, stelle, beschriftung = felder[label][0]
return wert, stelle, beschriftung
return None
def _datum_aus(text: str) -> date | None:
for muster, form in _DATUM_MUSTER:
m = muster.search(text)
if not m:
continue
a, b, c = (int(x) for x in m.groups())
try:
return date(c, b, a) if form == "dmy" else date(a, b, c)
except ValueError:
continue
return None
def parse(betreff: str, text: str, mail_datum: datetime | None = None) -> Erkennung:
"""Betrag, Datum, Empfänger und Richtung aus einer Mail lesen."""
e = Erkennung()
felder = _felder(text)
ganzes = f"{betreff}\n{text}"
klein = ganzes.lower()
# --- Richtung -----------------------------------------------------------
for wort in WORTE_EINNAHME:
if wort in klein:
e.richtung = Feld("income", f"Wort „{wort}“")
break
if e.richtung is None:
for wort in WORTE_AUSGABE:
if wort in klein:
e.richtung = Feld("expense", f"Wort „{wort}“")
break
if e.richtung is None:
e.richtung = Feld("expense", "Vorgabe: Ausgabe", sicher=False)
e.unklar.append("richtung")
# --- Betrag: beschriftetes Feld schlägt Fund im Fliesstext --------------
treffer = _erster_treffer(felder, LABELS_BETRAG)
if treffer:
wert, _, label = treffer
gefunden = _betraege(wert)
if gefunden:
cent, waehrung, _ = gefunden[0]
e.betrag = Feld(cent, f"nach „{label}“")
if waehrung:
e.waehrung = Feld(waehrung, f"nach „{label}“")
if e.betrag is None:
aus_betreff = _betraege(betreff)
kandidaten = aus_betreff or _betraege(text)
quelle = "aus dem Betreff" if aus_betreff else "aus dem Text"
if len(kandidaten) == 1 or (kandidaten and aus_betreff):
cent, waehrung, _ = kandidaten[0]
e.betrag = Feld(cent, quelle)
if waehrung:
e.waehrung = Feld(waehrung, quelle)
elif kandidaten:
# Mehrere gleichwertige Beträge: raten wäre hier teuer.
e.unklar.append("betrag")
# --- Datum --------------------------------------------------------------
treffer = _erster_treffer(felder, LABELS_DATUM)
if treffer:
wert, _, label = treffer
d = _datum_aus(wert)
if d is not None:
e.datum = Feld(d, f"nach „{label}“")
if e.datum is None:
d = _datum_aus(text)
if d is not None:
e.datum = Feld(d, "aus dem Text")
elif mail_datum is not None:
# Revolut schreibt „Samstag, 1 August, 12:41“ – ohne Jahr. Der
# Zeitstempel der Mail ist bei Sofort-Mitteilungen zuverlässiger
# als ein zusammengeratenes Datum.
e.datum = Feld(mail_datum.date(), "Zeitpunkt der E-Mail")
# --- Empfänger ----------------------------------------------------------
treffer = _erster_treffer(felder, LABELS_EMPFAENGER)
if treffer:
wert, _, label = treffer
e.empfaenger = Feld(wert[:255], f"nach „{label}“")
# --- Kontobezeichnung ---------------------------------------------------
treffer = _erster_treffer(felder, LABELS_KONTO)
if treffer:
wert, _, label = treffer
e.konto_text = Feld(wert[:255], f"nach „{label}“")
# --- Status: nur verbuchte Zahlungen zählen -----------------------------
treffer = _erster_treffer(felder, LABELS_STATUS)
if treffer:
wert, _, label = treffer
if not any(g in wert.lower() for g in STATUS_GEBUCHT):
e.unklar.append("status")
if e.betrag is None:
e.unklar.append("betrag") if "betrag" not in e.unklar else None
return e
@@ -0,0 +1,48 @@
From: noreply@ebanking.raiffeisen.ch
To: kundin@example.com
Date: Fri, 31 Jul 2026 00:59:29 +0200
Message-Id: <raiffeisen-muster-1@example.invalid>
Subject: =?utf-8?Q?Raiffeisen_E-Banking_-_=C3=84nderung_Zahlungsstatus?=
Mime-Version: 1.0
Content-Transfer-Encoding: quoted-printable
Content-Type: text/plain; charset=utf-8
Sehr geehrte Kundin, sehr geehrter Kunde
Sie haben im Raiffeisen E-Banking die E-Mail-Benachrichtigung bei =C3=84nd=
erungen von Zahlungsstatus abonniert. Gerne informieren wir Sie =C3=BCber =
folgende Ver=C3=A4nderung:
Auf Ihrem YoungMember Privatkonto "YoungMember Privatkonto" hat der Status=
eines Zahlungsauftrages ge=C3=A4ndert.
Details :
Konto: YoungMember Privatkonto "YoungMember Privatkonto"
Beg=C3=BCnstigter: Sunrise GmbH / Yallo
Betrag: 14.90 CHF
Ausf=C3=BChrungsdatum: 31.07.2026
Neuer Status: Verbucht
Freundliche Gr=C3=BCsse
Ihr Raiffeisen E-Banking Team
Hinweis: Auftr=C3=A4ge via E-Mail werden aus Sicherheitsgr=C3=BCnden nicht=
entgegengenommen. Bitte benutzen Sie die entsprechende Funktion im Raiffe=
isen E-Banking.
*****************************************************
This e-mail may contain confidential material. It is intended only for the=
person or entity which it is addressed to. In case you should not be supp=
osed to get this e-mail we ask you to delete it without taking notice of i=
ts content. Any views or opinions expressed in this e-mail are those of th=
e sender and do not necessarily coincide with those of The Swiss Raiffeise=
n Group. Therefore this e-mail does not represent a binding agreement nor =
an offer to deal. E-Mail transmission can be insecure and can contain erro=
rs. Information could be intercepted, corrupted, lost, destroyed, incomple=
te or may contain viruses. Neither The Swiss Raiffeisen Group nor the send=
er can accept any liability for any kind of damage as the result of viruse=
s or transmission errors.
*****************************************************
+161
View File
@@ -0,0 +1,161 @@
From: Revolut <no-reply@revolut.com>
To: kundin@example.com
Date: Sat, 01 Aug 2026 10:41:51 +0000
Message-Id: <revolut-muster-1@example.invalid>
Subject: =?utf-8?Q?Du_hast_324_=E2=82=AC_an_Alex_Muster_gesendet?=
Mime-Version: 1.0
Content-Type: text/html; charset=utf-8
<!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<body style="word-spacing: normal; width: 100%;">
<div class="body-container " style>
<div class="content-wrapper" style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:auto;text-align:center;">
<div class="logo" style="padding-left: 0; padding-right: 0; margin: 0px auto; max-width: NaNpx; box-sizing: border-box;padding-top:12px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:auto;text-align:left;">
<div class="mj-column-per-100 mj-outlook-group-fix logo-logo" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:middle;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%">
<tbody>
<tr>
<td style="vertical-align:middle;padding:auto;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style width="100%">
<tbody>
<tr>
<td align="left" class="logo-image" style="font-size:0px;padding:auto;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:NaNInfinity;">
<a href="https://click.revolut.com/f/a/sgS5c05Ip1cirTh3JwuFVA~~/AAD4-xA~/s9Nuf7b9WqSl9q-IEwPuNVc-FR9aXYkDhH-nZakn_0Imp98HIzqviy2AZTfeIjBsWZoeoRl_dX904l-fMzrrgAmj9VAhnpezLAyehPOI6wXZTiU7gnvPUDu03wwtwGxm" target="_blank" style="font-family: Inter, -apple-system, Roboto, 'Segoe UI', Arial, sans-serif; font-weight: 400; line-height: 1.375; text-decoration: none; font-size: inherit; color: #6FA0FF;">
<img src="https://assets.revolut.com/email/logo/v2/Revolut-black-background.png" style="border:0;display:block;outline:none;text-decoration:none;height:auto;max-height:32px;width:100%;font-size:13px;" width="Infinity" height="auto">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div style="margin:0px auto;max-width:NaNpx;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:auto;text-align:left;">
<div class="mj-column-per-100 mj-outlook-group-fix title" style="padding-left: 0; padding-right: 0; font-size: 0px; text-align: left; direction: ltr; display: inline-block; vertical-align: top; width: 100%; box-sizing: border-box;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%">
<tbody>
<tr>
<td style="vertical-align:top;padding:auto;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style width="100%">
<tbody>
<tr>
<td align="left" class="title-title" style="font-size:0px;padding:auto;word-break:break-word;">
<div style="font-family:Inter, -apple-system, Roboto, Arial, Noto Sans Arabic, Noto Sans Hebrew, sans-serif;font-size:13px;line-height:1;text-align:left;color:#000000;"><h1 style="margin: 0; padding-top: 24px; padding-bottom: 8px; font-family: Aeonik Pro, -apple-system, Roboto, 'Segoe UI', Arial, sans-serif; font-size: 32px; font-weight: 500; color: #191C1F; line-height: 1.125;">Deine Überweisung war erfolgreich</h1></div>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div class="text content-block" style="padding-left: 0; padding-right: 0; margin: 0px auto; max-width: NaNpx; box-sizing: border-box;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:auto;text-align:left;">
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%">
<tbody>
<tr>
<td style="vertical-align:top;padding:auto;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style width="100%">
<tbody>
<tr>
<td align="left" class="text-compat " style="font-size: 0px; padding: auto; word-break: break-word; padding-top: 16px; padding-bottom: 16px;">
<div style="font-family:Inter, -apple-system, Roboto, Arial, Noto Sans Arabic, Noto Sans Hebrew, sans-serif;font-size:13px;line-height:1;text-align:left;color:#000000;"><div class="text-container" style="color: #191C1F; font-family: Inter, -apple-system, Roboto, 'Segoe UI', Arial, sans-serif; font-weight: 400; line-height: 1.375; font-size: 16px;"><p style="margin: 0; padding: 0; color: #191C1F; font-family: Inter, -apple-system, Roboto, 'Segoe UI', Arial, sans-serif; font-weight: 400; line-height: 1.375; font-size: 16px; padding-top: 8px; padding-bottom: 8px;">Hallo Alex,<br><br>Du hast 324 € an Alex Muster gesendet. Die Details findest du unten:</p><div class="blockquote" style="margin: 0; color: #000000; border-radius: 16px; font-size: 16px; line-height: 1.375; padding: 8px 16px; background-color: #F4F5F6;"><strong style="color: #717173; font-family: Inter, -apple-system, Roboto, 'Segoe UI', Arial, sans-serif; font-weight: 400; font-size: 16px; line-height: 1.375;">An</strong><br>Alex Muster<br><br><strong style="color: #717173; font-family: Inter, -apple-system, Roboto, 'Segoe UI', Arial, sans-serif; font-weight: 400; font-size: 16px; line-height: 1.375;">Gesendet </strong><br>324 € <br><br><strong style="color: #717173; font-family: Inter, -apple-system, Roboto, 'Segoe UI', Arial, sans-serif; font-weight: 400; font-size: 16px; line-height: 1.375;">Verwendungszweck</strong><br>Gesendet von Revolut <br><br><strong style="color: #717173; font-family: Inter, -apple-system, Roboto, 'Segoe UI', Arial, sans-serif; font-weight: 400; font-size: 16px; line-height: 1.375;">Am </strong><br>Samstag, 1 August, 12:41<br><strong style="color: #717173; font-family: Inter, -apple-system, Roboto, 'Segoe UI', Arial, sans-serif; font-weight: 400; font-size: 16px; line-height: 1.375;"><br></strong><strong style="color: #717173; font-family: Inter, -apple-system, Roboto, 'Segoe UI', Arial, sans-serif; font-weight: 400; font-size: 16px; line-height: 1.375;">Erwartete Ankunft</strong><br>Innerhalb weniger Sekunden</div><p style="margin: 0; padding: 0; color: #191C1F; font-family: Inter, -apple-system, Roboto, 'Segoe UI', Arial, sans-serif; font-weight: 400; line-height: 1.375; font-size: 16px; padding-top: 8px; padding-bottom: 8px;"><br></td></tr></table></body></html>
+105
View File
@@ -0,0 +1,105 @@
"""Erkennung von Zahlungsdaten aus echten Benachrichtigungs-Mails.
Die Muster unter ``fixtures/mail`` sind echte Mails von Raiffeisen und Revolut,
anonymisiert. Aufbau, Zeilenumbrüche und Formulierungen sind unverändert – nur
daran lässt sich prüfen, ob die Erkennung wirklich trägt.
"""
import email
import email.policy
import pathlib
from datetime import date
from app.services import mail_parse
MUSTER = pathlib.Path(__file__).parent / "fixtures" / "mail"
def _lies(name: str):
with open(MUSTER / name, "rb") as f:
msg = email.message_from_binary_file(f, policy=email.policy.default)
teil = msg.get_body(preferencelist=("plain",))
if teil is not None:
text = teil.get_content()
else:
text = mail_parse.html_zu_text(msg.get_body(preferencelist=("html",)).get_content())
return msg["Subject"], text, msg
def test_raiffeisen_zahlungsstatus():
"""Beschriftete Felder in einer Klartext-Mail: ``Betrag: 14.90 CHF``."""
betreff, text, msg = _lies("raiffeisen_zahlungsstatus.eml")
e = mail_parse.parse(betreff, text, email.utils.parsedate_to_datetime(msg["Date"]))
assert e.betrag.wert == 1490
assert e.waehrung.wert == "CHF"
assert e.datum.wert == date(2026, 7, 31)
assert e.empfaenger.wert == "Sunrise GmbH / Yallo"
assert e.richtung.wert == "expense"
assert e.unklar == []
# Jeder Wert muss erklären können, woher er stammt.
assert "Betrag" in e.betrag.quelle
assert "Ausführungsdatum" in e.datum.quelle
def test_revolut_ueberweisung():
"""Nur HTML, Wert steht UNTER der Beschriftung, Datum ohne Jahr."""
betreff, text, msg = _lies("revolut_ueberweisung.eml")
e = mail_parse.parse(betreff, text, email.utils.parsedate_to_datetime(msg["Date"]))
assert e.betrag.wert == 32400
assert e.waehrung.wert == "EUR"
assert e.empfaenger.wert == "Alex Muster"
assert e.richtung.wert == "expense" # „gesendet“
# „Samstag, 1 August, 12:41“ trägt kein Jahr – der Zeitstempel der Mail
# ist hier die verlässlichere Quelle.
assert e.datum.wert == date(2026, 8, 1)
assert "E-Mail" in e.datum.quelle
def test_stilbloecke_liefern_keine_betraege():
"""CSS enthält Zahlen wie ``width:100%`` und ``480px`` – kein Geld."""
roh = "<style>@media (min-width:480px){.a{width:100%}}</style><p>Betrag: 12,50 EUR</p>"
text = mail_parse.html_zu_text(roh)
assert "480" not in text
e = mail_parse.parse("", text)
assert e.betrag.wert == 1250
def test_nicht_verbuchter_auftrag_gilt_als_unklar():
"""Ein erfasster, aber nicht verbuchter Auftrag ist noch nicht geflossen."""
text = ("Auf Ihrem Privatkonto hat der Status eines Zahlungsauftrages geändert.\n"
" Begünstigter: Muster AG\n"
" Betrag: 40.00 CHF\n"
" Ausführungsdatum: 05.03.2026\n"
" Neuer Status: Zur Visierung\n")
e = mail_parse.parse("Änderung Zahlungsstatus", text)
assert "status" in e.unklar
e2 = mail_parse.parse("Änderung Zahlungsstatus", text.replace("Zur Visierung", "Verbucht"))
assert e2.unklar == []
def test_mehrere_betraege_ohne_beschriftung_bleiben_unklar():
"""Lieber nachfragen als still den falschen Betrag buchen."""
e = mail_parse.parse("Kontoinformation", "Umsatz 25,00 EUR\nNeuer Saldo 813,55 EUR\n")
assert "betrag" in e.unklar
assert e.betrag is None
def test_beschriftung_schlaegt_den_saldo():
"""Steht der Betrag beschriftet da, gewinnt er gegen den Kontostand."""
e = mail_parse.parse("Kontoinformation", "Betrag: 25,00 EUR\nNeuer Saldo: 813,55 EUR\n")
assert e.betrag.wert == 2500
def test_gutschrift_wird_als_einnahme_erkannt():
e = mail_parse.parse("Du hast 50 € erhalten", "Empfangen\n50 €\n")
assert e.richtung.wert == "income"
assert e.betrag.wert == 5000
def test_blosse_zahlen_sind_keine_betraege():
e = mail_parse.parse("Auftrag 12345", "Referenz 998877\nBestellnummer 4711\n")
assert e.betrag is None
assert "betrag" in e.unklar