Der Kontoauszug kommt erst am Monatsende; wer im Laden bezahlt hat, weiss in dem Moment noch, wofuer. iOS laesst keine App fremde Mitteilungen mitlesen, also fuehrt der Weg ueber einen Kurzbefehl, der die Zahlung an Certain schickt. Gebaut: - Geraetemarken mit eigener Abhaengigkeit statt Sonderfall in get_current_user. Eine Marke gilt NUR an /quick – auf allen anderen Wegen ist sie keine Anmeldung. Geht das Telefon verloren, ist kein Datenabfluss moeglich; Tests halten das fuer Buchungen, Konten, Kategorien, Export und Datenbanksicherung fest. - POST /quick/booking laeuft durch detect_rows, damit Schlagwort- und Betragsregeln die Kategorie schon vorschlagen. external_id verhindert, dass ein doppelt ausgeloester Kurzbefehl zweimal bucht. - inbox_items merkt sich die Zahlung ueber das Buchen hinaus. Beim spaeteren CSV-Import greift der Import-Schluessel nicht: Der Text vom Telefon ist selten wortgleich mit dem des Auszugs und die Bank bucht ein bis zwei Tage spaeter. detect_rows vergleicht deshalb zusaetzlich unscharf – exakter Betrag, Datum +/- 3 Tage, je Eintrag nur einmal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
118 lines
4.2 KiB
Python
118 lines
4.2 KiB
Python
"""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, User
|
||
from ..schemas import ApiTokenCreate, ApiTokenCreated, ApiTokenOut, InboxItemOut
|
||
from ..security import generate_session_token, hash_session_token
|
||
|
||
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()
|
||
)
|
||
|
||
|
||
@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
|