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
+62
View File
@@ -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"),
)