feat: 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 landete alles auf
demselben Konto.

Die Erkennung liest die Waehrung ohnehin aus; sie wird jetzt VOR der
Zuordnung ausgewertet und ist ein optionales Filterkriterium. Zuordnungen
mit Waehrung werden zuerst geprueft – wer neben ein allgemeines "revolut"
ein genaues "revolut + EUR" stellt, meint das genaue, unabhaengig von der
Anlagereihenfolge.

Uebergangene Mails melden ausserdem die erkannte Waehrung mit, damit beim
Einrichten sichtbar ist, welcher Wert zu filtern waere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-22 20:48:55 +02:00
co-authored by Claude Opus 5
parent 512dac2b04
commit 899241f991
8 changed files with 141 additions and 16 deletions
+4
View File
@@ -736,6 +736,10 @@ class MailSender(Base):
# Teilstrings, gegen Absender und Betreff geprüft. Leer = jede Mail. # Teilstrings, gegen Absender und Betreff geprüft. Leer = jede Mail.
from_contains: Mapped[str | None] = mapped_column(String(255), nullable=True) from_contains: Mapped[str | None] = mapped_column(String(255), nullable=True)
subject_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( account_id: Mapped[int | None] = mapped_column(
ForeignKey("accounts.id", ondelete="SET NULL"), nullable=True ForeignKey("accounts.id", ondelete="SET NULL"), nullable=True
) )
+1
View File
@@ -584,6 +584,7 @@ class MailSenderIn(BaseModel):
name: str = Field(default="", max_length=80) name: str = Field(default="", max_length=80)
from_contains: str | None = Field(default=None, max_length=255) from_contains: str | None = Field(default=None, max_length=255)
subject_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 account_id: int | None = None
+23 -11
View File
@@ -139,7 +139,8 @@ ERKENNUNG_TEXTLAENGE = 2000
def _passender_absender( 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: ) -> MailSender | None:
"""Erste Zuordnung, deren Erkennungstext greift. """Erste Zuordnung, deren Erkennungstext greift.
@@ -152,11 +153,15 @@ def _passender_absender(
""" """
heu = f"{von}\n{betreff}\n{text[:ERKENNUNG_TEXTLAENGE]}".lower() heu = f"{von}\n{betreff}\n{text[:ERKENNUNG_TEXTLAENGE]}".lower()
betreff_klein = betreff.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: if a.from_contains and a.from_contains.lower() not in heu:
continue continue
if a.subject_contains and a.subject_contains.lower() not in betreff_klein: if a.subject_contains and a.subject_contains.lower() not in betreff_klein:
continue continue
if a.currency and (waehrung or "").upper() != a.currency.upper():
continue
return a return a
return None return None
@@ -223,15 +228,6 @@ def hole_neue(db: Session, quelle: MailSource) -> dict:
betreff, text = _text_der_mail(msg) betreff, text = _text_der_mail(msg)
von = _absenderfeld(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 gesendet = None
if msg["Date"]: if msg["Date"]:
@@ -240,7 +236,23 @@ def hole_neue(db: Session, quelle: MailSource) -> dict:
except (TypeError, ValueError): except (TypeError, ValueError):
gesendet = None 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) 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 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()) tag = e.datum.wert if e.datum else (gesendet.date() if gesendet else date.today())
betrag = e.betrag.wert if e.betrag else 0 betrag = e.betrag.wert if e.betrag else 0
@@ -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")
+60
View File
@@ -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 assert admin.delete(f"/api/mail/senders/{a['id']}").status_code == 204
uebrig = admin.get(f"/api/mail/sources/{quelle['id']}/senders").json() uebrig = admin.get(f"/api/mail/sources/{quelle['id']}/senders").json()
assert not [x for x in uebrig if x["id"] == a["id"]] 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 <no-reply@revolut.com>\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 <no-reply@revolut.com>\nTo: k@example.com\n"
"Date: Sat, 01 Aug 2026 10:41:51 +0000\n"
"Message-Id: <vorrang-eur@example.invalid>\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"]
+3 -2
View File
@@ -533,8 +533,9 @@
"skippedTitle": "Diese Mails wurden übergangen", "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.", "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.", "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", "deleteSender": "Zuordnung entfernen",
"deleteSenderConfirm": "Zuordnung „{{text}}“ entfernen? Bereits erfasste Zahlungen bleiben." "deleteSenderConfirm": "Zuordnung „{{text}}“ entfernen? Bereits erfasste Zahlungen bleiben.",
"anyCurrency": "jede Währung"
} }
} }
+3 -2
View File
@@ -533,8 +533,9 @@
"skippedTitle": "These emails were skipped", "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.", "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.", "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", "deleteSender": "Remove mapping",
"deleteSenderConfirm": "Remove mapping “{{text}}”? Payments already captured stay." "deleteSenderConfirm": "Remove mapping “{{text}}”? Payments already captured stay.",
"anyCurrency": "any currency"
} }
} }
+21 -1
View File
@@ -6,6 +6,9 @@ import { useConfirm } from "../../confirm";
import Icon from "../../components/Icon"; import Icon from "../../components/Icon";
import Modal from "../../components/Modal"; import Modal from "../../components/Modal";
// Reicht für den Alltag; wer eine andere braucht, sagt Bescheid.
const WAEHRUNGEN = ["CHF", "EUR", "USD", "GBP"];
const LEER = { const LEER = {
name: "", host: "", port: 993, security: "ssl", username: "", password: "", name: "", host: "", port: 993, security: "ssl", username: "", password: "",
folder: "INBOX", done_folder: "", mark_seen: true, is_active: true, poll_minutes: 15, 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 }) { function SenderRow({ absender, konten, t, onSave, onDelete }) {
const [text, setText] = useState(absender.from_contains || ""); const [text, setText] = useState(absender.from_contains || "");
const [konto, setKonto] = useState(absender.account_id ? String(absender.account_id) : ""); const [konto, setKonto] = useState(absender.account_id ? String(absender.account_id) : "");
const [waehrung, setWaehrung] = useState(absender.currency || "");
const geaendert = const geaendert =
text !== (absender.from_contains || "") || text !== (absender.from_contains || "") ||
waehrung !== (absender.currency || "") ||
konto !== (absender.account_id ? String(absender.account_id) : ""); konto !== (absender.account_id ? String(absender.account_id) : "");
return ( return (
<div className="sender-row"> <div className="sender-row">
<input value={text} onChange={(e) => setText(e.target.value)} <input value={text} onChange={(e) => setText(e.target.value)}
placeholder={t("mailbox.fromPlaceholder")} /> placeholder={t("mailbox.fromPlaceholder")} />
<select value={waehrung} onChange={(e) => setWaehrung(e.target.value)}>
<option value="">{t("mailbox.anyCurrency")}</option>
{WAEHRUNGEN.map((w) => <option key={w} value={w}>{w}</option>)}
</select>
<select value={konto} onChange={(e) => setKonto(e.target.value)}> <select value={konto} onChange={(e) => setKonto(e.target.value)}>
<option value="">{t("mailbox.noAccount")}</option> <option value="">{t("mailbox.noAccount")}</option>
{konten.map((k) => <option key={k.id} value={k.id}>{k.name}</option>)} {konten.map((k) => <option key={k.id} value={k.id}>{k.name}</option>)}
@@ -292,6 +301,7 @@ function SenderRow({ absender, konten, t, onSave, onDelete }) {
{geaendert && ( {geaendert && (
<button type="button" className="btn sm primary" <button type="button" className="btn sm primary"
onClick={() => onSave({ from_contains: text.trim() || null, onClick={() => onSave({ from_contains: text.trim() || null,
currency: waehrung || null,
account_id: konto ? Number(konto) : null })}> account_id: konto ? Number(konto) : null })}>
{t("common.save")} {t("common.save")}
</button> </button>
@@ -308,10 +318,16 @@ function SenderRow({ absender, konten, t, onSave, onDelete }) {
function SenderForm({ konten, onAdd, t }) { function SenderForm({ konten, onAdd, t }) {
const [von, setVon] = useState(""); const [von, setVon] = useState("");
const [konto, setKonto] = useState(""); const [konto, setKonto] = useState("");
const [waehrung, setWaehrung] = useState("");
return ( return (
<div style={{ marginTop: "var(--sp-2)" }}> <div style={{ marginTop: "var(--sp-2)" }}>
<input style={{ width: "100%" }} placeholder={t("mailbox.fromPlaceholder")} value={von} <input style={{ width: "100%" }} placeholder={t("mailbox.fromPlaceholder")} value={von}
onChange={(e) => setVon(e.target.value)} /> onChange={(e) => setVon(e.target.value)} />
<select style={{ width: "100%", marginTop: 4 }} value={waehrung}
onChange={(e) => setWaehrung(e.target.value)}>
<option value="">{t("mailbox.anyCurrency")}</option>
{WAEHRUNGEN.map((w) => <option key={w} value={w}>{w}</option>)}
</select>
<select style={{ width: "100%", marginTop: 4 }} value={konto} <select style={{ width: "100%", marginTop: 4 }} value={konto}
onChange={(e) => setKonto(e.target.value)}> onChange={(e) => setKonto(e.target.value)}>
<option value="">{t("mailbox.noAccount")}</option> <option value="">{t("mailbox.noAccount")}</option>
@@ -319,7 +335,11 @@ function SenderForm({ konten, onAdd, t }) {
</select> </select>
<p className="muted small" style={{ margin: "4px 0 0" }}>{t("mailbox.senderHint")}</p> <p className="muted small" style={{ margin: "4px 0 0" }}>{t("mailbox.senderHint")}</p>
<button type="button" className="btn sm" style={{ marginTop: 4 }} disabled={!von.trim()} <button type="button" className="btn sm" style={{ marginTop: 4 }} disabled={!von.trim()}
onClick={() => { onAdd({ from_contains: von.trim(), account_id: konto ? Number(konto) : null }); setVon(""); }}> onClick={() => {
onAdd({ from_contains: von.trim(), currency: waehrung || null,
account_id: konto ? Number(konto) : null });
setVon("");
}}>
<Icon name="plus" size={14} /> {t("mailbox.addSender")} <Icon name="plus" size={14} /> {t("mailbox.addSender")}
</button> </button>
</div> </div>