Files
certain/backend/app/main.py
T
GuidoandClaude Opus 5 c9a598a056 feat: Ueberträge als Verknuepfung erkennen, ohne Buchungen anzufassen
Ein Uebertrag steht in zwei Auszuegen und wird zweimal gebucht – richtig fuer
die Salden, aber er zaehlt als Ausgabe UND als Einnahme in der Auswertung.

Statt beide zu einer Uebertrags-Buchung zusammenzufuehren (der urspruengliche
Plan) legt Certain nur eine Schicht darueber: transfer_links haelt fest, dass
zwei Buchungen zusammengehoeren. An den Buchungen selbst aendert sich nichts –
Betrag, Konto, Kategorie, Import-Schluessel und Stapel bleiben.

Das haelt alles heraus, was am Zusammenfuehren haette brechen koennen: der
Duplikatschutz verliert keinen Schluessel, undo_batch reisst keine Gegenseite
mit, und eine falsche Paarung ist ein Etikett, das man abzieht.

Die Auswertung ueberspringt verknuepfte Buchungen – nicht umkategorisiert:
Loest man die Verknuepfung, zaehlt die Buchung mit ihrer alten Kategorie
sofort wieder mit.

Automatisch nur, wenn die Zuordnung zwingend ist UND keine Seite eine
Kategorie traegt UND der Text auf einen Uebertrag hindeutet. Sonst Vorschlag.
Waehrungswechsel werden ueber den EZB-Kurs des Tages geprueft (2,5 % Toleranz
fuer den Bankaufschlag) – der Kurs dient nur als Pruefung, gespeichert wird
nichts davon.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 22:41:34 +02:00

