feat: Zahlungen finden ihren Vertrag von selbst – Preisaenderungen werden gemeldet
Ein Abo stand bisher unverbunden neben seinen Zahlungen. Wer wissen wollte, ob Netflix diesen Monat abgebucht wurde, musste es in der Buchungsliste suchen. Jetzt verknuepft Certain Buchung und Vertrag beim Import selbst. Drei Stufen, bewusst unterschiedlich mutig: - sicher (Betrag und Text passen, Datum im Fenster): wird zugeordnet. - Vorschlag: Betrag passt, Text nicht – oder der Text passt und der Betrag weicht ab. Der zweite Fall ist die Preiserhoehung. - nichts: im Zweifel lieber schweigen. Eine Preiserhoehung wird nie stillschweigend uebernommen. Sie ist eine Nachricht an den Nutzer, kein Wartungsvorgang – er entscheidet, ob der neue Preis der richtige ist. Die Zuordnung ist ein Etikett, keine Umbuchung: die Buchung selbst bleibt unangetastet, und ein geloeschter Vertrag nimmt sie nicht mit. Beim Backup wandern Vertraege nun vor die Buchungen, sonst haenge eine Rueckspielung Zahlungen an die falschen Vertraege. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
41db67f53e
commit
152483707e
@@ -491,6 +491,20 @@ class Transaction(Base):
|
|||||||
# gleich aus, und dann verschluckt eine Zahlung die andere.
|
# gleich aus, und dann verschluckt eine Zahlung die andere.
|
||||||
source_uid: Mapped[str | None] = mapped_column(String(320), nullable=True)
|
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)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from ..models import (
|
|||||||
CategoryKind,
|
CategoryKind,
|
||||||
Subscription,
|
Subscription,
|
||||||
SubscriptionLogo,
|
SubscriptionLogo,
|
||||||
|
Transaction,
|
||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
from ..schemas import (
|
from ..schemas import (
|
||||||
@@ -32,7 +33,7 @@ from ..schemas import (
|
|||||||
SubscriptionSummary,
|
SubscriptionSummary,
|
||||||
SubscriptionUpdate,
|
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
|
from .settings import BASE_CURRENCY_KEY, get_user_setting
|
||||||
|
|
||||||
router = APIRouter(prefix="/subscriptions", tags=["subscriptions"])
|
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())
|
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)
|
@router.get("/{sub_id}", response_model=SubscriptionOut)
|
||||||
def get_subscription(sub_id: int, db: Session = Depends(get_db),
|
def get_subscription(sub_id: int, db: Session = Depends(get_db),
|
||||||
user: User = Depends(get_current_user)):
|
user: User = Depends(get_current_user)):
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ def to_out(db: Session, txn: Transaction, base: str, ccy_by_account: dict[int, s
|
|||||||
category_id=txn.category_id,
|
category_id=txn.category_id,
|
||||||
budget_treatment=txn.budget_treatment,
|
budget_treatment=txn.budget_treatment,
|
||||||
budget_category_id=txn.budget_category_id,
|
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=txn.budget_period or budget.period_of(txn.date, start_tag),
|
||||||
budget_period_manuell=txn.budget_period is not None,
|
budget_period_manuell=txn.budget_period is not None,
|
||||||
import_batch_id=txn.import_batch_id,
|
import_batch_id=txn.import_batch_id,
|
||||||
|
|||||||
@@ -462,6 +462,10 @@ class TransactionOut(BaseModel):
|
|||||||
category_id: int | None
|
category_id: int | None
|
||||||
budget_treatment: BudgetTreatment | None
|
budget_treatment: BudgetTreatment | None
|
||||||
budget_category_id: int | 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
|
# 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.
|
# Hand gewaehlt wurde oder aus dem Datum stammt, sagt das zweite Feld.
|
||||||
budget_period: str
|
budget_period: str
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -104,12 +104,26 @@ USER_TABLES: list[TableSpec] = [
|
|||||||
TableSpec("import_batches", ImportBatch, "owner",
|
TableSpec("import_batches", ImportBatch, "owner",
|
||||||
{"account_id": "accounts", "template_id": "import_templates"},
|
{"account_id": "accounts", "template_id": "import_templates"},
|
||||||
label="Import-Läufe"),
|
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", {
|
TableSpec("transactions", Transaction, "owner", {
|
||||||
"account_id": "accounts",
|
"account_id": "accounts",
|
||||||
"to_account_id": "accounts",
|
"to_account_id": "accounts",
|
||||||
"category_id": "categories",
|
"category_id": "categories",
|
||||||
"budget_category_id": "categories",
|
"budget_category_id": "categories",
|
||||||
"import_batch_id": "import_batches",
|
"import_batch_id": "import_batches",
|
||||||
|
"subscription_id": "subscriptions",
|
||||||
}, label="Buchungen"),
|
}, label="Buchungen"),
|
||||||
TableSpec("budget_template_lines", BudgetTemplateLine, "owner",
|
TableSpec("budget_template_lines", BudgetTemplateLine, "owner",
|
||||||
{"category_id": "categories"}, label="Budget-Vorlage"),
|
{"category_id": "categories"}, label="Budget-Vorlage"),
|
||||||
@@ -134,18 +148,6 @@ USER_TABLES: list[TableSpec] = [
|
|||||||
"category_id": "categories",
|
"category_id": "categories",
|
||||||
"transaction_id": "transactions",
|
"transaction_id": "transactions",
|
||||||
}, label="Eingang"),
|
}, 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",
|
TableSpec("transfer_links", TransferLink, "owner",
|
||||||
{"from_txn_id": "transactions", "to_txn_id": "transactions"},
|
{"from_txn_id": "transactions", "to_txn_id": "transactions"},
|
||||||
label="Übertrags-Verknüpfungen"),
|
label="Übertrags-Verknüpfungen"),
|
||||||
|
|||||||
@@ -1030,6 +1030,15 @@ def commit_import(db: Session, user: User, account: Account, template_id: int |
|
|||||||
except Exception: # Nie den Import daran scheitern lassen
|
except Exception: # Nie den Import daran scheitern lassen
|
||||||
log.exception("Übertrags-Suche nach dem Import fehlgeschlagen")
|
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)
|
db.refresh(batch)
|
||||||
return batch
|
return batch
|
||||||
|
|
||||||
|
|||||||
@@ -111,11 +111,21 @@ def suche_uebertraege(db: Session, user: User) -> int:
|
|||||||
Ein Fehler hier darf das Buchen nicht kippen: Die Buchung ist schon
|
Ein Fehler hier darf das Buchen nicht kippen: Die Buchung ist schon
|
||||||
festgeschrieben, die Verknuepfung ist nur eine Schicht darueber.
|
festgeschrieben, die Verknuepfung ist nur eine Schicht darueber.
|
||||||
"""
|
"""
|
||||||
from . import uebertraege
|
from . import abo_erkennung, uebertraege
|
||||||
|
|
||||||
|
verknuepft = 0
|
||||||
try:
|
try:
|
||||||
return uebertraege.finde(db, user).get("verknuepft", 0)
|
verknuepft = uebertraege.finde(db, user).get("verknuepft", 0)
|
||||||
except Exception: # pragma: no cover - Sicherheitsnetz
|
except Exception: # pragma: no cover - Sicherheitsnetz
|
||||||
log.exception("Uebertragssuche nach dem Buchen fehlgeschlagen")
|
log.exception("Uebertragssuche nach dem Buchen fehlgeschlagen")
|
||||||
db.rollback()
|
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
|
||||||
|
|||||||
@@ -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"))
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user