Drei neue Abfrageparameter an GET /api/transactions: q sucht in Empfaenger, Verwendungszweck und Notiz, tag_id filtert auf ein Etikett, subscription_id auf einen Vertrag. Bisher gab es das nur im Browser - die Weboberflaeche laedt alle Buchungen und siebt sie dort. Am Telefon ist das der Unterschied zwischen "laedt" und "benutzbar"; das Limit steht nicht umsonst auf 5000. Zwei Dinge, die die Tests festhalten: Prozent- und Unterstrich-Zeichen im Suchtext sind Zeichen, keine Platzhalter - sonst faende die Eingabe "50%" alles. Und ein fremder tag_id liefert nichts statt alles, weil der Filter auf TransactionTag.owner_id anschlaegt. Der Tag-Filter nimmt eine Unterabfrage wie der uncategorized-Zweig daneben, keinen Join: Ein Join gaebe jede Buchung so oft zurueck, wie sie Tags traegt. Die lokale Abfragevariable hiess q und heisst jetzt abfrage, damit der Parameter den Namen bekommen konnte. Bekannte Grenze: SQLite gleicht bei LIKE nur ASCII gross/klein ab, "Mueller" mit Umlaut findet also nicht jede Schreibweise. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
532 lines
22 KiB
Python
532 lines
22 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 or_, 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,
|
||
Tag,
|
||
Transaction,
|
||
TransactionTag,
|
||
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,
|
||
tags: dict[int, list[int]] | 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,
|
||
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,
|
||
tag_ids=(tags or {}).get(txn.id, []),
|
||
)
|
||
|
||
|
||
def _tags(db: Session, user: User, txns: list[Transaction]) -> dict[int, list[int]]:
|
||
"""Die Tags je Buchung – als EINE Abfrage, nicht je Zeile.
|
||
|
||
Die Buchungsliste geht bis 5000 Zeilen; je Zeile zu fragen waere genau das
|
||
N+1, das die vier Nachbarn hier schon vermeiden.
|
||
"""
|
||
ids = [t.id for t in txns]
|
||
if not ids:
|
||
return {}
|
||
nach: dict[int, list[int]] = {}
|
||
for txn_id, tag_id in db.query(
|
||
TransactionTag.transaction_id, TransactionTag.tag_id
|
||
).filter(TransactionTag.owner_id == user.id,
|
||
TransactionTag.transaction_id.in_(ids)):
|
||
nach.setdefault(txn_id, []).append(tag_id)
|
||
return nach
|
||
|
||
|
||
def _setze_tags(db: Session, user: User, txn: Transaction, tag_ids: list[int]) -> None:
|
||
"""Die Tags einer Buchung auf genau diese Liste bringen.
|
||
|
||
Ersetzen statt ergaenzen: Die Oberflaeche schickt immer den vollen Stand,
|
||
sonst liesse sich ein Tag nie wieder abnehmen. Jede ID wird geprueft – ein
|
||
fremder Tag darf auch dann nicht haengenbleiben, wenn jemand die ID raet.
|
||
"""
|
||
gewuenscht = {int(i) for i in tag_ids}
|
||
for tag_id in gewuenscht:
|
||
get_owned(db, Tag, tag_id, user)
|
||
vorhanden = {
|
||
z.tag_id: z for z in db.query(TransactionTag).filter(
|
||
TransactionTag.owner_id == user.id,
|
||
TransactionTag.transaction_id == txn.id)
|
||
}
|
||
for tag_id, zeile in vorhanden.items():
|
||
if tag_id not in gewuenscht:
|
||
db.delete(zeile)
|
||
for tag_id in gewuenscht - set(vorhanden):
|
||
db.add(TransactionTag(owner_id=user.id, transaction_id=txn.id, tag_id=tag_id))
|
||
|
||
|
||
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,
|
||
# Suche und die zwei Filter, die es bisher nur im Browser gab. Die
|
||
# Weboberfläche lädt alle Buchungen und siebt sie dort; ein Telefon soll
|
||
# dafür nicht 5000 Zeilen über die Leitung ziehen müssen.
|
||
q: str | None = None,
|
||
tag_id: int | None = None,
|
||
subscription_id: int | 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,
|
||
):
|
||
abfrage = db.query(Transaction).filter(Transaction.owner_id == user.id)
|
||
if account_id is not None:
|
||
abfrage = abfrage.filter(
|
||
(Transaction.account_id == account_id) | (Transaction.to_account_id == account_id)
|
||
)
|
||
if category_id is not None:
|
||
abfrage = abfrage.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),
|
||
)
|
||
abfrage = abfrage.filter(Transaction.category_id.is_(None),
|
||
Transaction.type != TxnType.transfer,
|
||
Transaction.id.notin_(verknuepft))
|
||
if type is not None:
|
||
abfrage = abfrage.filter(Transaction.type == type)
|
||
if date_from is not None:
|
||
abfrage = abfrage.filter(Transaction.date >= date_from)
|
||
if date_to is not None:
|
||
abfrage = abfrage.filter(Transaction.date <= date_to)
|
||
if q:
|
||
# LIKE statt Volltextindex: Ein Haushalt bleibt im Zehntausenderbereich,
|
||
# da kostet die Suche nichts Spürbares. Was SQLite dabei nicht kann:
|
||
# Umlaute gross/klein gleichsetzen – „Müller" findet „müller" nicht.
|
||
# Die Prozent- und Unterstrich-Zeichen des Suchtexts werden entwertet,
|
||
# sonst wäre jede Eingabe mit „%" ein Platzhalter statt eines Zeichens.
|
||
muster = "%" + q.translate(
|
||
str.maketrans({"\\": "\\\\", "%": "\\%", "_": "\\_"})
|
||
) + "%"
|
||
abfrage = abfrage.filter(or_(
|
||
Transaction.payee.ilike(muster, escape="\\"),
|
||
Transaction.description.ilike(muster, escape="\\"),
|
||
Transaction.notes.ilike(muster, escape="\\"),
|
||
))
|
||
if tag_id is not None:
|
||
# Unterabfrage statt Join, wie im uncategorized-Zweig: Ein Join würde
|
||
# jede Buchung so oft liefern, wie sie Tags trägt.
|
||
mit_tag = select(TransactionTag.transaction_id).where(
|
||
TransactionTag.owner_id == user.id, TransactionTag.tag_id == tag_id
|
||
)
|
||
abfrage = abfrage.filter(Transaction.id.in_(mit_tag))
|
||
if subscription_id is not None:
|
||
abfrage = abfrage.filter(Transaction.subscription_id == subscription_id)
|
||
rows = abfrage.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)
|
||
etiketten = _tags(db, user, rows)
|
||
return [to_out(db, t, base, ccy, woher, tag, etiketten) 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)
|
||
if payload.tag_ids:
|
||
db.flush() # erst mit id laesst sich das Etikett haengen
|
||
_setze_tags(db, user, txn, payload.tag_ids)
|
||
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),
|
||
_tags(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]), budget.start_tag(db, user),
|
||
_tags(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"]
|
||
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)
|
||
# Nach exclude_unset: mitgeschickt heisst ersetzen (auch mit leerer Liste),
|
||
# weggelassen heisst unberuehrt lassen.
|
||
if data.get("tag_ids") is not None:
|
||
_setze_tags(db, user, txn, data["tag_ids"])
|
||
|
||
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),
|
||
_tags(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]), budget.start_tag(db, user),
|
||
_tags(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()
|