220 lines
7.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""FastAPI-Anwendung.
Ein einzelner Prozess liefert sowohl die JSON-API unter ``/api/*`` als auch das
gebaute Frontend (``web/dist``) aus – hinter dem Nginx Proxy Manager des Nutzers.
Beim Start werden die Alembic-Migrationen angewandt und der erste Admin angelegt.
"""
import logging
import threading
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request
from fastapi.encoders import jsonable_encoder
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from starlette.exceptions import HTTPException as StarletteHTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from .branding import APP_NAME, VERSION, git_sha
from .config import get_settings
from .database import SessionLocal
from .deps import enforce_account_state
from .migrate import upgrade_to_head
log = logging.getLogger(__name__)
from .routers import (
accounts,
auth,
backup,
budgets,
categories,
dashboard,
documents,
imports,
policy,
inbox,
mail,
quick,
uebertraege,
rates,
settings as settings_router,
transactions,
users,
)
from .seed import ensure_first_admin
from .services import ecb_rates
settings = get_settings()
# Pfad zum gebauten Frontend (im Dev von Vite selbst ausgeliefert -> fehlt hier).
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
DIST_DIR = REPO_ROOT / "web" / "dist"
@asynccontextmanager
async def lifespan(app: FastAPI):
# Schema auf den aktuellen Stand bringen (idempotent).
upgrade_to_head()
db = SessionLocal()
try:
ensure_first_admin(db)
finally:
db.close()
# EZB-Kurse im HINTERGRUND laden – der Start darf nie darauf warten
# (langsames/geblocktes Netz oder hängendes DNS würde den Dienst sonst
# blockieren und der Health-Check nie grün werden).
if settings.ecb_fetch_on_start:
def _laden() -> None:
s = SessionLocal()
try:
ecb_rates.ensure_rates_on_startup(s, True)
finally:
s.close()
threading.Thread(target=_laden, name="ecb-rates", daemon=True).start()
# Zahlungs-Mails regelmässig abholen. Wie beim Kursabruf im Hintergrund:
# Ein langsames oder nicht erreichbares Postfach darf den Dienst nicht
# blockieren. Fehler landen sichtbar an der Quelle, nicht im Log-Nirwana.
stopp = threading.Event()
def _mails() -> None:
from datetime import datetime, timedelta, timezone
from .models import MailSource
from .services import mail_import
while not stopp.wait(60):
s = SessionLocal()
try:
jetzt = datetime.now(timezone.utc)
for quelle in s.query(MailSource).filter(MailSource.is_active.is_(True)):
faellig = quelle.last_poll_at is None or quelle.last_poll_at + timedelta(
minutes=quelle.poll_minutes
) <= jetzt
if faellig:
mail_import.abruf_mit_fehlerbehandlung(s, quelle)
except Exception:
log.exception("Mail-Abruf fehlgeschlagen")
finally:
s.close()
threading.Thread(target=_mails, name="mail-abruf", daemon=True).start()
yield
stopp.set()
app = FastAPI(title=APP_NAME, version=VERSION, lifespan=lifespan)
# CORS nur, wenn ausdrücklich konfiguriert (Standard: gleiche Herkunft, aus).
if settings.cors_origins.strip():
origins = (
["*"]
if settings.cors_origins.strip() == "*"
else [o.strip() for o in settings.cors_origins.split(",") if o.strip()]
)
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# --- API -------------------------------------------------------------------
# Die Abhängigkeit hängt am Sammel-Router, nicht an jedem einzelnen Endpunkt:
# Konten mit offener Pflicht (Passwortwechsel, 2FA-Einrichtung) kommen so an
# keiner Stelle vorbei, auch nicht an neu hinzukommenden.
api = APIRouter(prefix="/api", dependencies=[Depends(enforce_account_state)])
@api.get("/health", tags=["meta"])
def health() -> dict:
return {"status": "ok"}
@api.get("/version", tags=["meta"])
def version() -> dict:
return {"name": APP_NAME, "version": VERSION, "git_sha": git_sha()}
api.include_router(auth.router)
api.include_router(users.router)
api.include_router(settings_router.router)
api.include_router(policy.router)
api.include_router(accounts.router)
api.include_router(categories.router)
api.include_router(transactions.router)
api.include_router(budgets.router)
api.include_router(dashboard.router)
api.include_router(imports.router)
api.include_router(documents.router)
api.include_router(backup.router)
api.include_router(rates.router)
api.include_router(quick.router)
api.include_router(inbox.router)
api.include_router(mail.router)
api.include_router(uebertraege.router)
# Fehler an den Kurzbefehl-Endpunkten zusätzlich als „message" ausliefern.
# Der Kurzbefehl auf dem Telefon zeigt genau dieses Feld an; bei einem Fehler
# steht sonst nur „detail" in der Antwort und die Mitteilung bleibt leer –
# man sieht dann nicht, DASS etwas schiefging, geschweige denn was.
def _ist_kurzbefehl(request: Request) -> bool:
return request.url.path.startswith("/api/quick/")
@app.exception_handler(StarletteHTTPException)
async def http_fehler(request: Request, exc: StarletteHTTPException):
inhalt = {"detail": exc.detail}
if _ist_kurzbefehl(request):
inhalt["message"] = str(exc.detail)
inhalt["ok"] = False
return JSONResponse(status_code=exc.status_code, content=inhalt,
headers=getattr(exc, "headers", None))
@app.exception_handler(RequestValidationError)
async def eingabe_fehler(request: Request, exc: RequestValidationError):
inhalt = {"detail": exc.errors()}
if _ist_kurzbefehl(request):
fehlend = [
".".join(str(t) for t in f.get("loc", ()) if t not in ("body", "query"))
for f in exc.errors()
]
inhalt["message"] = ("Die Anfrage ist unvollständig: "
+ ", ".join(f for f in fehlend if f)
+ ". Beim Datei-Kurzbefehl muss der Anfragetext "
+ "'Formular' sein, mit den Feldern file (Datei) "
+ "und account_id.")
inhalt["ok"] = False
return JSONResponse(status_code=422, content=jsonable_encoder(inhalt))
app.include_router(api)
# --- Frontend (SPA) --------------------------------------------------------
# Gebaute, gehashte Assets unter /assets aggressiv cachebar ausliefern; alle
# übrigen (nicht-/api) Pfade auf index.html abbilden, damit Client-seitige
# Routen wie /accounts direkt aufrufbar sind (verlinkbar).
if DIST_DIR.exists():
assets_dir = DIST_DIR / "assets"
if assets_dir.exists():
app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
@app.get("/{full_path:path}", include_in_schema=False)
async def spa(full_path: str):
# /api-Pfade, die keine echte Route trafen, sind echte 404 (kein SPA).
if full_path.startswith("api"):
raise HTTPException(status_code=404, detail="Not Found")
candidate = DIST_DIR / full_path
if full_path and candidate.is_file():
return FileResponse(candidate)
return FileResponse(DIST_DIR / "index.html", headers={"Cache-Control": "no-cache"})