fix: Die Kennung der Bank steht jetzt an der Buchung selbst

Bisher lag sie nur am Eingangs-Eintrag. Das reicht nicht: Der Eintrag ist eine
Zwischenstation und faellt weg, wenn ein Konto geloescht wird oder ein aelteres
Backup ihn nicht kannte. Dann war die Zahlung fuer den naechsten Abruf neu,
obwohl sie laengst gebucht war.

Am schlimmsten bei einem als Uebertrag gebuchten Umsatz: Der ist von der
Aehnlichkeitspruefung ausgenommen, dort griff also gar nichts mehr.

Die Kennung gehoert deshalb an die Buchung (transactions.source_uid, mit der
Quelle davor). Dort ueberlebt sie alles, was mit dem Eingang passiert, und die
Eindeutigkeit haelt die Datenbank selbst durch – nicht erst der pruefende Code
davor. Gestempelt wird an allen drei Buchungswegen: Eingang, Kurzbefehl und
Uebertrag.

Vorhandene Buchungen bekommen die Kennung nachgetragen, solange ihr
Eingangs-Eintrag noch da ist.

Ohne Kennung – von Hand erfasst, aus einer CSV-Datei – bleibt es beim
bisherigen Schutz ueber die Aehnlichkeit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-07 11:59:43 +02:00
co-authored by Claude Opus 5
parent 175d9f50e0
commit 991a8e4fc2
8 changed files with 308 additions and 3 deletions
+36
View File
@@ -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.