216 lines
6.9 KiB
Python
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"]
|