Files
moneyfy/backend/app/api/routes/merchants.py
T
moneyfyandClaude Opus 5 b586d27b77 feat(api): Core-API mit Authentifizierung, CRUD und Monatsreport
- Anmeldung über Argon2id und JWT in httpOnly-Cookies, Refresh mit echter
  Rotation über die neue Tabelle refresh_token
- AuthProvider-Protokoll als Vorbereitung für OIDC, Administrator-Anlage beim
  Erststart mit erzwungenem Passwortwechsel
- CRUD für Konten, Kategorien (zweistufiger Baum), Firmen, Recurrences,
  Preisversionen, Buchungen, Budgets, Vorlagen und Sparziele
- Fälligkeiten mit Overlay-Logik: abrufen, bestätigen, auslassen, zurücksetzen
- Kontosalden zum Stichtag, Monatsübersicht mit Plan-Ist-Vergleich
- SECRET_KEY jetzt mindestens 32 Zeichen; Platzhalter in Produktion abgelehnt
- 61 neue Integrationstests, insgesamt 148 grün

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014e7t8UpmoVNMtWivY5LiSH
2026-09-09 13:40:37 +02:00

119 lines
4.1 KiB
Python

"""Firmen und Zahlungsempfänger."""
from fastapi import APIRouter, Query, status
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from app.api.deps import DbSession
from app.core.errors import ConflictError
from app.models import Merchant, Recurrence, Transaction
from app.models.enums import LogoStatus
from app.schemas.common import ErrorResponse, MessageResponse, Page
from app.schemas.merchant import MerchantCreate, MerchantOut, MerchantUpdate
from app.services.crud import apply_updates, get_or_404
from app.services.merchants import normalize_name, search_statement
router = APIRouter(prefix="/merchants", tags=["merchants"])
NOT_FOUND = {status.HTTP_404_NOT_FOUND: {"model": ErrorResponse}}
@router.get(
"",
response_model=Page[MerchantOut],
summary="Firmen suchen",
description="Volltextsuche über Name, normalisierten Namen und Domain.",
)
async def list_merchants(
session: DbSession,
q: str | None = Query(default=None, description="Suchbegriff."),
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
) -> Page[MerchantOut]:
stmt = search_statement(q)
total = (
await session.execute(select(func.count()).select_from(stmt.order_by(None).subquery()))
).scalar_one()
items = (await session.execute(stmt.limit(limit).offset(offset))).scalars().all()
return Page[MerchantOut](
items=[MerchantOut.model_validate(item) for item in items],
total=total,
limit=limit,
offset=offset,
)
@router.post(
"",
response_model=MerchantOut,
status_code=status.HTTP_201_CREATED,
summary="Firma anlegen",
description="Antwortet sofort. Der Logo-Status steht zunächst auf `pending`.",
)
async def create_merchant(payload: MerchantCreate, session: DbSession) -> Merchant:
merchant = Merchant(
name=payload.name,
normalized_name=normalize_name(payload.name),
domain=payload.domain,
aliases=payload.aliases,
brand_color=payload.brand_color,
brand_color_dark=payload.brand_color_dark,
logo_status=LogoStatus.PENDING,
)
session.add(merchant)
try:
await session.commit()
except IntegrityError as exc:
await session.rollback()
raise ConflictError(f"Die Firma '{payload.name}' ist bereits angelegt.") from exc
await session.refresh(merchant)
return merchant
@router.get(
"/{merchant_id}", response_model=MerchantOut, responses=NOT_FOUND, summary="Firma lesen"
)
async def read_merchant(merchant_id: int, session: DbSession) -> Merchant:
return await get_or_404(session, Merchant, merchant_id)
@router.patch(
"/{merchant_id}", response_model=MerchantOut, responses=NOT_FOUND, summary="Firma ändern"
)
async def update_merchant(
merchant_id: int, payload: MerchantUpdate, session: DbSession
) -> Merchant:
merchant = await get_or_404(session, Merchant, merchant_id)
apply_updates(merchant, payload)
if payload.name is not None:
merchant.normalized_name = normalize_name(payload.name)
try:
await session.commit()
except IntegrityError as exc:
await session.rollback()
raise ConflictError("Eine Firma mit diesem Namen ist bereits angelegt.") from exc
await session.refresh(merchant)
return merchant
@router.delete(
"/{merchant_id}",
response_model=MessageResponse,
responses=NOT_FOUND,
summary="Firma löschen",
description="Verweise aus Buchungen und Posten werden dabei auf 'keine Firma' gesetzt.",
)
async def delete_merchant(merchant_id: int, session: DbSession) -> MessageResponse:
merchant = await get_or_404(session, Merchant, merchant_id)
# ON DELETE SET NULL greift erst in der Datenbank; die geladenen Objekte
# müssen daher explizit nachgezogen werden.
for model in (Recurrence, Transaction):
stmt = select(model).where(model.merchant_id == merchant_id)
for row in (await session.execute(stmt)).scalars():
row.merchant_id = None
await session.delete(merchant)
await session.commit()
return MessageResponse(detail="Firma gelöscht.")