Files
certain/backend/tests/test_mail_parse.py
T
GuidoandClaude Opus 5 7fb7df3554 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>
2026-08-22 19:46:31 +02:00

106 lines
3.9 KiB
Python
Raw 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.
"""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