Files
pay-service/src/presentation/routing/order.py
2026-05-12 23:37:30 +03:00

383 lines
16 KiB
Python

import asyncio
from decimal import Decimal
from urllib.parse import parse_qs
import orjson
from fastapi import APIRouter,Depends,Query,Request,WebSocket,WebSocketDisconnect
from fastapi.responses import ORJSONResponse
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 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,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.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'])
ERROR_RESPONSES = {
400: {'model': ErrorResponse, 'description': 'Bad Request'},
401: {'model': ErrorResponse, 'description': 'Unauthorized'},
403: {'model': ErrorResponse, 'description': 'Forbidden'},
404: {'model': ErrorResponse, 'description': 'Not Found'},
409: {'model': ErrorResponse, 'description': 'Conflict'},
422: {'model': ErrorResponse, 'description': 'Validation Error'},
429: {'model': ErrorResponse, 'description': 'Too Many Requests'},
500: {'model': ErrorResponse, 'description': 'Internal Server Error'},
502: {'model': ErrorResponse, 'description': 'Bad Gateway'},
503: {'model': ErrorResponse, 'description': 'Service Unavailable'},
}
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(
'/create',
response_model=CreateOrderResponse,
status_code=201,
responses=ERROR_RESPONSES,
)
@csrf_protect()
async def create_order(
request: Request,
payment_data: CreateOrder,
auth: AuthContext = Depends(require_access_token),
command: CreateOrderCommand = Depends(get_create_order_command),
logger: ILogger = Depends(get_logger),
) -> CreateOrderResponse:
o = await command(payment_data, auth.user_id)
itpay_error = o.status in (
OrderStatus.CANCELLED,
OrderStatus.REJECTED,
OrderStatus.ERROR,
)
log_ids = {
'event': 'order_create_itpay_failed' if itpay_error else 'order_created',
'order_id': o.id,
'user_id': o.user_id,
'client_payment_id': o.client_payment_id,
'itpay_id': o.itpay_id,
'order_status': o.status.value if o.status is not None else None,
}
logger.info(log_ids)
if itpay_error:
raise ConflictException(message='Payment provider rejected order')
content = CreateOrderResponse(
status_code=201,
order=_order_response(o),
)
return content
@payment_router.get(
'/config',
response_model=PaymentConfigResponse,
status_code=200,
responses=ERROR_RESPONSES,
)
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,
status_code=200,
responses=ERROR_RESPONSES,
)
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,
status_code=200,
responses=ERROR_RESPONSES,
)
@csrf_protect()
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,
status_code=200,
responses=ERROR_RESPONSES,
)
@csrf_protect()
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,
status_code=200,
responses=ERROR_RESPONSES,
)
@csrf_protect()
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,
status_code=200,
responses=ERROR_RESPONSES,
)
@csrf_protect()
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',
status_code=200,
responses=ERROR_RESPONSES,
)
async def itpay_webhook(
request: Request,
payment_command: CreatePaymentCommand = Depends(get_create_payment_command),
logger: ILogger = Depends(get_logger),
) -> ORJSONResponse:
raw = await request.body()
ct = (request.headers.get('content-type') or '').lower()
if 'application/json' in ct:
payload = orjson.loads(raw)
elif 'application/x-www-form-urlencoded' in ct:
decoded = raw.decode('utf-8', errors='replace')
qs = parse_qs(decoded, keep_blank_values=True)
payload = {k: (vals[0] if len(vals) == 1 else vals) for k, vals in qs.items()}
else:
payload = orjson.loads(raw)
data = payload.get('data') if isinstance(payload.get('data'), dict) else {}
metadata = data.get('metadata') if isinstance(data.get('metadata'), dict) else {}
trace_id = str(metadata.get('trace_id') or '').strip()
if trace_id:
logger.set_trace_id(trace_id)
status = str(data.get('status') or '').strip().lower()
log_payload = {
'event': 'itpay_webhook_received',
'webhook_id': payload.get('id'),
'webhook_type': payload.get('type'),
'payment_id': data.get('id'),
'client_payment_id': data.get('client_payment_id'),
'payment_status': status,
'itpay_metadata': metadata,
}
logger.info(log_payload)
if status == 'completed':
payment = ItpayPaymentData.model_validate(data)
await payment_command(payment)
return ORJSONResponse(content={'status': 0})