feat: Ueberträge als Verknuepfung erkennen, ohne Buchungen anzufassen

Ein Uebertrag steht in zwei Auszuegen und wird zweimal gebucht – richtig fuer
die Salden, aber er zaehlt als Ausgabe UND als Einnahme in der Auswertung.

Statt beide zu einer Uebertrags-Buchung zusammenzufuehren (der urspruengliche
Plan) legt Certain nur eine Schicht darueber: transfer_links haelt fest, dass
zwei Buchungen zusammengehoeren. An den Buchungen selbst aendert sich nichts –
Betrag, Konto, Kategorie, Import-Schluessel und Stapel bleiben.

Das haelt alles heraus, was am Zusammenfuehren haette brechen koennen: der
Duplikatschutz verliert keinen Schluessel, undo_batch reisst keine Gegenseite
mit, und eine falsche Paarung ist ein Etikett, das man abzieht.

Die Auswertung ueberspringt verknuepfte Buchungen – nicht umkategorisiert:
Loest man die Verknuepfung, zaehlt die Buchung mit ihrer alten Kategorie
sofort wieder mit.

Automatisch nur, wenn die Zuordnung zwingend ist UND keine Seite eine
Kategorie traegt UND der Text auf einen Uebertrag hindeutet. Sonst Vorschlag.
Waehrungswechsel werden ueber den EZB-Kurs des Tages geprueft (2,5 % Toleranz
fuer den Bankaufschlag) – der Kurs dient nur als Pruefung, gespeichert wird
nichts davon.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-23 22:41:34 +02:00
co-authored by Claude Opus 5
parent e8a862bd63
commit c9a598a056
9 changed files with 874 additions and 2 deletions
+12
View File
@@ -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),
+103
View File
@@ -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}