"""Schnellerfassung für Geräte ohne Anmeldung (iOS-Kurzbefehle). Der Kontoauszug kommt erst am Monatsende. Wer im Laden bezahlt hat, weiss in dem Moment noch, wofür – auf dem Auszug steht später oft nur ein nichtssagender Betreibername. Diese Endpunkte nehmen so eine Zahlung entgegen. Die Anmeldung läuft NICHT über die Sitzung, sondern über eine Gerätemarke (``require_quick_token``). Deren Geltungsbereich ist eng: erfassen ja, lesen nein. Die einzige Ausnahme ist die Kontoliste, die der Kurzbefehl für seine Auswahl braucht – sie liefert nur Name, Symbol und Farbe, keine Salden. """ from datetime import date as date_t from decimal import ROUND_HALF_UP, Decimal from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status from sqlalchemy.orm import Session from ..database import get_db from ..deps import require_quick_token from ..models import ( Account, ApiToken, InboxItem, InboxSource, InboxStatus, Transaction, PendingUpload, TxnType, User, ) from ..schemas import InboxItemOut, QuickBookingIn, QuickBookingOut from ..services import csv_import, currency router = APIRouter(prefix="/quick", tags=["quick"]) def _konto(db: Session, marke: ApiToken, gewuenscht: int | None) -> Account: """Zielkonto bestimmen: ausdrücklich genannt, sonst das der Marke.""" konto_id = gewuenscht or marke.default_account_id if konto_id is None: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Kein Konto angegeben und die Marke hat kein Standardkonto", ) konto = db.get(Account, konto_id) if konto is None or konto.owner_id != marke.owner_id: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Konto nicht gefunden") return konto @router.get("/accounts") def quick_accounts( marke: ApiToken = Depends(require_quick_token), db: Session = Depends(get_db) ) -> list[dict]: """Konten für die Auswahlliste im Kurzbefehl – ohne Salden.""" konten = ( db.query(Account) .filter(Account.owner_id == marke.owner_id, Account.is_archived.is_(False)) .order_by(Account.position, Account.name) .all() ) return [ {"id": a.id, "name": a.name, "currency": a.currency, "icon": a.icon, "color": a.color, "default": a.id == marke.default_account_id} for a in konten ] @router.post("/booking", response_model=QuickBookingOut, status_code=status.HTTP_201_CREATED) def quick_booking( payload: QuickBookingIn, marke: ApiToken = Depends(require_quick_token), db: Session = Depends(get_db), ) -> QuickBookingOut: konto = _konto(db, marke, payload.account_id) user = db.get(User, marke.owner_id) tag = payload.date or date_t.today() betrag = int( (payload.amount.copy_abs() * 100).quantize(Decimal("1"), rounding=ROUND_HALF_UP) ) if betrag == 0: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Betrag ist null") # Ein doppelt ausgelöster Kurzbefehl (schlechtes Netz, Doppeltipp) darf # nicht zweimal buchen. Die bestehende Erfassung wird zurückgemeldet. if payload.external_id: schon = ( db.query(InboxItem) .filter(InboxItem.owner_id == user.id, InboxItem.source == InboxSource.shortcut, InboxItem.external_id == payload.external_id) .first() ) if schon is not None: return QuickBookingOut( message="Bereits erfasst", item=InboxItemOut.model_validate(schon) ) # Durch die normale Erkennung schicken, damit Schlagwort- und Betragsregeln # die Kategorie vorschlagen – dieselbe Strecke wie beim CSV-Import. vorzeichen = -betrag if payload.kind == "expense" else betrag erkannt = csv_import.detect_rows(db, user, konto, [{ "date": tag.isoformat(), "amount_cents": vorzeichen, "payee": payload.payee, "purpose": payload.note, }]) kategorie_id = erkannt[0].get("category_id") if erkannt else None txn = Transaction( owner_id=user.id, type=TxnType.expense if payload.kind == "expense" else TxnType.income, date=tag, account_id=konto.id, amount_cents=betrag, amount_eur_cents=currency.compute_amount_eur_cents(db, betrag, konto.currency, tag), category_id=kategorie_id, payee=(payload.payee or None), description=(payload.note or None), ) db.add(txn) db.flush() eintrag = InboxItem( owner_id=user.id, source=InboxSource.shortcut, status=InboxStatus.booked, account_id=konto.id, date=tag, amount_cents=vorzeichen, currency=konto.currency, payee=(payload.payee or None), note=(payload.note or None), category_id=kategorie_id, transaction_id=txn.id, external_id=(payload.external_id or None), ) db.add(eintrag) db.commit() db.refresh(eintrag) wohin = f" · {payload.payee}" if payload.payee else "" return QuickBookingOut( message=f"{betrag / 100:.2f} {konto.currency} auf {konto.name}{wohin}", item=InboxItemOut.model_validate(eintrag), ) # Grosszuegig, aber nicht unbegrenzt: Ein Jahresauszug liegt weit darunter. MAX_UPLOAD = 5 * 1024 * 1024 @router.post("/statement", status_code=status.HTTP_201_CREATED) async def quick_statement( file: UploadFile = File(...), account_id: int | None = Form(None), marke: ApiToken = Depends(require_quick_token), db: Session = Depends(get_db), ) -> dict: """Kontoauszug vom Telefon entgegennehmen (Teilen-Menü). Hier wird NICHT gebucht. Ein Auszug hat schnell hunderte Zeilen; die am Telefon durchzusehen wäre keine Erleichterung. Die Datei wird abgelegt und wartet in der Import-Ansicht – dort gibt es Vorschau, Saldo-Abgleich und die Möglichkeit, einzelne Zeilen abzuwählen. """ daten = await file.read() if not daten: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Datei ist leer") if len(daten) > MAX_UPLOAD: raise HTTPException( status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail=f"Datei zu gross ({len(daten) // 1024} KB, erlaubt sind " f"{MAX_UPLOAD // 1024 // 1024} MB)", ) # Ohne ausdrückliche Angabe das Konto aus der Datei ableiten – dann reicht # EIN Kurzbefehl fürs Telefon statt einer je Konto. if account_id is not None: konto = _konto(db, marke, account_id) herkunft = "angegeben" else: user = db.get(User, marke.owner_id) erkannt = csv_import.erkenne_format(daten) gefunden = csv_import.erkenne_konto(db, user, daten, erkannt) if gefunden["account_id"] is None: if marke.default_account_id: konto = _konto(db, marke, marke.default_account_id) herkunft = "Standardkonto des Schlüssels" else: namen = ", ".join( f"{a.name} = {a.id}" for a in db.query(Account).filter( Account.owner_id == marke.owner_id, Account.is_archived.is_(False)) ) raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=(f"{gefunden['grund']}. Bitte account_id im Kurzbefehl " f"angeben. Deine Konten: {namen}"), ) else: konto = _konto(db, marke, gefunden["account_id"]) herkunft = gefunden["grund"] upload = PendingUpload( owner_id=marke.owner_id, account_id=konto.id, filename=(file.filename or "Vom Telefon")[:255], content=daten, size_bytes=len(daten), ) db.add(upload) db.commit() db.refresh(upload) return { "id": upload.id, "konto": konto.name, "size_bytes": upload.size_bytes, "erkannt_als": herkunft, "message": f"Empfangen für {konto.name} ({herkunft}) – zum Prüfen in Certain öffnen", }