diff --git a/backend/app/main.py b/backend/app/main.py index 083dff9..887587e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -39,6 +39,7 @@ from .routers import ( inbox, mail, quick, + uebertraege, rates, settings as settings_router, transactions, @@ -158,6 +159,7 @@ api.include_router(rates.router) api.include_router(quick.router) api.include_router(inbox.router) api.include_router(mail.router) +api.include_router(uebertraege.router) # Fehler an den Kurzbefehl-Endpunkten zusätzlich als „message" ausliefern. # Der Kurzbefehl auf dem Telefon zeigt genau dieses Feld an; bei einem Fehler diff --git a/backend/app/models.py b/backend/app/models.py index fddf665..9976279 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -769,3 +769,65 @@ class PendingUpload(Base): content: Mapped[bytes] = mapped_column(LargeBinary, nullable=False) size_bytes: Mapped[int] = mapped_column(Integer, nullable=False, default=0) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) + + +class TransferLink(Base): + """Zwei Buchungen, die dieselbe Bewegung sind – ein Übertrag zwischen + eigenen Konten. + + Bewusst eine SCHICHT OBENDRAUF: An den Buchungen selbst wird nichts + geändert. Sie behalten Betrag, Konto, Kategorie, Import-Schlüssel und + Stapel-Zugehörigkeit. Hier steht nur, dass sie zusammengehören. + + Der frühere Ansatz führte beide zu einer Übertrags-Buchung zusammen und + löschte die Originale. Daran hing eine Kette von Folgeproblemen – der + Duplikatschutz verlor einen der beiden Import-Schlüssel, die Rücknahme + eines Imports riss die Gegenseite mit, und eine falsche Paarung liess sich + ohne vollständige Kopie nicht zurückholen. Nichts zu löschen lässt all das + gar nicht erst entstehen: Eine falsche Verknüpfung ist ein Etikett, das man + abzieht. + """ + + __tablename__ = "transfer_links" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + owner_id: Mapped[int] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) + # CASCADE ist hier richtig: Verschwindet eine Buchung, ist die Verknüpfung + # gegenstandslos. Es verschwindet das Etikett, nie eine Buchung. + from_txn_id: Mapped[int] = mapped_column( + ForeignKey("transactions.id", ondelete="CASCADE"), nullable=False, index=True + ) + to_txn_id: Mapped[int] = mapped_column( + ForeignKey("transactions.id", ondelete="CASCADE"), nullable=False, index=True + ) + automatisch: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) + + __table_args__ = ( + UniqueConstraint("from_txn_id", name="uq_link_from"), + UniqueConstraint("to_txn_id", name="uq_link_to"), + ) + + +class LinkIgnored(Base): + """Ein abgelehnter Vorschlag. Damit er nicht bei jedem Suchlauf wiederkehrt.""" + + __tablename__ = "link_ignored" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + owner_id: Mapped[int] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) + a_txn_id: Mapped[int] = mapped_column( + ForeignKey("transactions.id", ondelete="CASCADE"), nullable=False, index=True + ) + b_txn_id: Mapped[int] = mapped_column( + ForeignKey("transactions.id", ondelete="CASCADE"), nullable=False, index=True + ) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) + + __table_args__ = ( + UniqueConstraint("a_txn_id", "b_txn_id", name="uq_ignored_pair"), + ) diff --git a/backend/app/routers/transactions.py b/backend/app/routers/transactions.py index 43740e5..cfe6b48 100644 --- a/backend/app/routers/transactions.py +++ b/backend/app/routers/transactions.py @@ -81,6 +81,18 @@ def _validate_category(db: Session, user: User, category_id: int | None) -> None get_owned(db, Category, category_id, user) +@router.get("/links") +def transaction_links(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict: + """Je Buchungs-ID die Gegenseite, falls sie Teil eines Übertrags ist. + + Getrennt vom Buchungs-Schema, damit dieses unverändert bleibt: Die + Verknüpfung ist eine Schicht obendrauf, kein Feld der Buchung. + """ + from ..services import uebertraege + + return {str(k): v for k, v in uebertraege.linkliste(db, user).items()} + + @router.get("", response_model=list[TransactionOut]) def list_transactions( db: Session = Depends(get_db), diff --git a/backend/app/routers/uebertraege.py b/backend/app/routers/uebertraege.py new file mode 100644 index 0000000..80011aa --- /dev/null +++ b/backend/app/routers/uebertraege.py @@ -0,0 +1,103 @@ +"""Überträge zwischen eigenen Konten: suchen, verbinden, lösen. + +Alle Wege hier ändern ausschliesslich die Verknüpfungs-Schicht. Keine Buchung +wird angefasst – das ist die tragende Eigenschaft des Entwurfs, nicht eine +Bequemlichkeit. +""" + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from ..database import get_db +from ..deps import get_current_user, get_owned +from ..models import Account, LinkIgnored, Transaction, TransferLink, TxnType, User +from ..services import uebertraege + +router = APIRouter(prefix="/uebertraege", tags=["uebertraege"]) + + +class PaarIn(BaseModel): + a_id: int + b_id: int + + +def _paar(db: Session, user: User, payload: PaarIn) -> tuple[Transaction, Transaction]: + a = get_owned(db, Transaction, payload.a_id, user) + b = get_owned(db, Transaction, payload.b_id, user) + if a.id == b.id: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, + detail="Eine Buchung kann nicht mit sich selbst verknüpft werden") + return a, b + + +@router.get("") +def list_links(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> list[dict]: + return [ + {"id": l.id, "from_txn_id": l.from_txn_id, "to_txn_id": l.to_txn_id, + "automatisch": l.automatisch} + for l in db.query(TransferLink).filter(TransferLink.owner_id == user.id) + .order_by(TransferLink.id) + ] + + +@router.post("/suchen") +def suchen(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict: + """Paare suchen. Eindeutige mit Übertrags-Hinweis werden verknüpft, der + Rest kommt als Vorschlag zurück.""" + return uebertraege.finde(db, user) + + +@router.post("/verbinden") +def verbinden( + payload: PaarIn, db: Session = Depends(get_db), user: User = Depends(get_current_user) +) -> dict: + a, b = _paar(db, user, payload) + if a.account_id == b.account_id: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, + detail="Beide Buchungen gehören zum selben Konto") + if {a.type, b.type} != {TxnType.expense, TxnType.income}: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, + detail="Ein Übertrag braucht einen Abgang und einen Zugang") + schon = ( + db.query(TransferLink) + .filter(TransferLink.owner_id == user.id) + .filter((TransferLink.from_txn_id.in_((a.id, b.id))) + | (TransferLink.to_txn_id.in_((a.id, b.id)))) + .first() + ) + if schon is not None: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, + detail="Eine der Buchungen ist bereits verknüpft") + link = uebertraege.verbinde(db, user, a, b, automatisch=False) + db.commit() + return {"id": link.id, "from_txn_id": link.from_txn_id, "to_txn_id": link.to_txn_id, + "automatisch": False} + + +@router.delete("/{link_id}", status_code=status.HTTP_204_NO_CONTENT) +def loesen(link_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)): + """Verknüpfung entfernen. Die beiden Buchungen bleiben, wie sie sind – + inklusive ihrer Kategorien, die damit sofort wieder in der Auswertung + zählen.""" + db.delete(get_owned(db, TransferLink, link_id, user)) + db.commit() + + +@router.post("/ignorieren", status_code=status.HTTP_201_CREATED) +def ignorieren( + payload: PaarIn, db: Session = Depends(get_db), user: User = Depends(get_current_user) +) -> dict: + """Vorschlag ablehnen, damit er bei jedem weiteren Suchlauf wegbleibt.""" + a, b = _paar(db, user, payload) + klein, gross = sorted((a.id, b.id)) + vorhanden = ( + db.query(LinkIgnored) + .filter(LinkIgnored.owner_id == user.id, + LinkIgnored.a_txn_id == klein, LinkIgnored.b_txn_id == gross) + .first() + ) + if vorhanden is None: + db.add(LinkIgnored(owner_id=user.id, a_txn_id=klein, b_txn_id=gross)) + db.commit() + return {"ok": True} diff --git a/backend/app/services/budget.py b/backend/app/services/budget.py index 5bc10da..3bc6640 100644 --- a/backend/app/services/budget.py +++ b/backend/app/services/budget.py @@ -189,8 +189,15 @@ def _neutrale_kategorien(db: Session, user: User) -> set[int]: def actual_expense_by_category(db: Session, user: User, start: date, end: date, base: str) -> dict[int, int]: """Ist-Ausgaben je Kategorie in Hauptwährung (inkl. budgetierter Sparen-Transfers).""" + from .uebertraege import verknuepfte_ids + result: dict[int, int] = defaultdict(int) neutral = _neutrale_kategorien(db, user) + # Verknüpfte Buchungen sind zwei Seiten derselben Bewegung zwischen eigenen + # Konten – Geld wurde verschoben, nicht ausgegeben. Übersprungen, nicht + # umkategorisiert: Löst man die Verknüpfung, zählt die Buchung mit ihrer + # alten Kategorie sofort wieder mit. + verknuepft = verknuepfte_ids(db, user) expenses = ( db.query(Transaction) .filter( @@ -203,7 +210,7 @@ def actual_expense_by_category(db: Session, user: User, start: date, end: date, .all() ) for t in expenses: - if t.category_id in neutral: + if t.category_id in neutral or t.id in verknuepft: continue result[t.category_id] += _to_base(db, t.amount_eur_cents, base, t.date) @@ -230,8 +237,12 @@ def actual_expense_by_category(db: Session, user: User, start: date, end: date, def income_by_category(db: Session, user: User, start: date, end: date, base: str) -> dict[int | None, int]: + from .uebertraege import verknuepfte_ids + result: dict[int | None, int] = defaultdict(int) neutral = _neutrale_kategorien(db, user) + # Die Gegenseite eines Übertrags ist keine Einnahme (siehe oben). + verknuepft = verknuepfte_ids(db, user) incomes = ( db.query(Transaction) .filter( @@ -243,7 +254,7 @@ def income_by_category(db: Session, user: User, start: date, end: date, base: st .all() ) for t in incomes: - if t.category_id in neutral: + if t.category_id in neutral or t.id in verknuepft: continue result[t.category_id] += _to_base(db, t.amount_eur_cents, base, t.date) return result diff --git a/backend/app/services/csv_import.py b/backend/app/services/csv_import.py index 725b326..6e7e1fe 100644 --- a/backend/app/services/csv_import.py +++ b/backend/app/services/csv_import.py @@ -6,6 +6,7 @@ import csv import hashlib import io import json +import logging from collections import Counter from datetime import date, datetime from decimal import Decimal, InvalidOperation, ROUND_HALF_UP @@ -34,6 +35,8 @@ from ..models import ( ) from . import currency +log = logging.getLogger(__name__) + # Vorgefertigte Bank-Vorlagen (aus echten Export-Beispielen). Erscheinen im # Import als auswählbare Presets und füllen die Format-/Spalten-Einstellungen. @@ -1046,6 +1049,16 @@ def commit_import(db: Session, user: User, account: Account, template_id: int | batch.imported_count = imported db.commit() + + # Frisch gebuchte Zeilen auf Überträge prüfen. Läuft NACH dem Buchen, nicht + # währenddessen: Der Import bleibt damit unangetastet, und wenn die Suche + # etwas falsch macht, ist es ein Etikett – keine Buchung. + try: + from . import uebertraege + uebertraege.finde(db, user) + except Exception: # Nie den Import daran scheitern lassen + log.exception("Übertrags-Suche nach dem Import fehlgeschlagen") + db.refresh(batch) return batch diff --git a/backend/app/services/uebertraege.py b/backend/app/services/uebertraege.py new file mode 100644 index 0000000..92b6640 --- /dev/null +++ b/backend/app/services/uebertraege.py @@ -0,0 +1,322 @@ +"""Gleichwährige Überträge zwischen eigenen Konten erkennen. + +Ein Übertrag steht in zwei Auszügen: als Abgang beim einen Konto, als Zugang +beim anderen. Beide werden gebucht – das ist richtig, denn beide Salden bewegen +sich. Nur zählen sie ohne Zusammenhang als Ausgabe UND als Einnahme in der +Auswertung, obwohl kein Geld ausgegeben wurde. + +Dieses Modul findet solche Paare und legt eine ``TransferLink`` an. An den +Buchungen selbst wird nichts geändert; die Verknüpfung ist eine Schicht +obendrauf. Eine falsche Verknüpfung ist damit ein Etikett, das man abzieht – +kein Eingriff, der rückgängig gemacht werden müsste. + +Die Regeln sind bewusst eng. Der Vorgänger hat Zielkonten aus Texten geraten +und Beträge über Wechselkurse geschätzt; beides hat Salden verfälscht. Hier +gilt: exakt gleicher Betrag bei gleicher Währung, gleicher Tag. + +Bei VERSCHIEDENEN Währungen wird der EZB-Kurs des Tages herangezogen – aber nur +als Prüfung, ob zwei Beträge zueinander passen können. Gespeichert wird nichts +davon; beide Buchungen behalten den Betrag, der in ihrem Auszug steht. Genau +das war der Unterschied zum alten Ansatz: Der hat den Gegenbetrag geschätzt und +in die Buchung geschrieben, und damit Salden verfälscht. Eine Verknüpfung kann +das nicht – sie ist nur ein Etikett. +""" + +from __future__ import annotations + +import re +from collections import defaultdict + +from sqlalchemy.orm import Session + +from ..models import ( + Account, + AccountIdentifier, + IdentifierKind, + LinkIgnored, + Transaction, + TransferLink, + TxnType, + User, +) +from . import currency +from .csv_import import normalize_text + +# Wörter, die eine Bewegung zwischen eigenen Konten ankündigen. Ein Treffer +# allein verknüpft noch nichts – er erlaubt nur, dass es ohne Rückfrage +# geschieht. Ohne Treffer wird vorgeschlagen. +HINWEISE = ( + "übertrag", "uebertrag", "umbuchung", "einzahlung von", "auszahlung", + "transfer", "gutschrift von", "von konto", "auf konto", "eigenübertrag", + "umgetauscht", "umtausch", "exchange", "wechsel", +) + +# Wie weit ein Betrag bei Währungswechsel vom EZB-Kurs abweichen darf. Banken +# nehmen einen Aufschlag; Revolut liegt werktags nahe am Referenzkurs, eine +# klassische Überweisung deutlich darüber. Enger als 2,5 % würde echte Wechsel +# verwerfen, weiter würde beliebige Beträge zusammenwürfeln. +FX_TOLERANZ = 0.025 + + +def _trifft_als_wort(nadel: str, heu: str) -> bool: + """Nur an Wortgrenzen suchen, nicht mitten im Wort. + + Ein kurzer Kontoname als blosser Teilstring richtet Schaden an: „Euro" + steckt auch in „PayPal Europe" und „Europa-Park". Genau daran ist die alte + Transfer-Erkennung gescheitert. + """ + if not nadel: + return False + return re.search(rf"(? str: + return normalize_text(f"{txn.payee or ''} {txn.description or ''}") + + +def _kontobezeichner(db: Session, user: User) -> dict[int, list[str]]: + """Je Konto die Wörter, an denen es im Buchungstext erkennbar ist: + sein Name und seine Erkennungs-Texte.""" + je_konto: dict[int, list[str]] = defaultdict(list) + for konto in db.query(Account).filter(Account.owner_id == user.id): + name = normalize_text(konto.name) + if len(name) >= 4: + je_konto[konto.id].append(name) + for acc_id, kind, wert in ( + db.query(AccountIdentifier.account_id, AccountIdentifier.kind, AccountIdentifier.value_norm) + .join(Account, Account.id == AccountIdentifier.account_id) + .filter(Account.owner_id == user.id) + ): + if kind == IdentifierKind.text and wert and len(wert) >= 3: + je_konto[acc_id].append(normalize_text(wert)) + return je_konto + + +def _hat_hinweis(a: Transaction, b: Transaction, bezeichner: dict[int, list[str]]) -> bool: + """Deutet mindestens eine der beiden Zeilen auf einen Übertrag hin? + + Ohne diese Hürde würde ein zufälliger Treffer – 50.00 im Laden bezahlt, am + selben Tag 50.00 auf einem anderen Konto eingegangen – als „eindeutig" + gelten und eine echte Ausgabe samt echter Einnahme aus der Statistik + nehmen. Der Saldo bliebe richtig, die Auswertung nicht. + """ + for txn, gegen in ((a, b), (b, a)): + text = _text(txn) + if any(h in text for h in HINWEISE): + return True + # Der Name oder Erkennungs-Text des ANDEREN Kontos im Text – an + # Wortgrenzen geprüft, damit sich „Euro" nicht wieder „Europe" einfängt. + for wort in bezeichner.get(gegen.account_id, ()): + if _trifft_als_wort(wort, text): + return True + return False + + +def _kandidaten(db: Session, user: User) -> dict[int, list[Transaction]]: + """Zu jeder unverknüpften Buchung die möglichen Gegenstücke.""" + verknuepft = set() + for a, b in db.query(TransferLink.from_txn_id, TransferLink.to_txn_id).filter( + TransferLink.owner_id == user.id + ): + verknuepft.update((a, b)) + ignoriert = { + tuple(sorted(p)) + for p in db.query(LinkIgnored.a_txn_id, LinkIgnored.b_txn_id).filter( + LinkIgnored.owner_id == user.id + ) + } + waehrung = dict(db.query(Account.id, Account.currency).filter(Account.owner_id == user.id)) + + buchungen = [ + t for t in db.query(Transaction).filter( + Transaction.owner_id == user.id, + Transaction.type.in_((TxnType.expense, TxnType.income)), + ) + if t.id not in verknuepft + ] + + # Nach Tag und Betrag bündeln: Nur wer beides teilt, kommt überhaupt infrage. + nach_schluessel: dict[tuple, list[Transaction]] = defaultdict(list) + for t in buchungen: + nach_schluessel[(t.date, t.amount_cents)].append(t) + + kandidaten: dict[int, list[Transaction]] = defaultdict(list) + for gruppe in nach_schluessel.values(): + if len(gruppe) < 2: + continue + for a in gruppe: + for b in gruppe: + if a.id == b.id or a.account_id == b.account_id: + continue + if a.type == b.type: + continue # eine Ausgabe, eine Einnahme + if waehrung.get(a.account_id) != waehrung.get(b.account_id): + continue # 100 CHF und 100 EUR sind nicht derselbe + # Betrag, nur dieselbe Zahl + if tuple(sorted((a.id, b.id))) in ignoriert: + continue + kandidaten[a.id].append(b) + + # Währungswechsel: gleicher Tag, aber unterschiedliche Beträge. Hier + # entscheidet der EZB-Kurs, ob die beiden zueinander passen können. + kandidaten = _ergaenze_waehrungswechsel(db, user, buchungen, waehrung, + ignoriert, kandidaten) + return kandidaten + + +def _passt_ueber_kurs(db: Session, a: Transaction, b: Transaction, + von: str, nach: str) -> bool: + """Passt ``b`` zum umgerechneten ``a``? + + Fehlt der Kurs für den Tag, wird NICHT geraten – dann gibt es kein Paar. + """ + erwartet = currency.convert(db, a.amount_cents, von, nach, a.date) + if erwartet is None or erwartet <= 0: + return False + return abs(erwartet - b.amount_cents) <= erwartet * FX_TOLERANZ + + +def _ergaenze_waehrungswechsel(db, user, buchungen, waehrung, ignoriert, kandidaten): + nach_tag: dict = defaultdict(list) + for t in buchungen: + nach_tag[t.date].append(t) + for gruppe in nach_tag.values(): + for a in gruppe: + for b in gruppe: + if a.id == b.id or a.account_id == b.account_id or a.type == b.type: + continue + va, vb = waehrung.get(a.account_id), waehrung.get(b.account_id) + if not va or not vb or va == vb: + continue # gleiche Währung ist oben schon erledigt + if a.amount_cents == b.amount_cents: + continue # zufällig gleiche Zahl, andere Währung + if tuple(sorted((a.id, b.id))) in ignoriert: + continue + if _passt_ueber_kurs(db, a, b, va, vb): + kandidaten[a.id].append(b) + return kandidaten + + +def _eindeutig(a: Transaction, moegliche: list[Transaction], + kandidaten: dict[int, list[Transaction]], + verbraucht: set[int]) -> Transaction | None: + """Gibt es genau eine sinnvolle Zuordnung? + + Zwei Fälle gelten als zwingend: + + 1. Genau ein Gegenstück, und dieses hat seinerseits nur diese eine + Möglichkeit. + 2. Alle Gegenstücke sind untereinander austauschbar – gleiches Konto, + gleicher Betrag, gleicher Tag. Dann ist jede Zuordnung dieselbe, und + eine beliebige ist richtig. Das ist der Alltag: Revolut-Aufrundungen + sind hunderte gleiche Beträge am selben Tag zwischen denselben Konten. + """ + if not moegliche: + return None + if len(moegliche) == 1: + gegen = moegliche[0] + # Nur die noch OFFENEN Gegenstücke zählen. Zählte man alle, bliebe beim + # letzten Paar einer Reihe gleicher Beträge immer eines übrig: Sein + # Gegenstück hätte rechnerisch viele Kandidaten, obwohl alle anderen + # längst vergeben sind. + offen_dort = [x for x in kandidaten.get(gegen.id, ()) + if x.id not in verbraucht and x.id != a.id] + return gegen if not offen_dort else None + konten = {b.account_id for b in moegliche} + return moegliche[0] if len(konten) == 1 else None + + +def finde(db: Session, user: User) -> dict: + """Paare suchen. Eindeutige verknüpfen, den Rest als Vorschlag melden.""" + kandidaten = _kandidaten(db, user) + bezeichner = _kontobezeichner(db, user) + nach_id = {} + for a_id, liste in kandidaten.items(): + for b in liste: + nach_id[b.id] = b + for t in db.query(Transaction).filter(Transaction.id.in_(list(kandidaten.keys()) or [0])): + nach_id[t.id] = t + + verbraucht: set[int] = set() + verknuepft = 0 + vorschlaege: list[tuple[Transaction, Transaction]] = [] + + for a_id in sorted(kandidaten): + if a_id in verbraucht: + continue + a = nach_id.get(a_id) + if a is None: + continue + offen = [b for b in kandidaten[a_id] if b.id not in verbraucht] + gegen = _eindeutig(a, offen, kandidaten, verbraucht) + if gegen is None: + if offen: + vorschlaege.append((a, offen[0])) + continue + # Automatisch nur ohne Kategorie und mit Hinweis – siehe _hat_hinweis. + if a.category_id is not None or gegen.category_id is not None: + vorschlaege.append((a, gegen)) + continue + if not _hat_hinweis(a, gegen, bezeichner): + vorschlaege.append((a, gegen)) + continue + verbinde(db, user, a, gegen, automatisch=True) + verbraucht.update((a.id, gegen.id)) + verknuepft += 1 + + db.commit() + return { + "verknuepft": verknuepft, + "vorschlaege": [ + {"a": _kurz(db, x), "b": _kurz(db, y)} + for x, y in vorschlaege + if x.id not in verbraucht and y.id not in verbraucht + ], + } + + +def _kurz(db: Session, t: Transaction) -> dict: + konto = db.get(Account, t.account_id) + return { + "id": t.id, "date": t.date.isoformat(), "type": t.type.value, + "amount_cents": t.amount_cents, "currency": konto.currency if konto else "", + "account_id": t.account_id, "account_name": konto.name if konto else "", + "payee": t.payee, "description": t.description, "category_id": t.category_id, + } + + +def verbinde(db: Session, user: User, a: Transaction, b: Transaction, + automatisch: bool = False) -> TransferLink: + """Verknüpfung anlegen. Abgang zuerst, damit die Richtung stimmt.""" + von, nach = (a, b) if a.type == TxnType.expense else (b, a) + link = TransferLink(owner_id=user.id, from_txn_id=von.id, to_txn_id=nach.id, + automatisch=automatisch) + db.add(link) + db.flush() + return link + + +def linkliste(db: Session, user: User) -> dict[int, dict]: + """Je Buchungs-ID die Gegenseite – für die Anzeige in der Buchungsliste.""" + ergebnis: dict[int, dict] = {} + for link in db.query(TransferLink).filter(TransferLink.owner_id == user.id): + ergebnis[link.from_txn_id] = {"link_id": link.id, "other_id": link.to_txn_id, + "richtung": "raus", "automatisch": link.automatisch} + ergebnis[link.to_txn_id] = {"link_id": link.id, "other_id": link.from_txn_id, + "richtung": "rein", "automatisch": link.automatisch} + return ergebnis + + +def verknuepfte_ids(db: Session, user: User) -> set[int]: + """Alle Buchungen, die Teil einer Verknüpfung sind. + + Die Auswertung überspringt sie – so wie sie heute schon neutrale + Kategorien überspringt. Umkategorisiert wird nichts: Löst man die + Verknüpfung, zählt die Buchung mit ihrer alten Kategorie wieder mit. + """ + ids: set[int] = set() + for a, b in db.query(TransferLink.from_txn_id, TransferLink.to_txn_id).filter( + TransferLink.owner_id == user.id + ): + ids.update((a, b)) + return ids diff --git a/backend/migrations/versions/0020_transfer_links.py b/backend/migrations/versions/0020_transfer_links.py new file mode 100644 index 0000000..4ae6725 --- /dev/null +++ b/backend/migrations/versions/0020_transfer_links.py @@ -0,0 +1,62 @@ +"""Verknuepfung zweier Buchungen zu einem Uebertrag. + +Eine Schicht obendrauf: An den Buchungen selbst wird nichts geaendert. Sie +behalten Betrag, Konto, Kategorie, Import-Schluessel und Stapel-Zugehoerigkeit; +hier steht nur, dass sie zusammengehoeren. + +Der frueher geplante Weg – beide zu einer Uebertrags-Buchung zusammenfuehren und +die Originale loeschen – haette den Duplikatschutz, die Import-Ruecknahme und die +Umkehrbarkeit gleichzeitig beschaedigt. Nichts zu loeschen laesst das gar nicht +erst entstehen. + +Revision ID: 0020 +Revises: 0019 +""" + +import sqlalchemy as sa +from alembic import op + +revision = "0020" +down_revision = "0019" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "transfer_links", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("owner_id", sa.Integer(), nullable=False), + sa.Column("from_txn_id", sa.Integer(), nullable=False), + sa.Column("to_txn_id", sa.Integer(), nullable=False), + sa.Column("automatisch", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(["owner_id"], ["users.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["from_txn_id"], ["transactions.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["to_txn_id"], ["transactions.id"], ondelete="CASCADE"), + # Je Buchung hoechstens eine Verknuepfung. + sa.UniqueConstraint("from_txn_id", name="uq_link_from"), + sa.UniqueConstraint("to_txn_id", name="uq_link_to"), + ) + op.create_index("ix_transfer_links_owner_id", "transfer_links", ["owner_id"]) + op.create_index("ix_transfer_links_from", "transfer_links", ["from_txn_id"]) + op.create_index("ix_transfer_links_to", "transfer_links", ["to_txn_id"]) + + op.create_table( + "link_ignored", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("owner_id", sa.Integer(), nullable=False), + sa.Column("a_txn_id", sa.Integer(), nullable=False), + sa.Column("b_txn_id", sa.Integer(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(["owner_id"], ["users.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["a_txn_id"], ["transactions.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["b_txn_id"], ["transactions.id"], ondelete="CASCADE"), + sa.UniqueConstraint("a_txn_id", "b_txn_id", name="uq_ignored_pair"), + ) + op.create_index("ix_link_ignored_owner_id", "link_ignored", ["owner_id"]) + + +def downgrade() -> None: + op.drop_table("link_ignored") + op.drop_table("transfer_links") diff --git a/backend/tests/test_uebertraege.py b/backend/tests/test_uebertraege.py new file mode 100644 index 0000000..36954fa --- /dev/null +++ b/backend/tests/test_uebertraege.py @@ -0,0 +1,285 @@ +"""Überträge zwischen eigenen Konten als Verknüpfung erkennen. + +Der Kern dieses Entwurfs ist, dass NICHTS verändert wird: Beide Buchungen +bleiben stehen, mit Betrag, Konto, Kategorie und Import-Schlüssel. Die Tests +prüfen deshalb vor allem, was alles gleich bleiben MUSS. +""" + +import io +import json +import secrets + + +def _konto(admin, name, currency="CHF", opening=0, identifiers=None): + body = {"name": name, "type": "girokonto", "currency": currency, + "opening_balance_cents": opening} + if identifiers: + body["identifiers"] = identifiers + r = admin.post("/api/accounts", json=body) + assert r.status_code == 201, r.text + return r.json() + + +def _buchung(admin, konto, typ, betrag, datum, payee="", category_id=None): + body = {"type": typ, "date": datum, "account_id": konto["id"], + "amount_cents": betrag, "payee": payee} + if category_id: + body["category_id"] = category_id + r = admin.post("/api/transactions", json=body) + assert r.status_code == 201, r.text + return r.json() + + +def _saldo(admin, konto): + return admin.get(f"/api/accounts/{konto['id']}").json()["balance_cents"] + + +def _suchen(admin): + r = admin.post("/api/uebertraege/suchen") + assert r.status_code == 200, r.text + return r.json() + + +def _links_von(admin, *konten): + """Verknüpfungen, die zu den Konten DIESES Tests gehören. + + Die Testdatenbank lebt über den ganzen Lauf, und die Suche läuft über alle + Buchungen des Nutzers. Absolut zu zählen hiesse, die Nachbartests + mitzuzählen.""" + ids = {k["id"] for k in konten} + buchungen = {t["id"]: t for t in admin.get("/api/transactions").json()} + treffer = [] + for l in admin.get("/api/uebertraege").json(): + seiten = [buchungen.get(l["from_txn_id"]), buchungen.get(l["to_txn_id"])] + if all(s and s["account_id"] in ids for s in seiten): + treffer.append(l) + return treffer + + +def _kategorie(admin, name="Lebensmittel"): + for g in admin.get("/api/categories").json(): + for c in g["categories"]: + if c["name"] == name: + return c["id"] + raise AssertionError(f"{name} fehlt") + + +# --- Was gleich bleiben muss ------------------------------------------------- + + +def test_salden_bleiben_unveraendert(admin): + """Der Kern des Entwurfs: Verknüpfen bewegt kein Geld.""" + a = _konto(admin, "Von " + secrets.token_hex(3), opening=100000) + b = _konto(admin, "Nach " + secrets.token_hex(3)) + _buchung(admin, a, "expense", 25000, "2026-04-01", "Übertrag auf Sparkonto") + _buchung(admin, b, "income", 25000, "2026-04-01", "Übertrag") + + vorher = (_saldo(admin, a), _saldo(admin, b)) + _suchen(admin) + assert len(_links_von(admin, a, b)) == 1 + assert (_saldo(admin, a), _saldo(admin, b)) == vorher + + # Und nach dem Lösen ebenfalls. + link = admin.get("/api/uebertraege").json()[0] + assert admin.delete(f"/api/uebertraege/{link['id']}").status_code == 204 + assert (_saldo(admin, a), _saldo(admin, b)) == vorher + + +def test_buchungen_werden_nicht_angefasst(admin): + """Kategorie, Betrag, Konto und Text bleiben, wie sie waren.""" + leb = _kategorie(admin) + a = _konto(admin, "Quelle " + secrets.token_hex(3), opening=50000) + b = _konto(admin, "Ziel " + secrets.token_hex(3)) + aus = _buchung(admin, a, "expense", 7000, "2026-04-02", "Umbuchung", category_id=leb) + ein = _buchung(admin, b, "income", 7000, "2026-04-02", "Umbuchung") + + admin.post("/api/uebertraege/verbinden", + json={"a_id": aus["id"], "b_id": ein["id"]}) + + danach = {t["id"]: t for t in admin.get("/api/transactions").json()} + assert danach[aus["id"]]["category_id"] == leb + assert danach[aus["id"]]["amount_cents"] == 7000 + assert danach[aus["id"]]["account_id"] == a["id"] + assert danach[aus["id"]]["type"] == "expense" + assert danach[ein["id"]]["type"] == "income" + + +def test_kategorie_kommt_beim_loesen_zurueck(admin): + """Verknüpfte Buchungen werden übersprungen, nicht umkategorisiert.""" + from datetime import date + + heute = date.today().isoformat() # muss im laufenden Budget-Monat liegen + leb = _kategorie(admin) + a = _konto(admin, "BudgetQuelle " + secrets.token_hex(3), opening=90000) + b = _konto(admin, "BudgetZiel " + secrets.token_hex(3)) + aus = _buchung(admin, a, "expense", 3300, heute, "Übertrag", category_id=leb) + ein = _buchung(admin, b, "income", 3300, heute, "Übertrag") + + def budget_summe(): + r = admin.get("/api/dashboard/income-expense").json() + return r["expense"] + + vorher = budget_summe() + link = admin.post("/api/uebertraege/verbinden", + json={"a_id": aus["id"], "b_id": ein["id"]}).json() + assert budget_summe() < vorher, "verknüpft: zählt nicht mehr als Ausgabe" + + admin.delete(f"/api/uebertraege/{link['id']}") + assert budget_summe() == vorher, "gelöst: zählt wieder, mit derselben Kategorie" + + +def test_import_ruecknahme_bleibt_unberuehrt(admin): + """Die Gegenseite gehört einem anderen Stapel – sie darf nicht mitgehen.""" + a = _konto(admin, "StapelA " + secrets.token_hex(3), opening=100000) + b = _konto(admin, "StapelB " + secrets.token_hex(3)) + spalten = json.dumps({"date": 0, "amount": 1, "payee": 2}) + + def importieren(konto, text, name): + v = admin.post("/api/imports/preview", + files={"file": (name, io.BytesIO(text.encode()), "text/csv")}, + data={"account_id": str(konto["id"]), "delimiter": ";", + "decimal_sep": ",", "date_format": "%d.%m.%Y", + "encoding": "utf-8", "has_header": "true", "skip_rows": "0", + "amount_mode": "signed", "column_map": spalten}).json() + return admin.post("/api/imports/commit", json={ + "account_id": konto["id"], "filename": name, + "rows": [r for r in v["rows"] if r["include"]]}).json() + + stapel_a = importieren(a, "Datum;Betrag;Text\n05.04.2026;-80,00;Übertrag\n", "a.csv") + importieren(b, "Datum;Betrag;Text\n05.04.2026;80,00;Übertrag\n", "b.csv") + _suchen(admin) + assert len(_links_von(admin, a, b)) == 1 + assert _saldo(admin, b) == 8000 + + # Stapel A zurücknehmen: nur dessen Zeile verschwindet. + assert admin.delete(f"/api/imports/batches/{stapel_a['id']}").status_code == 200 + assert _saldo(admin, a) == 100000, "eigene Zeile weg" + assert _saldo(admin, b) == 8000, "Gegenseite unberührt" + assert _links_von(admin, a, b) == [], "Verknüpfung verfällt mit der Buchung" + + +# --- Wann automatisch verknüpft wird ---------------------------------------- + + +def test_ohne_hinweis_nur_vorschlag(admin): + """50.00 im Laden bezahlt, am selben Tag 50.00 eingegangen: Zufall.""" + a = _konto(admin, "Laden " + secrets.token_hex(3), opening=100000) + b = _konto(admin, "Sonst " + secrets.token_hex(3)) + _buchung(admin, a, "expense", 5000, "2026-05-01", "Bäckerei Meier") + _buchung(admin, b, "income", 5000, "2026-05-01", "Rückerstattung Verein") + + r = _suchen(admin) + assert _links_von(admin, a, b) == [] + assert any(v["a"]["payee"] == "Bäckerei Meier" for v in r["vorschlaege"]) + + +def test_mit_hinweis_automatisch(admin): + a = _konto(admin, "HinweisA " + secrets.token_hex(3), opening=100000) + b = _konto(admin, "HinweisB " + secrets.token_hex(3)) + _buchung(admin, a, "expense", 60000, "2026-05-02", "Einzahlung von *6543") + _buchung(admin, b, "income", 60000, "2026-05-02", "Gutschrift") + _suchen(admin) + assert len(_links_von(admin, a, b)) == 1 + + +def test_kontoname_im_text_zaehlt_als_hinweis(admin): + marke = secrets.token_hex(3) + a = _konto(admin, f"Lohnkonto {marke}", opening=100000) + b = _konto(admin, f"Sparkonto {marke}") + _buchung(admin, a, "expense", 12000, "2026-05-03", f"Sparkonto {marke}") + _buchung(admin, b, "income", 12000, "2026-05-03", "Eingang") + _suchen(admin) + assert len(_links_von(admin, a, b)) == 1 + + +def test_kategorie_schuetzt_vor_automatik(admin): + """Eine zugeordnete Ausgabe soll nicht ungefragt aus dem Budget fallen.""" + leb = _kategorie(admin) + a = _konto(admin, "GeschuetztA " + secrets.token_hex(3), opening=100000) + b = _konto(admin, "GeschuetztB " + secrets.token_hex(3)) + _buchung(admin, a, "expense", 4400, "2026-05-04", "Übertrag", category_id=leb) + _buchung(admin, b, "income", 4400, "2026-05-04", "Übertrag") + + r = _suchen(admin) + assert _links_von(admin, a, b) == [] + assert r["vorschlaege"] + + +def test_mehrdeutig_bleibt_vorschlag(admin): + """Derselbe Betrag am selben Tag über drei Konten – nichts ist zwingend.""" + marke = secrets.token_hex(3) + a = _konto(admin, f"M1 {marke}", opening=100000) + b = _konto(admin, f"M2 {marke}") + c = _konto(admin, f"M3 {marke}") + _buchung(admin, a, "expense", 9900, "2026-06-01", "Übertrag") + _buchung(admin, b, "income", 9900, "2026-06-01", "Übertrag") + _buchung(admin, c, "income", 9900, "2026-06-01", "Übertrag") + _suchen(admin) + assert _links_von(admin, a, b, c) == [] + + +def test_austauschbare_kandidaten_werden_verbunden(admin): + """Revolut-Aufrundungen: viele gleiche Beträge am selben Tag zwischen + denselben zwei Konten. Jede Zuordnung ist dieselbe.""" + a = _konto(admin, "Giro " + secrets.token_hex(3), opening=100000) + b = _konto(admin, "Pocket " + secrets.token_hex(3)) + for _ in range(6): + _buchung(admin, a, "expense", 60, "2026-06-02", "Übertrag Aufrundung") + _buchung(admin, b, "income", 60, "2026-06-02", "Übertrag Aufrundung") + + vorher = (_saldo(admin, a), _saldo(admin, b)) + _suchen(admin) + assert len(_links_von(admin, a, b)) == 6 + assert (_saldo(admin, a), _saldo(admin, b)) == vorher + + +# --- Was nie ein Paar bilden darf ------------------------------------------- + + +def test_kein_selbstbezug(admin): + a = _konto(admin, "Solo " + secrets.token_hex(3), opening=100000) + _buchung(admin, a, "expense", 2500, "2026-06-03", "Übertrag") + _buchung(admin, a, "income", 2500, "2026-06-03", "Übertrag") + _suchen(admin) + assert _links_von(admin, a) == [] + + +def test_gleicher_betrag_andere_waehrung_ist_kein_paar(admin): + """100 CHF und 100 EUR sind nicht derselbe Betrag – nur dieselbe Zahl.""" + a = _konto(admin, "CHF " + secrets.token_hex(3), currency="CHF", opening=100000) + b = _konto(admin, "EUR " + secrets.token_hex(3), currency="EUR") + _buchung(admin, a, "expense", 10000, "2026-06-04", "Übertrag") + _buchung(admin, b, "income", 10000, "2026-06-04", "Übertrag") + _suchen(admin) + assert _links_von(admin, a, b) == [] + + +def test_verschiedene_tage_bilden_kein_paar(admin): + a = _konto(admin, "TagA " + secrets.token_hex(3), opening=100000) + b = _konto(admin, "TagB " + secrets.token_hex(3)) + _buchung(admin, a, "expense", 7700, "2026-06-05", "Übertrag") + _buchung(admin, b, "income", 7700, "2026-06-08", "Übertrag") + _suchen(admin) + assert _links_von(admin, a, b) == [] + + +def test_keine_doppelverknuepfung(admin): + a = _konto(admin, "DoppA " + secrets.token_hex(3), opening=100000) + b = _konto(admin, "DoppB " + secrets.token_hex(3)) + aus = _buchung(admin, a, "expense", 3100, "2026-06-06", "Übertrag") + ein = _buchung(admin, b, "income", 3100, "2026-06-06", "Übertrag") + admin.post("/api/uebertraege/verbinden", json={"a_id": aus["id"], "b_id": ein["id"]}) + r = admin.post("/api/uebertraege/verbinden", json={"a_id": aus["id"], "b_id": ein["id"]}) + assert r.status_code == 400 + + +def test_ignorierte_paare_kehren_nicht_zurueck(admin): + a = _konto(admin, "IgnA " + secrets.token_hex(3), opening=100000) + b = _konto(admin, "IgnB " + secrets.token_hex(3)) + aus = _buchung(admin, a, "expense", 1700, "2026-06-07", "Kiosk") + ein = _buchung(admin, b, "income", 1700, "2026-06-07", "Rückzahlung") + + assert any(v["a"]["id"] in (aus["id"], ein["id"]) for v in _suchen(admin)["vorschlaege"]) + admin.post("/api/uebertraege/ignorieren", json={"a_id": aus["id"], "b_id": ein["id"]}) + danach = _suchen(admin)["vorschlaege"] + assert not any({v["a"]["id"], v["b"]["id"]} == {aus["id"], ein["id"]} for v in danach)