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

@@ -19,6 +19,16 @@ class IOrderRepository(ABC):
raise NotImplementedError 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 @abstractmethod
async def update_after_itpay_payment_created(self,order: OrderEntity) -> OrderEntity: async def update_after_itpay_payment_created(self,order: OrderEntity) -> OrderEntity:
raise NotImplementedError raise NotImplementedError

View File

@@ -1,4 +1,6 @@
from abc import ABC,abstractmethod from abc import ABC,abstractmethod
from src.application.domain.entities import PaymentEntity
from src.application.domain.enums import PaymentStatus
class IPaymentRepository(ABC): class IPaymentRepository(ABC):
@@ -12,7 +14,22 @@ class IPaymentRepository(ABC):
raise NotImplementedError raise NotImplementedError
@abstractmethod
async def update_status(self,*,order_id:str,status:PaymentStatus) -> None:
raise NotImplementedError
@abstractmethod @abstractmethod
async def update_receipt(self,*,order_id:str,receipt_cloudekassir_id:str|None,receipt_cloudekassir_link:str|None) -> None: async def update_receipt(self,*,order_id:str,receipt_cloudekassir_id:str|None,receipt_cloudekassir_link:str|None) -> None:
raise NotImplementedError 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

View File

@@ -1,3 +1,4 @@
from src.application.commands.create_order_command import CreateOrderCommand from src.application.commands.create_order_command import CreateOrderCommand
from src.application.commands.create_payment_command import CreatePaymentCommand from src.application.commands.create_payment_command import CreatePaymentCommand
from src.application.commands.create_crypto_transfer_completed_command import CreateCryptoTransferCompletedCommand 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

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
from decimal import Decimal from decimal import Decimal
from src.application.abstractions import IUnitOfWork from src.application.abstractions import IUnitOfWork
from src.application.contracts import ILogger,IReceipt from src.application.contracts import ILogger,IReceipt
from src.application.domain.enums import PaymentStatus
from src.application.domain.exceptions import ApplicationException from src.application.domain.exceptions import ApplicationException
from src.infrastructure.database.decorators import transactional from src.infrastructure.database.decorators import transactional
@@ -62,6 +63,7 @@ class CreateCryptoTransferCompletedCommand:
if principal_amount < 0: if principal_amount < 0:
raise ApplicationException(status_code=400, message='Invalid receipt amounts: principal negative') raise ApplicationException(status_code=400, message='Invalid receipt amounts: principal negative')
try:
receipt_response = await self._receipt.create_receipt( receipt_response = await self._receipt.create_receipt(
order_id=order_id, order_id=order_id,
user_id=user_id, user_id=user_id,
@@ -74,6 +76,13 @@ class CreateCryptoTransferCompletedCommand:
customer_birthday=customer_birthday, customer_birthday=customer_birthday,
request_id=self._logger.get_trace_id(), 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') receipt_model = receipt_response.get('Model')
if not isinstance(receipt_model, dict): if not isinstance(receipt_model, dict):
receipt_model = {} receipt_model = {}

View File

@@ -1,14 +1,14 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timezone from datetime import datetime, timezone
from decimal import Decimal, ROUND_UP from decimal import Decimal
from ulid import ULID from ulid import ULID
from src.application.abstractions import IUnitOfWork 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.contracts import IItPayService
from src.application.domain.entities.order import OrderEntity from src.application.domain.entities.order import OrderEntity
from src.application.domain.enums import OrderStatus from src.application.domain.enums import OrderStatus
from src.application.domain.exceptions import ApplicationException,ServiceUnavailableException from src.application.domain.exceptions import ApplicationException
from src.infrastructure.config import settings from src.application.services import PaymentQuoteService
from src.infrastructure.database.decorators import transactional from src.infrastructure.database.decorators import transactional
from src.presentation.schemas.order import CreateOrder from src.presentation.schemas.order import CreateOrder
@@ -20,12 +20,12 @@ class CreateOrderCommand:
*, *,
unit_of_work: IUnitOfWork, unit_of_work: IUnitOfWork,
logger: ILogger, logger: ILogger,
remote_cache: ICache, payment_quote_service: PaymentQuoteService,
itpay_service: IItPayService, itpay_service: IItPayService,
) -> None: ) -> None:
self._unit_of_work = unit_of_work self._unit_of_work = unit_of_work
self._logger = logger self._logger = logger
self._remote_cache = remote_cache self._payment_quote_service = payment_quote_service
self._itpay_service = itpay_service self._itpay_service = itpay_service
@@ -33,22 +33,11 @@ class CreateOrderCommand:
async def __call__(self, payment_data: CreateOrder, user_id: str) -> OrderEntity: async def __call__(self, payment_data: CreateOrder, user_id: str) -> OrderEntity:
client_payment_id = str(ULID()) client_payment_id = str(ULID())
rate_raw = await self._remote_cache.hget('tradex:rub:rate','value') quote = await self._payment_quote_service.get_quote(payment_data.usdt_amount)
gas_raw = await self._remote_cache.hget('gwei:eth:last','normal_rub') actual_gas_fee = quote.gas_fee
self._logger.info(f'Actual market values: rate={rate_raw}, gas={gas_raw}') actual_usdt_exchange_rate = quote.usdt_exchange_rate
actual_service_fee = quote.service_fee
if rate_raw is None: actual_total_price = quote.total_price
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'))
if actual_total_price > payment_data.total_price * Decimal('1.01'): if actual_total_price > payment_data.total_price * Decimal('1.01'):
self._logger.error('Price has changed, please refresh and try again') 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') raise ApplicationException(status_code=409, message='Price has changed, please refresh and try again')

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
from ulid import ULID from ulid import ULID
from src.application.abstractions import IUnitOfWork from src.application.abstractions import IUnitOfWork
from src.application.contracts import ILogger,IQueueMessanger from src.application.contracts import ILogger,IQueueMessanger
from src.application.domain.enums import PaymentStatus
from src.application.domain.exceptions import ApplicationException from src.application.domain.exceptions import ApplicationException
from src.infrastructure.config import settings from src.infrastructure.config import settings
from src.infrastructure.database.decorators import transactional from src.infrastructure.database.decorators import transactional
@@ -52,3 +53,7 @@ class CreatePaymentCommand:
correlation_id=message['trace_id'], correlation_id=message['trace_id'],
headers={'trace_id': trace_id}, headers={'trace_id': trace_id},
) )
await self._unit_of_work.payment_repository.update_status(
order_id=order_id,
status=PaymentStatus.WEB3_PROCESSING,
)

