Files
certain/backend/app/routers/transactions.py
T
GuidoandClaude Opus 5 bd79b83339 feat: Zeitraum an der Karte, und eine schaerfere Suche nach Dubletten
Zwei Dinge, die zusammen die Frage beantworten "zaehlt das wirklich ab
meinem Stichtag, und sind das wirklich zwei Zahlungen?".

Die Karte Ausgaben-Anteile nennt jetzt ihren Zeitraum. Gerechnet hat sie
schon immer ueber den eingestellten Budget-Monat (25.08.-24.09. bei
Starttag 25); nachvollziehen liess sich das nur nicht, weil die Karte
nirgends sagte, worueber sie summiert.

Die Dublettensuche vergleicht nicht mehr stur ueber den Text. Ein krummer
Betrag wie 10.43 kommt an einem Tag kaum zweimal vor - auch wenn zwei
Quellen ihn verschieden benennen, und genau daran scheiterte der
Textvergleich. Bei einem runden Betrag (Vielfaches von 50 Rappen) muss der
Text weiter passen, sonst waeren zwei Zwanziger am selben Tag ein Fund.

Und was die Bank selbst als verschieden gemeldet hat, gilt als geklaert:
Stammen alle Zeilen aus demselben Bankkonto und tragen verschiedene
Kennungen, verschweigt die Karte sie. Das sind die Revolut-Aufrundungen,
die echt sind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 14:39:08 +02:00

389 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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, 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 to_out(db: Session, txn: Transaction, base: str, ccy_by_account: dict[int, str],
herkunft: dict[int, str] | None = None) -> 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,
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)
return [to_out(db, t, base, ccy, woher) 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
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]}
@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,
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]))
@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]))
@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"]
# 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]))
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]))
@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()