"""Eingang extern erfasster Zahlungen und die Gerätemarken dafür. Der Eingang zeigt, was per Kurzbefehl (später auch per Mail) hereinkam. Die Einträge bleiben nach dem Buchen bestehen: Der spätere CSV-Import gleicht gegen sie ab, damit dieselbe Zahlung nicht ein zweites Mal verbucht wird. """ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session from ..database import get_db from ..deps import get_current_user, get_owned from ..models import ( Account, ApiToken, InboxItem, InboxStatus, Transaction, TxnType, User, ) from ..schemas import ApiTokenCreate, ApiTokenCreated, ApiTokenOut, InboxItemOut from ..security import generate_session_token, hash_session_token from ..services import currency router = APIRouter(prefix="/inbox", tags=["inbox"]) # --- Gerätemarken ------------------------------------------------------------ @router.get("/tokens", response_model=list[ApiTokenOut]) def list_tokens(db: Session = Depends(get_db), user: User = Depends(get_current_user)): return ( db.query(ApiToken) .filter(ApiToken.owner_id == user.id) .order_by(ApiToken.created_at.desc()) .all() ) @router.post("/tokens", response_model=ApiTokenCreated, status_code=status.HTTP_201_CREATED) def create_token( payload: ApiTokenCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user), ): """Legt eine Marke an. Der Klartext ist NUR in dieser Antwort zu sehen.""" if payload.default_account_id is not None: get_owned(db, Account, payload.default_account_id, user) klartext = generate_session_token() marke = ApiToken( owner_id=user.id, name=payload.name.strip(), token_hash=hash_session_token(klartext), default_account_id=payload.default_account_id, ) db.add(marke) db.commit() db.refresh(marke) return ApiTokenCreated(**ApiTokenOut.model_validate(marke).model_dump(), token=klartext) @router.delete("/tokens/{token_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_token(token_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)): marke = get_owned(db, ApiToken, token_id, user) db.delete(marke) db.commit() # --- Eingang ----------------------------------------------------------------- @router.get("", response_model=list[InboxItemOut]) def list_inbox( limit: int = 200, db: Session = Depends(get_db), user: User = Depends(get_current_user), ): return ( db.query(InboxItem) .filter(InboxItem.owner_id == user.id, InboxItem.status != InboxStatus.discarded) .order_by(InboxItem.date.desc(), InboxItem.id.desc()) .limit(min(limit, 1000)) .all() ) def _buche(db: Session, user: User, eintrag: InboxItem) -> Transaction | None: """Aus einem Vorschlag eine Buchung machen. Ohne Konto geht es nicht.""" if eintrag.status == InboxStatus.booked or eintrag.transaction_id is not None: return None if eintrag.account_id is None: return None konto = db.get(Account, eintrag.account_id) if konto is None or konto.owner_id != user.id: return None betrag = abs(eintrag.amount_cents) txn = Transaction( owner_id=user.id, type=TxnType.expense if eintrag.amount_cents < 0 else TxnType.income, date=eintrag.date, account_id=konto.id, amount_cents=betrag, amount_eur_cents=currency.compute_amount_eur_cents( db, betrag, konto.currency, eintrag.date), category_id=eintrag.category_id, payee=eintrag.payee, description=eintrag.note, ) db.add(txn) db.flush() eintrag.transaction_id = txn.id eintrag.status = InboxStatus.booked return txn @router.post("/{item_id}/book", response_model=InboxItemOut) def book_item(item_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)): eintrag = get_owned(db, InboxItem, item_id, user) if eintrag.account_id is None: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Kein Konto zugeordnet") if _buche(db, user, eintrag) is None: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Lässt sich nicht buchen") db.commit() db.refresh(eintrag) return eintrag @router.post("/book-all") def book_all(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict: """Alle offenen Vorschläge buchen, die ein Konto haben. Unklare bleiben liegen: Bei ihnen fehlt etwas oder die Erkennung war unsicher – die soll man ansehen, nicht im Stapel durchwinken. """ offen = ( db.query(InboxItem) .filter(InboxItem.owner_id == user.id, InboxItem.status == InboxStatus.pending, InboxItem.account_id.isnot(None)) .all() ) gebucht = sum(1 for e in offen if _buche(db, user, e) is not None) db.commit() return {"gebucht": gebucht} @router.delete("/{item_id}", status_code=status.HTTP_204_NO_CONTENT) def discard_item(item_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)): """Eintrag verwerfen – samt der Buchung, die daraus entstanden ist. Der Eintrag selbst bleibt als ``discarded`` stehen. Sonst wäre die Zahlung beim nächsten Auszug wieder neu, obwohl der Nutzer sie bewusst weggeworfen hat; so kann der Abgleich sie weiterhin erkennen. """ eintrag = get_owned(db, InboxItem, item_id, user) if eintrag.transaction_id is not None: txn = db.get(Transaction, eintrag.transaction_id) if txn is not None and txn.owner_id == user.id: db.delete(txn) eintrag.status = InboxStatus.discarded eintrag.transaction_id = None db.commit() @router.post("/{item_id}/category/{category_id}", response_model=InboxItemOut) def set_category( item_id: int, category_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user), ): """Kategorie setzen – auf dem Eintrag UND auf der erzeugten Buchung.""" from ..models import Category eintrag = get_owned(db, InboxItem, item_id, user) kategorie = get_owned(db, Category, category_id, user) eintrag.category_id = kategorie.id if eintrag.transaction_id is not None: txn = db.get(Transaction, eintrag.transaction_id) if txn is not None and txn.owner_id == user.id: txn.category_id = kategorie.id db.commit() db.refresh(eintrag) return eintrag