View File

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

View File

@@ -7,6 +7,10 @@ from src.application.domain.enums import PaymentStatus
@dataclass(slots=True) @dataclass(slots=True)
class PaymentEntity: class PaymentEntity:
id: str | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
user_id: str | None = None user_id: str | None = None
order_id: str | None = None order_id: str | None = None
@@ -16,7 +20,8 @@ class PaymentEntity:
receipt_cloudekassir_link: str | None = None receipt_cloudekassir_link: str | None = None
itpay_payment_id: str | None = None itpay_payment_id: str | None = None
itpay_paid_amount: Decimal | None = None
transaction_id: str | None = None transaction_id: str | None = None
web3_transaction_hash: str | None = None web3_transaction_hash: str | None = None
paid_at: str | None = None paid_at: datetime | None = None
expired_date: str | None = None expired_date: datetime | None = None

View File

@@ -4,6 +4,7 @@ from enum import Enum
class PaymentStatus(str,Enum): class PaymentStatus(str,Enum):
PENDING='pending' PENDING='pending'
MONEY_ACCEPTED='money_accepted' MONEY_ACCEPTED='money_accepted'
WEB3_PROCESSING='web3_processing'
WEB3_HASH_ERROR='web3_hash_error' WEB3_HASH_ERROR='web3_hash_error'
WEB3_BALANCE_PROBLEM='web3_balance_problem' WEB3_BALANCE_PROBLEM='web3_balance_problem'
USDT_DELIVERED='usdt_delivered' USDT_DELIVERED='usdt_delivered'

View File

@@ -0,0 +1,4 @@
from src.application.services.payment_quote_service import PaymentQuote,PaymentQuoteService
__all__ = ['PaymentQuote', 'PaymentQuoteService']

View File

@@ -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),
)

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from dataclasses import replace from dataclasses import replace
from datetime import datetime,timezone from datetime import datetime,timezone
from decimal import Decimal from decimal import Decimal
from sqlalchemy import select,update from sqlalchemy import desc,select,update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.application.abstractions.repositories.i_order_repository import IOrderRepository from src.application.abstractions.repositories.i_order_repository import IOrderRepository
from src.application.contracts import ILogger from src.application.contracts import ILogger
@@ -85,6 +85,26 @@ class OrderRepository(IOrderRepository):
return self._to_entity(model) 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: async def update_after_itpay_payment_created(self,order: OrderEntity) -> OrderEntity:
if not order.id: if not order.id:
raise ValueError('OrderEntity.id is required') raise ValueError('OrderEntity.id is required')

View File

@@ -2,10 +2,11 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from decimal import Decimal from decimal import Decimal
from sqlalchemy import select from sqlalchemy import desc,select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.application.abstractions.repositories.i_payment_repository import IPaymentRepository from src.application.abstractions.repositories.i_payment_repository import IPaymentRepository
from src.application.contracts import ILogger from src.application.contracts import ILogger
from src.application.domain.entities import PaymentEntity
from src.application.domain.enums import PaymentStatus from src.application.domain.enums import PaymentStatus
from src.infrastructure.database.models.payment import Payment from src.infrastructure.database.models.payment import Payment
@@ -16,6 +17,26 @@ class PaymentRepository(IPaymentRepository):
self._logger=logger 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: 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) stmt=select(Payment).where(Payment.order_id==order_id)
existing=await self._session.scalar(stmt) existing=await self._session.scalar(stmt)
@@ -52,6 +73,16 @@ class PaymentRepository(IPaymentRepository):
return 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: 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) stmt=select(Payment).where(Payment.order_id==order_id)
model=await self._session.scalar(stmt) model=await self._session.scalar(stmt)
@@ -63,3 +94,23 @@ class PaymentRepository(IPaymentRepository):
await self._session.flush() await self._session.flush()
return 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()]

