Die Liste sagt ausdruecklich, dass sie nichts entscheidet: Drei Aufrundungen ueber 1.00 am selben Tag sind echt, drei gleiche Einkaeufe koennen es auch sein – das weiss nur der Nutzer. Dann darf sie aber auch nicht zu einer Entscheidung draengen, und genau das tat sie: Der einzige Knopf war der Papierkorb. Wer die Meldung loswerden wollte, musste eine richtige Buchung wegwerfen. Jetzt steht neben jeder Gruppe "Alle behalten". An den Buchungen aendert sich dabei nichts; es ist ein Haken an der Frage. Der Haken gilt dem, was man gesehen hat – nicht allem, was noch kommt: Eine Gruppe faellt erst weg, wenn ALLE ihre Zeilen ihn tragen. Kommt spaeter eine dritte gleich aussehende Buchung dazu, steht die Frage wieder da, und zwar fuer alle drei. Ein Test haelt das fest. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
447 lines
18 KiB
Python
447 lines
18 KiB
Python
"""Transaktionen: Ausgabe, Einnahme, interner Transfer. Owner-gescoped.
|
||
|
||
Beim Speichern wird der eingefrorene EUR-Wert (``amount_eur_cents``) zum
|
||
Buchungsdatum berechnet (Pivot für Budget/Dashboard). Ein manuell gesetzter
|
||
Kurs (``fx_rate``) hat Vorrang vor dem EZB-Kurs.
|
||
"""
|
||
|
||
from datetime import date
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||
from pydantic import BaseModel
|
||
from sqlalchemy import select, union
|
||
from sqlalchemy.orm import Session
|
||
|
||
from ..database import get_db
|
||
from ..deps import get_current_user, get_owned
|
||
from ..models import (
|
||
Account,
|
||
Category,
|
||
InboxItem,
|
||
InboxStatus,
|
||
Transaction,
|
||
TransferLink,
|
||
TxnType,
|
||
User,
|
||
)
|
||
from ..schemas import TransactionCreate, TransactionOut, TransactionUpdate
|
||
from ..services import buchung, budget, currency, uebertrag_buchen
|
||
from ..services.uebertrag_buchen import UebertragFehler
|
||
from .accounts import base_currency
|
||
|
||
router = APIRouter(prefix="/transactions", tags=["transactions"])
|
||
|
||
|
||
def _herkunft(db: Session, user: User, txns: list[Transaction]) -> dict[int, str]:
|
||
"""Woher jede Buchung stammt.
|
||
|
||
Der Eingangs-Eintrag sagt es für alles, was über Bank, Mail oder Kurzbefehl
|
||
kam; sonst entscheidet der Import-Stapel: mit ihm aus einer Datei, ohne ihn
|
||
von Hand. Als Sammelabfrage, nicht je Zeile – die Liste geht bis 500.
|
||
"""
|
||
ids = [t.id for t in txns]
|
||
if not ids:
|
||
return {}
|
||
aus_eingang: dict[int, str] = {}
|
||
for txn_id, quelle in db.query(InboxItem.transaction_id, InboxItem.source).filter(
|
||
InboxItem.owner_id == user.id, InboxItem.transaction_id.in_(ids)
|
||
):
|
||
# Bei einem Übertrag zeigen ZWEI Einträge auf dieselbe Buchung. Die
|
||
# Gegenseite hat Certain selbst angelegt; gemeint ist die echte Quelle.
|
||
if txn_id in aus_eingang and quelle.value == "transfer":
|
||
continue
|
||
aus_eingang[txn_id] = quelle.value
|
||
return {
|
||
t.id: aus_eingang.get(t.id, "csv" if t.import_batch_id is not None else "manuell")
|
||
for t in txns
|
||
}
|
||
|
||
|
||
def _pruefe_zeitraum(db: Session, user: User, label: str | None, tag: date) -> str | None:
|
||
"""Einen von Hand gewaehlten Budget-Monat pruefen.
|
||
|
||
``None`` nimmt die Wahl zurueck – dann entscheidet wieder das Datum. Sonst
|
||
muss der Monat in Reichweite des Datums liegen: Ein Vertipper wie 2019-03
|
||
liesse die Buchung aus jeder Auswertung verschwinden, ohne dass irgendwo
|
||
etwas fehlt. Drei Monate in jede Richtung decken den echten Fall ab (eine
|
||
Zahlung kurz vor oder nach dem Stichtag, eine verspaetete Rechnung).
|
||
"""
|
||
if label is None:
|
||
return None
|
||
eigen = budget.period_of(tag, budget.start_tag(db, user))
|
||
erlaubt = {budget.shift_label(eigen, n) for n in range(-3, 4)}
|
||
if label not in erlaubt:
|
||
raise HTTPException(
|
||
status.HTTP_400_BAD_REQUEST,
|
||
f"Budget-Monat {label} liegt zu weit von der Buchung entfernt "
|
||
f"(erlaubt: {min(erlaubt)} bis {max(erlaubt)})",
|
||
)
|
||
# Der abgeleitete Monat wird nicht festgeschrieben: Verschiebt sich das
|
||
# Datum spaeter, soll die Buchung wieder von selbst mitwandern.
|
||
return None if label == eigen else label
|
||
|
||
|
||
def to_out(db: Session, txn: Transaction, base: str, ccy_by_account: dict[int, str],
|
||
herkunft: dict[int, str] | None = None, start_tag: int = 1) -> TransactionOut:
|
||
acc_ccy = ccy_by_account.get(txn.account_id, "EUR")
|
||
if base == "EUR":
|
||
amount_base = txn.amount_eur_cents
|
||
else:
|
||
conv = currency.from_eur_cents(db, txn.amount_eur_cents, base, txn.date)
|
||
amount_base = conv if conv is not None else txn.amount_eur_cents
|
||
return TransactionOut(
|
||
id=txn.id,
|
||
type=txn.type,
|
||
date=txn.date,
|
||
time=txn.time,
|
||
account_id=txn.account_id,
|
||
to_account_id=txn.to_account_id,
|
||
account_currency=acc_ccy,
|
||
amount_cents=txn.amount_cents,
|
||
to_amount_cents=txn.to_amount_cents,
|
||
fx_rate=buchung.fx(txn),
|
||
amount_eur_cents=txn.amount_eur_cents,
|
||
amount_base_cents=amount_base,
|
||
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,
|
||
herkunft=(herkunft or {}).get(txn.id, "manuell"),
|
||
payee=txn.payee,
|
||
description=txn.description,
|
||
notes=txn.notes,
|
||
)
|
||
|
||
|
||
def _currency_map(db: Session, user: User) -> dict[int, str]:
|
||
rows = db.query(Account.id, Account.currency).filter(Account.owner_id == user.id).all()
|
||
return {row[0]: row[1] for row in rows}
|
||
|
||
|
||
def _validate_category(db: Session, user: User, category_id: int | None) -> None:
|
||
if category_id is not None:
|
||
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])
|
||
def list_transactions(
|
||
db: Session = Depends(get_db),
|
||
user: User = Depends(get_current_user),
|
||
account_id: int | None = None,
|
||
category_id: int | None = None,
|
||
# Zum Abarbeiten: nur Buchungen, die noch keine Kategorie haben. Überträge
|
||
# bleiben aussen vor – sie ordnen kein Geld ein, sie verschieben es nur.
|
||
uncategorized: bool = False,
|
||
type: TxnType | None = None,
|
||
date_from: date | None = None,
|
||
date_to: date | None = None,
|
||
# Großzügiges Limit: die Buchungsliste (DataTable) filtert/sortiert im Browser,
|
||
# deshalb sollen standardmäßig ALLE Buchungen geladen werden, nicht nur 200.
|
||
limit: int = Query(default=5000, le=50000),
|
||
offset: int = 0,
|
||
):
|
||
q = db.query(Transaction).filter(Transaction.owner_id == user.id)
|
||
if account_id is not None:
|
||
q = q.filter(
|
||
(Transaction.account_id == account_id) | (Transaction.to_account_id == account_id)
|
||
)
|
||
if category_id is not None:
|
||
q = q.filter(Transaction.category_id == category_id)
|
||
if uncategorized:
|
||
# Verknüpfte Buchungen sind erledigt: Sie gehören zu einem Übertrag und
|
||
# zählen in keiner Auswertung. Sie hier nach einer Kategorie zu fragen,
|
||
# verlangt Arbeit, die nichts bewirkt.
|
||
verknuepft = union(
|
||
select(TransferLink.from_txn_id).where(TransferLink.owner_id == user.id),
|
||
select(TransferLink.to_txn_id).where(TransferLink.owner_id == user.id),
|
||
)
|
||
q = q.filter(Transaction.category_id.is_(None),
|
||
Transaction.type != TxnType.transfer,
|
||
Transaction.id.notin_(verknuepft))
|
||
if type is not None:
|
||
q = q.filter(Transaction.type == type)
|
||
if date_from is not None:
|
||
q = q.filter(Transaction.date >= date_from)
|
||
if date_to is not None:
|
||
q = q.filter(Transaction.date <= date_to)
|
||
rows = q.order_by(Transaction.date.desc(), Transaction.id.desc()).limit(limit).offset(offset).all()
|
||
base = base_currency(db, user)
|
||
ccy = _currency_map(db, user)
|
||
woher = _herkunft(db, user, rows)
|
||
tag = budget.start_tag(db, user)
|
||
return [to_out(db, t, base, ccy, woher, tag) for t in rows]
|
||
|
||
|
||
@router.get("/dubletten")
|
||
def duplicates(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
|
||
"""Buchungen, die gleich aussehen: gleiches Konto, gleicher Tag, gleicher
|
||
Betrag, gleicher Text.
|
||
|
||
Entschieden wird hier NICHTS. Drei Aufrundungen über 1.00 am selben Tag
|
||
sind echt, drei gleiche Einkäufe können es auch sein – das weiss nur der
|
||
Nutzer. Deshalb steht zu jeder Zeile die Kennung der Bank dabei: Tragen
|
||
zwei Zeilen dieselbe, ist es sicher dieselbe Zahlung; tragen sie
|
||
verschiedene, hat die Bank sie als verschieden gemeldet.
|
||
"""
|
||
from ..services.csv_import import normalize_text
|
||
|
||
gruppen: dict[tuple, list] = {}
|
||
texte: dict[int, str] = {}
|
||
for txn in db.query(Transaction).filter(
|
||
Transaction.owner_id == user.id,
|
||
Transaction.type != TxnType.transfer,
|
||
).order_by(Transaction.date.desc(), Transaction.id):
|
||
# Konto, Tag und Betrag muessen immer stimmen. Der Text NICHT: Ein
|
||
# krummer Betrag wie 10.43 kommt an einem Tag kaum zweimal vor, auch
|
||
# wenn zwei Quellen ihn verschieden benennen – und genau daran
|
||
# scheitert ein Vergleich ueber den Text.
|
||
gruppen.setdefault((txn.account_id, txn.type.value, txn.date,
|
||
txn.amount_cents), []).append(txn)
|
||
texte[txn.id] = normalize_text(txn.payee or txn.description or "")
|
||
|
||
# Bei einem runden Betrag ist zweimal am selben Tag durchaus moeglich –
|
||
# dort muss auch der Text uebereinstimmen, sonst waere es blosser Zufall.
|
||
def rund(cents: int) -> bool:
|
||
return cents % 50 == 0
|
||
|
||
verdaechtig = {}
|
||
for schluessel, zeilen in gruppen.items():
|
||
if len(zeilen) < 2:
|
||
continue
|
||
if rund(schluessel[3]) and len({texte[t.id] for t in zeilen}) > 1:
|
||
continue
|
||
# Schon entschieden. Nicht die einzelne Zeile ausblenden, sondern die
|
||
# Gruppe erst, wenn ALLE ihren Haken tragen: Kommt spaeter eine dritte
|
||
# gleich aussehende dazu, ist die Frage wieder offen – und zwar fuer
|
||
# alle drei.
|
||
if all(t.duplicate_ok for t in zeilen):
|
||
continue
|
||
verdaechtig[schluessel] = zeilen
|
||
if not verdaechtig:
|
||
return {"gruppen": []}
|
||
|
||
alle_ids = [t.id for zeilen in verdaechtig.values() for t in zeilen]
|
||
herkunft = _herkunft(db, user, [t for zeilen in verdaechtig.values() for t in zeilen])
|
||
kennungen = {
|
||
txn_id: (wert or "")
|
||
for txn_id, wert in db.query(InboxItem.transaction_id, InboxItem.external_id)
|
||
.filter(InboxItem.owner_id == user.id, InboxItem.transaction_id.in_(alle_ids))
|
||
}
|
||
# Melden nur, wenn es etwas zu entscheiden gibt: Stammen alle Zeilen aus
|
||
# DEMSELBEN Bankkonto und tragen verschiedene Kennungen, hat die Bank sie
|
||
# ausdruecklich als verschieden gemeldet – so sind Revolut-Aufrundungen
|
||
# ueber denselben Betrag, und die sind echt.
|
||
def entschieden(zeilen) -> bool:
|
||
werte = [kennungen.get(t.id) for t in zeilen]
|
||
if any(w is None for w in werte):
|
||
return False
|
||
konten = {w.split(":", 1)[0] for w in werte}
|
||
return len(konten) == 1 and len(set(werte)) == len(werte)
|
||
|
||
verdaechtig = {k: v for k, v in verdaechtig.items() if not entschieden(v)}
|
||
if not verdaechtig:
|
||
return {"gruppen": []}
|
||
|
||
return {"gruppen": [
|
||
{
|
||
"account_id": k[0], "datum": k[2].isoformat(), "amount_cents": k[3],
|
||
"typ": k[1],
|
||
"zeilen": [
|
||
{"id": t.id, "payee": t.payee, "description": t.description,
|
||
"category_id": t.category_id, "herkunft": herkunft.get(t.id, "manuell"),
|
||
"kennung": (kennungen.get(t.id) or "").split(":", 1)[-1] or None}
|
||
for t in zeilen
|
||
],
|
||
}
|
||
for k, zeilen in sorted(verdaechtig.items(), key=lambda p: p[0][2], reverse=True)
|
||
][:100]}
|
||
|
||
|
||
class BehaltenIn(BaseModel):
|
||
ids: list[int]
|
||
|
||
|
||
@router.post("/dubletten/behalten", status_code=status.HTTP_204_NO_CONTENT)
|
||
def dubletten_behalten(
|
||
payload: BehaltenIn, db: Session = Depends(get_db),
|
||
user: User = Depends(get_current_user),
|
||
) -> None:
|
||
"""Beide behalten – die Frage ist beantwortet.
|
||
|
||
An den Buchungen aendert sich nichts. Ohne diesen Weg blieb nur Loeschen,
|
||
um die Meldung loszuwerden: Die Liste draengte zu einer Entscheidung, die
|
||
sie selbst ausdruecklich nicht treffen will.
|
||
"""
|
||
for txn_id in payload.ids:
|
||
get_owned(db, Transaction, txn_id, user).duplicate_ok = True
|
||
db.commit()
|
||
|
||
|
||
@router.post("", response_model=TransactionOut, status_code=status.HTTP_201_CREATED)
|
||
def create_transaction(
|
||
payload: TransactionCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)
|
||
):
|
||
from_acc = get_owned(db, Account, payload.account_id, user)
|
||
to_acc = None
|
||
category_id = payload.category_id
|
||
|
||
if payload.type == TxnType.transfer:
|
||
if payload.to_account_id is None or payload.to_account_id == payload.account_id:
|
||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Transfer braucht ein anderes Zielkonto")
|
||
to_acc = get_owned(db, Account, payload.to_account_id, user)
|
||
category_id = None # Transfers werden nicht regulär kategorisiert
|
||
else:
|
||
if payload.to_account_id is not None:
|
||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Nur Transfers haben ein Zielkonto")
|
||
_validate_category(db, user, category_id)
|
||
|
||
_validate_category(db, user, payload.budget_category_id)
|
||
|
||
txn = Transaction(
|
||
owner_id=user.id,
|
||
type=payload.type,
|
||
date=payload.date,
|
||
time=payload.time,
|
||
account_id=from_acc.id,
|
||
to_account_id=to_acc.id if to_acc else None,
|
||
amount_cents=payload.amount_cents,
|
||
to_amount_cents=payload.to_amount_cents,
|
||
fx_rate=payload.fx_rate,
|
||
category_id=category_id,
|
||
budget_treatment=payload.budget_treatment,
|
||
budget_category_id=payload.budget_category_id,
|
||
budget_period=_pruefe_zeitraum(db, user, payload.budget_period, payload.date),
|
||
payee=(payload.payee or None),
|
||
description=(payload.description or None),
|
||
notes=(payload.notes or None),
|
||
)
|
||
buchung.setze_betraege(db, txn, from_acc, to_acc)
|
||
db.add(txn)
|
||
db.commit()
|
||
db.refresh(txn)
|
||
return to_out(db, txn, base_currency(db, user), _currency_map(db, user),
|
||
_herkunft(db, user, [txn]), budget.start_tag(db, user))
|
||
|
||
|
||
@router.get("/{txn_id}", response_model=TransactionOut)
|
||
def get_transaction(
|
||
txn_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)
|
||
):
|
||
txn = get_owned(db, Transaction, txn_id, user)
|
||
return to_out(db, txn, base_currency(db, user), _currency_map(db, user),
|
||
_herkunft(db, user, [txn]), budget.start_tag(db, user))
|
||
|
||
|
||
@router.patch("/{txn_id}", response_model=TransactionOut)
|
||
def update_transaction(
|
||
txn_id: int,
|
||
payload: TransactionUpdate,
|
||
db: Session = Depends(get_db),
|
||
user: User = Depends(get_current_user),
|
||
):
|
||
txn = get_owned(db, Transaction, txn_id, user)
|
||
data = payload.model_dump(exclude_unset=True)
|
||
|
||
for key in ("type", "date", "time", "amount_cents", "to_amount_cents", "fx_rate",
|
||
"budget_treatment", "payee", "description", "notes"):
|
||
if key in data:
|
||
setattr(txn, key, data[key])
|
||
|
||
if "account_id" in data and data["account_id"] is not None:
|
||
get_owned(db, Account, data["account_id"], user)
|
||
txn.account_id = data["account_id"]
|
||
if "to_account_id" in data:
|
||
if data["to_account_id"] is not None:
|
||
get_owned(db, Account, data["to_account_id"], user)
|
||
txn.to_account_id = data["to_account_id"]
|
||
if "category_id" in data:
|
||
_validate_category(db, user, data["category_id"])
|
||
txn.category_id = data["category_id"]
|
||
if "budget_category_id" in data:
|
||
_validate_category(db, user, data["budget_category_id"])
|
||
txn.budget_category_id = data["budget_category_id"]
|
||
if "budget_period" in data:
|
||
txn.budget_period = _pruefe_zeitraum(db, user, data["budget_period"],
|
||
data.get("date", txn.date))
|
||
|
||
# Konsistenz je nach Typ + Neuberechnung der abgeleiteten Werte.
|
||
from_acc = get_owned(db, Account, txn.account_id, user)
|
||
to_acc = None
|
||
if txn.type == TxnType.transfer:
|
||
if txn.to_account_id is None or txn.to_account_id == txn.account_id:
|
||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Transfer braucht ein anderes Zielkonto")
|
||
to_acc = get_owned(db, Account, txn.to_account_id, user)
|
||
txn.category_id = None
|
||
else:
|
||
txn.to_account_id = None
|
||
buchung.setze_betraege(db, txn, from_acc, to_acc)
|
||
|
||
db.commit()
|
||
db.refresh(txn)
|
||
return to_out(db, txn, base_currency(db, user), _currency_map(db, user),
|
||
_herkunft(db, user, [txn]), budget.start_tag(db, user))
|
||
|
||
|
||
class TransactionTransferIn(BaseModel):
|
||
to_account_id: int
|
||
# Nur setzen, wenn man es besser weiss als Certain – siehe uebertrag_buchen.
|
||
trotzdem: bool = False
|
||
|
||
|
||
@router.post("/{txn_id}/transfer", response_model=TransactionOut)
|
||
def make_transfer(txn_id: int, payload: TransactionTransferIn,
|
||
db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||
"""Eine gebuchte Ausgabe oder Einnahme zum Kontoübertrag machen.
|
||
|
||
Nicht über PATCH: Der dreht die Richtung bei einer Einnahme nicht um und
|
||
legt keine Gegenseite auf dem Zielkonto an. Beides gehört zusammen, deshalb
|
||
läuft es durch denselben Dienst wie der Weg aus dem Eingang.
|
||
"""
|
||
txn = get_owned(db, Transaction, txn_id, user)
|
||
ziel = get_owned(db, Account, payload.to_account_id, user)
|
||
try:
|
||
uebertrag_buchen.aus_buchung(db, user, txn, ziel, payload.trotzdem)
|
||
except UebertragFehler as e:
|
||
db.rollback()
|
||
raise HTTPException(
|
||
status_code=status.HTTP_409_CONFLICT if e.nachfragen
|
||
else status.HTTP_400_BAD_REQUEST,
|
||
detail=str(e),
|
||
) from e
|
||
db.commit()
|
||
db.refresh(txn)
|
||
return to_out(db, txn, base_currency(db, user), _currency_map(db, user),
|
||
_herkunft(db, user, [txn]), budget.start_tag(db, user))
|
||
|
||
|
||
@router.delete("/{txn_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||
def delete_transaction(
|
||
txn_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)
|
||
):
|
||
txn = get_owned(db, Transaction, txn_id, user)
|
||
# Eingangs-Eintraege, die auf diese Buchung zeigen, mit verwerfen. Der
|
||
# Fremdschluessel setzt sonst nur ihre Verweisspalte auf NULL, und sie
|
||
# blieben als "gebucht" ohne Buchung stehen – bei einem Uebertrag sind das
|
||
# zwei, die Quelle und die Gegenseite.
|
||
for eintrag in db.query(InboxItem).filter(InboxItem.owner_id == user.id,
|
||
InboxItem.transaction_id == txn.id):
|
||
eintrag.status = InboxStatus.discarded
|
||
eintrag.transaction_id = None
|
||
db.delete(txn)
|
||
db.commit()
|