diff --git a/backend/app/models.py b/backend/app/models.py index 0e0fb19..3b28a6f 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -736,6 +736,10 @@ class MailSender(Base): # Teilstrings, gegen Absender und Betreff geprüft. Leer = jede Mail. from_contains: Mapped[str | None] = mapped_column(String(255), nullable=True) subject_contains: Mapped[str | None] = mapped_column(String(255), nullable=True) + # Nur Zahlungen dieser Währung. Leer = jede. Revolut schickt für EUR und CHF + # dieselbe Mail vom selben Absender – erst die Währung sagt, welches Konto + # gemeint ist. + currency: Mapped[str | None] = mapped_column(String(3), nullable=True) account_id: Mapped[int | None] = mapped_column( ForeignKey("accounts.id", ondelete="SET NULL"), nullable=True ) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 32beeca..1f2e47b 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -584,6 +584,7 @@ class MailSenderIn(BaseModel): name: str = Field(default="", max_length=80) from_contains: str | None = Field(default=None, max_length=255) subject_contains: str | None = Field(default=None, max_length=255) + currency: str | None = Field(default=None, max_length=3) account_id: int | None = None diff --git a/backend/app/services/mail_import.py b/backend/app/services/mail_import.py index 288a2fa..c64dda5 100644 --- a/backend/app/services/mail_import.py +++ b/backend/app/services/mail_import.py @@ -139,7 +139,8 @@ ERKENNUNG_TEXTLAENGE = 2000 def _passender_absender( - absender: list[MailSender], von: str, betreff: str, text: str = "" + absender: list[MailSender], von: str, betreff: str, text: str = "", + waehrung: str | None = None, ) -> MailSender | None: """Erste Zuordnung, deren Erkennungstext greift. @@ -152,11 +153,15 @@ def _passender_absender( """ heu = f"{von}\n{betreff}\n{text[:ERKENNUNG_TEXTLAENGE]}".lower() betreff_klein = betreff.lower() - for a in absender: + # Zuordnungen MIT Währung zuerst prüfen: Wer „revolut + EUR → Revolut Euro" + # anlegt und daneben ein allgemeines „revolut", meint die genauere zuerst. + for a in sorted(absender, key=lambda x: (x.currency is None, x.id)): if a.from_contains and a.from_contains.lower() not in heu: continue if a.subject_contains and a.subject_contains.lower() not in betreff_klein: continue + if a.currency and (waehrung or "").upper() != a.currency.upper(): + continue return a return None @@ -223,15 +228,6 @@ def hole_neue(db: Session, quelle: MailSource) -> dict: betreff, text = _text_der_mail(msg) von = _absenderfeld(msg) - zuordnung = _passender_absender(absender, von, betreff, text) - if zuordnung is None: - uebersprungen += 1 - if len(ohne_zuordnung) < 5: - ohne_zuordnung.append({ - "from": str(msg["From"] or "")[:120], - "subject": betreff[:120], - }) - continue gesendet = None if msg["Date"]: @@ -240,7 +236,23 @@ def hole_neue(db: Session, quelle: MailSource) -> dict: except (TypeError, ValueError): gesendet = None + # Auswerten VOR dem Zuordnen: Bei Revolut kommt dieselbe Mail vom + # selben Absender, und erst die erkannte Währung sagt, ob die + # Zahlung aufs Euro- oder aufs Franken-Konto gehört. e = mail_parse.parse(betreff, text, gesendet) + erkannte_waehrung = e.waehrung.wert if e.waehrung else None + + zuordnung = _passender_absender(absender, von, betreff, text, erkannte_waehrung) + if zuordnung is None: + uebersprungen += 1 + if len(ohne_zuordnung) < 5: + ohne_zuordnung.append({ + "from": str(msg["From"] or "")[:120], + "subject": betreff[:120], + "currency": erkannte_waehrung, + }) + continue + konto = konten.get(zuordnung.account_id) if zuordnung.account_id else None tag = e.datum.wert if e.datum else (gesendet.date() if gesendet else date.today()) betrag = e.betrag.wert if e.betrag else 0 diff --git a/backend/migrations/versions/0018_mail_sender_currency.py b/backend/migrations/versions/0018_mail_sender_currency.py new file mode 100644 index 0000000..4caf1ee --- /dev/null +++ b/backend/migrations/versions/0018_mail_sender_currency.py @@ -0,0 +1,26 @@ +"""Absender-Zuordnung kann nach Waehrung unterscheiden. + +Revolut verschickt fuer eine Zahlung in Euro und eine in Franken dieselbe Mail +vom selben Absender. Ohne die Waehrung als Kriterium liesse sich nicht sagen, +auf welches der beiden Revolut-Konten sie gehoert. + +Revision ID: 0018 +Revises: 0017 +""" + +import sqlalchemy as sa +from alembic import op + +revision = "0018" +down_revision = "0017" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("mail_senders", sa.Column("currency", sa.String(length=3), nullable=True)) + + +def downgrade() -> None: + with op.batch_alter_table("mail_senders") as batch: + batch.drop_column("currency") diff --git a/backend/tests/test_mail_import.py b/backend/tests/test_mail_import.py index 6e82903..b029caf 100644 --- a/backend/tests/test_mail_import.py +++ b/backend/tests/test_mail_import.py @@ -339,3 +339,63 @@ def test_zuordnung_laesst_sich_aendern_und_loeschen(admin): assert admin.delete(f"/api/mail/senders/{a['id']}").status_code == 204 uebrig = admin.get(f"/api/mail/sources/{quelle['id']}/senders").json() assert not [x for x in uebrig if x["id"] == a["id"]] + + +def test_waehrung_entscheidet_ueber_das_konto(admin, monkeypatch): + """Revolut schickt für Euro und Franken dieselbe Mail vom selben Absender. + + Erst die erkannte Währung sagt, auf welches der beiden Revolut-Konten die + Zahlung gehört – ohne dieses Kriterium landet alles auf demselben. + """ + eur = _konto(admin, name="Revolut Euro", currency="EUR") + chf = _konto(admin, name="Revolut CHF", currency="CHF") + quelle = _postfach(admin) + admin.post(f"/api/mail/sources/{quelle['id']}/senders", json={ + "from_contains": "revolut", "currency": "EUR", "account_id": eur["id"]}) + admin.post(f"/api/mail/sources/{quelle['id']}/senders", json={ + "from_contains": "revolut", "currency": "CHF", "account_id": chf["id"]}) + + def mail(message_id: str, betrag: str, waehrung: str) -> bytes: + return ( + f"From: Revolut \n" + f"To: kundin@example.com\n" + f"Date: Sat, 01 Aug 2026 10:41:51 +0000\n" + f"Message-Id: <{message_id}@example.invalid>\n" + f"Subject: Zahlung\n" + f"Content-Type: text/plain; charset=utf-8\n" + f"\nDu hast Geld gesendet.\nGesendet\n{betrag} {waehrung}\nAn\nAlex Muster\n" + ).encode("utf-8") + + monkeypatch.setattr(mail_import, "_verbinden", + lambda q: FakeImap([mail("waehrung-eur", "324.00", "EUR"), + mail("waehrung-chf", "50.00", "CHF")])) + assert admin.post(f"/api/mail/sources/{quelle['id']}/fetch").json()["neu"] == 2 + + eintraege = admin.get("/api/inbox").json() + in_eur = [e for e in eintraege if e["amount_cents"] == -32400] + in_chf = [e for e in eintraege if e["amount_cents"] == -5000] + assert in_eur and in_eur[0]["account_id"] == eur["id"], "EUR gehört aufs Euro-Konto" + assert in_chf and in_chf[0]["account_id"] == chf["id"], "CHF gehört aufs Franken-Konto" + + +def test_zuordnung_mit_waehrung_schlaegt_die_allgemeine(admin, monkeypatch): + """Wer eine genaue Regel neben eine allgemeine stellt, meint die genaue.""" + eur = _konto(admin, name="Genau EUR", currency="EUR") + sammel = _konto(admin, name="Sammelkonto", currency="CHF") + quelle = _postfach(admin) + # Die allgemeine zuerst anlegen – sie darf trotzdem nicht gewinnen. + admin.post(f"/api/mail/sources/{quelle['id']}/senders", json={ + "from_contains": "revolut", "account_id": sammel["id"]}) + admin.post(f"/api/mail/sources/{quelle['id']}/senders", json={ + "from_contains": "revolut", "currency": "EUR", "account_id": eur["id"]}) + + roh = ("From: Revolut \nTo: k@example.com\n" + "Date: Sat, 01 Aug 2026 10:41:51 +0000\n" + "Message-Id: \nSubject: Zahlung\n" + "Content-Type: text/plain; charset=utf-8\n" + "\nDu hast Geld gesendet.\nGesendet\n12.00 EUR\nAn\nAlex Muster\n").encode("utf-8") + monkeypatch.setattr(mail_import, "_verbinden", lambda q: FakeImap([roh])) + admin.post(f"/api/mail/sources/{quelle['id']}/fetch") + + treffer = [e for e in admin.get("/api/inbox").json() if e["amount_cents"] == -1200] + assert treffer and treffer[0]["account_id"] == eur["id"] diff --git a/web/src/locales/de.json b/web/src/locales/de.json index 4ffc90c..c575f1e 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -533,8 +533,9 @@ "skippedTitle": "Diese Mails wurden übergangen", "skippedHint": "Für sie passt keine Absender-Zuordnung. Trag einen Teil der Adresse oder des Betreffs unten ein, dann werden sie beim nächsten Abruf übernommen.", "fetchedNone": "Keine Mails im gewählten Ordner gefunden. Stimmt der Ordner? Beim ersten Abruf werden die letzten 14 Tage durchsucht.", - "senderHint": "Gesucht wird in Absender, Betreff und im Anfang des Textes. Damit greift es auch bei weitergeleiteten Mails, wo im Absender deine eigene Adresse steht.", + "senderHint": "Gesucht wird in Absender, Betreff und im Anfang des Textes – das greift auch bei weitergeleiteten Mails. Die Währung ist optional: Revolut schickt für Euro und Franken dieselbe Mail, erst sie sagt, welches Konto gemeint ist. Zuordnungen mit Währung gehen den allgemeinen vor.", "deleteSender": "Zuordnung entfernen", - "deleteSenderConfirm": "Zuordnung „{{text}}“ entfernen? Bereits erfasste Zahlungen bleiben." + "deleteSenderConfirm": "Zuordnung „{{text}}“ entfernen? Bereits erfasste Zahlungen bleiben.", + "anyCurrency": "jede Währung" } } diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 065591a..b042e38 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -533,8 +533,9 @@ "skippedTitle": "These emails were skipped", "skippedHint": "No sender mapping matched them. Enter part of the address or the subject below and they will be picked up on the next fetch.", "fetchedNone": "No emails found in the selected folder. Is the folder right? The first fetch looks at the last 14 days.", - "senderHint": "Searched in sender, subject and the start of the body. That way it also works for forwarded emails, where the sender is your own address.", + "senderHint": "Searched in sender, subject and the start of the body – that also works for forwarded emails. The currency is optional: Revolut sends the same email for euros and francs, and only it tells which account is meant. Mappings with a currency take precedence over general ones.", "deleteSender": "Remove mapping", - "deleteSenderConfirm": "Remove mapping “{{text}}”? Payments already captured stay." + "deleteSenderConfirm": "Remove mapping “{{text}}”? Payments already captured stay.", + "anyCurrency": "any currency" } } diff --git a/web/src/pages/settings/Postfaecher.jsx b/web/src/pages/settings/Postfaecher.jsx index 0f9a990..1ac90dc 100644 --- a/web/src/pages/settings/Postfaecher.jsx +++ b/web/src/pages/settings/Postfaecher.jsx @@ -6,6 +6,9 @@ import { useConfirm } from "../../confirm"; import Icon from "../../components/Icon"; import Modal from "../../components/Modal"; +// Reicht für den Alltag; wer eine andere braucht, sagt Bescheid. +const WAEHRUNGEN = ["CHF", "EUR", "USD", "GBP"]; + const LEER = { name: "", host: "", port: 993, security: "ssl", username: "", password: "", folder: "INBOX", done_folder: "", mark_seen: true, is_active: true, poll_minutes: 15, @@ -276,14 +279,20 @@ export default function Postfaecher() { function SenderRow({ absender, konten, t, onSave, onDelete }) { const [text, setText] = useState(absender.from_contains || ""); const [konto, setKonto] = useState(absender.account_id ? String(absender.account_id) : ""); + const [waehrung, setWaehrung] = useState(absender.currency || ""); const geaendert = text !== (absender.from_contains || "") || + waehrung !== (absender.currency || "") || konto !== (absender.account_id ? String(absender.account_id) : ""); return (
setText(e.target.value)} placeholder={t("mailbox.fromPlaceholder")} /> + setVon(e.target.value)} /> +

{t("mailbox.senderHint")}