diff --git a/backend/app/models.py b/backend/app/models.py index 820dc86..e11e2e0 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -23,6 +23,7 @@ from sqlalchemy import ( DateTime, Enum as SAEnum, ForeignKey, + Index, Integer, LargeBinary, Numeric, @@ -370,9 +371,44 @@ class Transaction(Base): ) import_hash: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + # Die Kennung, unter der die Quelle diese Zahlung fuehrt – bei der Bank die + # des Umsatzes, mit der Quelle davor ("bank:…", "mail:…", "shortcut:…"). + # + # Sie steht bewusst HIER und nicht nur am Eingangs-Eintrag. Der Eintrag ist + # eine Zwischenstation: Er kann verschwinden, weil ein Konto neu verbunden + # wurde, weil ein aelteres Backup ihn nicht kannte, weil jemand aufgeraeumt + # hat. Die Buchung bleibt. Solange die Kennung an der Buchung haengt, kann + # dieselbe Zahlung kein zweites Mal hereinkommen – und zwar unabhaengig + # davon, ob der pruefende Code davor daran gedacht hat: Die Eindeutigkeit + # weiter unten laesst es gar nicht zu. + # + # Leer bleibt sie bei allem, was keine Kennung mitbringt: von Hand erfasst, + # aus einer CSV-Datei. Dort schuetzt weiterhin nur ``import_hash``, und das + # ist eine Aehnlichkeit, keine Kennung. + # + # 320 statt 255: Die Kennung der Quelle darf selbst schon 255 lang sein, + # der Name der Quelle davor kostet ein paar Zeichen mehr. Abschneiden waere + # hier die falsche Sparsamkeit – zwei abgeschnittene Kennungen saehen + # gleich aus, und dann verschluckt eine Zahlung die andere. + source_uid: Mapped[str | None] = mapped_column(String(320), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now) + __table_args__ = ( + # Ein eindeutiger INDEX, keine UNIQUE-Spaltenregel: Letztere zwingt + # SQLite dazu, die Tabelle neu zu bauen – und dabei reisst das + # implizite DELETE die abhaengigen Zeilen ueber ON DELETE CASCADE mit + # (Uebertrags-Verknuepfungen, abgelehnte Vorschlaege). Siehe + # tests/test_migrations.py. Der Index haelt dasselbe durch, ohne die + # Tabelle anzufassen. + # + # NULL zaehlt in SQL nicht als gleich – Buchungen ohne Kennung stehen + # sich also nicht gegenseitig im Weg. Genau das ist gewollt: Die Regel + # greift nur dort, wo es wirklich eine Kennung gibt. + Index("uq_txn_owner_source_uid", "owner_id", "source_uid", unique=True), + ) + class ExchangeRate(Base): """EZB-Referenzkurs: wie viele Einheiten der Währung ein Euro wert war. diff --git a/backend/app/routers/quick.py b/backend/app/routers/quick.py index bbd95a0..ec8b64d 100644 --- a/backend/app/routers/quick.py +++ b/backend/app/routers/quick.py @@ -30,7 +30,7 @@ from ..models import ( User, ) from ..schemas import InboxItemOut, QuickBookingIn, QuickBookingOut -from ..services import csv_import, currency +from ..services import csv_import, currency, eingang router = APIRouter(prefix="/quick", tags=["quick"]) @@ -118,6 +118,10 @@ def quick_booking( category_id=kategorie_id, payee=(payload.payee or None), description=(payload.note or None), + # Die Kennung des Kurzbefehls gehoert auch an die Buchung, nicht nur an + # den Eingangs-Eintrag darueber: Nur dort ueberlebt sie, wenn der + # Eintrag einmal wegfaellt. + source_uid=eingang.quell_kennung(InboxSource.shortcut, payload.external_id), ) db.add(txn) db.flush() diff --git a/backend/app/services/backup_data.py b/backend/app/services/backup_data.py index 95ad1c6..e97275e 100644 --- a/backend/app/services/backup_data.py +++ b/backend/app/services/backup_data.py @@ -362,8 +362,13 @@ _MERGE_KEYS: dict[str, Callable[[dict], tuple | None]] = { "import_templates": lambda r: ("name", (r.get("name") or "").strip().lower()), "import_batches": lambda r: ("batch", r.get("account_id"), r.get("filename"), r.get("created_at")), + # Die Kennung der Quelle zuerst: Sie ist die einzige EXAKTE Aussage, dass + # zwei Zeilen dieselbe Zahlung sind – und ohne sie liefe „Ergaenzen" gegen + # die Eindeutigkeit von (owner_id, source_uid), statt die Zeile zu + # erkennen. Erst danach der Aehnlichkeits-Schluessel. "transactions": lambda r: ( - ("hash", r["import_hash"]) if r.get("import_hash") + ("uid", r["source_uid"]) if r.get("source_uid") + else ("hash", r["import_hash"]) if r.get("import_hash") else ("nat", r.get("date"), r.get("account_id"), r.get("amount_cents"), (r.get("payee") or "").strip().lower()) ), diff --git a/backend/app/services/bank_sync.py b/backend/app/services/bank_sync.py index 06c3906..fe75abb 100644 --- a/backend/app/services/bank_sync.py +++ b/backend/app/services/bank_sync.py @@ -673,6 +673,15 @@ def hole_umsaetze(db: Session, user: User, verbindung: BankConnection, InboxItem.external_id.in_(kennungen)) .first() ) + if schon is None: + # Und dann noch an den BUCHUNGEN selbst. Der Eingangs-Eintrag + # ist eine Zwischenstation: Er faellt weg, wenn ein Konto + # geloescht wird, und aeltere Backups kannten ihn nicht. Die + # Buchung bleibt – und traegt die Kennung seit 0027 selbst. + schon = eingang.gebuchte_mit_kennung( + db, user, + [eingang.quell_kennung(InboxSource.bank, k) for k in kennungen], + ) if schon is not None: continue kennung = kennungen[0] diff --git a/backend/app/services/eingang.py b/backend/app/services/eingang.py index 2392ab8..39ff9df 100644 --- a/backend/app/services/eingang.py +++ b/backend/app/services/eingang.py @@ -15,6 +15,7 @@ from sqlalchemy.orm import Session from ..models import ( Account, InboxItem, + InboxSource, InboxStatus, Transaction, TxnType, @@ -25,6 +26,35 @@ from . import currency log = logging.getLogger(__name__) +def quell_kennung(source: InboxSource, external_id: str | None) -> str | None: + """Die Kennung, wie sie an der Buchung steht – oder None ohne Kennung. + + Der Name der Quelle gehoert davor: Eine Nachrichten-Kennung aus dem + Postfach und eine Umsatzkennung der Bank duerfen nie zufaellig gleich + lauten, sonst verschluckt die eine Zahlung die andere. + """ + if not external_id: + return None + return f"{source.value}:{external_id}"[:320] + + +def gebuchte_mit_kennung(db: Session, user: User, + kennungen: list[str | None]) -> Transaction | None: + """Die Buchung zu einer dieser Kennungen, falls es sie schon gibt. + + Der Weg an ``inbox_items`` vorbei ist der Punkt: Der Eingangs-Eintrag ist + eine Zwischenstation und kann fehlen, die Buchung bleibt. + """ + werte = [k for k in kennungen if k] + if not werte: + return None + return ( + db.query(Transaction) + .filter(Transaction.owner_id == user.id, Transaction.source_uid.in_(werte)) + .first() + ) + + def buche(db: Session, user: User, eintrag: InboxItem) -> Transaction | None: """Aus einem Vorschlag eine Buchung machen. Ohne Konto geht es nicht.""" if eintrag.status == InboxStatus.booked or eintrag.transaction_id is not None: @@ -34,6 +64,22 @@ def buche(db: Session, user: User, eintrag: InboxItem) -> Transaction | None: konto = db.get(Account, eintrag.account_id) if konto is None or konto.owner_id != user.id: return None + + kennung = quell_kennung(eintrag.source, eintrag.external_id) + if kennung is not None: + vorhanden = gebuchte_mit_kennung(db, user, [kennung]) + if vorhanden is not None: + # Diese Zahlung ist schon gebucht – unter einem frueheren + # Eingangs-Eintrag, den es nicht mehr gibt. Der Eintrag wird + # trotzdem auf die vorhandene Buchung gezeigt und geschlossen: + # Bliebe er offen, luede er beim naechsten Ansehen zum zweiten + # Buchen ein, und genau das soll die Kennung verhindern. + eintrag.transaction_id = vorhanden.id + eintrag.status = InboxStatus.booked + log.info("Eingang %s zeigt auf die vorhandene Buchung %s (%s)", + eintrag.id, vorhanden.id, kennung) + return None + betrag = abs(eintrag.amount_cents) txn = Transaction( owner_id=user.id, @@ -46,6 +92,7 @@ def buche(db: Session, user: User, eintrag: InboxItem) -> Transaction | None: category_id=eintrag.category_id, payee=eintrag.payee, description=eintrag.note, + source_uid=kennung, ) db.add(txn) db.flush() diff --git a/backend/app/services/uebertrag_buchen.py b/backend/app/services/uebertrag_buchen.py index 551f827..c08dcda 100644 --- a/backend/app/services/uebertrag_buchen.py +++ b/backend/app/services/uebertrag_buchen.py @@ -42,7 +42,7 @@ from ..models import ( TxnType, User, ) -from . import buchung, csv_import, currency +from . import buchung, csv_import, currency, eingang from .csv_import import INBOX_TAGE from .uebertraege import FX_TOLERANZ @@ -320,8 +320,15 @@ def aus_eingang(db: Session, user: User, eintrag: InboxItem, ziel: Account, TxnType.expense if eintrag.amount_cents < 0 else TxnType.income, ziel_betrag, eintrag.date, exakt, trotzdem) + # Auch als Uebertrag gebucht bleibt es DIESELBE Zahlung der Bank. Ohne die + # Kennung an der Buchung waere sie beim naechsten Abruf wieder neu. + kennung = eingang.quell_kennung(eintrag.source, eintrag.external_id) + if kennung is not None and eingang.gebuchte_mit_kennung(db, user, [kennung]): + raise UebertragFehler("Diese Zahlung ist schon gebucht") + txn = _lege_an(db, user, konto, ziel, eintrag.amount_cents, ziel_betrag, eintrag.date, eintrag.payee, eintrag.note, kurs) + txn.source_uid = kennung eintrag.transaction_id = txn.id eintrag.status = InboxStatus.booked eintrag.category_id = None diff --git a/backend/migrations/versions/0027_buchung_quellkennung.py b/backend/migrations/versions/0027_buchung_quellkennung.py new file mode 100644 index 0000000..1bed02e --- /dev/null +++ b/backend/migrations/versions/0027_buchung_quellkennung.py @@ -0,0 +1,70 @@ +"""Die Kennung der Quelle steht an der Buchung selbst. + +Bisher lag sie nur am Eingangs-Eintrag (``inbox_items.external_id``). Das hat +gereicht, solange dieser Eintrag da war – und genau das ist er nicht immer: Er +faellt weg, wenn ein Konto geloescht wird, er fehlt in aelteren Backups, und +nach einem neu verbundenen Bankkonto passt sein Praefix nicht mehr. Dann war +die Zahlung fuer den naechsten Abruf neu, obwohl sie laengst gebucht war. + +Die Kennung gehoert deshalb an die Buchung. Dort ueberlebt sie alles, was mit +dem Eingang passiert, und die Eindeutigkeit haelt die Datenbank selbst durch – +nicht erst der pruefende Code davor. + +Vorhandene Buchungen bekommen die Kennung nachgetragen, solange ihr +Eingangs-Eintrag noch existiert. Was jetzt schon verloren ist, bleibt verloren; +ab hier faellt nichts Neues mehr aus. + +Revision ID: 0027 +Revises: 0026 +""" + +import sqlalchemy as sa +from alembic import op + +revision = "0027" +down_revision = "0026" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + with op.batch_alter_table("transactions") as batch: + batch.add_column(sa.Column("source_uid", sa.String(length=320), nullable=True)) + + # Nachtragen, was der Eingang noch weiss. Der Name der Quelle kommt davor: + # Eine Nachrichten-Kennung aus dem Postfach und eine Umsatzkennung der Bank + # duerfen nie zufaellig gleich lauten. + # + # Zwei Eingangs-Eintraege koennen auf dieselbe Buchung zeigen (die beiden + # Seiten eines Uebertrags). Dann gewinnt der aeltere – ``MIN(id)``. Zwei + # verschiedene Buchungen koennen dagegen nicht dieselbe Kennung bekommen: + # (owner_id, source, external_id) ist im Eingang bereits eindeutig. + op.execute(sa.text(""" + UPDATE transactions SET source_uid = ( + SELECT i.source || ':' || i.external_id + FROM inbox_items i + WHERE i.transaction_id = transactions.id + AND i.owner_id = transactions.owner_id + AND i.external_id IS NOT NULL + ORDER BY i.id + LIMIT 1 + ) + WHERE source_uid IS NULL + """)) + + # Erst jetzt eindeutig stellen – vorher stuende die Regel ueber leeren + # Spalten und der Nachtrag koennte sie nicht mehr fuellen. + # + # Ein INDEX, keine UNIQUE-Spaltenregel: Letztere ginge nur ueber einen + # Tabellen-Umbau, und der loescht auf SQLite per ON DELETE CASCADE die + # Uebertrags-Verknuepfungen und abgelehnten Vorschlaege mit. Genau davor + # steht tests/test_migrations.py. Der Index haelt dasselbe durch, ohne die + # Tabelle anzufassen. + op.create_index("uq_txn_owner_source_uid", "transactions", + ["owner_id", "source_uid"], unique=True) + + +def downgrade() -> None: + op.drop_index("uq_txn_owner_source_uid", table_name="transactions") + with op.batch_alter_table("transactions") as batch: + batch.drop_column("source_uid") diff --git a/backend/tests/test_bank.py b/backend/tests/test_bank.py index 6f79b13..e3462b7 100644 --- a/backend/tests/test_bank.py +++ b/backend/tests/test_bank.py @@ -1346,3 +1346,130 @@ def test_umsatz_unter_anderer_kennung_kommt_nicht_zweimal(admin, fake_bank): zweiter = admin.post(f"/api/bank/links/{link['id']}/sync").json() assert zweiter["neu"] == 0, "unter neuer Referenz noch einmal gebucht" assert admin.get(f"/api/accounts/{konto['id']}").json()["balance_cents"] == -28800 + + +# --- Die Kennung steht an der Buchung --------------------------------------- + + +def _eingaenge_loeschen(uid: str) -> int: + """Alle Eingangs-Eintraege dieses Bankkontos entfernen. + + Stellt nach, was im Betrieb wirklich passiert: Der Eintrag ist eine + Zwischenstation und faellt weg – geloeschtes Konto, aelteres Backup, + neu verbundene Bank mit neuer Kennung. Die Buchung bleibt. + """ + from app.database import SessionLocal + from app.models import InboxItem + + with SessionLocal() as s: + eintraege = s.query(InboxItem).filter( + InboxItem.external_id.like(f"{uid}:%")).all() + for e in eintraege: + s.delete(e) + s.commit() + return len(eintraege) + + +def test_kennung_der_bank_steht_an_der_buchung(admin, fake_bank): + """Nicht nur am Eingangs-Eintrag. Nur an der Buchung ueberlebt sie alles, + was mit dem Eingang passieren kann.""" + from app.database import SessionLocal + from app.models import Transaction + + _app(admin) + uid = f"acc-{secrets.token_hex(4)}" + tx = f"tx-{secrets.token_hex(5)}" + fake_bank(konten=[{"uid": uid, "currency": "EUR"}], + salden=[{"balance_type": "CLBD", + "balance_amount": {"amount": "0.00", "currency": "EUR"}}], + umsaetze=[{"transaction_id": tx, "booking_date": date.today().isoformat(), + "transaction_amount": {"amount": "31.40", "currency": "EUR"}, + "credit_debit_indicator": "DBIT", + "creditor": {"name": "Baecker Ohnesorg"}}]) + state = admin.post("/api/bank/connect", + json={"aspsp_name": f"Steht-{secrets.token_hex(3)}", + "country": "DE"}).json()["state"] + admin.post(f"/api/bank/callback?state={state}&code=guter-code") + link = [v for v in admin.get("/api/bank/links").json() if v["remote_uid"] == uid][0] + konto = _konto(admin, name=f"Steht {secrets.token_hex(3)}", currency="EUR") + admin.post(f"/api/bank/links/{link['id']}/account/{konto['id']}") + + assert admin.post(f"/api/bank/links/{link['id']}/sync").json()["neu"] == 1 + + with SessionLocal() as s: + txn = s.query(Transaction).filter(Transaction.account_id == konto["id"]).one() + assert txn.source_uid == f"bank:{uid}:{tx}" + + +def test_ohne_eingangs_eintrag_kommt_der_umsatz_nicht_zurueck(admin, fake_bank): + """Faellt der Eingangs-Eintrag weg, blieb bisher nur noch die unscharfe + Aehnlichkeit. Jetzt erkennt die Kennung an der Buchung den Umsatz exakt + wieder – daran, dass ``doppelt`` bei 0 bleibt, sieht man den Unterschied.""" + _app(admin) + heute = date.today().isoformat() + uid = f"acc-{secrets.token_hex(4)}" + salden = [{"balance_type": "CLBD", + "balance_amount": {"amount": "0.00", "currency": "EUR"}}] + umsatz = {"transaction_id": f"tx-{secrets.token_hex(5)}", "booking_date": heute, + "transaction_amount": {"amount": "76.20", "currency": "EUR"}, + "credit_debit_indicator": "DBIT", + "creditor": {"name": "Werkzeug Nadolny"}} + + fake_bank(konten=[{"uid": uid, "currency": "EUR"}], salden=salden, umsaetze=[umsatz]) + state = admin.post("/api/bank/connect", + json={"aspsp_name": f"Ohne-{secrets.token_hex(3)}", + "country": "DE"}).json()["state"] + admin.post(f"/api/bank/callback?state={state}&code=guter-code") + link = [v for v in admin.get("/api/bank/links").json() if v["remote_uid"] == uid][0] + konto = _konto(admin, name=f"Ohne {secrets.token_hex(3)}", currency="EUR") + admin.post(f"/api/bank/links/{link['id']}/account/{konto['id']}") + + assert admin.post(f"/api/bank/links/{link['id']}/sync").json()["neu"] == 1 + assert _eingaenge_loeschen(uid) == 1 + + fake_bank(konten=[{"uid": uid, "currency": "EUR"}], salden=salden, umsaetze=[umsatz]) + zweiter = admin.post(f"/api/bank/links/{link['id']}/sync").json() + assert zweiter["neu"] == 0, "ohne Eingangs-Eintrag noch einmal gebucht" + assert zweiter["doppelt"] == 0, "nur noch unscharf erkannt statt an der Kennung" + assert admin.get(f"/api/accounts/{konto['id']}").json()["balance_cents"] == -7620 + + +def test_als_uebertrag_gebuchter_umsatz_kommt_nicht_zurueck(admin, fake_bank): + """Der Fall, in dem sonst NICHTS mehr greift. + + Ein Uebertrag ist von der Aehnlichkeitspruefung ausgenommen (er hat kein + Gegenueber in der Ausgaben-/Einnahmen-Liste), und ohne Eingangs-Eintrag + gibt es auch dort nichts zu finden. Bleibt allein die Kennung. + """ + _app(admin) + heute = date.today().isoformat() + uid = f"acc-{secrets.token_hex(4)}" + salden = [{"balance_type": "CLBD", + "balance_amount": {"amount": "0.00", "currency": "EUR"}}] + umsatz = {"transaction_id": f"tx-{secrets.token_hex(5)}", "booking_date": heute, + "transaction_amount": {"amount": "500.00", "currency": "EUR"}, + "credit_debit_indicator": "DBIT", + "creditor": {"name": "Eigenes Sparkonto"}} + + fake_bank(konten=[{"uid": uid, "currency": "EUR"}], salden=salden, umsaetze=[umsatz]) + state = admin.post("/api/bank/connect", + json={"aspsp_name": f"Uebertrag-{secrets.token_hex(3)}", + "country": "DE"}).json()["state"] + admin.post(f"/api/bank/callback?state={state}&code=guter-code") + link = [v for v in admin.get("/api/bank/links").json() if v["remote_uid"] == uid][0] + konto = _konto(admin, name=f"Uebertrag {secrets.token_hex(3)}", currency="EUR") + sparkonto = _konto(admin, name=f"Sparen {secrets.token_hex(3)}", currency="EUR", + type="sparkonto") + admin.post(f"/api/bank/links/{link['id']}/account/{konto['id']}") + assert admin.post(f"/api/bank/links/{link['id']}/sync").json()["neu"] == 1 + + gebucht = admin.get(f"/api/transactions?account_id={konto['id']}").json()[0] + r = admin.post(f"/api/transactions/{gebucht['id']}/transfer", + json={"to_account_id": sparkonto["id"], "trotzdem": True}) + assert r.status_code == 200, r.text + assert _eingaenge_loeschen(uid) >= 1 + + fake_bank(konten=[{"uid": uid, "currency": "EUR"}], salden=salden, umsaetze=[umsatz]) + zweiter = admin.post(f"/api/bank/links/{link['id']}/sync").json() + assert zweiter["neu"] == 0, "als Uebertrag gebucht und trotzdem neu geholt" + assert admin.get(f"/api/accounts/{konto['id']}").json()["balance_cents"] == -50000