feat: add mpre endpoints

This commit is contained in:
2026-05-11 19:04:39 +03:00
parent 42fcfbff34
commit 489c9cb2da
18 changed files with 691 additions and 75 deletions

View File

@@ -1,20 +1,155 @@
import asyncio
from decimal import Decimal
from urllib.parse import parse_qs
import orjson
from fastapi import APIRouter, Depends, Request
from fastapi import APIRouter,Depends,Query,Request,WebSocket,WebSocketDisconnect
from fastapi.responses import ORJSONResponse
from src.application.commands import CreateOrderCommand
from src.application.commands import CreatePaymentCommand
from src.application.contracts import ILogger
from src.application.domain.dto import AuthContext
from fastapi.security.utils import get_authorization_scheme_param
from ulid import ULID
from src.application.commands import CreateOrderCommand,CreatePaymentCommand,GetOrderCommand,GetOrderStatusCommand,GetPaymentConfigCommand,GetPaymentQuoteCommand,ListOrdersCommand,ListPaymentsCommand
from src.application.contracts import IJwtService,ILogger
from src.application.domain.dto import AccessTokenPayload,AuthContext
from src.application.domain.entities import OrderEntity,PaymentEntity
from src.application.domain.enums import OrderStatus
from src.application.domain.exceptions import ConflictException
from src.application.domain.exceptions import ApplicationException,ConflictException
from src.application.services import PaymentQuote
from src.infrastructure.context_vars import trace_id_var
from src.presentation.decorators import require_access_token, csrf_protect
from src.presentation.dependencies.commands import get_create_order_command, get_create_payment_command
from src.presentation.dependencies.commands import get_create_order_command,get_create_payment_command,get_list_orders_command,get_list_payments_command,get_order_command,get_order_status_command,get_payment_config_command,get_payment_quote_command
from src.presentation.dependencies.logger import get_logger
from src.presentation.schemas.order import CreateOrder,CreateOrderResponse,ErrorResponse,OrderPaymentResponse
from src.presentation.dependencies.security import get_jwt_service
from src.presentation.schemas.order import CreateOrder,CreateOrderResponse,ErrorResponse,OrderDetailResponse,OrderPaymentResponse,OrdersResponse,OrderStatusResponse,OrderWithPaymentResponse,PaymentConfigResponse,PaymentQuoteResponse,PaymentResponse,PaymentsResponse
from src.presentation.schemas.itpay_payment_models import ItpayPaymentData
order_router = APIRouter(prefix='/order', tags=['orders'])
orders_router = APIRouter(tags=['orders'])
payment_router = APIRouter(prefix='/payment', tags=['payments'])
payments_router = APIRouter(tags=['payments'])
def _payment_config_response(quote: PaymentQuote) -> PaymentConfigResponse:
return PaymentConfigResponse(
status_code=200,
usdt_exchange_rate=str(quote.usdt_exchange_rate),
gas_fee=str(quote.gas_fee),
service_fee_rate=str(quote.service_fee_rate),
one_usdt_service_fee=str(quote.service_fee),
one_usdt_total_price=str(quote.total_price),
created_at=quote.created_at.isoformat(),
)
def _payment_quote_response(quote: PaymentQuote) -> PaymentQuoteResponse:
return PaymentQuoteResponse(
status_code=200,
usdt_amount=str(quote.usdt_amount),
usdt_exchange_rate=str(quote.usdt_exchange_rate),
gas_fee=str(quote.gas_fee),
service_fee=str(quote.service_fee),
total_price=str(quote.total_price),
service_fee_rate=str(quote.service_fee_rate),
created_at=quote.created_at.isoformat(),
)
def _order_response(o: OrderEntity) -> OrderPaymentResponse:
return OrderPaymentResponse(
id=o.id,
created_at=o.created_at.isoformat() if o.created_at is not None else None,
updated_at=o.updated_at.isoformat() if o.updated_at is not None else None,
user_id=o.user_id,
usdt_amount=str(o.usdt_amount) if o.usdt_amount is not None else None,
usdt_exchange_rate=str(o.usdt_exchange_rate) if o.usdt_exchange_rate is not None else None,
gas_fee=str(o.gas_fee) if o.gas_fee is not None else None,
total_price=str(o.total_price) if o.total_price is not None else None,
service_fee=str(o.service_fee) if o.service_fee is not None else None,
status=o.status,
client_payment_id=o.client_payment_id,
itpay_payment_qr_url_desktop=o.itpay_payment_qr_url_desktop,
itpay_payment_qr_url_android=o.itpay_payment_qr_url_android,
itpay_payment_qr_url_ios=o.itpay_payment_qr_url_ios,
itpay_payment_qr_image_desktop=o.itpay_payment_qr_image_desktop,
itpay_payment_qr_image_android=o.itpay_payment_qr_image_android,
itpay_payment_qr_image_ios=o.itpay_payment_qr_image_ios,
itpay_id=o.itpay_id,
itpay_qr_id=o.itpay_qr_id,
itpay_amount=str(o.itpay_amount) if o.itpay_amount is not None else None,
itpay_created_at=o.itpay_created_at.isoformat() if o.itpay_created_at is not None else None,
)
def _payment_response(payment: PaymentEntity) -> PaymentResponse:
return PaymentResponse(
id=payment.id,
created_at=payment.created_at.isoformat() if payment.created_at is not None else None,
updated_at=payment.updated_at.isoformat() if payment.updated_at is not None else None,
user_id=payment.user_id,
order_id=payment.order_id,
status=payment.status,
receipt_cloudekassir_id=payment.receipt_cloudekassir_id,
receipt_cloudekassir_link=payment.receipt_cloudekassir_link,
itpay_payment_id=payment.itpay_payment_id,
itpay_paid_amount=str(payment.itpay_paid_amount) if payment.itpay_paid_amount is not None else None,
transaction_id=payment.transaction_id,
web3_transaction_hash=payment.web3_transaction_hash,
paid_at=payment.paid_at.isoformat() if payment.paid_at is not None else None,
expired_date=payment.expired_date.isoformat() if payment.expired_date is not None else None,
)
def _order_detail_response(order: OrderEntity, payment: PaymentEntity | None) -> OrderDetailResponse:
return OrderDetailResponse(
status_code=200,
order=_order_response(order),
payment=_payment_response(payment) if payment is not None else None,
)
def _order_with_payment_response(order: OrderEntity, payment: PaymentEntity | None) -> OrderWithPaymentResponse:
return OrderWithPaymentResponse(
order=_order_response(order),
payment=_payment_response(payment) if payment is not None else None,
)
def _order_status_response(order: OrderEntity, payment: PaymentEntity | None) -> OrderStatusResponse:
return OrderStatusResponse(
status_code=200,
order_id=str(order.id),
order_status=order.status,
payment_status=payment.status if payment is not None else None,
receipt_cloudekassir_link=payment.receipt_cloudekassir_link if payment is not None else None,
web3_transaction_hash=payment.web3_transaction_hash if payment is not None else None,
updated_at=payment.updated_at.isoformat() if payment is not None and payment.updated_at is not None else order.updated_at.isoformat() if order.updated_at is not None else None,
)
def _extract_websocket_access_token(websocket: WebSocket) -> str | None:
token = websocket.cookies.get('access_token')
if token:
return token
auth = websocket.headers.get('Authorization')
if auth:
scheme,param = get_authorization_scheme_param(auth)
if scheme.lower() == 'bearer' and param:
return param
query_token = websocket.query_params.get('access_token') or websocket.query_params.get('token')
if query_token:
return str(query_token)
return None
async def _websocket_auth_context(websocket: WebSocket, jwt_service: IJwtService) -> AuthContext | None:
token = _extract_websocket_access_token(websocket)
if not token:
return None
try:
payload: AccessTokenPayload = await jwt_service.decode_access_token(token)
except ApplicationException:
return None
if payload.type != 'access':
return None
return AuthContext(user_id=payload.sub, sid=payload.sid, token=payload)
@order_router.post(
@@ -59,33 +194,113 @@ async def create_order(
raise ConflictException(message='Payment provider rejected order')
content = CreateOrderResponse(
status_code=201,
order=OrderPaymentResponse(
id=o.id,
created_at=o.created_at.isoformat() if o.created_at is not None else None,
updated_at=o.updated_at.isoformat() if o.updated_at is not None else None,
user_id=o.user_id,
usdt_amount=str(o.usdt_amount) if o.usdt_amount is not None else None,
usdt_exchange_rate=str(o.usdt_exchange_rate) if o.usdt_exchange_rate is not None else None,
gas_fee=str(o.gas_fee) if o.gas_fee is not None else None,
total_price=str(o.total_price) if o.total_price is not None else None,
service_fee=str(o.service_fee) if o.service_fee is not None else None,
status=o.status,
client_payment_id=o.client_payment_id,
itpay_payment_qr_url_desktop=o.itpay_payment_qr_url_desktop,
itpay_payment_qr_url_android=o.itpay_payment_qr_url_android,
itpay_payment_qr_url_ios=o.itpay_payment_qr_url_ios,
itpay_payment_qr_image_desktop=o.itpay_payment_qr_image_desktop,
itpay_payment_qr_image_android=o.itpay_payment_qr_image_android,
itpay_payment_qr_image_ios=o.itpay_payment_qr_image_ios,
itpay_id=o.itpay_id,
itpay_qr_id=o.itpay_qr_id,
itpay_amount=str(o.itpay_amount) if o.itpay_amount is not None else None,
itpay_created_at=o.itpay_created_at.isoformat() if o.itpay_created_at is not None else None,
),
order=_order_response(o),
)
return content
@payment_router.get('/config', response_model=PaymentConfigResponse)
async def payment_config(
command: GetPaymentConfigCommand = Depends(get_payment_config_command),
) -> PaymentConfigResponse:
quote = await command()
return _payment_config_response(quote)
@payment_router.get('/quote', response_model=PaymentQuoteResponse)
async def payment_quote(
usdt_amount: Decimal = Query(gt=0, decimal_places=2, max_digits=20),
command: GetPaymentQuoteCommand = Depends(get_payment_quote_command),
) -> PaymentQuoteResponse:
quote = await command(usdt_amount)
return _payment_quote_response(quote)
@orders_router.get('/orders', response_model=OrdersResponse)
async def list_orders(
limit: int = Query(default=20, ge=1, le=100),
offset: int = Query(default=0, ge=0),
auth: AuthContext = Depends(require_access_token),
command: ListOrdersCommand = Depends(get_list_orders_command),
) -> OrdersResponse:
orders = await command(user_id=auth.user_id,limit=limit,offset=offset)
items = [_order_with_payment_response(item.order,item.payment) for item in orders]
return OrdersResponse(status_code=200,orders=items,limit=limit,offset=offset)
@payments_router.get('/payments', response_model=PaymentsResponse)
async def list_payments(
limit: int = Query(default=20, ge=1, le=100),
offset: int = Query(default=0, ge=0),
auth: AuthContext = Depends(require_access_token),
command: ListPaymentsCommand = Depends(get_list_payments_command),
) -> PaymentsResponse:
payments = await command(user_id=auth.user_id,limit=limit,offset=offset)
return PaymentsResponse(
status_code=200,
payments=[_payment_response(payment) for payment in payments],
limit=limit,
offset=offset,
)
@order_router.get('/{order_id}/status', response_model=OrderStatusResponse)
async def order_status(
order_id: str,
auth: AuthContext = Depends(require_access_token),
command: GetOrderStatusCommand = Depends(get_order_status_command),
) -> OrderStatusResponse:
result = await command(order_id=order_id,user_id=auth.user_id)
return _order_status_response(result.order,result.payment)
@order_router.websocket('/{order_id}/events')
async def order_events(
websocket: WebSocket,
order_id: str,
command: GetOrderStatusCommand = Depends(get_order_status_command),
jwt_service: IJwtService = Depends(get_jwt_service),
logger: ILogger = Depends(get_logger),
) -> None:
trace_id = websocket.headers.get('X-Trace-ID') or websocket.headers.get('X-Request-ID') or str(ULID())
token = trace_id_var.set(trace_id)
try:
auth = await _websocket_auth_context(websocket, jwt_service)
if auth is None:
await websocket.close(code=1008)
return
await websocket.accept()
logger.info({'event':'order_events_connected','order_id':order_id,'user_id':auth.user_id})
last_payload: dict | None = None
while True:
try:
result = await command(order_id=order_id,user_id=auth.user_id)
except ApplicationException as exception:
await websocket.send_json({'event':'order_events_error','detail':exception.message,'status_code':exception.status_code})
await websocket.close(code=1008)
return
status_payload = _order_status_response(result.order,result.payment).model_dump(mode='json')
payload = {'event':'order_status','data':status_payload}
if payload != last_payload:
await websocket.send_text(orjson.dumps(payload).decode())
last_payload = payload
await asyncio.sleep(2)
except WebSocketDisconnect:
logger.info({'event':'order_events_disconnected','order_id':order_id})
finally:
trace_id_var.reset(token)
@order_router.get('/{order_id}', response_model=OrderDetailResponse)
async def order_detail(
order_id: str,
auth: AuthContext = Depends(require_access_token),
command: GetOrderCommand = Depends(get_order_command),
) -> OrderDetailResponse:
result = await command(order_id=order_id,user_id=auth.user_id)
return _order_detail_response(result.order,result.payment)
@order_router.post('/webhook/itpay')
async def itpay_webhook(
request: Request,