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})