diff --git a/backend/app/models.py b/backend/app/models.py index 08cfd69..7cd4c0e 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -491,6 +491,20 @@ class Transaction(Base): # gleich aus, und dann verschluckt eine Zahlung die andere. source_uid: Mapped[str | None] = mapped_column(String(320), nullable=True) + # Der Vertrag, zu dem diese Buchung gehoert – Netflix, Handy, Versicherung. + # + # Ein ETIKETT, wie die Uebertrags-Verknuepfung: An der Buchung selbst + # aendert sich nichts, sie behaelt Betrag, Konto und Kategorie. Hier steht + # nur, dass die beiden zusammengehoeren. Zieht man es ab, bleibt eine + # gewoehnliche Buchung zurueck. + # + # SET NULL: Verschwindet der Vertrag, verschwindet das Etikett – nie die + # Buchung. Wer ein Abo kuendigt und loescht, will nicht seine + # Zahlungshistorie mitloeschen. + subscription_id: Mapped[int | None] = mapped_column( + ForeignKey("subscriptions.id", ondelete="SET NULL"), nullable=True, index=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) diff --git a/backend/app/routers/subscriptions.py b/backend/app/routers/subscriptions.py index cf57a03..4b31bf1 100644 --- a/backend/app/routers/subscriptions.py +++ b/backend/app/routers/subscriptions.py @@ -23,6 +23,7 @@ from ..models import ( CategoryKind, Subscription, SubscriptionLogo, + Transaction, User, ) from ..schemas import ( @@ -32,7 +33,7 @@ from ..schemas import ( SubscriptionSummary, SubscriptionUpdate, ) -from ..services import currency, logo as logo_service +from ..services import abo_erkennung, currency, logo as logo_service from .settings import BASE_CURRENCY_KEY, get_user_setting router = APIRouter(prefix="/subscriptions", tags=["subscriptions"]) @@ -154,6 +155,89 @@ def create_subscription(payload: SubscriptionIn, db: Session = Depends(get_db), return to_out(db, s, _base(db, user), date.today()) +# --- Zuordnung: welche Buchung gehoert zu welchem Vertrag -------------------- + + +@router.post("/zuordnen") +def zuordnen(db: Session = Depends(get_db), + user: User = Depends(get_current_user)) -> dict: + """Buchungen ihren Vertraegen zuordnen. + + Eindeutig Sicheres wird zugeordnet, der Rest kommt als Vorschlag zurueck. + Laeuft ueber ALLE noch nicht zugeordneten Buchungen, auch alte – wer seine + Vertraege nachtraegt, bekommt damit rueckwirkend seinen Verlauf. + """ + return abo_erkennung.finde(db, user) + + +@router.get("/vorschlaege") +def vorschlaege(db: Session = Depends(get_db), + user: User = Depends(get_current_user)) -> dict: + """Nur fragen, nichts veraendern – fuer eine Ansicht, die bloss hinsieht.""" + return abo_erkennung.finde(db, user, nur_vorschlaege=True) + + +@router.post("/{sub_id}/buchungen/{txn_id}", response_model=SubscriptionOut) +def zuordnen_von_hand(sub_id: int, txn_id: int, db: Session = Depends(get_db), + user: User = Depends(get_current_user)): + """Eine Buchung diesem Vertrag zuordnen – einen Vorschlag annehmen.""" + s = get_owned(db, Subscription, sub_id, user) + txn = get_owned(db, Transaction, txn_id, user) + txn.subscription_id = s.id + if txn.date is not None: + abo_erkennung._ruecke_faelligkeit(s, txn.date) + db.commit() + db.refresh(s) + return to_out(db, s, _base(db, user), date.today()) + + +@router.delete("/buchungen/{txn_id}", status_code=status.HTTP_204_NO_CONTENT) +def zuordnung_loesen(txn_id: int, db: Session = Depends(get_db), + user: User = Depends(get_current_user)) -> None: + """Das Etikett abziehen. Die Buchung bleibt, wie sie ist.""" + txn = get_owned(db, Transaction, txn_id, user) + txn.subscription_id = None + db.commit() + + +@router.get("/{sub_id}/verlauf") +def verlauf(sub_id: int, db: Session = Depends(get_db), + user: User = Depends(get_current_user)) -> dict: + """Die erkannten Abbuchungen dieses Vertrags. + + Beantwortet "was habe ich dafuer insgesamt bezahlt" – und zeigt, wann der + Preis gestiegen ist. + """ + s = get_owned(db, Subscription, sub_id, user) + zeilen = abo_erkennung.verlauf(db, user, s) + return { + "buchungen": zeilen, + "anzahl": len(zeilen), + "summe_cents": sum(z["amount_cents"] for z in zeilen), + "currency": zeilen[0]["currency"] if zeilen else s.currency, + } + + +@router.post("/{sub_id}/preis", response_model=SubscriptionOut) +def preis_uebernehmen(sub_id: int, amount_cents: int, + db: Session = Depends(get_db), + user: User = Depends(get_current_user)): + """Einen geaenderten Preis uebernehmen. + + Eigener Schritt und nicht automatisch: Eine Preiserhoehung ist eine + Nachricht an den Nutzer, kein Wartungsvorgang. Wer sie stillschweigend + nachzieht, nimmt ihm die einzige Gelegenheit, sie zu bemerken. + """ + if amount_cents < 0: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, + detail="Betrag darf nicht negativ sein") + s = get_owned(db, Subscription, sub_id, user) + s.amount_cents = amount_cents + db.commit() + db.refresh(s) + return to_out(db, s, _base(db, user), date.today()) + + @router.get("/{sub_id}", response_model=SubscriptionOut) def get_subscription(sub_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)): diff --git a/backend/app/routers/transactions.py b/backend/app/routers/transactions.py index 0704a4e..a8eb594 100644 --- a/backend/app/routers/transactions.py +++ b/backend/app/routers/transactions.py @@ -105,6 +105,7 @@ def to_out(db: Session, txn: Transaction, base: str, ccy_by_account: dict[int, s category_id=txn.category_id, budget_treatment=txn.budget_treatment, budget_category_id=txn.budget_category_id, + subscription_id=txn.subscription_id, budget_period=txn.budget_period or budget.period_of(txn.date, start_tag), budget_period_manuell=txn.budget_period is not None, import_batch_id=txn.import_batch_id, diff --git a/backend/app/schemas.py b/backend/app/schemas.py index c0cd669..3bfc930 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -462,6 +462,10 @@ class TransactionOut(BaseModel): category_id: int | None budget_treatment: BudgetTreatment | None budget_category_id: int | None + # Der Vertrag, zu dem diese Buchung gehoert – ein Etikett, von der + # Erkennung gesetzt. Steht in der AUSGABE, nicht in der Eingabe: Beim + # Anlegen gibt man es nicht mit. + subscription_id: int | None = None # Der Budget-Monat, in dem die Buchung zaehlt – immer gesetzt. Ob er von # Hand gewaehlt wurde oder aus dem Datum stammt, sagt das zweite Feld. budget_period: str diff --git a/backend/app/services/abo_erkennung.py b/backend/app/services/abo_erkennung.py new file mode 100644 index 0000000..9d4661c --- /dev/null +++ b/backend/app/services/abo_erkennung.py @@ -0,0 +1,311 @@ +"""Welche Buchung gehört zu welchem Vertrag. + +Eine Schicht OBENDRAUF, wie die Übertrags-Verknüpfung: An der Buchung ändert +sich nichts. Hier steht nur, dass sie die Abbuchung eines Vertrags ist. Fällt +die Zuordnung falsch aus, zieht man ein Etikett ab – keine Buchung ist verbogen. + +Ein Vertrag weiß vier Dinge über seine Abbuchung: Konto, Betrag, ungefähres +Datum und einen Text. Daraus ergeben sich drei Stufen: + +**Sicher** Betrag stimmt auf den Cent, und der Text passt. Wird + zugeordnet, ohne zu fragen. +**Vorschlag** Betrag stimmt, Text nicht – oder der Text passt, aber der + Betrag ist ein anderer. Das zweite ist der interessante Fall: + So sieht eine Preiserhöhung aus. +**Nichts** Alles andere. Lieber nichts sagen als daneben raten. + +Was hier NICHT passiert: den Preis am Vertrag stillschweigend nachziehen. Eine +Preiserhöhung ist eine Nachricht an den Nutzer, kein Wartungsvorgang. +""" + +from __future__ import annotations + +import logging +from collections import defaultdict +from dataclasses import dataclass +from datetime import date, timedelta + +from dateutil.relativedelta import relativedelta +from sqlalchemy.orm import Session + +from ..models import Account, Subscription, Transaction, TxnType, User +from . import currency +from .csv_import import normalize_text + +log = logging.getLogger(__name__) + +# Wie weit das Buchungsdatum von der erwarteten Fälligkeit abweichen darf und +# trotzdem als dieselbe Abbuchung gilt. Grosszügig: Banken buchen über +# Wochenenden und Feiertage verschoben, und wer den Vertrag von Hand angelegt +# hat, kennt den genauen Tag oft nicht. +FENSTER_TAGE = 12 + +# Ohne eingetragene Fälligkeit muss ein weiteres Fenster herhalten – dann +# entscheidet praktisch allein der Betrag samt Text. +FENSTER_OHNE_FAELLIGKEIT = 45 + +# Wie weit der Betrag abweichen darf und noch als PREISÄNDERUNG desselben +# Vertrags gilt (statt als fremde Zahlung). Halb so viel bis doppelt so viel: +# Ein Abo, das sich verdreifacht, ist kein Abo mehr, sondern etwas anderes. +PREIS_UNTEN = 0.5 +PREIS_OBEN = 2.0 + + +@dataclass +class Treffer: + """Eine Buchung und der Vertrag, zu dem sie gehören könnte.""" + + txn: Transaction + vertrag: Subscription + sicher: bool + # Der Betrag des Vertrags in der Währung der Buchung – zum Vergleichen. + erwartet_cents: int | None + @property + def preis_geaendert(self) -> bool: + return self.erwartet_cents is not None and self.txn.amount_cents != self.erwartet_cents + + +def _texte(t: Transaction) -> str: + return normalize_text(f"{t.payee or ''} {t.description or ''}") + + +def _passt_text(vertrag: Subscription, text: str) -> bool: + """Steht der Erkennungstext des Vertrags in der Buchung? + + Wortweise und nicht als Ganzes: Auf dem Auszug steht „NETFLIX.COM 1234" + oder „Salt Mobile SA Rechnung" – der eingetragene Name kommt dort selten + am Stück vor. Kurze Wörter bleiben draussen; „SA" oder „AG" trifft sonst + die halbe Kontoauszug. + """ + muster = normalize_text(vertrag.payee_pattern or vertrag.name) + if not muster: + return False + if muster in text: + return True + woerter = [w for w in muster.split() if len(w) >= 4] + return bool(woerter) and any(w in text for w in woerter) + + +def _erwartet_in(db: Session, vertrag: Subscription, konto: Account, + am: date) -> int | None: + """Was der Vertrag in der Währung DES KONTOS kosten sollte. + + Ein Vertrag in Euro, bezahlt von einem Franken-Konto, kommt jeden Monat mit + einem anderen Frankenbetrag an. Der exakte Vergleich wäre dort immer + falsch – also wird umgerechnet, und der Betrag darf danach etwas + abweichen (siehe ``_betrag_passt``). + """ + if vertrag.currency == konto.currency: + return vertrag.amount_cents + return currency.convert(db, vertrag.amount_cents, vertrag.currency, + konto.currency, am) + + +def _betrag_passt(ist: int, erwartet: int, gleiche_waehrung: bool) -> bool: + """Ist das derselbe Betrag? + + Bei gleicher Währung auf den Cent – ein Abo kostet, was es kostet. Bei + umgerechneter Währung mit Spielraum: Der Kurs von heute ist nicht der vom + Buchungstag, und die Bank rechnet mit ihrem eigenen. + """ + if gleiche_waehrung: + return ist == erwartet + if erwartet <= 0: + return False + return abs(ist - erwartet) <= max(50, round(erwartet * 0.05)) + + +def _im_fenster(tag: date, faellig: date | None, intervall: int) -> bool: + """Liegt der Tag nahe genug an einer erwarteten Fälligkeit? + + Gerechnet wird über MEHRERE Intervalle rückwärts und vorwärts: Wer seine + Verträge nachträgt, hat Buchungen aus Monaten, die längst vorbei sind, und + ``next_due`` steht in der Zukunft. + """ + if faellig is None: + return True + for n in range(-24, 25): + erwartet = faellig + relativedelta(months=intervall * n) + if abs((tag - erwartet).days) <= FENSTER_TAGE: + return True + if erwartet > tag + timedelta(days=FENSTER_TAGE * 2) and n > 0: + break + return False + + +def _kandidaten(db: Session, user: User) -> list[Transaction]: + """Buchungen, die noch keinem Vertrag zugeordnet sind. + + Überträge bleiben draussen: Ein Abo wird bezahlt, nicht zwischen eigenen + Konten verschoben. Verknüpfte Buchungen ebenfalls – die sind schon als + etwas anderes erkannt. + """ + from .uebertraege import verknuepfte_ids + + verknuepft = verknuepfte_ids(db, user) + return [ + t for t in db.query(Transaction).filter( + Transaction.owner_id == user.id, + Transaction.type == TxnType.expense, + Transaction.subscription_id.is_(None), + ) + if t.id not in verknuepft + ] + + +def _pruefe(db: Session, t: Transaction, vertrag: Subscription, + konto: Account) -> Treffer | None: + """Passt diese eine Buchung zu diesem einen Vertrag?""" + if vertrag.account_id is not None and vertrag.account_id != t.account_id: + return None + erwartet = _erwartet_in(db, vertrag, konto, t.date) + if erwartet is None: + return None + gleiche = vertrag.currency == konto.currency + betrag_ok = _betrag_passt(t.amount_cents, erwartet, gleiche) + text_ok = _passt_text(vertrag, _texte(t)) + + if betrag_ok and text_ok and _im_fenster(t.date, vertrag.next_due, + vertrag.interval_months): + return Treffer(t, vertrag, True, erwartet) + + # Betrag stimmt, Text nicht: Auf dem Auszug steht oft der Abwickler + # ("STRIPE") und nicht die Marke. Fragen statt raten. + if betrag_ok and _im_fenster(t.date, vertrag.next_due, vertrag.interval_months): + return Treffer(t, vertrag, False, erwartet) + + # Text stimmt, Betrag nicht – SO SIEHT EINE PREISERHOEHUNG AUS. Der + # wertvollste Fall, und einer, den man nie automatisch verbuchen darf. + if text_ok and erwartet > 0: + verhaeltnis = t.amount_cents / erwartet + if PREIS_UNTEN <= verhaeltnis <= PREIS_OBEN: + fenster = (vertrag.next_due is not None + and _im_fenster(t.date, vertrag.next_due, vertrag.interval_months)) + ohne = vertrag.next_due is None + if fenster or ohne: + return Treffer(t, vertrag, False, erwartet) + return None + + +def finde(db: Session, user: User, nur_vorschlaege: bool = False) -> dict: + """Buchungen ihren Verträgen zuordnen. + + Eindeutig Sicheres wird zugeordnet, der Rest kommt als Vorschlag zurück. + + Mit ``nur_vorschlaege`` wird NICHTS zugeordnet – das braucht jede Ansicht, + die bloss fragen will, ohne beim Hinsehen schon etwas zu verändern. + """ + vertraege = ( + db.query(Subscription) + .filter(Subscription.owner_id == user.id, Subscription.is_active.is_(True)) + .all() + ) + if not vertraege: + return {"zugeordnet": 0, "vorschlaege": [], "preisaenderungen": []} + + konten = {a.id: a for a in db.query(Account).filter(Account.owner_id == user.id)} + zugeordnet = 0 + vorschlaege: list[Treffer] = [] + + for t in _kandidaten(db, user): + konto = konten.get(t.account_id) + if konto is None: + continue + treffer = [x for x in (_pruefe(db, t, v, konto) for v in vertraege) if x] + if not treffer: + continue + + sichere = [x for x in treffer if x.sicher] + # Nur wenn GENAU EINER sicher passt. Zwei Abos zum selben Preis am + # selben Tag gibt es, und dann soll der Nutzer entscheiden. + if len(sichere) == 1 and not nur_vorschlaege: + t.subscription_id = sichere[0].vertrag.id + _ruecke_faelligkeit(sichere[0].vertrag, t.date) + zugeordnet += 1 + continue + if len(sichere) == 1: + vorschlaege.append(sichere[0]) + continue + # Mehrdeutig oder unsicher: der beste Vorschlag je Buchung genuegt. + vorschlaege.append(sichere[0] if sichere else treffer[0]) + + if zugeordnet: + db.commit() + + return { + "zugeordnet": zugeordnet, + "vorschlaege": [_kurz(db, x) for x in vorschlaege], + "preisaenderungen": [_kurz(db, x) for x in vorschlaege if x.preis_geaendert], + } + + +def _ruecke_faelligkeit(vertrag: Subscription, gebucht_am: date) -> None: + """Die nächste Fälligkeit hinter die gerade erkannte Abbuchung schieben. + + Nur vorwärts: Wer alte Buchungen nachträgt, soll die Fälligkeit nicht in + die Vergangenheit ziehen. + """ + naechste = gebucht_am + relativedelta(months=vertrag.interval_months) + if vertrag.next_due is None or naechste > vertrag.next_due: + vertrag.next_due = naechste + + +def _kurz(db: Session, x: Treffer) -> dict: + konto = db.get(Account, x.txn.account_id) + return { + "txn_id": x.txn.id, + "date": x.txn.date.isoformat(), + "amount_cents": x.txn.amount_cents, + "currency": konto.currency if konto else "", + "payee": x.txn.payee, + "description": x.txn.description, + "account_name": konto.name if konto else "", + "subscription_id": x.vertrag.id, + "subscription_name": x.vertrag.name, + "erwartet_cents": x.erwartet_cents, + "preis_geaendert": x.preis_geaendert, + } + + +def verlauf(db: Session, user: User, vertrag: Subscription) -> list[dict]: + """Die erkannten Abbuchungen eines Vertrags, neueste zuerst. + + Damit beantwortet sich „was habe ich dafür insgesamt bezahlt" – und man + sieht, wann der Preis gestiegen ist. + """ + konten = {a.id: a for a in db.query(Account).filter(Account.owner_id == user.id)} + zeilen = [] + for t in ( + db.query(Transaction) + .filter(Transaction.owner_id == user.id, + Transaction.subscription_id == vertrag.id) + .order_by(Transaction.date.desc(), Transaction.id.desc()) + ): + konto = konten.get(t.account_id) + zeilen.append({ + "txn_id": t.id, + "date": t.date.isoformat(), + "amount_cents": t.amount_cents, + "currency": konto.currency if konto else "", + "payee": t.payee, + "account_name": konto.name if konto else "", + }) + return zeilen + + +def summe_je_vertrag(db: Session, user: User) -> dict[int, dict]: + """Je Vertrag: wie viele Abbuchungen erkannt sind und was sie zusammen + gekostet haben. Fuer die Liste, ohne je Zeile einzeln nachzufragen.""" + ergebnis: dict[int, dict] = defaultdict(lambda: {"anzahl": 0, "summe_cents": 0, + "letzte": None}) + for t in db.query(Transaction).filter( + Transaction.owner_id == user.id, + Transaction.subscription_id.isnot(None), + ): + eintrag = ergebnis[t.subscription_id] + eintrag["anzahl"] += 1 + eintrag["summe_cents"] += t.amount_cents + iso = t.date.isoformat() + if eintrag["letzte"] is None or iso > eintrag["letzte"]: + eintrag["letzte"] = iso + return dict(ergebnis) diff --git a/backend/app/services/backup_data.py b/backend/app/services/backup_data.py index 73095b9..2d6ea1a 100644 --- a/backend/app/services/backup_data.py +++ b/backend/app/services/backup_data.py @@ -104,12 +104,26 @@ USER_TABLES: list[TableSpec] = [ TableSpec("import_batches", ImportBatch, "owner", {"account_id": "accounts", "template_id": "import_templates"}, label="Import-Läufe"), + # Abos & Vertraege VOR den Buchungen: Eine Buchung traegt das Etikett ihres + # Vertrags, und ein Verweis darf nur auf eine schon eingefuegte Tabelle + # zeigen. Umgekehrt verweist kein Vertrag auf eine Buchung. + TableSpec("subscriptions", Subscription, "owner", { + "account_id": "accounts", + "savings_account_id": "accounts", + "category_id": "categories", + }, label="Abos & Vertraege"), + # Das Logo gehoert zum Vertrag. Klein gerechnet ist es ein paar Kilobyte – + # muehsam nachzutragen waere es trotzdem. + TableSpec("subscription_logos", SubscriptionLogo, "subscription", + {"subscription_id": "subscriptions"}, has_id=False, + label="Vertrags-Logos"), TableSpec("transactions", Transaction, "owner", { "account_id": "accounts", "to_account_id": "accounts", "category_id": "categories", "budget_category_id": "categories", "import_batch_id": "import_batches", + "subscription_id": "subscriptions", }, label="Buchungen"), TableSpec("budget_template_lines", BudgetTemplateLine, "owner", {"category_id": "categories"}, label="Budget-Vorlage"), @@ -134,18 +148,6 @@ USER_TABLES: list[TableSpec] = [ "category_id": "categories", "transaction_id": "transactions", }, label="Eingang"), - # Abos & Vertraege: reine Stammdaten, aber muehsam nachzutragen – und sie - # verweisen auf Konten und Kategorien, stehen deshalb nach beiden. - TableSpec("subscriptions", Subscription, "owner", { - "account_id": "accounts", - "savings_account_id": "accounts", - "category_id": "categories", - }, label="Abos & Vertraege"), - # Das Logo gehoert zum Vertrag. Klein gerechnet ist es ein paar Kilobyte – - # muehsam nachzutragen waere es trotzdem. - TableSpec("subscription_logos", SubscriptionLogo, "subscription", - {"subscription_id": "subscriptions"}, has_id=False, - label="Vertrags-Logos"), TableSpec("transfer_links", TransferLink, "owner", {"from_txn_id": "transactions", "to_txn_id": "transactions"}, label="Übertrags-Verknüpfungen"), diff --git a/backend/app/services/csv_import.py b/backend/app/services/csv_import.py index e986fa7..a1c9ae0 100644 --- a/backend/app/services/csv_import.py +++ b/backend/app/services/csv_import.py @@ -1030,6 +1030,15 @@ def commit_import(db: Session, user: User, account: Account, template_id: int | except Exception: # Nie den Import daran scheitern lassen log.exception("Übertrags-Suche nach dem Import fehlgeschlagen") + # Und nach den Verträgen sehen – aus demselben Grund und mit derselben + # Vorsicht: Was hier entsteht, ist ein Etikett. Geht es schief, ist der + # Import trotzdem gelaufen. + try: + from . import abo_erkennung + abo_erkennung.finde(db, user) + except Exception: + log.exception("Vertrags-Zuordnung nach dem Import fehlgeschlagen") + db.refresh(batch) return batch diff --git a/backend/app/services/eingang.py b/backend/app/services/eingang.py index 39ff9df..b82cbeb 100644 --- a/backend/app/services/eingang.py +++ b/backend/app/services/eingang.py @@ -111,11 +111,21 @@ def suche_uebertraege(db: Session, user: User) -> int: Ein Fehler hier darf das Buchen nicht kippen: Die Buchung ist schon festgeschrieben, die Verknuepfung ist nur eine Schicht darueber. """ - from . import uebertraege + from . import abo_erkennung, uebertraege + verknuepft = 0 try: - return uebertraege.finde(db, user).get("verknuepft", 0) + verknuepft = uebertraege.finde(db, user).get("verknuepft", 0) except Exception: # pragma: no cover - Sicherheitsnetz log.exception("Uebertragssuche nach dem Buchen fehlgeschlagen") db.rollback() - return 0 + + # Danach nach Vertraegen sehen. Ein Abo, das ueber die Bank hereinkommt, + # soll sich von selbst seinem Vertrag zuordnen – sonst muesste man nach + # jedem Abruf von Hand nachsehen. + try: + abo_erkennung.finde(db, user) + except Exception: # pragma: no cover - Sicherheitsnetz + log.exception("Vertrags-Zuordnung nach dem Buchen fehlgeschlagen") + db.rollback() + return verknuepft diff --git a/backend/migrations/versions/0034_buchung_zu_vertrag.py b/backend/migrations/versions/0034_buchung_zu_vertrag.py new file mode 100644 index 0000000..340ed14 --- /dev/null +++ b/backend/migrations/versions/0034_buchung_zu_vertrag.py @@ -0,0 +1,40 @@ +"""Welche Buchung zu welchem Vertrag gehoert. + +Bisher wurde der Erkennungstext am Vertrag nur erfasst und lag ungenutzt. Jetzt +bekommt die Buchung ein Etikett, das sagt, welcher Vertrag sie ist. + +Ein Etikett, keine Umbuchung: An der Buchung aendert sich nichts. Sie behaelt +Betrag, Konto und Kategorie – hier steht nur, dass die beiden zusammengehoeren. +Genau wie bei den Uebertrags-Verknuepfungen, und aus demselben Grund: Faellt +die Zuordnung falsch aus, zieht man ein Etikett ab und hat keine Buchung +verbogen. + +Revision ID: 0034 +Revises: 0033 +""" + +import sqlalchemy as sa +from alembic import op + +revision = "0034" +down_revision = "0033" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Ohne batch_alter_table: Eine Spalte anhaengen kann SQLite direkt, und der + # Tabellen-Umbau risse ueber ON DELETE CASCADE die Uebertrags- + # Verknuepfungen und abgelehnten Vorschlaege mit (tests/test_migrations.py). + # Der Fremdschluessel laesst sich so nicht mitschreiben; SQLite prueft ihn + # fuer diese Spalte dann nicht. Zu verschmerzen: Die API prueft den Vertrag + # ohnehin, und ein verwaister Verweis waere ein Etikett ohne Wirkung, kein + # Datenschaden. Die Zuordnung raeumt ihn beim naechsten Lauf weg. + op.execute(sa.text("ALTER TABLE transactions ADD COLUMN subscription_id INTEGER")) + op.create_index("ix_transactions_subscription_id", "transactions", + ["subscription_id"]) + + +def downgrade() -> None: + op.drop_index("ix_transactions_subscription_id", table_name="transactions") + op.execute(sa.text("ALTER TABLE transactions DROP COLUMN subscription_id")) diff --git a/backend/tests/test_abo_erkennung.py b/backend/tests/test_abo_erkennung.py new file mode 100644 index 0000000..24efcab --- /dev/null +++ b/backend/tests/test_abo_erkennung.py @@ -0,0 +1,331 @@ +"""Welche Buchung gehoert zu welchem Vertrag. + +Die Zuordnung ist ein ETIKETT: An der Buchung aendert sich nichts. Die Tests +pruefen deshalb vor allem, was gleich bleiben MUSS – und wo das Programm +lieber fragt, statt zu raten. +""" + +import secrets +from datetime import date, timedelta + + +def _konto(admin, name=None, currency="EUR"): + r = admin.post("/api/accounts", json={ + "name": name or ("Konto " + secrets.token_hex(3)), "type": "girokonto", + "currency": currency, "opening_balance_cents": 0}) + assert r.status_code == 201, r.text + return r.json() + + +def _abo(admin, **kw): + body = {"name": "Netflix " + secrets.token_hex(3), "amount_cents": 1990, + "currency": "EUR", "interval_months": 1} + body.update(kw) + r = admin.post("/api/subscriptions", json=body) + assert r.status_code == 201, r.text + return r.json() + + +def _buchung(admin, konto, betrag, datum, payee="", beschreibung=""): + r = admin.post("/api/transactions", json={ + "type": "expense", "date": datum, "account_id": konto["id"], + "amount_cents": betrag, "payee": payee, "description": beschreibung}) + assert r.status_code == 201, r.text + return r.json() + + +def _zuordnen(admin): + r = admin.post("/api/subscriptions/zuordnen") + assert r.status_code == 200, r.text + return r.json() + + +def _txn(admin, txn_id): + for t in admin.get("/api/transactions").json(): + if t["id"] == txn_id: + return t + raise AssertionError("Buchung verschwunden") + + +def _aufraeumen(admin, abo=None, txns=()): + for t in txns: + admin.delete(f"/api/transactions/{t}") + if abo: + admin.delete(f"/api/subscriptions/{abo['id']}") + + +# --- Der Normalfall ---------------------------------------------------------- + + +def test_abbuchung_findet_ihren_vertrag(admin): + konto = _konto(admin) + abo = _abo(admin, name="Netflix", amount_cents=1990, account_id=konto["id"], + next_due=str(date.today())) + b = _buchung(admin, konto, 1990, str(date.today()), payee="NETFLIX.COM 4711") + try: + assert _zuordnen(admin)["zugeordnet"] >= 1 + + verlauf = admin.get(f"/api/subscriptions/{abo['id']}/verlauf").json() + assert verlauf["anzahl"] == 1 + assert verlauf["summe_cents"] == 1990 + assert verlauf["buchungen"][0]["txn_id"] == b["id"] + finally: + _aufraeumen(admin, abo, [b["id"]]) + + +def test_die_buchung_bleibt_unveraendert(admin): + """Es ist ein Etikett. Betrag, Konto und Kategorie bleiben, wie sie sind.""" + konto = _konto(admin) + abo = _abo(admin, name="Spotify", amount_cents=1299, account_id=konto["id"], + next_due=str(date.today())) + b = _buchung(admin, konto, 1299, str(date.today()), payee="SPOTIFY AB") + try: + _zuordnen(admin) + danach = _txn(admin, b["id"]) + assert danach["amount_cents"] == 1299 + assert danach["account_id"] == konto["id"] + assert danach["date"] == b["date"] + assert danach["type"] == "expense" + finally: + _aufraeumen(admin, abo, [b["id"]]) + + +def test_faelligkeit_rueckt_nach_der_erkannten_abbuchung_weiter(admin): + konto = _konto(admin) + heute = date.today() + abo = _abo(admin, name="Netflix", amount_cents=1990, account_id=konto["id"], + next_due=str(heute), interval_months=1) + b = _buchung(admin, konto, 1990, str(heute), payee="NETFLIX") + try: + _zuordnen(admin) + danach = admin.get(f"/api/subscriptions/{abo['id']}").json() + assert danach["next_due"] is not None + assert danach["next_due"] > str(heute), "Faelligkeit blieb stehen" + finally: + _aufraeumen(admin, abo, [b["id"]]) + + +def test_jede_abbuchung_wird_einzeln_erkannt(admin): + """Drei Monate nachgetragen ergeben drei Zeilen im Verlauf, nicht eine.""" + konto = _konto(admin) + heute = date.today() + abo = _abo(admin, name="Netflix", amount_cents=1990, account_id=konto["id"], + next_due=str(heute), interval_months=1) + tage = [heute - timedelta(days=62), heute - timedelta(days=31), heute] + ids = [_buchung(admin, konto, 1990, str(t), payee="NETFLIX")["id"] for t in tage] + try: + _zuordnen(admin) + verlauf = admin.get(f"/api/subscriptions/{abo['id']}/verlauf").json() + assert verlauf["anzahl"] == 3, verlauf + assert verlauf["summe_cents"] == 3 * 1990 + finally: + _aufraeumen(admin, abo, ids) + + +# --- Wo das Programm lieber fragt -------------------------------------------- + + +def test_fremder_betrag_wird_nicht_zugeordnet(admin): + """Lieber nichts sagen als daneben raten.""" + konto = _konto(admin) + abo = _abo(admin, name="Netflix", amount_cents=1990, account_id=konto["id"], + next_due=str(date.today())) + b = _buchung(admin, konto, 7350, str(date.today()), payee="BAUMARKT") + try: + _zuordnen(admin) + assert _txn(admin, b["id"]).get("subscription_id") in (None, 0) + assert admin.get(f"/api/subscriptions/{abo['id']}/verlauf").json()["anzahl"] == 0 + finally: + _aufraeumen(admin, abo, [b["id"]]) + + +def test_richtiger_betrag_fremder_text_kommt_als_vorschlag(admin): + """Auf dem Auszug steht oft der Abwickler, nicht die Marke. Fragen statt + raten – aber die Frage muss gestellt werden.""" + konto = _konto(admin) + abo = _abo(admin, name="Netflix", amount_cents=1990, account_id=konto["id"], + next_due=str(date.today())) + b = _buchung(admin, konto, 1990, str(date.today()), payee="STRIPE PAYMENTS") + try: + antwort = _zuordnen(admin) + assert _txn(admin, b["id"]).get("subscription_id") in (None, 0), \ + "ungefragt zugeordnet" + passend = [v for v in antwort["vorschlaege"] if v["txn_id"] == b["id"]] + assert passend, antwort["vorschlaege"] + assert passend[0]["subscription_id"] == abo["id"] + finally: + _aufraeumen(admin, abo, [b["id"]]) + + +def test_buchung_auf_fremdem_konto_zaehlt_nicht(admin): + """Der Vertrag sagt, ueber welches Konto er laeuft – das gilt.""" + konto = _konto(admin) + anderes = _konto(admin) + abo = _abo(admin, name="Netflix", amount_cents=1990, account_id=konto["id"], + next_due=str(date.today())) + b = _buchung(admin, anderes, 1990, str(date.today()), payee="NETFLIX") + try: + _zuordnen(admin) + assert _txn(admin, b["id"]).get("subscription_id") in (None, 0) + finally: + _aufraeumen(admin, abo, [b["id"]]) + + +def test_gekuendigte_vertraege_ziehen_nichts_mehr_an(admin): + konto = _konto(admin) + abo = _abo(admin, name="Altes Abo", amount_cents=1990, account_id=konto["id"], + next_due=str(date.today()), is_active=False) + b = _buchung(admin, konto, 1990, str(date.today()), payee="ALTES ABO") + try: + _zuordnen(admin) + assert _txn(admin, b["id"]).get("subscription_id") in (None, 0) + finally: + _aufraeumen(admin, abo, [b["id"]]) + + +# --- Die Preiserhoehung ------------------------------------------------------ + + +def test_preiserhoehung_wird_gemeldet_statt_verbucht(admin): + """Der wertvollste Fall – und einer, den man nie automatisch nachziehen + darf. Eine Preiserhoehung ist eine Nachricht an den Nutzer.""" + konto = _konto(admin) + abo = _abo(admin, name="Netflix", amount_cents=1790, account_id=konto["id"], + next_due=str(date.today())) + b = _buchung(admin, konto, 1990, str(date.today()), payee="NETFLIX.COM") + try: + antwort = _zuordnen(admin) + meldungen = [p for p in antwort["preisaenderungen"] if p["txn_id"] == b["id"]] + assert meldungen, antwort + assert meldungen[0]["erwartet_cents"] == 1790 + assert meldungen[0]["amount_cents"] == 1990 + + # Ungefragt geaendert hat sich nichts. + assert admin.get(f"/api/subscriptions/{abo['id']}").json()["amount_cents"] == 1790 + assert _txn(admin, b["id"]).get("subscription_id") in (None, 0) + finally: + _aufraeumen(admin, abo, [b["id"]]) + + +def test_neuen_preis_uebernehmen_und_zuordnen(admin): + """Der Weg, den der Nutzer danach geht.""" + konto = _konto(admin) + abo = _abo(admin, name="Netflix", amount_cents=1790, account_id=konto["id"], + next_due=str(date.today())) + b = _buchung(admin, konto, 1990, str(date.today()), payee="NETFLIX.COM") + try: + r = admin.post(f"/api/subscriptions/{abo['id']}/preis", + params={"amount_cents": 1990}) + assert r.status_code == 200, r.text + assert r.json()["amount_cents"] == 1990 + + r = admin.post(f"/api/subscriptions/{abo['id']}/buchungen/{b['id']}") + assert r.status_code == 200, r.text + assert admin.get(f"/api/subscriptions/{abo['id']}/verlauf").json()["anzahl"] == 1 + finally: + _aufraeumen(admin, abo, [b["id"]]) + + +def test_ein_vielfaches_gilt_nicht_als_preisaenderung(admin): + """Ein Abo, das sich verdreifacht, ist kein Abo mehr, sondern etwas + anderes – sonst zieht jeder groessere Einkauf beim selben Haendler an.""" + konto = _konto(admin) + abo = _abo(admin, name="Netflix", amount_cents=1990, account_id=konto["id"], + next_due=str(date.today())) + b = _buchung(admin, konto, 19900, str(date.today()), payee="NETFLIX.COM") + try: + antwort = _zuordnen(admin) + assert not [p for p in antwort["preisaenderungen"] if p["txn_id"] == b["id"]] + assert not [v for v in antwort["vorschlaege"] if v["txn_id"] == b["id"]] + finally: + _aufraeumen(admin, abo, [b["id"]]) + + +# --- Das Etikett wieder abziehen --------------------------------------------- + + +def test_zuordnung_laesst_sich_loesen_und_die_buchung_bleibt(admin): + konto = _konto(admin) + abo = _abo(admin, name="Netflix", amount_cents=1990, account_id=konto["id"], + next_due=str(date.today())) + b = _buchung(admin, konto, 1990, str(date.today()), payee="NETFLIX") + try: + _zuordnen(admin) + assert admin.get(f"/api/subscriptions/{abo['id']}/verlauf").json()["anzahl"] == 1 + + r = admin.delete(f"/api/subscriptions/buchungen/{b['id']}") + assert r.status_code == 204, r.text + assert admin.get(f"/api/subscriptions/{abo['id']}/verlauf").json()["anzahl"] == 0 + # Die Buchung selbst steht unveraendert da. + assert _txn(admin, b["id"])["amount_cents"] == 1990 + finally: + _aufraeumen(admin, abo, [b["id"]]) + + +def test_geloeschter_vertrag_nimmt_die_buchung_nicht_mit(admin): + """Wer ein Abo kuendigt und loescht, will nicht seine Zahlungshistorie + mitloeschen.""" + konto = _konto(admin) + abo = _abo(admin, name="Kurzlebig", amount_cents=1990, account_id=konto["id"], + next_due=str(date.today())) + b = _buchung(admin, konto, 1990, str(date.today()), payee="KURZLEBIG") + try: + _zuordnen(admin) + assert admin.delete(f"/api/subscriptions/{abo['id']}").status_code == 204 + danach = _txn(admin, b["id"]) + assert danach["amount_cents"] == 1990, "Buchung mitgeloescht" + finally: + admin.delete(f"/api/transactions/{b['id']}") + + +def test_vorschlaege_veraendern_nichts(admin): + """Eine Ansicht, die bloss hinsieht, darf nichts zuordnen.""" + konto = _konto(admin) + abo = _abo(admin, name="Netflix", amount_cents=1990, account_id=konto["id"], + next_due=str(date.today())) + b = _buchung(admin, konto, 1990, str(date.today()), payee="NETFLIX") + try: + r = admin.get("/api/subscriptions/vorschlaege") + assert r.status_code == 200, r.text + assert r.json()["zugeordnet"] == 0 + assert _txn(admin, b["id"]).get("subscription_id") in (None, 0) + finally: + _aufraeumen(admin, abo, [b["id"]]) + + +def test_zuordnung_ueberlebt_sichern_und_einspielen(admin): + """Ohne den Verweis in der Sicherung zeigte das Etikett danach auf einen + FREMDEN Vertrag – die Nummern werden beim Einspielen neu vergeben.""" + import json + + konto = _konto(admin) + name = "Backup-Abo " + secrets.token_hex(3) + abo = _abo(admin, name=name, amount_cents=1990, account_id=konto["id"], + next_due=str(date.today())) + b = _buchung(admin, konto, 1990, str(date.today()), payee=name) + try: + _zuordnen(admin) + assert _txn(admin, b["id"])["subscription_id"] == abo["id"] + + roh = admin.get("/api/export/backup.json").content + haufen = json.loads(roh) + haufen = haufen.get("data", haufen) + # Der Verweis muss ueberhaupt in der Sicherung stehen. + gesichert = [t for t in haufen["transactions"] + if t.get("subscription_id") is not None] + assert gesichert, "kein einziges Etikett gesichert" + + r = admin.post("/api/backup/restore", + files={"file": ("backup.json", roh, "application/json")}, + data={"mode": "replace"}) + assert r.status_code == 200, r.text + + # Nach dem Einspielen sind die Nummern andere – die Zuordnung muss + # trotzdem auf DENSELBEN Vertrag zeigen. + abos = admin.get("/api/subscriptions").json()["subscriptions"] + wieder = next(s for s in abos if s["name"] == name) + verlauf = admin.get(f"/api/subscriptions/{wieder['id']}/verlauf").json() + assert verlauf["anzahl"] == 1, "Zuordnung nach dem Einspielen verloren" + assert verlauf["summe_cents"] == 1990 + finally: + pass