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:
@@ -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.
|
||||
|
||||
*****************************************************
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user