Ohne den Beguenstigten ist ein Eintrag im Eingang wertlos – man sieht einen Betrag und weiss nicht, wofuer. Genau das passierte bei weitergeleiteten Mails: Gmail zitiert den Text mit "> " davor, die Beschriftung stand also nicht mehr am Zeilenanfang, und je nach Kodierung kam "Beguenstigter" statt "Beguenstigter" mit Umlaut an. Beschriftungen werden jetzt normalisiert verglichen (Umlaute verschriftlicht, alles ausser Buchstaben und Ziffern verworfen), und Zitatzeichen fallen vor der Feldsuche weg. Die Suchbegriffe laufen durch dieselbe Normalisierung, damit sie lesbar bleiben statt doppelt gepflegt zu werden. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
123 lines
4.8 KiB
Python
123 lines
4.8 KiB
Python
"""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
|
||
|
||
|
||
def test_beschriftung_wird_unabhaengig_von_umlaut_und_zitat_erkannt():
|
||
"""Weitergeleitete Mails kommen zitiert an, und Umlaute schreibt nicht jedes
|
||
Programm gleich. „Begünstigter“ muss trotzdem greifen – ohne den
|
||
Begünstigten ist ein Eintrag im Eingang wertlos."""
|
||
varianten = [
|
||
" Begünstigter: Sunrise GmbH / Yallo\n Betrag: 14.90 CHF\n",
|
||
"> Begünstigter: Sunrise GmbH / Yallo\n> Betrag: 14.90 CHF\n",
|
||
">> BEGÜNSTIGTER : Sunrise GmbH / Yallo\n>> Betrag: 14.90 CHF\n",
|
||
" Beguenstigter: Sunrise GmbH / Yallo\n Betrag: 14.90 CHF\n",
|
||
]
|
||
for text in varianten:
|
||
e = mail_parse.parse("Zahlungsauftrag", text)
|
||
assert e.empfaenger is not None, f"kein Empfänger erkannt in: {text!r}"
|
||
assert e.empfaenger.wert == "Sunrise GmbH / Yallo"
|
||
assert e.betrag.wert == 1490
|