diff --git a/backend/app/routers/transactions.py b/backend/app/routers/transactions.py
index e357b9c..cb7a7d8 100644
--- a/backend/app/routers/transactions.py
+++ b/backend/app/routers/transactions.py
@@ -30,7 +30,33 @@ from .accounts import base_currency
router = APIRouter(prefix="/transactions", tags=["transactions"])
-def to_out(db: Session, txn: Transaction, base: str, ccy_by_account: dict[int, str]) -> TransactionOut:
+def _herkunft(db: Session, user: User, txns: list[Transaction]) -> dict[int, str]:
+ """Woher jede Buchung stammt.
+
+ Der Eingangs-Eintrag sagt es für alles, was über Bank, Mail oder Kurzbefehl
+ kam; sonst entscheidet der Import-Stapel: mit ihm aus einer Datei, ohne ihn
+ von Hand. Als Sammelabfrage, nicht je Zeile – die Liste geht bis 500.
+ """
+ ids = [t.id for t in txns]
+ if not ids:
+ return {}
+ aus_eingang: dict[int, str] = {}
+ for txn_id, quelle in db.query(InboxItem.transaction_id, InboxItem.source).filter(
+ InboxItem.owner_id == user.id, InboxItem.transaction_id.in_(ids)
+ ):
+ # Bei einem Übertrag zeigen ZWEI Einträge auf dieselbe Buchung. Die
+ # Gegenseite hat Certain selbst angelegt; gemeint ist die echte Quelle.
+ if txn_id in aus_eingang and quelle.value == "transfer":
+ continue
+ aus_eingang[txn_id] = quelle.value
+ return {
+ t.id: aus_eingang.get(t.id, "csv" if t.import_batch_id is not None else "manuell")
+ for t in txns
+ }
+
+
+def to_out(db: Session, txn: Transaction, base: str, ccy_by_account: dict[int, str],
+ herkunft: dict[int, str] | None = None) -> TransactionOut:
acc_ccy = ccy_by_account.get(txn.account_id, "EUR")
if base == "EUR":
amount_base = txn.amount_eur_cents
@@ -54,6 +80,7 @@ def to_out(db: Session, txn: Transaction, base: str, ccy_by_account: dict[int, s
budget_treatment=txn.budget_treatment,
budget_category_id=txn.budget_category_id,
import_batch_id=txn.import_batch_id,
+ herkunft=(herkunft or {}).get(txn.id, "manuell"),
payee=txn.payee,
description=txn.description,
notes=txn.notes,
@@ -117,7 +144,8 @@ def list_transactions(
rows = q.order_by(Transaction.date.desc(), Transaction.id.desc()).limit(limit).offset(offset).all()
base = base_currency(db, user)
ccy = _currency_map(db, user)
- return [to_out(db, t, base, ccy) for t in rows]
+ woher = _herkunft(db, user, rows)
+ return [to_out(db, t, base, ccy, woher) for t in rows]
@router.post("", response_model=TransactionOut, status_code=status.HTTP_201_CREATED)
@@ -161,7 +189,8 @@ def create_transaction(
db.add(txn)
db.commit()
db.refresh(txn)
- return to_out(db, txn, base_currency(db, user), _currency_map(db, user))
+ return to_out(db, txn, base_currency(db, user), _currency_map(db, user),
+ _herkunft(db, user, [txn]))
@router.get("/{txn_id}", response_model=TransactionOut)
@@ -169,7 +198,8 @@ def get_transaction(
txn_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)
):
txn = get_owned(db, Transaction, txn_id, user)
- return to_out(db, txn, base_currency(db, user), _currency_map(db, user))
+ return to_out(db, txn, base_currency(db, user), _currency_map(db, user),
+ _herkunft(db, user, [txn]))
@router.patch("/{txn_id}", response_model=TransactionOut)
@@ -215,7 +245,8 @@ def update_transaction(
db.commit()
db.refresh(txn)
- return to_out(db, txn, base_currency(db, user), _currency_map(db, user))
+ return to_out(db, txn, base_currency(db, user), _currency_map(db, user),
+ _herkunft(db, user, [txn]))
class TransactionTransferIn(BaseModel):
@@ -246,7 +277,8 @@ def make_transfer(txn_id: int, payload: TransactionTransferIn,
) from e
db.commit()
db.refresh(txn)
- return to_out(db, txn, base_currency(db, user), _currency_map(db, user))
+ return to_out(db, txn, base_currency(db, user), _currency_map(db, user),
+ _herkunft(db, user, [txn]))
@router.delete("/{txn_id}", status_code=status.HTTP_204_NO_CONTENT)
diff --git a/backend/app/schemas.py b/backend/app/schemas.py
index a602476..b38834e 100644
--- a/backend/app/schemas.py
+++ b/backend/app/schemas.py
@@ -326,6 +326,8 @@ class TransactionOut(BaseModel):
# Woher die Buchung stammt, fuer die Arbeitsliste: Ist sie gesetzt,
# kam die Buchung aus einem Datei-Import.
import_batch_id: int | None = None
+ # Woher die Buchung stammt: bank, mail, shortcut, transfer, csv, manuell.
+ herkunft: str = "manuell"
payee: str | None
description: str | None
notes: str | None
diff --git a/backend/app/services/mail_import.py b/backend/app/services/mail_import.py
index 268d370..3403fab 100644
--- a/backend/app/services/mail_import.py
+++ b/backend/app/services/mail_import.py
@@ -214,6 +214,10 @@ def hole_neue(db: Session, quelle: MailSource, tage: int | None = None) -> dict:
# Was durchgefallen ist, mit Absender und Betreff – sonst raet man beim
# Einrichten, was in den Filter gehoert.
ohne_zuordnung: list[dict] = []
+ # Und die, die eine Regel TRAFEN, aber nicht zu lesen waren. Ohne diese
+ # Liste steht am Ende nur eine Zahl, und man weiss nicht, welche Mail
+ # gemeint ist oder was ihr fehlt.
+ unklar_liste: list[dict] = []
try:
code, _ = verbindung.select(f'"{quelle.folder}"')
if code != "OK":
@@ -281,6 +285,19 @@ def hole_neue(db: Session, quelle: MailSource, tage: int | None = None) -> dict:
if e.unklar or betrag == 0 or konto is None:
status = InboxStatus.unclear
unklar += 1
+ gruende = []
+ if konto is None:
+ gruende.append("kein Konto zugeordnet")
+ if betrag == 0:
+ gruende.append("kein Betrag erkannt")
+ for teil in e.unklar:
+ gruende.append({
+ "richtung": "Richtung unklar (Ein- oder Ausgang?)",
+ "betrag": "Betrag nicht eindeutig",
+ "status": "Zahlung noch nicht ausgeführt",
+ }.get(teil, teil))
+ unklar_liste.append({"from": von, "subject": betreff,
+ "gruende": sorted(set(gruende))})
db.add(InboxItem(
owner_id=quelle.owner_id,
@@ -310,7 +327,7 @@ def hole_neue(db: Session, quelle: MailSource, tage: int | None = None) -> dict:
# nicht "stimmt der Ordner", sondern "wie weit wurde ueberhaupt gesucht".
return {"gefunden": gefunden, "neu": neu, "uebersprungen": uebersprungen,
"unklar": unklar, "seit": seit.isoformat(),
- "ohne_zuordnung": ohne_zuordnung}
+ "ohne_zuordnung": ohne_zuordnung, "unklar_liste": unklar_liste}
def _nachbereiten(verbindung: imaplib.IMAP4, quelle: MailSource, uid: bytes) -> None:
@@ -336,5 +353,5 @@ def abruf_mit_fehlerbehandlung(db: Session, quelle: MailSource) -> dict:
quelle.last_poll_at = datetime.now(timezone.utc)
db.commit()
return {"gefunden": 0, "neu": 0, "uebersprungen": 0, "unklar": 0,
- "seit": None,
+ "seit": None, "unklar_liste": [],
"ohne_zuordnung": [], "fehler": str(e)}
diff --git a/backend/tests/test_inbox.py b/backend/tests/test_inbox.py
index b42d38c..7b3d901 100644
--- a/backend/tests/test_inbox.py
+++ b/backend/tests/test_inbox.py
@@ -453,3 +453,35 @@ def test_lohnzahlung_auf_zwei_konten_wird_abgefangen(admin):
assert r.status_code == 409, r.text
assert admin.get(f"/api/accounts/{revolut['id']}").json()["balance_cents"] == 157500, \
"das Geld darf nicht zweimal dastehen"
+
+
+def test_jede_buchung_sagt_woher_sie_kommt(admin):
+ """Ohne das sieht man einer Buchung nicht an, ob sie von der Bank kam, aus
+ einer Mail, aus einer Datei oder von Hand."""
+ konto = _konto(admin, name=f"Herkunft {secrets.token_hex(3)}", currency="CHF")
+
+ # Von Hand angelegt.
+ txn = admin.post("/api/transactions", json={
+ "type": "expense", "date": "2026-08-19", "account_id": konto["id"],
+ "amount_cents": 1000, "payee": "Von Hand"}).json()
+ assert txn["herkunft"] == "manuell"
+
+ # Aus dem Eingang gebucht – die Quelle bleibt erhalten.
+ eintrag = _eingang(admin, konto, -2000, datum="2026-08-19", payee="Aus der Mail")
+ gebucht = admin.post(f"/api/inbox/{eintrag['id']}/book").json()
+ aus_liste = [t for t in admin.get("/api/transactions").json()
+ if t["id"] == gebucht["transaction_id"]][0]
+ assert aus_liste["herkunft"] == "mail"
+
+
+def test_uebertrag_nennt_die_echte_quelle_nicht_die_gegenseite(admin):
+ """Bei einem Uebertrag zeigen ZWEI Eintraege auf dieselbe Buchung. Die
+ Gegenseite hat Certain selbst angelegt – gemeint ist die echte Quelle."""
+ von = _konto(admin, name=f"Q-Von {secrets.token_hex(3)}", currency="CHF")
+ nach = _konto(admin, name=f"Q-Nach {secrets.token_hex(3)}", currency="CHF")
+ eintrag = _eingang(admin, von, -(70000 + secrets.randbelow(900)))
+ r = admin.post(f"/api/inbox/{eintrag['id']}/transfer", json={"to_account_id": nach["id"]})
+ txn_id = r.json()["transaction_id"]
+
+ aus_liste = [t for t in admin.get("/api/transactions").json() if t["id"] == txn_id][0]
+ assert aus_liste["herkunft"] == "mail", "nicht 'transfer' – das ist die Gegenseite"
diff --git a/web/src/finance.css b/web/src/finance.css
index 4572320..dd2e24e 100644
--- a/web/src/finance.css
+++ b/web/src/finance.css
@@ -1315,3 +1315,16 @@ tr.row-dup {
margin-top: 0;
padding: 4px 8px;
}
+
+/* Herkunft einer Buchung: Bank, Mail, Kurzbefehl, Datei, von Hand. Bewusst
+ klein und ruhig – sie beantwortet eine Rückfrage, sie ist nicht die
+ Hauptaussage der Zeile. */
+.herkunft {
+ display: inline-block;
+ padding: 0 5px;
+ border: 1px solid var(--border-strong);
+ border-radius: var(--radius-sm);
+ font-size: 0.72rem;
+ line-height: 1.5;
+ color: var(--muted);
+}
diff --git a/web/src/locales/de.json b/web/src/locales/de.json
index dc3710a..0014a8f 100644
--- a/web/src/locales/de.json
+++ b/web/src/locales/de.json
@@ -550,7 +550,7 @@
"test": "Verbindung testen",
"testOk": "Verbindung steht – {{n}} Ordner gefunden.",
"fetchNow": "Jetzt abrufen",
- "fetched": "{{gefunden}} Mails geprüft · {{neu}} neu · {{unklar}} unklar.",
+ "fetched": "{{gefunden}} Mails geprüft · {{neu}} neu · {{bekannt}} schon bekannt · {{unklar}} unklar.",
"lastPoll": "zuletzt abgerufen",
"neverPolled": "noch nie abgerufen",
"lastError": "Fehler",
@@ -576,7 +576,9 @@
"fetchOlder": "Älteres nachholen",
"every": "alle {{n}} Minuten",
"manualOnly": "nur auf Knopfdruck",
- "lastPollAt": "zuletzt abgerufen: {{when}}"
+ "lastPollAt": "zuletzt abgerufen: {{when}}",
+ "unclearTitle": "Diese Mails passten, waren aber nicht ganz zu lesen",
+ "unclearHint": "Sie liegen im Eingang und warten auf dich – dort trägst du nach, was fehlt."
},
"bank": {
"title": "Bankkonten Anbindung",
diff --git a/web/src/locales/en.json b/web/src/locales/en.json
index 7c06c66..d50a764 100644
--- a/web/src/locales/en.json
+++ b/web/src/locales/en.json
@@ -550,7 +550,7 @@
"test": "Test connection",
"testOk": "Connection works – {{n}} folders found.",
"fetchNow": "Fetch now",
- "fetched": "{{gefunden}} emails checked · {{neu}} new · {{unklar}} unclear.",
+ "fetched": "{{gefunden}} mails checked · {{neu}} new · {{bekannt}} already known · {{unklar}} unclear.",
"lastPoll": "last fetched",
"neverPolled": "never fetched",
"lastError": "Error",
@@ -576,7 +576,9 @@
"fetchOlder": "Catch up on older",
"every": "every {{n}} minutes",
"manualOnly": "only on demand",
- "lastPollAt": "last fetched: {{when}}"
+ "lastPollAt": "last fetched: {{when}}",
+ "unclearTitle": "These mails matched but could not be read fully",
+ "unclearHint": "They are waiting in the to-do list – add what is missing there."
},
"bank": {
"title": "Bank account connection",
diff --git a/web/src/pages/Transactions.jsx b/web/src/pages/Transactions.jsx
index ae9460e..658a05c 100644
--- a/web/src/pages/Transactions.jsx
+++ b/web/src/pages/Transactions.jsx
@@ -262,7 +262,13 @@ export default function Transactions() {
{t("mailbox.unclearHint")}
+