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:
@@ -39,6 +39,7 @@ from .routers import (
|
|||||||
inbox,
|
inbox,
|
||||||
mail,
|
mail,
|
||||||
quick,
|
quick,
|
||||||
|
uebertraege,
|
||||||
rates,
|
rates,
|
||||||
settings as settings_router,
|
settings as settings_router,
|
||||||
transactions,
|
transactions,
|
||||||
@@ -158,6 +159,7 @@ api.include_router(rates.router)
|
|||||||
api.include_router(quick.router)
|
api.include_router(quick.router)
|
||||||
api.include_router(inbox.router)
|
api.include_router(inbox.router)
|
||||||
api.include_router(mail.router)
|
api.include_router(mail.router)
|
||||||
|
api.include_router(uebertraege.router)
|
||||||
|
|
||||||
# Fehler an den Kurzbefehl-Endpunkten zusätzlich als „message" ausliefern.
|
# Fehler an den Kurzbefehl-Endpunkten zusätzlich als „message" ausliefern.
|
||||||
# Der Kurzbefehl auf dem Telefon zeigt genau dieses Feld an; bei einem Fehler
|
# Der Kurzbefehl auf dem Telefon zeigt genau dieses Feld an; bei einem Fehler
|
||||||
|
|||||||
@@ -769,3 +769,65 @@ class PendingUpload(Base):
|
|||||||
content: Mapped[bytes] = mapped_column(LargeBinary, nullable=False)
|
content: Mapped[bytes] = mapped_column(LargeBinary, nullable=False)
|
||||||
size_bytes: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
size_bytes: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
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"),
|
||||||
|
)
|
||||||
|
|||||||
@@ -81,6 +81,18 @@ def _validate_category(db: Session, user: User, category_id: int | None) -> None
|
|||||||
get_owned(db, Category, category_id, user)
|
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])
|
@router.get("", response_model=list[TransactionOut])
|
||||||
def list_transactions(
|
def list_transactions(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -189,8 +189,15 @@ def _neutrale_kategorien(db: Session, user: User) -> set[int]:
|
|||||||
|
|
||||||
def actual_expense_by_category(db: Session, user: User, start: date, end: date, base: str) -> dict[int, int]:
|
def actual_expense_by_category(db: Session, user: User, start: date, end: date, base: str) -> dict[int, int]:
|
||||||
"""Ist-Ausgaben je Kategorie in Hauptwährung (inkl. budgetierter Sparen-Transfers)."""
|
"""Ist-Ausgaben je Kategorie in Hauptwährung (inkl. budgetierter Sparen-Transfers)."""
|
||||||
|
from .uebertraege import verknuepfte_ids
|
||||||
|
|
||||||
result: dict[int, int] = defaultdict(int)
|
result: dict[int, int] = defaultdict(int)
|
||||||
neutral = _neutrale_kategorien(db, user)
|
neutral = _neutrale_kategorien(db, user)
|
||||||
|
# Verknüpfte Buchungen sind zwei Seiten derselben Bewegung zwischen eigenen
|
||||||
|
# Konten – Geld wurde verschoben, nicht ausgegeben. Übersprungen, nicht
|
||||||
|
# umkategorisiert: Löst man die Verknüpfung, zählt die Buchung mit ihrer
|
||||||
|
# alten Kategorie sofort wieder mit.
|
||||||
|
verknuepft = verknuepfte_ids(db, user)
|
||||||
expenses = (
|
expenses = (
|
||||||
db.query(Transaction)
|
db.query(Transaction)
|
||||||
.filter(
|
.filter(
|
||||||
@@ -203,7 +210,7 @@ def actual_expense_by_category(db: Session, user: User, start: date, end: date,
|
|||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
for t in expenses:
|
for t in expenses:
|
||||||
if t.category_id in neutral:
|
if t.category_id in neutral or t.id in verknuepft:
|
||||||
continue
|
continue
|
||||||
result[t.category_id] += _to_base(db, t.amount_eur_cents, base, t.date)
|
result[t.category_id] += _to_base(db, t.amount_eur_cents, base, t.date)
|
||||||
|
|
||||||
@@ -230,8 +237,12 @@ def actual_expense_by_category(db: Session, user: User, start: date, end: date,
|
|||||||
|
|
||||||
|
|
||||||
def income_by_category(db: Session, user: User, start: date, end: date, base: str) -> dict[int | None, int]:
|
def income_by_category(db: Session, user: User, start: date, end: date, base: str) -> dict[int | None, int]:
|
||||||
|
from .uebertraege import verknuepfte_ids
|
||||||
|
|
||||||
result: dict[int | None, int] = defaultdict(int)
|
result: dict[int | None, int] = defaultdict(int)
|
||||||
neutral = _neutrale_kategorien(db, user)
|
neutral = _neutrale_kategorien(db, user)
|
||||||
|
# Die Gegenseite eines Übertrags ist keine Einnahme (siehe oben).
|
||||||
|
verknuepft = verknuepfte_ids(db, user)
|
||||||
incomes = (
|
incomes = (
|
||||||
db.query(Transaction)
|
db.query(Transaction)
|
||||||
.filter(
|
.filter(
|
||||||
@@ -243,7 +254,7 @@ def income_by_category(db: Session, user: User, start: date, end: date, base: st
|
|||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
for t in incomes:
|
for t in incomes:
|
||||||
if t.category_id in neutral:
|
if t.category_id in neutral or t.id in verknuepft:
|
||||||
continue
|
continue
|
||||||
result[t.category_id] += _to_base(db, t.amount_eur_cents, base, t.date)
|
result[t.category_id] += _to_base(db, t.amount_eur_cents, base, t.date)
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import csv
|
|||||||
import hashlib
|
import hashlib
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
|
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
|
||||||
@@ -34,6 +35,8 @@ from ..models import (
|
|||||||
)
|
)
|
||||||
from . import currency
|
from . import currency
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# Vorgefertigte Bank-Vorlagen (aus echten Export-Beispielen). Erscheinen im
|
# Vorgefertigte Bank-Vorlagen (aus echten Export-Beispielen). Erscheinen im
|
||||||
# Import als auswählbare Presets und füllen die Format-/Spalten-Einstellungen.
|
# Import als auswählbare Presets und füllen die Format-/Spalten-Einstellungen.
|
||||||
@@ -1046,6 +1049,16 @@ def commit_import(db: Session, user: User, account: Account, template_id: int |
|
|||||||
|
|
||||||
batch.imported_count = imported
|
batch.imported_count = imported
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
# Frisch gebuchte Zeilen auf Überträge prüfen. Läuft NACH dem Buchen, nicht
|
||||||
|
# währenddessen: Der Import bleibt damit unangetastet, und wenn die Suche
|
||||||
|
# etwas falsch macht, ist es ein Etikett – keine Buchung.
|
||||||
|
try:
|
||||||
|
from . import uebertraege
|
||||||
|
uebertraege.finde(db, user)
|
||||||
|
except Exception: # Nie den Import daran scheitern lassen
|
||||||
|
log.exception("Übertrags-Suche nach dem Import fehlgeschlagen")
|
||||||
|
|
||||||
db.refresh(batch)
|
db.refresh(batch)
|
||||||
return batch
|
return batch
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,322 @@
|
|||||||
|
"""Gleichwährige Überträge zwischen eigenen Konten erkennen.
|
||||||
|
|
||||||
|
Ein Übertrag steht in zwei Auszügen: als Abgang beim einen Konto, als Zugang
|
||||||
|
beim anderen. Beide werden gebucht – das ist richtig, denn beide Salden bewegen
|
||||||
|
sich. Nur zählen sie ohne Zusammenhang als Ausgabe UND als Einnahme in der
|
||||||
|
Auswertung, obwohl kein Geld ausgegeben wurde.
|
||||||
|
|
||||||
|
Dieses Modul findet solche Paare und legt eine ``TransferLink`` an. An den
|
||||||
|
Buchungen selbst wird nichts geändert; die Verknüpfung ist eine Schicht
|
||||||
|
obendrauf. Eine falsche Verknüpfung ist damit ein Etikett, das man abzieht –
|
||||||
|
kein Eingriff, der rückgängig gemacht werden müsste.
|
||||||
|
|
||||||
|
Die Regeln sind bewusst eng. Der Vorgänger hat Zielkonten aus Texten geraten
|
||||||
|
und Beträge über Wechselkurse geschätzt; beides hat Salden verfälscht. Hier
|
||||||
|
gilt: exakt gleicher Betrag bei gleicher Währung, gleicher Tag.
|
||||||
|
|
||||||
|
Bei VERSCHIEDENEN Währungen wird der EZB-Kurs des Tages herangezogen – aber nur
|
||||||
|
als Prüfung, ob zwei Beträge zueinander passen können. Gespeichert wird nichts
|
||||||
|
davon; beide Buchungen behalten den Betrag, der in ihrem Auszug steht. Genau
|
||||||
|
das war der Unterschied zum alten Ansatz: Der hat den Gegenbetrag geschätzt und
|
||||||
|
in die Buchung geschrieben, und damit Salden verfälscht. Eine Verknüpfung kann
|
||||||
|
das nicht – sie ist nur ein Etikett.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..models import (
|
||||||
|
Account,
|
||||||
|
AccountIdentifier,
|
||||||
|
IdentifierKind,
|
||||||
|
LinkIgnored,
|
||||||
|
Transaction,
|
||||||
|
TransferLink,
|
||||||
|
TxnType,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
from . import currency
|
||||||
|
from .csv_import import normalize_text
|
||||||
|
|
||||||
|
# Wörter, die eine Bewegung zwischen eigenen Konten ankündigen. Ein Treffer
|
||||||
|
# allein verknüpft noch nichts – er erlaubt nur, dass es ohne Rückfrage
|
||||||
|
# geschieht. Ohne Treffer wird vorgeschlagen.
|
||||||
|
HINWEISE = (
|
||||||
|
"übertrag", "uebertrag", "umbuchung", "einzahlung von", "auszahlung",
|
||||||
|
"transfer", "gutschrift von", "von konto", "auf konto", "eigenübertrag",
|
||||||
|
"umgetauscht", "umtausch", "exchange", "wechsel",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Wie weit ein Betrag bei Währungswechsel vom EZB-Kurs abweichen darf. Banken
|
||||||
|
# nehmen einen Aufschlag; Revolut liegt werktags nahe am Referenzkurs, eine
|
||||||
|
# klassische Überweisung deutlich darüber. Enger als 2,5 % würde echte Wechsel
|
||||||
|
# verwerfen, weiter würde beliebige Beträge zusammenwürfeln.
|
||||||
|
FX_TOLERANZ = 0.025
|
||||||
|
|
||||||
|
|
||||||
|
def _trifft_als_wort(nadel: str, heu: str) -> bool:
|
||||||
|
"""Nur an Wortgrenzen suchen, nicht mitten im Wort.
|
||||||
|
|
||||||
|
Ein kurzer Kontoname als blosser Teilstring richtet Schaden an: „Euro"
|
||||||
|
steckt auch in „PayPal Europe" und „Europa-Park". Genau daran ist die alte
|
||||||
|
Transfer-Erkennung gescheitert.
|
||||||
|
"""
|
||||||
|
if not nadel:
|
||||||
|
return False
|
||||||
|
return re.search(rf"(?<![0-9a-zäöüß]){re.escape(nadel)}(?![0-9a-zäöüß])", heu) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _text(txn: Transaction) -> str:
|
||||||
|
return normalize_text(f"{txn.payee or ''} {txn.description or ''}")
|
||||||
|
|
||||||
|
|
||||||
|
def _kontobezeichner(db: Session, user: User) -> dict[int, list[str]]:
|
||||||
|
"""Je Konto die Wörter, an denen es im Buchungstext erkennbar ist:
|
||||||
|
sein Name und seine Erkennungs-Texte."""
|
||||||
|
je_konto: dict[int, list[str]] = defaultdict(list)
|
||||||
|
for konto in db.query(Account).filter(Account.owner_id == user.id):
|
||||||
|
name = normalize_text(konto.name)
|
||||||
|
if len(name) >= 4:
|
||||||
|
je_konto[konto.id].append(name)
|
||||||
|
for acc_id, kind, wert in (
|
||||||
|
db.query(AccountIdentifier.account_id, AccountIdentifier.kind, AccountIdentifier.value_norm)
|
||||||
|
.join(Account, Account.id == AccountIdentifier.account_id)
|
||||||
|
.filter(Account.owner_id == user.id)
|
||||||
|
):
|
||||||
|
if kind == IdentifierKind.text and wert and len(wert) >= 3:
|
||||||
|
je_konto[acc_id].append(normalize_text(wert))
|
||||||
|
return je_konto
|
||||||
|
|
||||||
|
|
||||||
|
def _hat_hinweis(a: Transaction, b: Transaction, bezeichner: dict[int, list[str]]) -> bool:
|
||||||
|
"""Deutet mindestens eine der beiden Zeilen auf einen Übertrag hin?
|
||||||
|
|
||||||
|
Ohne diese Hürde würde ein zufälliger Treffer – 50.00 im Laden bezahlt, am
|
||||||
|
selben Tag 50.00 auf einem anderen Konto eingegangen – als „eindeutig"
|
||||||
|
gelten und eine echte Ausgabe samt echter Einnahme aus der Statistik
|
||||||
|
nehmen. Der Saldo bliebe richtig, die Auswertung nicht.
|
||||||
|
"""
|
||||||
|
for txn, gegen in ((a, b), (b, a)):
|
||||||
|
text = _text(txn)
|
||||||
|
if any(h in text for h in HINWEISE):
|
||||||
|
return True
|
||||||
|
# Der Name oder Erkennungs-Text des ANDEREN Kontos im Text – an
|
||||||
|
# Wortgrenzen geprüft, damit sich „Euro" nicht wieder „Europe" einfängt.
|
||||||
|
for wort in bezeichner.get(gegen.account_id, ()):
|
||||||
|
if _trifft_als_wort(wort, text):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _kandidaten(db: Session, user: User) -> dict[int, list[Transaction]]:
|
||||||
|
"""Zu jeder unverknüpften Buchung die möglichen Gegenstücke."""
|
||||||
|
verknuepft = set()
|
||||||
|
for a, b in db.query(TransferLink.from_txn_id, TransferLink.to_txn_id).filter(
|
||||||
|
TransferLink.owner_id == user.id
|
||||||
|
):
|
||||||
|
verknuepft.update((a, b))
|
||||||
|
ignoriert = {
|
||||||
|
tuple(sorted(p))
|
||||||
|
for p in db.query(LinkIgnored.a_txn_id, LinkIgnored.b_txn_id).filter(
|
||||||
|
LinkIgnored.owner_id == user.id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
waehrung = dict(db.query(Account.id, Account.currency).filter(Account.owner_id == user.id))
|
||||||
|
|
||||||
|
buchungen = [
|
||||||
|
t for t in db.query(Transaction).filter(
|
||||||
|
Transaction.owner_id == user.id,
|
||||||
|
Transaction.type.in_((TxnType.expense, TxnType.income)),
|
||||||
|
)
|
||||||
|
if t.id not in verknuepft
|
||||||
|
]
|
||||||
|
|
||||||
|
# Nach Tag und Betrag bündeln: Nur wer beides teilt, kommt überhaupt infrage.
|
||||||
|
nach_schluessel: dict[tuple, list[Transaction]] = defaultdict(list)
|
||||||
|
for t in buchungen:
|
||||||
|
nach_schluessel[(t.date, t.amount_cents)].append(t)
|
||||||
|
|
||||||
|
kandidaten: dict[int, list[Transaction]] = defaultdict(list)
|
||||||
|
for gruppe in nach_schluessel.values():
|
||||||
|
if len(gruppe) < 2:
|
||||||
|
continue
|
||||||
|
for a in gruppe:
|
||||||
|
for b in gruppe:
|
||||||
|
if a.id == b.id or a.account_id == b.account_id:
|
||||||
|
continue
|
||||||
|
if a.type == b.type:
|
||||||
|
continue # eine Ausgabe, eine Einnahme
|
||||||
|
if waehrung.get(a.account_id) != waehrung.get(b.account_id):
|
||||||
|
continue # 100 CHF und 100 EUR sind nicht derselbe
|
||||||
|
# Betrag, nur dieselbe Zahl
|
||||||
|
if tuple(sorted((a.id, b.id))) in ignoriert:
|
||||||
|
continue
|
||||||
|
kandidaten[a.id].append(b)
|
||||||
|
|
||||||
|
# Währungswechsel: gleicher Tag, aber unterschiedliche Beträge. Hier
|
||||||
|
# entscheidet der EZB-Kurs, ob die beiden zueinander passen können.
|
||||||
|
kandidaten = _ergaenze_waehrungswechsel(db, user, buchungen, waehrung,
|
||||||
|
ignoriert, kandidaten)
|
||||||
|
return kandidaten
|
||||||
|
|
||||||
|
|
||||||
|
def _passt_ueber_kurs(db: Session, a: Transaction, b: Transaction,
|
||||||
|
von: str, nach: str) -> bool:
|
||||||
|
"""Passt ``b`` zum umgerechneten ``a``?
|
||||||
|
|
||||||
|
Fehlt der Kurs für den Tag, wird NICHT geraten – dann gibt es kein Paar.
|
||||||
|
"""
|
||||||
|
erwartet = currency.convert(db, a.amount_cents, von, nach, a.date)
|
||||||
|
if erwartet is None or erwartet <= 0:
|
||||||
|
return False
|
||||||
|
return abs(erwartet - b.amount_cents) <= erwartet * FX_TOLERANZ
|
||||||
|
|
||||||
|
|
||||||
|
def _ergaenze_waehrungswechsel(db, user, buchungen, waehrung, ignoriert, kandidaten):
|
||||||
|
nach_tag: dict = defaultdict(list)
|
||||||
|
for t in buchungen:
|
||||||
|
nach_tag[t.date].append(t)
|
||||||
|
for gruppe in nach_tag.values():
|
||||||
|
for a in gruppe:
|
||||||
|
for b in gruppe:
|
||||||
|
if a.id == b.id or a.account_id == b.account_id or a.type == b.type:
|
||||||
|
continue
|
||||||
|
va, vb = waehrung.get(a.account_id), waehrung.get(b.account_id)
|
||||||
|
if not va or not vb or va == vb:
|
||||||
|
continue # gleiche Währung ist oben schon erledigt
|
||||||
|
if a.amount_cents == b.amount_cents:
|
||||||
|
continue # zufällig gleiche Zahl, andere Währung
|
||||||
|
if tuple(sorted((a.id, b.id))) in ignoriert:
|
||||||
|
continue
|
||||||
|
if _passt_ueber_kurs(db, a, b, va, vb):
|
||||||
|
kandidaten[a.id].append(b)
|
||||||
|
return kandidaten
|
||||||
|
|
||||||
|
|
||||||
|
def _eindeutig(a: Transaction, moegliche: list[Transaction],
|
||||||
|
kandidaten: dict[int, list[Transaction]],
|
||||||
|
verbraucht: set[int]) -> Transaction | None:
|
||||||
|
"""Gibt es genau eine sinnvolle Zuordnung?
|
||||||
|
|
||||||
|
Zwei Fälle gelten als zwingend:
|
||||||
|
|
||||||
|
1. Genau ein Gegenstück, und dieses hat seinerseits nur diese eine
|
||||||
|
Möglichkeit.
|
||||||
|
2. Alle Gegenstücke sind untereinander austauschbar – gleiches Konto,
|
||||||
|
gleicher Betrag, gleicher Tag. Dann ist jede Zuordnung dieselbe, und
|
||||||
|
eine beliebige ist richtig. Das ist der Alltag: Revolut-Aufrundungen
|
||||||
|
sind hunderte gleiche Beträge am selben Tag zwischen denselben Konten.
|
||||||
|
"""
|
||||||
|
if not moegliche:
|
||||||
|
return None
|
||||||
|
if len(moegliche) == 1:
|
||||||
|
gegen = moegliche[0]
|
||||||
|
# Nur die noch OFFENEN Gegenstücke zählen. Zählte man alle, bliebe beim
|
||||||
|
# letzten Paar einer Reihe gleicher Beträge immer eines übrig: Sein
|
||||||
|
# Gegenstück hätte rechnerisch viele Kandidaten, obwohl alle anderen
|
||||||
|
# längst vergeben sind.
|
||||||
|
offen_dort = [x for x in kandidaten.get(gegen.id, ())
|
||||||
|
if x.id not in verbraucht and x.id != a.id]
|
||||||
|
return gegen if not offen_dort else None
|
||||||
|
konten = {b.account_id for b in moegliche}
|
||||||
|
return moegliche[0] if len(konten) == 1 else None
|
||||||
|
|
||||||
|
|
||||||
|
def finde(db: Session, user: User) -> dict:
|
||||||
|
"""Paare suchen. Eindeutige verknüpfen, den Rest als Vorschlag melden."""
|
||||||
|
kandidaten = _kandidaten(db, user)
|
||||||
|
bezeichner = _kontobezeichner(db, user)
|
||||||
|
nach_id = {}
|
||||||
|
for a_id, liste in kandidaten.items():
|
||||||
|
for b in liste:
|
||||||
|
nach_id[b.id] = b
|
||||||
|
for t in db.query(Transaction).filter(Transaction.id.in_(list(kandidaten.keys()) or [0])):
|
||||||
|
nach_id[t.id] = t
|
||||||
|
|
||||||
|
verbraucht: set[int] = set()
|
||||||
|
verknuepft = 0
|
||||||
|
vorschlaege: list[tuple[Transaction, Transaction]] = []
|
||||||
|
|
||||||
|
for a_id in sorted(kandidaten):
|
||||||
|
if a_id in verbraucht:
|
||||||
|
continue
|
||||||
|
a = nach_id.get(a_id)
|
||||||
|
if a is None:
|
||||||
|
continue
|
||||||
|
offen = [b for b in kandidaten[a_id] if b.id not in verbraucht]
|
||||||
|
gegen = _eindeutig(a, offen, kandidaten, verbraucht)
|
||||||
|
if gegen is None:
|
||||||
|
if offen:
|
||||||
|
vorschlaege.append((a, offen[0]))
|
||||||
|
continue
|
||||||
|
# Automatisch nur ohne Kategorie und mit Hinweis – siehe _hat_hinweis.
|
||||||
|
if a.category_id is not None or gegen.category_id is not None:
|
||||||
|
vorschlaege.append((a, gegen))
|
||||||
|
continue
|
||||||
|
if not _hat_hinweis(a, gegen, bezeichner):
|
||||||
|
vorschlaege.append((a, gegen))
|
||||||
|
continue
|
||||||
|
verbinde(db, user, a, gegen, automatisch=True)
|
||||||
|
verbraucht.update((a.id, gegen.id))
|
||||||
|
verknuepft += 1
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {
|
||||||
|
"verknuepft": verknuepft,
|
||||||
|
"vorschlaege": [
|
||||||
|
{"a": _kurz(db, x), "b": _kurz(db, y)}
|
||||||
|
for x, y in vorschlaege
|
||||||
|
if x.id not in verbraucht and y.id not in verbraucht
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _kurz(db: Session, t: Transaction) -> dict:
|
||||||
|
konto = db.get(Account, t.account_id)
|
||||||
|
return {
|
||||||
|
"id": t.id, "date": t.date.isoformat(), "type": t.type.value,
|
||||||
|
"amount_cents": t.amount_cents, "currency": konto.currency if konto else "",
|
||||||
|
"account_id": t.account_id, "account_name": konto.name if konto else "",
|
||||||
|
"payee": t.payee, "description": t.description, "category_id": t.category_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def verbinde(db: Session, user: User, a: Transaction, b: Transaction,
|
||||||
|
automatisch: bool = False) -> TransferLink:
|
||||||
|
"""Verknüpfung anlegen. Abgang zuerst, damit die Richtung stimmt."""
|
||||||
|
von, nach = (a, b) if a.type == TxnType.expense else (b, a)
|
||||||
|
link = TransferLink(owner_id=user.id, from_txn_id=von.id, to_txn_id=nach.id,
|
||||||
|
automatisch=automatisch)
|
||||||
|
db.add(link)
|
||||||
|
db.flush()
|
||||||
|
return link
|
||||||
|
|
||||||
|
|
||||||
|
def linkliste(db: Session, user: User) -> dict[int, dict]:
|
||||||
|
"""Je Buchungs-ID die Gegenseite – für die Anzeige in der Buchungsliste."""
|
||||||
|
ergebnis: dict[int, dict] = {}
|
||||||
|
for link in db.query(TransferLink).filter(TransferLink.owner_id == user.id):
|
||||||
|
ergebnis[link.from_txn_id] = {"link_id": link.id, "other_id": link.to_txn_id,
|
||||||
|
"richtung": "raus", "automatisch": link.automatisch}
|
||||||
|
ergebnis[link.to_txn_id] = {"link_id": link.id, "other_id": link.from_txn_id,
|
||||||
|
"richtung": "rein", "automatisch": link.automatisch}
|
||||||
|
return ergebnis
|
||||||
|
|
||||||
|
|
||||||
|
def verknuepfte_ids(db: Session, user: User) -> set[int]:
|
||||||
|
"""Alle Buchungen, die Teil einer Verknüpfung sind.
|
||||||
|
|
||||||
|
Die Auswertung überspringt sie – so wie sie heute schon neutrale
|
||||||
|
Kategorien überspringt. Umkategorisiert wird nichts: Löst man die
|
||||||
|
Verknüpfung, zählt die Buchung mit ihrer alten Kategorie wieder mit.
|
||||||
|
"""
|
||||||
|
ids: set[int] = set()
|
||||||
|
for a, b in db.query(TransferLink.from_txn_id, TransferLink.to_txn_id).filter(
|
||||||
|
TransferLink.owner_id == user.id
|
||||||
|
):
|
||||||
|
ids.update((a, b))
|
||||||
|
return ids
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""Verknuepfung zweier Buchungen zu einem Uebertrag.
|
||||||
|
|
||||||
|
Eine Schicht obendrauf: An den Buchungen selbst wird nichts geaendert. Sie
|
||||||
|
behalten Betrag, Konto, Kategorie, Import-Schluessel und Stapel-Zugehoerigkeit;
|
||||||
|
hier steht nur, dass sie zusammengehoeren.
|
||||||
|
|
||||||
|
Der frueher geplante Weg – beide zu einer Uebertrags-Buchung zusammenfuehren und
|
||||||
|
die Originale loeschen – haette den Duplikatschutz, die Import-Ruecknahme und die
|
||||||
|
Umkehrbarkeit gleichzeitig beschaedigt. Nichts zu loeschen laesst das gar nicht
|
||||||
|
erst entstehen.
|
||||||
|
|
||||||
|
Revision ID: 0020
|
||||||
|
Revises: 0019
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0020"
|
||||||
|
down_revision = "0019"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"transfer_links",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("owner_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("from_txn_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("to_txn_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("automatisch", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(["owner_id"], ["users.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["from_txn_id"], ["transactions.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["to_txn_id"], ["transactions.id"], ondelete="CASCADE"),
|
||||||
|
# Je Buchung hoechstens eine Verknuepfung.
|
||||||
|
sa.UniqueConstraint("from_txn_id", name="uq_link_from"),
|
||||||
|
sa.UniqueConstraint("to_txn_id", name="uq_link_to"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_transfer_links_owner_id", "transfer_links", ["owner_id"])
|
||||||
|
op.create_index("ix_transfer_links_from", "transfer_links", ["from_txn_id"])
|
||||||
|
op.create_index("ix_transfer_links_to", "transfer_links", ["to_txn_id"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"link_ignored",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("owner_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("a_txn_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("b_txn_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(["owner_id"], ["users.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["a_txn_id"], ["transactions.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["b_txn_id"], ["transactions.id"], ondelete="CASCADE"),
|
||||||
|
sa.UniqueConstraint("a_txn_id", "b_txn_id", name="uq_ignored_pair"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_link_ignored_owner_id", "link_ignored", ["owner_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("link_ignored")
|
||||||
|
op.drop_table("transfer_links")
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
"""Überträge zwischen eigenen Konten als Verknüpfung erkennen.
|
||||||
|
|
||||||
|
Der Kern dieses Entwurfs ist, dass NICHTS verändert wird: Beide Buchungen
|
||||||
|
bleiben stehen, mit Betrag, Konto, Kategorie und Import-Schlüssel. Die Tests
|
||||||
|
prüfen deshalb vor allem, was alles gleich bleiben MUSS.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
|
||||||
|
def _konto(admin, name, currency="CHF", opening=0, identifiers=None):
|
||||||
|
body = {"name": name, "type": "girokonto", "currency": currency,
|
||||||
|
"opening_balance_cents": opening}
|
||||||
|
if identifiers:
|
||||||
|
body["identifiers"] = identifiers
|
||||||
|
r = admin.post("/api/accounts", json=body)
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
def _buchung(admin, konto, typ, betrag, datum, payee="", category_id=None):
|
||||||
|
body = {"type": typ, "date": datum, "account_id": konto["id"],
|
||||||
|
"amount_cents": betrag, "payee": payee}
|
||||||
|
if category_id:
|
||||||
|
body["category_id"] = category_id
|
||||||
|
r = admin.post("/api/transactions", json=body)
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
def _saldo(admin, konto):
|
||||||
|
return admin.get(f"/api/accounts/{konto['id']}").json()["balance_cents"]
|
||||||
|
|
||||||
|
|
||||||
|
def _suchen(admin):
|
||||||
|
r = admin.post("/api/uebertraege/suchen")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
def _links_von(admin, *konten):
|
||||||
|
"""Verknüpfungen, die zu den Konten DIESES Tests gehören.
|
||||||
|
|
||||||
|
Die Testdatenbank lebt über den ganzen Lauf, und die Suche läuft über alle
|
||||||
|
Buchungen des Nutzers. Absolut zu zählen hiesse, die Nachbartests
|
||||||
|
mitzuzählen."""
|
||||||
|
ids = {k["id"] for k in konten}
|
||||||
|
buchungen = {t["id"]: t for t in admin.get("/api/transactions").json()}
|
||||||
|
treffer = []
|
||||||
|
for l in admin.get("/api/uebertraege").json():
|
||||||
|
seiten = [buchungen.get(l["from_txn_id"]), buchungen.get(l["to_txn_id"])]
|
||||||
|
if all(s and s["account_id"] in ids for s in seiten):
|
||||||
|
treffer.append(l)
|
||||||
|
return treffer
|
||||||
|
|
||||||
|
|
||||||
|
def _kategorie(admin, name="Lebensmittel"):
|
||||||
|
for g in admin.get("/api/categories").json():
|
||||||
|
for c in g["categories"]:
|
||||||
|
if c["name"] == name:
|
||||||
|
return c["id"]
|
||||||
|
raise AssertionError(f"{name} fehlt")
|
||||||
|
|
||||||
|
|
||||||
|
# --- Was gleich bleiben muss -------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_salden_bleiben_unveraendert(admin):
|
||||||
|
"""Der Kern des Entwurfs: Verknüpfen bewegt kein Geld."""
|
||||||
|
a = _konto(admin, "Von " + secrets.token_hex(3), opening=100000)
|
||||||
|
b = _konto(admin, "Nach " + secrets.token_hex(3))
|
||||||
|
_buchung(admin, a, "expense", 25000, "2026-04-01", "Übertrag auf Sparkonto")
|
||||||
|
_buchung(admin, b, "income", 25000, "2026-04-01", "Übertrag")
|
||||||
|
|
||||||
|
vorher = (_saldo(admin, a), _saldo(admin, b))
|
||||||
|
_suchen(admin)
|
||||||
|
assert len(_links_von(admin, a, b)) == 1
|
||||||
|
assert (_saldo(admin, a), _saldo(admin, b)) == vorher
|
||||||
|
|
||||||
|
# Und nach dem Lösen ebenfalls.
|
||||||
|
link = admin.get("/api/uebertraege").json()[0]
|
||||||
|
assert admin.delete(f"/api/uebertraege/{link['id']}").status_code == 204
|
||||||
|
assert (_saldo(admin, a), _saldo(admin, b)) == vorher
|
||||||
|
|
||||||
|
|
||||||
|
def test_buchungen_werden_nicht_angefasst(admin):
|
||||||
|
"""Kategorie, Betrag, Konto und Text bleiben, wie sie waren."""
|
||||||
|
leb = _kategorie(admin)
|
||||||
|
a = _konto(admin, "Quelle " + secrets.token_hex(3), opening=50000)
|
||||||
|
b = _konto(admin, "Ziel " + secrets.token_hex(3))
|
||||||
|
aus = _buchung(admin, a, "expense", 7000, "2026-04-02", "Umbuchung", category_id=leb)
|
||||||
|
ein = _buchung(admin, b, "income", 7000, "2026-04-02", "Umbuchung")
|
||||||
|
|
||||||
|
admin.post("/api/uebertraege/verbinden",
|
||||||
|
json={"a_id": aus["id"], "b_id": ein["id"]})
|
||||||
|
|
||||||
|
danach = {t["id"]: t for t in admin.get("/api/transactions").json()}
|
||||||
|
assert danach[aus["id"]]["category_id"] == leb
|
||||||
|
assert danach[aus["id"]]["amount_cents"] == 7000
|
||||||
|
assert danach[aus["id"]]["account_id"] == a["id"]
|
||||||
|
assert danach[aus["id"]]["type"] == "expense"
|
||||||
|
assert danach[ein["id"]]["type"] == "income"
|
||||||
|
|
||||||
|
|
||||||
|
def test_kategorie_kommt_beim_loesen_zurueck(admin):
|
||||||
|
"""Verknüpfte Buchungen werden übersprungen, nicht umkategorisiert."""
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
heute = date.today().isoformat() # muss im laufenden Budget-Monat liegen
|
||||||
|
leb = _kategorie(admin)
|
||||||
|
a = _konto(admin, "BudgetQuelle " + secrets.token_hex(3), opening=90000)
|
||||||
|
b = _konto(admin, "BudgetZiel " + secrets.token_hex(3))
|
||||||
|
aus = _buchung(admin, a, "expense", 3300, heute, "Übertrag", category_id=leb)
|
||||||
|
ein = _buchung(admin, b, "income", 3300, heute, "Übertrag")
|
||||||
|
|
||||||
|
def budget_summe():
|
||||||
|
r = admin.get("/api/dashboard/income-expense").json()
|
||||||
|
return r["expense"]
|
||||||
|
|
||||||
|
vorher = budget_summe()
|
||||||
|
link = admin.post("/api/uebertraege/verbinden",
|
||||||
|
json={"a_id": aus["id"], "b_id": ein["id"]}).json()
|
||||||
|
assert budget_summe() < vorher, "verknüpft: zählt nicht mehr als Ausgabe"
|
||||||
|
|
||||||
|
admin.delete(f"/api/uebertraege/{link['id']}")
|
||||||
|
assert budget_summe() == vorher, "gelöst: zählt wieder, mit derselben Kategorie"
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_ruecknahme_bleibt_unberuehrt(admin):
|
||||||
|
"""Die Gegenseite gehört einem anderen Stapel – sie darf nicht mitgehen."""
|
||||||
|
a = _konto(admin, "StapelA " + secrets.token_hex(3), opening=100000)
|
||||||
|
b = _konto(admin, "StapelB " + secrets.token_hex(3))
|
||||||
|
spalten = json.dumps({"date": 0, "amount": 1, "payee": 2})
|
||||||
|
|
||||||
|
def importieren(konto, text, name):
|
||||||
|
v = admin.post("/api/imports/preview",
|
||||||
|
files={"file": (name, io.BytesIO(text.encode()), "text/csv")},
|
||||||
|
data={"account_id": str(konto["id"]), "delimiter": ";",
|
||||||
|
"decimal_sep": ",", "date_format": "%d.%m.%Y",
|
||||||
|
"encoding": "utf-8", "has_header": "true", "skip_rows": "0",
|
||||||
|
"amount_mode": "signed", "column_map": spalten}).json()
|
||||||
|
return admin.post("/api/imports/commit", json={
|
||||||
|
"account_id": konto["id"], "filename": name,
|
||||||
|
"rows": [r for r in v["rows"] if r["include"]]}).json()
|
||||||
|
|
||||||
|
stapel_a = importieren(a, "Datum;Betrag;Text\n05.04.2026;-80,00;Übertrag\n", "a.csv")
|
||||||
|
importieren(b, "Datum;Betrag;Text\n05.04.2026;80,00;Übertrag\n", "b.csv")
|
||||||
|
_suchen(admin)
|
||||||
|
assert len(_links_von(admin, a, b)) == 1
|
||||||
|
assert _saldo(admin, b) == 8000
|
||||||
|
|
||||||
|
# Stapel A zurücknehmen: nur dessen Zeile verschwindet.
|
||||||
|
assert admin.delete(f"/api/imports/batches/{stapel_a['id']}").status_code == 200
|
||||||
|
assert _saldo(admin, a) == 100000, "eigene Zeile weg"
|
||||||
|
assert _saldo(admin, b) == 8000, "Gegenseite unberührt"
|
||||||
|
assert _links_von(admin, a, b) == [], "Verknüpfung verfällt mit der Buchung"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Wann automatisch verknüpft wird ----------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_ohne_hinweis_nur_vorschlag(admin):
|
||||||
|
"""50.00 im Laden bezahlt, am selben Tag 50.00 eingegangen: Zufall."""
|
||||||
|
a = _konto(admin, "Laden " + secrets.token_hex(3), opening=100000)
|
||||||
|
b = _konto(admin, "Sonst " + secrets.token_hex(3))
|
||||||
|
_buchung(admin, a, "expense", 5000, "2026-05-01", "Bäckerei Meier")
|
||||||
|
_buchung(admin, b, "income", 5000, "2026-05-01", "Rückerstattung Verein")
|
||||||
|
|
||||||
|
r = _suchen(admin)
|
||||||
|
assert _links_von(admin, a, b) == []
|
||||||
|
assert any(v["a"]["payee"] == "Bäckerei Meier" for v in r["vorschlaege"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_mit_hinweis_automatisch(admin):
|
||||||
|
a = _konto(admin, "HinweisA " + secrets.token_hex(3), opening=100000)
|
||||||
|
b = _konto(admin, "HinweisB " + secrets.token_hex(3))
|
||||||
|
_buchung(admin, a, "expense", 60000, "2026-05-02", "Einzahlung von *6543")
|
||||||
|
_buchung(admin, b, "income", 60000, "2026-05-02", "Gutschrift")
|
||||||
|
_suchen(admin)
|
||||||
|
assert len(_links_von(admin, a, b)) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_kontoname_im_text_zaehlt_als_hinweis(admin):
|
||||||
|
marke = secrets.token_hex(3)
|
||||||
|
a = _konto(admin, f"Lohnkonto {marke}", opening=100000)
|
||||||
|
b = _konto(admin, f"Sparkonto {marke}")
|
||||||
|
_buchung(admin, a, "expense", 12000, "2026-05-03", f"Sparkonto {marke}")
|
||||||
|
_buchung(admin, b, "income", 12000, "2026-05-03", "Eingang")
|
||||||
|
_suchen(admin)
|
||||||
|
assert len(_links_von(admin, a, b)) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_kategorie_schuetzt_vor_automatik(admin):
|
||||||
|
"""Eine zugeordnete Ausgabe soll nicht ungefragt aus dem Budget fallen."""
|
||||||
|
leb = _kategorie(admin)
|
||||||
|
a = _konto(admin, "GeschuetztA " + secrets.token_hex(3), opening=100000)
|
||||||
|
b = _konto(admin, "GeschuetztB " + secrets.token_hex(3))
|
||||||
|
_buchung(admin, a, "expense", 4400, "2026-05-04", "Übertrag", category_id=leb)
|
||||||
|
_buchung(admin, b, "income", 4400, "2026-05-04", "Übertrag")
|
||||||
|
|
||||||
|
r = _suchen(admin)
|
||||||
|
assert _links_von(admin, a, b) == []
|
||||||
|
assert r["vorschlaege"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_mehrdeutig_bleibt_vorschlag(admin):
|
||||||
|
"""Derselbe Betrag am selben Tag über drei Konten – nichts ist zwingend."""
|
||||||
|
marke = secrets.token_hex(3)
|
||||||
|
a = _konto(admin, f"M1 {marke}", opening=100000)
|
||||||
|
b = _konto(admin, f"M2 {marke}")
|
||||||
|
c = _konto(admin, f"M3 {marke}")
|
||||||
|
_buchung(admin, a, "expense", 9900, "2026-06-01", "Übertrag")
|
||||||
|
_buchung(admin, b, "income", 9900, "2026-06-01", "Übertrag")
|
||||||
|
_buchung(admin, c, "income", 9900, "2026-06-01", "Übertrag")
|
||||||
|
_suchen(admin)
|
||||||
|
assert _links_von(admin, a, b, c) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_austauschbare_kandidaten_werden_verbunden(admin):
|
||||||
|
"""Revolut-Aufrundungen: viele gleiche Beträge am selben Tag zwischen
|
||||||
|
denselben zwei Konten. Jede Zuordnung ist dieselbe."""
|
||||||
|
a = _konto(admin, "Giro " + secrets.token_hex(3), opening=100000)
|
||||||
|
b = _konto(admin, "Pocket " + secrets.token_hex(3))
|
||||||
|
for _ in range(6):
|
||||||
|
_buchung(admin, a, "expense", 60, "2026-06-02", "Übertrag Aufrundung")
|
||||||
|
_buchung(admin, b, "income", 60, "2026-06-02", "Übertrag Aufrundung")
|
||||||
|
|
||||||
|
vorher = (_saldo(admin, a), _saldo(admin, b))
|
||||||
|
_suchen(admin)
|
||||||
|
assert len(_links_von(admin, a, b)) == 6
|
||||||
|
assert (_saldo(admin, a), _saldo(admin, b)) == vorher
|
||||||
|
|
||||||
|
|
||||||
|
# --- Was nie ein Paar bilden darf -------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_kein_selbstbezug(admin):
|
||||||
|
a = _konto(admin, "Solo " + secrets.token_hex(3), opening=100000)
|
||||||
|
_buchung(admin, a, "expense", 2500, "2026-06-03", "Übertrag")
|
||||||
|
_buchung(admin, a, "income", 2500, "2026-06-03", "Übertrag")
|
||||||
|
_suchen(admin)
|
||||||
|
assert _links_von(admin, a) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_gleicher_betrag_andere_waehrung_ist_kein_paar(admin):
|
||||||
|
"""100 CHF und 100 EUR sind nicht derselbe Betrag – nur dieselbe Zahl."""
|
||||||
|
a = _konto(admin, "CHF " + secrets.token_hex(3), currency="CHF", opening=100000)
|
||||||
|
b = _konto(admin, "EUR " + secrets.token_hex(3), currency="EUR")
|
||||||
|
_buchung(admin, a, "expense", 10000, "2026-06-04", "Übertrag")
|
||||||
|
_buchung(admin, b, "income", 10000, "2026-06-04", "Übertrag")
|
||||||
|
_suchen(admin)
|
||||||
|
assert _links_von(admin, a, b) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_verschiedene_tage_bilden_kein_paar(admin):
|
||||||
|
a = _konto(admin, "TagA " + secrets.token_hex(3), opening=100000)
|
||||||
|
b = _konto(admin, "TagB " + secrets.token_hex(3))
|
||||||
|
_buchung(admin, a, "expense", 7700, "2026-06-05", "Übertrag")
|
||||||
|
_buchung(admin, b, "income", 7700, "2026-06-08", "Übertrag")
|
||||||
|
_suchen(admin)
|
||||||
|
assert _links_von(admin, a, b) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_keine_doppelverknuepfung(admin):
|
||||||
|
a = _konto(admin, "DoppA " + secrets.token_hex(3), opening=100000)
|
||||||
|
b = _konto(admin, "DoppB " + secrets.token_hex(3))
|
||||||
|
aus = _buchung(admin, a, "expense", 3100, "2026-06-06", "Übertrag")
|
||||||
|
ein = _buchung(admin, b, "income", 3100, "2026-06-06", "Übertrag")
|
||||||
|
admin.post("/api/uebertraege/verbinden", json={"a_id": aus["id"], "b_id": ein["id"]})
|
||||||
|
r = admin.post("/api/uebertraege/verbinden", json={"a_id": aus["id"], "b_id": ein["id"]})
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_ignorierte_paare_kehren_nicht_zurueck(admin):
|
||||||
|
a = _konto(admin, "IgnA " + secrets.token_hex(3), opening=100000)
|
||||||
|
b = _konto(admin, "IgnB " + secrets.token_hex(3))
|
||||||
|
aus = _buchung(admin, a, "expense", 1700, "2026-06-07", "Kiosk")
|
||||||
|
ein = _buchung(admin, b, "income", 1700, "2026-06-07", "Rückzahlung")
|
||||||
|
|
||||||
|
assert any(v["a"]["id"] in (aus["id"], ein["id"]) for v in _suchen(admin)["vorschlaege"])
|
||||||
|
admin.post("/api/uebertraege/ignorieren", json={"a_id": aus["id"], "b_id": ein["id"]})
|
||||||
|
danach = _suchen(admin)["vorschlaege"]
|
||||||
|
assert not any({v["a"]["id"], v["b"]["id"]} == {aus["id"], ein["id"]} for v in danach)
|
||||||
Reference in New Issue
Block a user