Files
certain/backend/app/routers/uebertraege.py
T
ScarriffleandClaude Opus 5 eb72f0cb27 feat: Einer Buchung sieht man an, ob sie ein Uebertrag ist – und was sie war
Drei Dinge, die zusammengehoeren.

1. Uebertraege sind als solche lesbar.

Stand nur eine Seite in der Liste – weil nach einem Konto gefiltert wird
oder die andere nicht mitgeladen war – trug die Zeile den Rohtext der
Bank als Ueberschrift. "To CHF MB:8f33c0aa-9c92-456b-a6fe-b64a858a019c"
sah aus wie eine Zahlung an jemanden; die Verknuepfung stand klein
darunter. Jetzt steht oben, zwischen WELCHEN Konten das Geld lief, in
beiden Faellen gleich, und daneben eine Marke "Transfer". Ein Uebertrag
ist weder Ausgabe noch Einnahme – das gehoert in die erste Zeile, nicht
in eine Fussnote.

2. Notiz je Buchung.

Das Feld gab es im Modell seit jeher, die Oberflaeche bot es nie an. Die
Kategorie sagt, in welche Schublade etwas gehoert – nicht, WAS es war.
"Amazon" kann Einrichtung sein, Werkzeug oder ein Geschenk, und in drei
Monaten weiss das niemand mehr. Die Notiz steht in der Liste und ist
mitdurchsuchbar: Wer sie schreibt, will spaeter danach suchen.

3. "Ignorieren" ist keine Einbahnstrasse mehr.

Ein abgelehntes Paar blieb bei jedem weiteren Suchlauf stumm, und nichts
verriet, dass es je einen Vorschlag gab. Wer einmal falsch klickte,
suchte danach einen Fehler in der Erkennung, den es nicht gab. Die
abgelehnten Paare lassen sich jetzt ansehen und zurueckholen.

Und wenn die Vorschlaege nicht geladen werden koennen, wird das gesagt
statt verschluckt – sonst sieht die Liste aus, als gaebe es nichts zu
verbinden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-01 20:49:01 +02:00

174 lines
6.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Ü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.get("/vorschlaege")
def vorschlaege(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
"""Nur fragen, nichts tun.
Getrennt von ``/suchen``: Eine Ansicht, die bloss „ist das ein Übertrag?"
anzeigt, darf beim Hinsehen nichts verknüpfen.
"""
return uebertraege.finde(db, user, nur_vorschlaege=True)
@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.get("/ignoriert")
def ignorierte(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> list[dict]:
"""Welche Vorschlaege wurden abgelehnt?
Ohne diese Liste war „Ignorieren" eine Einbahnstrasse: Das Paar blieb bei
jedem weiteren Suchlauf stumm, und nichts verriet, dass es je einen
Vorschlag gab. Wer einmal falsch klickte, suchte danach einen Fehler in
der Erkennung, den es nicht gab.
"""
eintraege = (
db.query(LinkIgnored)
.filter(LinkIgnored.owner_id == user.id)
.order_by(LinkIgnored.id.desc())
.all()
)
ids = {i for e in eintraege for i in (e.a_txn_id, e.b_txn_id)}
buchungen = {
t.id: t for t in db.query(Transaction).filter(Transaction.id.in_(ids or [0]))
}
namen = dict(db.query(Account.id, Account.name).filter(Account.owner_id == user.id))
def kurz(txn_id: int) -> dict | None:
t = buchungen.get(txn_id)
if t is None:
return None
return {
"id": t.id, "date": t.date.isoformat(), "amount_cents": t.amount_cents,
"payee": t.payee or t.description,
"account_name": namen.get(t.account_id, "?"),
}
ergebnis = []
for e in eintraege:
a, b = kurz(e.a_txn_id), kurz(e.b_txn_id)
# Ist eine der beiden Buchungen geloescht, gibt es nichts mehr
# zurueckzunehmen – der Eintrag waere nur noch Rauschen.
if a and b:
ergebnis.append({"a": a, "b": b})
return ergebnis
@router.delete("/ignoriert", status_code=status.HTTP_204_NO_CONTENT)
def ignorieren_aufheben(
payload: PaarIn, db: Session = Depends(get_db), user: User = Depends(get_current_user)
) -> None:
"""Die Ablehnung zuruecknehmen – das Paar wird wieder vorgeschlagen."""
a, b = _paar(db, user, payload)
klein, gross = sorted((a.id, b.id))
(
db.query(LinkIgnored)
.filter(LinkIgnored.owner_id == user.id,
LinkIgnored.a_txn_id == klein, LinkIgnored.b_txn_id == gross)
.delete()
)
db.commit()
# Vor der Route mit dem Platzhalter, sonst verschluckt "/{link_id}" den
# wortwoertlichen Pfad: FastAPI nimmt die erste passende Route, und
# "ignoriert" laesst sich nicht als Zahl lesen (422 statt 204).
@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}