feat: Schnellerfassung per Kurzbefehl (Etappe A)

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>
This commit is contained in:
Guido
2026-08-22 19:34:14 +02:00
co-authored by Claude Opus 5
parent 9d6a53a224
commit 02260d7cd2
9 changed files with 841 additions and 1 deletions
+42 -1
View File
@@ -10,15 +10,18 @@ einrichten). Bisher wurde das nur im Frontend umgeleitet – wer die API direkt
ansprach, kam daran vorbei.
"""
from datetime import datetime, timezone
from fastapi import Depends, HTTPException, Request, status
from sqlalchemy.orm import Session
from .config import get_settings
from .database import get_db
from .models import Role, User
from .models import ApiToken, Role, TokenScope, User
from .schemas import UserOut
from .services import policy as policy_service
from .services import sessions
from .security import hash_session_token
_credentials_exc = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@@ -90,6 +93,44 @@ def enforce_account_state(request: Request, db: Session = Depends(get_db)) -> No
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=grund)
_token_exc = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Ungültige oder fehlende Gerätemarke",
)
def require_quick_token(request: Request, db: Session = Depends(get_db)) -> ApiToken:
"""Prüft die Gerätemarke im Header ``X-Certain-Token``.
Bewusst eine EIGENE Abhängigkeit statt eines Sonderfalls in
``get_current_user``: So gilt eine Marke ausschliesslich an den Endpunkten,
die sie ausdrücklich verlangen. Auf allen übrigen Wegen ist sie schlicht
keine Anmeldung – wer sie erbeutet, kommt an keine Daten.
"""
roh = (request.headers.get("X-Certain-Token") or "").strip()
if not roh:
raise _token_exc
marke = (
db.query(ApiToken)
.filter(ApiToken.token_hash == hash_session_token(roh),
ApiToken.scope == TokenScope.quick_add)
.first()
)
if marke is None:
raise _token_exc
besitzer = db.get(User, marke.owner_id)
if besitzer is None or not besitzer.is_active:
raise _token_exc
# Wer sein Passwort wechseln muss, soll auch nicht per Kurzbefehl buchen.
grund = pending_reason(db, besitzer)
if grund is not None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=grund)
marke.last_used_at = datetime.now(timezone.utc)
db.commit()
request.state.token_user = besitzer
return marke
def require_admin(current_user: User = Depends(get_current_user)) -> User:
if current_user.role != Role.admin:
raise HTTPException(