View File

@@ -16,7 +16,7 @@ from src.infrastructure.config import settings
from src.presentation.handler import application_exception_handler, unhandled_exception_handler from src.presentation.handler import application_exception_handler, unhandled_exception_handler
from src.presentation.messaging import crypto_transfer_router from src.presentation.messaging import crypto_transfer_router
from src.presentation.middleware import TraceIDMiddleware, SecurityHeadersMiddleware 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() security = HTTPBasic()
@@ -74,6 +74,9 @@ app.add_exception_handler(ApplicationException, application_exception_handler)
app.add_exception_handler(Exception, unhandled_exception_handler) app.add_exception_handler(Exception, unhandled_exception_handler)
app.include_router(order_router) 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) app.include_router(crypto_transfer_router)

View File

@@ -1,9 +1,10 @@
from __future__ import annotations from __future__ import annotations
from fastapi import Depends from fastapi import Depends
from src.application.abstractions import IUnitOfWork 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 import ICache,ILogger,IQueueMessanger,IReceipt
from src.application.contracts.i_itpay_service import IItPayService 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 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.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 from src.infrastructure.config import settings
@@ -28,14 +29,64 @@ def get_create_order_command(
remote_cache: ICache = Depends(get_remote_cache), remote_cache: ICache = Depends(get_remote_cache),
itpay_service: IItPayService = Depends(get_itpay_service), itpay_service: IItPayService = Depends(get_itpay_service),
) -> CreateOrderCommand: ) -> CreateOrderCommand:
payment_quote_service = PaymentQuoteService(remote_cache=remote_cache,logger=logger)
return CreateOrderCommand( return CreateOrderCommand(
unit_of_work=unit_of_work, unit_of_work=unit_of_work,
logger=logger, logger=logger,
remote_cache=remote_cache, payment_quote_service=payment_quote_service,
itpay_service=itpay_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( def get_create_payment_command(
logger: ILogger = Depends(get_logger), logger: ILogger = Depends(get_logger),
unit_of_work: IUnitOfWork = Depends(get_unit_of_work), unit_of_work: IUnitOfWork = Depends(get_unit_of_work),

View File

@@ -1 +1 @@
from src.presentation.routing.order import order_router from src.presentation.routing.order import order_router,orders_router,payment_router,payments_router

View File

@@ -1,20 +1,155 @@
import asyncio
from decimal import Decimal
from urllib.parse import parse_qs from urllib.parse import parse_qs
import orjson import orjson
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter,Depends,Query,Request,WebSocket,WebSocketDisconnect
from fastapi.responses import ORJSONResponse from fastapi.responses import ORJSONResponse
from src.application.commands import CreateOrderCommand from fastapi.security.utils import get_authorization_scheme_param
from src.application.commands import CreatePaymentCommand from ulid import ULID
from src.application.contracts import ILogger from src.application.commands import CreateOrderCommand,CreatePaymentCommand,GetOrderCommand,GetOrderStatusCommand,GetPaymentConfigCommand,GetPaymentQuoteCommand,ListOrdersCommand,ListPaymentsCommand
from src.application.domain.dto import AuthContext 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.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.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.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 from src.presentation.schemas.itpay_payment_models import ItpayPaymentData
order_router = APIRouter(prefix='/order', tags=['orders']) 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( @order_router.post(
@@ -59,33 +194,113 @@ async def create_order(
raise ConflictException(message='Payment provider rejected order') raise ConflictException(message='Payment provider rejected order')
content = CreateOrderResponse( content = CreateOrderResponse(
status_code=201, status_code=201,
order=OrderPaymentResponse( order=_order_response(o),
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,
),
) )
return content 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') @order_router.post('/webhook/itpay')
async def itpay_webhook( async def itpay_webhook(
request: Request, request: Request,

View File

@@ -1,6 +1,6 @@
from decimal import Decimal from decimal import Decimal
from pydantic import BaseModel,Field from pydantic import BaseModel,Field
from src.application.domain.enums import OrderStatus from src.application.domain.enums import OrderStatus,PaymentStatus
class CreateOrder(BaseModel): class CreateOrder(BaseModel):
@@ -34,11 +34,82 @@ class OrderPaymentResponse(BaseModel):
itpay_created_at: str | None = None 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): class CreateOrderResponse(BaseModel):
status_code: int status_code: int
order: OrderPaymentResponse 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): class ErrorResponse(BaseModel):
detail: str detail: str
status_code: int status_code: int