Files
govoplan-payments/src/govoplan_payments/backend/router.py
T
zemion 630a7d39f7
Module Package Release / publish-packages (push) Successful in 13s
feat: add governed payment request slice
2026-08-19 12:33:34 +02:00

216 lines
6.9 KiB
Python

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"]