feat: add governed payment request slice
Module Package Release / publish-packages (push) Successful in 13s
Module Package Release / publish-packages (push) Successful in 13s
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
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"]
|
||||
Reference in New Issue
Block a user