diff --git a/src/application/abstractions/repositories/i_order_repository.py b/src/application/abstractions/repositories/i_order_repository.py index c986479..8ea69c4 100644 --- a/src/application/abstractions/repositories/i_order_repository.py +++ b/src/application/abstractions/repositories/i_order_repository.py @@ -19,6 +19,16 @@ class IOrderRepository(ABC): raise NotImplementedError + @abstractmethod + async def get_by_id_for_user(self,*,order_id: str,user_id: str) -> OrderEntity | None: + raise NotImplementedError + + + @abstractmethod + async def list_by_user_id(self,*,user_id: str,limit: int,offset: int) -> list[OrderEntity]: + raise NotImplementedError + + @abstractmethod async def update_after_itpay_payment_created(self,order: OrderEntity) -> OrderEntity: raise NotImplementedError diff --git a/src/application/abstractions/repositories/i_payment_repository.py b/src/application/abstractions/repositories/i_payment_repository.py index 4a72c48..56289de 100644 --- a/src/application/abstractions/repositories/i_payment_repository.py +++ b/src/application/abstractions/repositories/i_payment_repository.py @@ -1,4 +1,6 @@ from abc import ABC,abstractmethod +from src.application.domain.entities import PaymentEntity +from src.application.domain.enums import PaymentStatus class IPaymentRepository(ABC): @@ -12,7 +14,22 @@ class IPaymentRepository(ABC): raise NotImplementedError + @abstractmethod + async def update_status(self,*,order_id:str,status:PaymentStatus) -> None: + raise NotImplementedError + + @abstractmethod async def update_receipt(self,*,order_id:str,receipt_cloudekassir_id:str|None,receipt_cloudekassir_link:str|None) -> None: raise NotImplementedError + + @abstractmethod + async def get_by_order_id(self,order_id:str) -> PaymentEntity | None: + raise NotImplementedError + + + @abstractmethod + async def list_by_user_id(self,*,user_id:str,limit:int,offset:int) -> list[PaymentEntity]: + raise NotImplementedError + diff --git a/src/application/commands/__init__.py b/src/application/commands/__init__.py index 05aba30..a107c56 100644 --- a/src/application/commands/__init__.py +++ b/src/application/commands/__init__.py @@ -1,3 +1,4 @@ from src.application.commands.create_order_command import CreateOrderCommand from src.application.commands.create_payment_command import CreatePaymentCommand -from src.application.commands.create_crypto_transfer_completed_command import CreateCryptoTransferCompletedCommand \ No newline at end of file +from src.application.commands.create_crypto_transfer_completed_command import CreateCryptoTransferCompletedCommand +from src.application.commands.payment_read_commands import GetOrderCommand,GetOrderStatusCommand,GetPaymentConfigCommand,GetPaymentQuoteCommand,ListOrdersCommand,ListPaymentsCommand,OrderPaymentResult \ No newline at end of file diff --git a/src/application/commands/create_crypto_transfer_completed_command.py b/src/application/commands/create_crypto_transfer_completed_command.py index 31b37b3..bb93ccb 100644 --- a/src/application/commands/create_crypto_transfer_completed_command.py +++ b/src/application/commands/create_crypto_transfer_completed_command.py @@ -2,6 +2,7 @@ from __future__ import annotations from decimal import Decimal from src.application.abstractions import IUnitOfWork from src.application.contracts import ILogger,IReceipt +from src.application.domain.enums import PaymentStatus from src.application.domain.exceptions import ApplicationException from src.infrastructure.database.decorators import transactional @@ -62,18 +63,26 @@ class CreateCryptoTransferCompletedCommand: if principal_amount < 0: raise ApplicationException(status_code=400, message='Invalid receipt amounts: principal negative') - receipt_response = await self._receipt.create_receipt( - order_id=order_id, - user_id=user_id, - email=email, - total_amount=total_amount, - principal_amount=principal_amount, - service_fee=service_fee, - customer_info=customer_info, - customer_inn=customer_inn, - customer_birthday=customer_birthday, - request_id=self._logger.get_trace_id(), - ) + try: + receipt_response = await self._receipt.create_receipt( + order_id=order_id, + user_id=user_id, + email=email, + total_amount=total_amount, + principal_amount=principal_amount, + service_fee=service_fee, + customer_info=customer_info, + customer_inn=customer_inn, + customer_birthday=customer_birthday, + request_id=self._logger.get_trace_id(), + ) + except ApplicationException as exception: + self._logger.error({'event':'receipt_create_failed','order_id':order_id,'error':exception.message}) + await self._unit_of_work.payment_repository.update_status( + order_id=order_id, + status=PaymentStatus.RECEIPT_ERROR, + ) + return receipt_model = receipt_response.get('Model') if not isinstance(receipt_model, dict): receipt_model = {} diff --git a/src/application/commands/create_order_command.py b/src/application/commands/create_order_command.py index 4b40ad9..e13ea38 100644 --- a/src/application/commands/create_order_command.py +++ b/src/application/commands/create_order_command.py @@ -1,14 +1,14 @@ from __future__ import annotations from datetime import datetime, timezone -from decimal import Decimal, ROUND_UP +from decimal import Decimal from ulid import ULID from src.application.abstractions import IUnitOfWork -from src.application.contracts import ICache,ILogger +from src.application.contracts import ILogger from src.application.contracts import IItPayService from src.application.domain.entities.order import OrderEntity from src.application.domain.enums import OrderStatus -from src.application.domain.exceptions import ApplicationException,ServiceUnavailableException -from src.infrastructure.config import settings +from src.application.domain.exceptions import ApplicationException +from src.application.services import PaymentQuoteService from src.infrastructure.database.decorators import transactional from src.presentation.schemas.order import CreateOrder @@ -20,12 +20,12 @@ class CreateOrderCommand: *, unit_of_work: IUnitOfWork, logger: ILogger, - remote_cache: ICache, + payment_quote_service: PaymentQuoteService, itpay_service: IItPayService, ) -> None: self._unit_of_work = unit_of_work self._logger = logger - self._remote_cache = remote_cache + self._payment_quote_service = payment_quote_service self._itpay_service = itpay_service @@ -33,22 +33,11 @@ class CreateOrderCommand: async def __call__(self, payment_data: CreateOrder, user_id: str) -> OrderEntity: client_payment_id = str(ULID()) - rate_raw = await self._remote_cache.hget('tradex:rub:rate','value') - gas_raw = await self._remote_cache.hget('gwei:eth:last','normal_rub') - self._logger.info(f'Actual market values: rate={rate_raw}, gas={gas_raw}') - - if rate_raw is None: - self._logger.error('Exchange rate unavailable') - raise ServiceUnavailableException(message='Exchange rate unavailable') - - if gas_raw is None: - self._logger.error('Exchange gas unavailable') - raise ServiceUnavailableException(message='Exchange gas unavailable') - - actual_gas_fee = Decimal(gas_raw).quantize(Decimal('0.00'), rounding=ROUND_UP) - actual_usdt_exchange_rate = Decimal(rate_raw).quantize(Decimal('0.00'), rounding=ROUND_UP) - actual_service_fee = (payment_data.usdt_amount * actual_usdt_exchange_rate * settings.PAYMENT_SERVICE_FEE_RATE).quantize(Decimal('0.01')) - actual_total_price = (payment_data.usdt_amount * actual_usdt_exchange_rate + actual_service_fee + actual_gas_fee).quantize(Decimal('0.01')) + quote = await self._payment_quote_service.get_quote(payment_data.usdt_amount) + actual_gas_fee = quote.gas_fee + actual_usdt_exchange_rate = quote.usdt_exchange_rate + actual_service_fee = quote.service_fee + actual_total_price = quote.total_price if actual_total_price > payment_data.total_price * Decimal('1.01'): self._logger.error('Price has changed, please refresh and try again') raise ApplicationException(status_code=409, message='Price has changed, please refresh and try again') diff --git a/src/application/commands/create_payment_command.py b/src/application/commands/create_payment_command.py index ef24042..1acf81f 100644 --- a/src/application/commands/create_payment_command.py +++ b/src/application/commands/create_payment_command.py @@ -2,6 +2,7 @@ from __future__ import annotations from ulid import ULID from src.application.abstractions import IUnitOfWork from src.application.contracts import ILogger,IQueueMessanger +from src.application.domain.enums import PaymentStatus from src.application.domain.exceptions import ApplicationException from src.infrastructure.config import settings from src.infrastructure.database.decorators import transactional @@ -52,3 +53,7 @@ class CreatePaymentCommand: correlation_id=message['trace_id'], headers={'trace_id': trace_id}, ) + await self._unit_of_work.payment_repository.update_status( + order_id=order_id, + status=PaymentStatus.WEB3_PROCESSING, + ) diff --git a/src/application/commands/payment_read_commands.py b/src/application/commands/payment_read_commands.py new file mode 100644 index 0000000..941a12a --- /dev/null +++ b/src/application/commands/payment_read_commands.py @@ -0,0 +1,111 @@ +from __future__ import annotations +from dataclasses import dataclass +from decimal import Decimal +from src.application.abstractions import IUnitOfWork +from src.application.contracts import ILogger +from src.application.domain.entities import OrderEntity,PaymentEntity +from src.application.domain.exceptions import NotFoundException +from src.application.services import PaymentQuote,PaymentQuoteService +from src.infrastructure.database.decorators import transactional + + +@dataclass(slots=True) +class OrderPaymentResult: + order: OrderEntity + payment: PaymentEntity | None + + +class GetPaymentConfigCommand: + def __init__(self, *, payment_quote_service: PaymentQuoteService, logger: ILogger): + self._payment_quote_service = payment_quote_service + self._logger = logger + + + async def __call__(self) -> PaymentQuote: + quote = await self._payment_quote_service.get_quote(Decimal('1.00')) + self._logger.info({'event':'payment_config_requested'}) + return quote + + +class GetPaymentQuoteCommand: + def __init__(self, *, payment_quote_service: PaymentQuoteService, logger: ILogger): + self._payment_quote_service = payment_quote_service + self._logger = logger + + + async def __call__(self, usdt_amount: Decimal) -> PaymentQuote: + quote = await self._payment_quote_service.get_quote(usdt_amount) + self._logger.info({'event':'payment_quote_requested','usdt_amount':str(usdt_amount)}) + return quote + + +class ListOrdersCommand: + def __init__(self, *, unit_of_work: IUnitOfWork, logger: ILogger): + self._unit_of_work = unit_of_work + self._logger = logger + + + @transactional + async def __call__(self, *, user_id: str, limit: int, offset: int) -> list[OrderPaymentResult]: + orders = await self._unit_of_work.order_repository.list_by_user_id( + user_id=user_id, + limit=limit, + offset=offset, + ) + items: list[OrderPaymentResult] = [] + for order in orders: + if order.id is None: + continue + payment = await self._unit_of_work.payment_repository.get_by_order_id(order.id) + items.append(OrderPaymentResult(order=order,payment=payment)) + self._logger.info({'event':'orders_list_requested','user_id':user_id,'limit':limit,'offset':offset}) + return items + + +class ListPaymentsCommand: + def __init__(self, *, unit_of_work: IUnitOfWork, logger: ILogger): + self._unit_of_work = unit_of_work + self._logger = logger + + + @transactional + async def __call__(self, *, user_id: str, limit: int, offset: int) -> list[PaymentEntity]: + payments = await self._unit_of_work.payment_repository.list_by_user_id( + user_id=user_id, + limit=limit, + offset=offset, + ) + self._logger.info({'event':'payments_list_requested','user_id':user_id,'limit':limit,'offset':offset}) + return payments + + +class GetOrderCommand: + def __init__(self, *, unit_of_work: IUnitOfWork, logger: ILogger): + self._unit_of_work = unit_of_work + self._logger = logger + + + @transactional + async def __call__(self, *, order_id: str, user_id: str) -> OrderPaymentResult: + order = await self._unit_of_work.order_repository.get_by_id_for_user(order_id=order_id,user_id=user_id) + if order is None: + raise NotFoundException(message='Order not found') + payment = await self._unit_of_work.payment_repository.get_by_order_id(order_id) + self._logger.info({'event':'order_detail_requested','order_id':order_id,'user_id':user_id}) + return OrderPaymentResult(order=order,payment=payment) + + +class GetOrderStatusCommand: + def __init__(self, *, unit_of_work: IUnitOfWork, logger: ILogger): + self._unit_of_work = unit_of_work + self._logger = logger + + + @transactional + async def __call__(self, *, order_id: str, user_id: str) -> OrderPaymentResult: + order = await self._unit_of_work.order_repository.get_by_id_for_user(order_id=order_id,user_id=user_id) + if order is None: + raise NotFoundException(message='Order not found') + payment = await self._unit_of_work.payment_repository.get_by_order_id(order_id) + self._logger.info({'event':'order_status_requested','order_id':order_id,'user_id':user_id}) + return OrderPaymentResult(order=order,payment=payment) diff --git a/src/application/domain/entities/payment.py b/src/application/domain/entities/payment.py index 61d83fc..8377825 100644 --- a/src/application/domain/entities/payment.py +++ b/src/application/domain/entities/payment.py @@ -7,6 +7,10 @@ from src.application.domain.enums import PaymentStatus @dataclass(slots=True) class PaymentEntity: + id: str | None = None + created_at: datetime | None = None + updated_at: datetime | None = None + user_id: str | None = None order_id: str | None = None @@ -16,7 +20,8 @@ class PaymentEntity: receipt_cloudekassir_link: str | None = None itpay_payment_id: str | None = None + itpay_paid_amount: Decimal | None = None transaction_id: str | None = None web3_transaction_hash: str | None = None - paid_at: str | None = None - expired_date: str | None = None + paid_at: datetime | None = None + expired_date: datetime | None = None diff --git a/src/application/domain/enums/payment_status.py b/src/application/domain/enums/payment_status.py index 96faf26..45ce47e 100644 --- a/src/application/domain/enums/payment_status.py +++ b/src/application/domain/enums/payment_status.py @@ -4,6 +4,7 @@ from enum import Enum class PaymentStatus(str,Enum): PENDING='pending' MONEY_ACCEPTED='money_accepted' + WEB3_PROCESSING='web3_processing' WEB3_HASH_ERROR='web3_hash_error' WEB3_BALANCE_PROBLEM='web3_balance_problem' USDT_DELIVERED='usdt_delivered' diff --git a/src/application/services/__init__.py b/src/application/services/__init__.py new file mode 100644 index 0000000..99e00c0 --- /dev/null +++ b/src/application/services/__init__.py @@ -0,0 +1,4 @@ +from src.application.services.payment_quote_service import PaymentQuote,PaymentQuoteService + + +__all__ = ['PaymentQuote', 'PaymentQuoteService'] diff --git a/src/application/services/payment_quote_service.py b/src/application/services/payment_quote_service.py new file mode 100644 index 0000000..ccb7742 --- /dev/null +++ b/src/application/services/payment_quote_service.py @@ -0,0 +1,53 @@ +from __future__ import annotations +from dataclasses import dataclass +from datetime import datetime,timezone +from decimal import Decimal,ROUND_UP +from src.application.contracts import ICache,ILogger +from src.application.domain.exceptions import ServiceUnavailableException +from src.infrastructure.config import settings + + +@dataclass(slots=True) +class PaymentQuote: + usdt_amount: Decimal + usdt_exchange_rate: Decimal + gas_fee: Decimal + service_fee: Decimal + total_price: Decimal + service_fee_rate: Decimal + created_at: datetime + + +class PaymentQuoteService: + def __init__(self, *, remote_cache: ICache, logger: ILogger): + self._remote_cache = remote_cache + self._logger = logger + + + async def get_quote(self, usdt_amount: Decimal) -> PaymentQuote: + rate_raw = await self._remote_cache.hget('tradex:rub:rate','value') + gas_raw = await self._remote_cache.hget('gwei:eth:last','normal_rub') + self._logger.info(f'Actual market values: rate={rate_raw}, gas={gas_raw}') + + if rate_raw is None: + self._logger.error('Exchange rate unavailable') + raise ServiceUnavailableException(message='Exchange rate unavailable') + + if gas_raw is None: + self._logger.error('Exchange gas unavailable') + raise ServiceUnavailableException(message='Exchange gas unavailable') + + gas_fee = Decimal(gas_raw).quantize(Decimal('0.00'), rounding=ROUND_UP) + usdt_exchange_rate = Decimal(rate_raw).quantize(Decimal('0.00'), rounding=ROUND_UP) + service_fee = (usdt_amount * usdt_exchange_rate * settings.PAYMENT_SERVICE_FEE_RATE).quantize(Decimal('0.01')) + total_price = (usdt_amount * usdt_exchange_rate + service_fee + gas_fee).quantize(Decimal('0.01')) + + return PaymentQuote( + usdt_amount=usdt_amount, + usdt_exchange_rate=usdt_exchange_rate, + gas_fee=gas_fee, + service_fee=service_fee, + total_price=total_price, + service_fee_rate=settings.PAYMENT_SERVICE_FEE_RATE, + created_at=datetime.now(timezone.utc), + ) diff --git a/src/infrastructure/database/repositories/order_repository.py b/src/infrastructure/database/repositories/order_repository.py index 2c42159..af23188 100644 --- a/src/infrastructure/database/repositories/order_repository.py +++ b/src/infrastructure/database/repositories/order_repository.py @@ -2,7 +2,7 @@ from __future__ import annotations from dataclasses import replace from datetime import datetime,timezone from decimal import Decimal -from sqlalchemy import select,update +from sqlalchemy import desc,select,update from sqlalchemy.ext.asyncio import AsyncSession from src.application.abstractions.repositories.i_order_repository import IOrderRepository from src.application.contracts import ILogger @@ -85,6 +85,26 @@ class OrderRepository(IOrderRepository): return self._to_entity(model) + async def get_by_id_for_user(self,*,order_id: str,user_id: str) -> OrderEntity | None: + stmt=select(Order).where(Order.id==order_id,Order.user_id==user_id) + model=await self._session.scalar(stmt) + if model is None: + return None + return self._to_entity(model) + + + async def list_by_user_id(self,*,user_id: str,limit: int,offset: int) -> list[OrderEntity]: + stmt=( + select(Order) + .where(Order.user_id==user_id) + .order_by(desc(Order.created_at)) + .limit(limit) + .offset(offset) + ) + result=await self._session.scalars(stmt) + return [self._to_entity(model) for model in result.all()] + + async def update_after_itpay_payment_created(self,order: OrderEntity) -> OrderEntity: if not order.id: raise ValueError('OrderEntity.id is required') diff --git a/src/infrastructure/database/repositories/payment_repository.py b/src/infrastructure/database/repositories/payment_repository.py index c63dedd..4e7ba3f 100644 --- a/src/infrastructure/database/repositories/payment_repository.py +++ b/src/infrastructure/database/repositories/payment_repository.py @@ -2,10 +2,11 @@ from __future__ import annotations from datetime import datetime from decimal import Decimal -from sqlalchemy import select +from sqlalchemy import desc,select from sqlalchemy.ext.asyncio import AsyncSession from src.application.abstractions.repositories.i_payment_repository import IPaymentRepository from src.application.contracts import ILogger +from src.application.domain.entities import PaymentEntity from src.application.domain.enums import PaymentStatus from src.infrastructure.database.models.payment import Payment @@ -16,6 +17,26 @@ class PaymentRepository(IPaymentRepository): self._logger=logger + @staticmethod + def _to_entity(model: Payment) -> PaymentEntity: + return PaymentEntity( + id=model.id, + created_at=model.created_at, + updated_at=model.updated_at, + user_id=model.user_id, + order_id=model.order_id, + status=model.status, + receipt_cloudekassir_id=model.receipt_cloudekassir_id, + receipt_cloudekassir_link=model.receipt_cloudekassir_link, + itpay_payment_id=model.itpay_payment_id, + itpay_paid_amount=model.itpay_paid_amount, + transaction_id=model.transaction_id, + web3_transaction_hash=model.web3_transaction_hash, + paid_at=model.paid_at, + expired_date=model.expired_date, + ) + + async def create_completed(self,*,user_id:str,order_id:str,itpay_payment_id:str,itpay_paid_amount:str|None,transaction_id:str|None,paid_at:str|None,expired_date:str|None) -> bool: stmt=select(Payment).where(Payment.order_id==order_id) existing=await self._session.scalar(stmt) @@ -52,6 +73,16 @@ class PaymentRepository(IPaymentRepository): return + async def update_status(self,*,order_id:str,status:PaymentStatus) -> None: + stmt=select(Payment).where(Payment.order_id==order_id) + model=await self._session.scalar(stmt) + if model is None: + return + model.status=status + await self._session.flush() + return + + async def update_receipt(self,*,order_id:str,receipt_cloudekassir_id:str|None,receipt_cloudekassir_link:str|None) -> None: stmt=select(Payment).where(Payment.order_id==order_id) model=await self._session.scalar(stmt) @@ -63,3 +94,23 @@ class PaymentRepository(IPaymentRepository): await self._session.flush() return + + async def get_by_order_id(self,order_id:str) -> PaymentEntity | None: + stmt=select(Payment).where(Payment.order_id==order_id) + model=await self._session.scalar(stmt) + if model is None: + return None + return self._to_entity(model) + + + async def list_by_user_id(self,*,user_id:str,limit:int,offset:int) -> list[PaymentEntity]: + stmt=( + select(Payment) + .where(Payment.user_id==user_id) + .order_by(desc(Payment.created_at)) + .limit(limit) + .offset(offset) + ) + result=await self._session.scalars(stmt) + return [self._to_entity(model) for model in result.all()] + diff --git a/src/main.py b/src/main.py index 76c8e49..f45aa52 100644 --- a/src/main.py +++ b/src/main.py @@ -16,7 +16,7 @@ from src.infrastructure.config import settings from src.presentation.handler import application_exception_handler, unhandled_exception_handler from src.presentation.messaging import crypto_transfer_router from src.presentation.middleware import TraceIDMiddleware, SecurityHeadersMiddleware -from src.presentation.routing import order_router +from src.presentation.routing import order_router,orders_router,payment_router,payments_router security = HTTPBasic() @@ -74,6 +74,9 @@ app.add_exception_handler(ApplicationException, application_exception_handler) app.add_exception_handler(Exception, unhandled_exception_handler) app.include_router(order_router) +app.include_router(orders_router) +app.include_router(payment_router) +app.include_router(payments_router) app.include_router(crypto_transfer_router) diff --git a/src/presentation/dependencies/commands.py b/src/presentation/dependencies/commands.py index 18cba5d..b5f0b70 100644 --- a/src/presentation/dependencies/commands.py +++ b/src/presentation/dependencies/commands.py @@ -1,9 +1,10 @@ from __future__ import annotations from fastapi import Depends from src.application.abstractions import IUnitOfWork -from src.application.commands import CreateCryptoTransferCompletedCommand,CreateOrderCommand,CreatePaymentCommand +from src.application.commands import CreateCryptoTransferCompletedCommand,CreateOrderCommand,CreatePaymentCommand,GetOrderCommand,GetOrderStatusCommand,GetPaymentConfigCommand,GetPaymentQuoteCommand,ListOrdersCommand,ListPaymentsCommand from src.application.contracts import ICache,ILogger,IQueueMessanger,IReceipt from src.application.contracts.i_itpay_service import IItPayService +from src.application.services import PaymentQuoteService from src.infrastructure.cloud_kassir import ClaudeKassirClient from src.infrastructure.cloud_kassir.constants import CLOUD_KASSIR_API_BASE_URL,CLOUD_KASSIR_FAIL_URL,CLOUD_KASSIR_INN,CLOUD_KASSIR_SUCCESS_URL from src.infrastructure.config import settings @@ -28,14 +29,64 @@ def get_create_order_command( remote_cache: ICache = Depends(get_remote_cache), itpay_service: IItPayService = Depends(get_itpay_service), ) -> CreateOrderCommand: + payment_quote_service = PaymentQuoteService(remote_cache=remote_cache,logger=logger) return CreateOrderCommand( unit_of_work=unit_of_work, logger=logger, - remote_cache=remote_cache, + payment_quote_service=payment_quote_service, itpay_service=itpay_service, ) +def get_payment_quote_service( + logger: ILogger = Depends(get_logger), + remote_cache: ICache = Depends(get_remote_cache), +) -> PaymentQuoteService: + return PaymentQuoteService(remote_cache=remote_cache,logger=logger) + + +def get_payment_config_command( + logger: ILogger = Depends(get_logger), + payment_quote_service: PaymentQuoteService = Depends(get_payment_quote_service), +) -> GetPaymentConfigCommand: + return GetPaymentConfigCommand(payment_quote_service=payment_quote_service,logger=logger) + + +def get_payment_quote_command( + logger: ILogger = Depends(get_logger), + payment_quote_service: PaymentQuoteService = Depends(get_payment_quote_service), +) -> GetPaymentQuoteCommand: + return GetPaymentQuoteCommand(payment_quote_service=payment_quote_service,logger=logger) + + +def get_list_orders_command( + logger: ILogger = Depends(get_logger), + unit_of_work: IUnitOfWork = Depends(get_unit_of_work), +) -> ListOrdersCommand: + return ListOrdersCommand(unit_of_work=unit_of_work,logger=logger) + + +def get_list_payments_command( + logger: ILogger = Depends(get_logger), + unit_of_work: IUnitOfWork = Depends(get_unit_of_work), +) -> ListPaymentsCommand: + return ListPaymentsCommand(unit_of_work=unit_of_work,logger=logger) + + +def get_order_command( + logger: ILogger = Depends(get_logger), + unit_of_work: IUnitOfWork = Depends(get_unit_of_work), +) -> GetOrderCommand: + return GetOrderCommand(unit_of_work=unit_of_work,logger=logger) + + +def get_order_status_command( + logger: ILogger = Depends(get_logger), + unit_of_work: IUnitOfWork = Depends(get_unit_of_work), +) -> GetOrderStatusCommand: + return GetOrderStatusCommand(unit_of_work=unit_of_work,logger=logger) + + def get_create_payment_command( logger: ILogger = Depends(get_logger), unit_of_work: IUnitOfWork = Depends(get_unit_of_work), diff --git a/src/presentation/routing/__init__.py b/src/presentation/routing/__init__.py index b6c4034..cc6874e 100644 --- a/src/presentation/routing/__init__.py +++ b/src/presentation/routing/__init__.py @@ -1 +1 @@ -from src.presentation.routing.order import order_router \ No newline at end of file +from src.presentation.routing.order import order_router,orders_router,payment_router,payments_router \ No newline at end of file diff --git a/src/presentation/routing/order.py b/src/presentation/routing/order.py index 2dae80d..6ae05c9 100644 --- a/src/presentation/routing/order.py +++ b/src/presentation/routing/order.py @@ -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, diff --git a/src/presentation/schemas/order.py b/src/presentation/schemas/order.py index 3a877e6..60e7433 100644 --- a/src/presentation/schemas/order.py +++ b/src/presentation/schemas/order.py @@ -1,6 +1,6 @@ from decimal import Decimal from pydantic import BaseModel,Field -from src.application.domain.enums import OrderStatus +from src.application.domain.enums import OrderStatus,PaymentStatus class CreateOrder(BaseModel): @@ -34,11 +34,82 @@ class OrderPaymentResponse(BaseModel): itpay_created_at: str | None = None +class PaymentResponse(BaseModel): + id: str | None = None + created_at: str | None = None + updated_at: str | None = None + user_id: str | None = None + order_id: str | None = None + status: PaymentStatus | None = None + receipt_cloudekassir_id: str | None = None + receipt_cloudekassir_link: str | None = None + itpay_payment_id: str | None = None + itpay_paid_amount: str | None = None + transaction_id: str | None = None + web3_transaction_hash: str | None = None + paid_at: str | None = None + expired_date: str | None = None + + class CreateOrderResponse(BaseModel): status_code: int order: OrderPaymentResponse +class PaymentConfigResponse(BaseModel): + status_code: int + usdt_exchange_rate: str + gas_fee: str + service_fee_rate: str + one_usdt_service_fee: str + one_usdt_total_price: str + created_at: str + + +class PaymentQuoteResponse(BaseModel): + status_code: int + usdt_amount: str + usdt_exchange_rate: str + gas_fee: str + service_fee: str + total_price: str + service_fee_rate: str + created_at: str + + +class OrderWithPaymentResponse(BaseModel): + order: OrderPaymentResponse + payment: PaymentResponse | None = None + + +class OrderDetailResponse(OrderWithPaymentResponse): + status_code: int + + +class OrdersResponse(BaseModel): + status_code: int + orders: list[OrderWithPaymentResponse] + limit: int + offset: int + + +class PaymentsResponse(BaseModel): + status_code: int + payments: list[PaymentResponse] + limit: int + offset: int + + +class OrderStatusResponse(BaseModel): + status_code: int + order_id: str + order_status: OrderStatus | None = None + payment_status: PaymentStatus | None = None + receipt_cloudekassir_link: str | None = None + web3_transaction_hash: str | None = None + updated_at: str | None = None + + class ErrorResponse(BaseModel): detail: str status_code: int