from __future__ import annotations from datetime import UTC, datetime from typing import Any from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy.orm import Session from govoplan_core.audit.logging import audit_event from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope from govoplan_core.core.institutional import EvidenceReference, InstitutionalContextError from govoplan_core.core.payments import ( ManualPaymentReconciliationCommand, PaymentRequestCommand, ) from govoplan_core.db.session import get_session from govoplan_payments.backend.manifest import ( READ_SCOPE, RECONCILE_SCOPE, WRITE_SCOPE, ) from govoplan_payments.backend.schemas import ( ManualPaymentReconciliationCreate, PaymentListResponse, PaymentRequestCreate, ) from govoplan_payments.backend.service import ( PaymentConflict, PaymentError, SqlPaymentRequestProvider, ) router = APIRouter(prefix="/payments", tags=["payments"]) provider = SqlPaymentRequestProvider() @router.get("/requests", response_model=PaymentListResponse) def api_list_payment_requests( payment_status: str | None = Query(default=None, alias="status"), source_resource_id: str | None = Query(default=None, max_length=255), limit: int = Query(default=100, ge=1, le=200), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> PaymentListResponse: _require(principal, READ_SCOPE) try: items = provider.list_payments( session, tenant_id=principal.tenant_id, status=payment_status, source_resource_id=source_resource_id, limit=limit, ) except PaymentError as exc: raise _error(exc) from exc return PaymentListResponse(payments=[dict(item) for item in items]) @router.get("/requests/{payment_id}", response_model=dict[str, Any]) def api_get_payment_request( payment_id: str, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> dict[str, object]: _require(principal, READ_SCOPE) item = provider.get_payment( session, tenant_id=principal.tenant_id, payment_id=payment_id, ) if item is None: raise HTTPException(status_code=404, detail="Payment request not found") return dict(item) @router.post( "/requests", response_model=dict[str, Any], status_code=status.HTTP_201_CREATED, ) def api_create_payment_request( payload: PaymentRequestCreate, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> dict[str, object]: _require(principal, WRITE_SCOPE) try: item = provider.request_payment( session, PaymentRequestCommand( tenant_id=principal.tenant_id, source_module=payload.source_module, source_resource_type=payload.source_resource_type, source_resource_id=payload.source_resource_id, amount_minor=payload.amount_minor, currency=payload.currency, subject=payload.subject, idempotency_key=payload.idempotency_key, requested_at=datetime.now(UTC), requested_by_ref=_actor_ref(principal), due_at=payload.due_at, context_refs=payload.context_refs, metadata=payload.metadata, ), ) _audit( session, principal, action="payments.requested", payment=item, ) session.commit() except (PaymentError, InstitutionalContextError) as exc: session.rollback() raise _error(exc) from exc return dict(item) @router.post( "/requests/{payment_id}/manual-reconciliations", response_model=dict[str, Any], ) def api_reconcile_manual_payment( payment_id: str, payload: ManualPaymentReconciliationCreate, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> dict[str, object]: _require(principal, RECONCILE_SCOPE) try: item = provider.reconcile_manual_payment( session, ManualPaymentReconciliationCommand( tenant_id=principal.tenant_id, payment_id=payment_id, amount_minor=payload.amount_minor, currency=payload.currency, transaction_reference=payload.transaction_reference, evidence_ref=EvidenceReference.from_mapping(payload.evidence_ref), idempotency_key=payload.idempotency_key, received_at=payload.received_at, recorded_at=datetime.now(UTC), recorded_by_ref=_actor_ref(principal), metadata=payload.metadata, ), ) _audit( session, principal, action="payments.reconciled.manual", payment=item, ) session.commit() except (PaymentError, InstitutionalContextError) as exc: session.rollback() raise _error(exc) from exc return dict(item) def _require(principal: ApiPrincipal, scope: str) -> None: if not has_scope(principal, scope): raise HTTPException(status_code=403, detail=f"Missing scope: {scope}") def _actor_ref(principal: ApiPrincipal) -> str: if principal.api_key_id: return f"api_key:{principal.api_key_id}" account_id = str(getattr(principal, "account_id", "") or "").strip() if account_id: return f"account:{account_id}" user_id = str(getattr(getattr(principal, "user", None), "id", "") or "").strip() if user_id: return f"user:{user_id}" raise PaymentError("Payment action requires an acting identity.") def _audit( session: Session, principal: ApiPrincipal, *, action: str, payment: dict[str, object] | Any, ) -> None: item = dict(payment) source = item.get("source") if isinstance(item.get("source"), dict) else {} audit_event( session, tenant_id=principal.tenant_id, user_id=getattr(getattr(principal, "user", None), "id", None), api_key_id=principal.api_key_id, action=action, object_type="payment", object_id=str(item.get("payment_id") or ""), details={ "payment_reference": item.get("payment_reference"), "status": item.get("status"), "amount_minor": item.get("amount_minor"), "currency": item.get("currency"), "source_module": source.get("module"), "source_resource_type": source.get("resource_type"), "source_resource_id": source.get("resource_id"), "replayed": item.get("replayed"), }, ) def _error(exc: Exception) -> HTTPException: return HTTPException( status_code=409 if isinstance(exc, PaymentConflict) else 400, detail=str(exc), ) __all__ = ["router"]