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() {
{l.title}
- {l.sub &&
{l.sub}
} +
+ {/* Woher die Buchung stammt – man sieht es ihr sonst nicht an. */} + {t(`todo.from.${tx.herkunft}`, { + defaultValue: tx.herkunft, + })} + {l.sub ? ` · ${l.sub}` : ""} +
{/* Verknüpft heisst: zählt nicht als Ausgabe oder Einnahme. Ohne das Gegenkonto sagt „Übertrag" nichts. */} {links[String(tx.id)] && ( diff --git a/web/src/pages/settings/Postfaecher.jsx b/web/src/pages/settings/Postfaecher.jsx index 55844c6..8a854d2 100644 --- a/web/src/pages/settings/Postfaecher.jsx +++ b/web/src/pages/settings/Postfaecher.jsx @@ -39,6 +39,8 @@ export default function Postfaecher() { // Was beim letzten Abruf durchgefallen ist – beim Einrichten die einzige // Möglichkeit zu sehen, was in den Absender-Filter gehört. const [uebergangen, setUebergangen] = useState([]); + // Mails, die eine Regel TRAFEN, aber nicht zu lesen waren. + const [unklar, setUnklar] = useState([]); async function laden() { try { @@ -96,8 +98,12 @@ export default function Postfaecher() { // „wie weit wurde überhaupt gesucht" – deshalb steht das Datum dabei. toast(r.gefunden === 0 ? t("mailbox.fetchedNone", { seit: r.seit ? formatDate(r.seit) : "?" }) - : t("mailbox.fetched", { gefunden: r.gefunden, neu: r.neu, unklar: r.unklar })); + : t("mailbox.fetched", { + gefunden: r.gefunden, neu: r.neu, unklar: r.unklar, + bekannt: r.uebersprungen, + })); setUebergangen(r.ohne_zuordnung || []); + setUnklar(r.unklar_liste || []); laden(); } catch (e) { toast(e.message, "error"); @@ -172,6 +178,19 @@ export default function Postfaecher() { )}
absenderAnlegen(q, d)} t={t} /> + {unklar.length > 0 && ( +
+ {t("mailbox.unclearTitle")} +

{t("mailbox.unclearHint")}

+
    + {unklar.map((m, i) => ( +
  • + {m.subject} — {(m.gruende || []).join(", ")} +
  • + ))} +
+
+ )} {uebergangen.length > 0 && (
{t("mailbox.skippedTitle")}