feat: add full pay path

This commit is contained in:
2026-05-01 13:10:13 +03:00
parent d1ac7e8e84
commit bf68aca4fa
53 changed files with 1436 additions and 334 deletions

View File

@@ -1,6 +1,6 @@
from __future__ import annotations
from typing import Protocol, runtime_checkable
from src.application.abstractions.repositories import IUserRepository, ISessionRepository
from src.application.abstractions.repositories import IOrderRepository,IPaymentRepository
@runtime_checkable
@@ -12,8 +12,8 @@ class IUnitOfWork(Protocol):
async def rollback(self) -> None: ...
@property
def user_repository(self) -> IUserRepository: ...
def order_repository(self) -> IOrderRepository: ...
@property
def session_repository(self) -> ISessionRepository: ...
def payment_repository(self) -> IPaymentRepository: ...

View File

@@ -1,18 +1,2 @@
from __future__ import annotations
from typing import Protocol,runtime_checkable
from src.application.domain.entities import SessionEntity,UserEntity
@runtime_checkable
class IUserRepository(Protocol):
...
@runtime_checkable
class ISessionRepository(Protocol):
...
__all__=['IUserRepository','ISessionRepository','UserEntity','SessionEntity']
from src.application.abstractions.repositories.i_order_repository import IOrderRepository
from src.application.abstractions.repositories.i_payment_repository import IPaymentRepository

View File

@@ -0,0 +1,24 @@
from abc import ABC,abstractmethod
from src.application.domain.entities.order import OrderEntity
class IOrderRepository(ABC):
@abstractmethod
async def create(self,order: OrderEntity) -> OrderEntity:
raise NotImplementedError
@abstractmethod
async def get_by_client_payment_id(self,client_payment_id: str) -> OrderEntity | None:
raise NotImplementedError
@abstractmethod
async def update_after_itpay_payment_created(self,order: OrderEntity) -> OrderEntity:
raise NotImplementedError
@abstractmethod
async def update_after_itpay_failure(self,order: OrderEntity) -> OrderEntity:
raise NotImplementedError

View File

@@ -0,0 +1,10 @@
from abc import ABC,abstractmethod
from src.infrastructure.database.models.payment import Payment
class IPaymentRepository(ABC):
@abstractmethod
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) -> Payment:
raise NotImplementedError

View File

@@ -1,20 +0,0 @@
from src.infrastructure.database.decorators import transactional
from src.presentation.schemas.order import CreateOrder
class UserLoginStartCommand:
def __init__(
self,
):
pass
@transactional
async def __call__(self, payment_data: CreateOrder) -> bool:
metadata: dict = {
'user_id': str(payment_data.user_id),
}

View File

@@ -0,0 +1,2 @@
from src.application.commands.create_order_command import CreateOrderCommand
from src.application.commands.create_payment_command import CreatePaymentCommand

View File

@@ -0,0 +1,81 @@
from __future__ import annotations
from datetime import datetime, timezone
from decimal import Decimal, ROUND_UP
from ulid import ULID
from src.application.abstractions import IUnitOfWork
from src.application.contracts import ICache, 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
from src.infrastructure.database.decorators import transactional
from src.presentation.schemas.order import CreateOrder
class CreateOrderCommand:
def __init__(
self,
*,
unit_of_work: IUnitOfWork,
logger: ILogger,
cache_local: ICache,
remote_cache: ICache,
itpay_service: IItPayService,
) -> None:
self._unit_of_work = unit_of_work
self._logger = logger
self._cache_local = cache_local
self._remote_cache = remote_cache
self._itpay_service = itpay_service
@transactional
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')
if rate_raw is None:
self._logger.error('Exchange rate unavailable')
rate_raw = '2.00'
#raise ApplicationException(status_code=503, message='Exchange rate unavailable')
if gas_raw is None:
self._logger.error('Exchange gas unavailable')
gas_raw = '1.00'
#raise ApplicationException(status_code=503, 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 * Decimal('0.04')).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'):
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')
order = OrderEntity(
user_id=user_id,
usdt_amount=payment_data.usdt_amount,
usdt_exchange_rate=actual_usdt_exchange_rate,
gas_fee=actual_gas_fee,
service_fee=actual_service_fee,
total_price=actual_total_price,
status=OrderStatus.PENDING,
created_at=datetime.now(timezone.utc),
client_payment_id=client_payment_id,
)
saved = await self._unit_of_work.order_repository.create(order)
with_itpay = await self._itpay_service.create_payment(saved)
if with_itpay.status in (
OrderStatus.CANCELLED,
OrderStatus.REJECTED,
OrderStatus.ERROR,
):
await self._unit_of_work.order_repository.update_after_itpay_failure(with_itpay)
else:
await self._unit_of_work.order_repository.update_after_itpay_payment_created(with_itpay)
return with_itpay

View File

@@ -0,0 +1,49 @@
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.exceptions import ApplicationException
from src.infrastructure.config import settings
from src.infrastructure.database.decorators import transactional
from src.presentation.schemas.itpay_payment_models import ItpayPaymentData
class CreatePaymentCommand:
def __init__(self, *, unit_of_work: IUnitOfWork, logger: ILogger, queue_messanger: IQueueMessanger):
self._unit_of_work = unit_of_work
self._logger = logger
self._queue_messanger = queue_messanger
@transactional
async def __call__(self, payment: ItpayPaymentData) -> None:
if str(payment.status).strip().lower() != 'completed':
return
metadata = payment.metadata or {}
order_id = str(metadata.get('order_id') or '')
user_id = str(metadata.get('user_id') or '')
if not order_id:
raise ApplicationException(status_code=400, message='Itpay webhook metadata missing order_id')
if not user_id:
raise ApplicationException(status_code=400, message='Itpay webhook metadata missing user_id')
await self._unit_of_work.payment_repository.create_completed(
user_id=user_id,
order_id=order_id,
itpay_payment_id=str(payment.id),
itpay_paid_amount=str(payment.amount) if payment.amount is not None else None,
transaction_id=str(payment.transaction_id) if payment.transaction_id is not None else None,
paid_at=str(payment.paid) if payment.paid is not None else None,
expired_date=str(payment.expired_date) if payment.expired_date is not None else None,
)
message_id = str(ULID())
message: dict[str,str] = {
'order_id': order_id,
'user_id': user_id,
'trace_id': self._logger.get_trace_id(),
'message_id': message_id,
}
await self._queue_messanger.publish_to_queue(
queue=settings.RABBIT_CRYPTO_TRANSFER_QUEUE,
message=message,
message_id=message_id,
correlation_id=message['trace_id'],
)

View File

@@ -3,4 +3,6 @@ from src.application.contracts.i_jwt_service import IJwtService
from src.application.contracts.i_csrf_service import ICsrfService
from src.application.contracts.i_cache import ICache
from src.application.contracts.i_hash_service import IHashService
from src.application.contracts.i_queue_messanger import IQueueMessanger
from src.application.contracts.i_queue_messanger import IQueueMessanger
from src.application.contracts.i_itpay_service import IItPayService
from src.application.contracts.i_receipt import IReceipt

View File

@@ -17,6 +17,10 @@ class ICache(ABC):
async def get(self, key: str) -> str | None:
raise NotImplementedError
@abstractmethod
async def hget(self, key: str, field: str) -> str | None:
raise NotImplementedError
@abstractmethod
async def delete(self, key: str) -> bool:
raise NotImplementedError

View File

@@ -0,0 +1,11 @@
from abc import ABC,abstractmethod
from src.application.domain.entities.order import OrderEntity
class IItPayService(ABC):
@abstractmethod
async def create_payment(self,order: OrderEntity) -> OrderEntity:
pass

View File

@@ -0,0 +1,24 @@
from abc import ABC,abstractmethod
from decimal import Decimal
from typing import Any
class IReceipt(ABC):
@abstractmethod
async def create_receipt(
self,
*,
order_id: str,
user_id: str,
email: str,
total_amount: Decimal,
principal_amount: Decimal,
service_fee: Decimal,
phone: str | None = None,
customer_inn: str = '',
success_url: str | None = None,
fail_url: str | None = None,
request_id: str | None = None,
) -> dict[str,Any]:
raise NotImplementedError

View File

@@ -1,5 +1,5 @@
from src.application.domain.entities.user import UserEntity
from src.application.domain.entities.session import SessionEntity
from src.application.domain.entities.order import OrderEntity
from src.application.domain.entities.payment import PaymentEntity
__all__ = ['UserEntity', 'SessionEntity']
__all__ = ['PaymentEntity', 'OrderEntity']

View File

@@ -0,0 +1,36 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal
from src.application.domain.enums import OrderStatus
@dataclass(slots=True)
class OrderEntity:
id: str | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
user_id: str | None = None
usdt_amount: Decimal | None = None
usdt_exchange_rate: Decimal | None = None
gas_fee: Decimal | None = None
total_price: Decimal | None = None
service_fee: Decimal | None = None
status: OrderStatus | None = None
client_payment_id: str | None = None
itpay_payment_qr_url_desktop: str | None = None
itpay_payment_qr_url_android: str | None = None
itpay_payment_qr_url_ios: str | None = None
itpay_payment_qr_image_desktop: str | None = None
itpay_payment_qr_image_android: str | None = None
itpay_payment_qr_image_ios: str | None = None
itpay_id: str | None = None
itpay_qr_id: str | None = None
itpay_amount: Decimal | None = None
itpay_created_at: datetime | None = None

View File

@@ -0,0 +1,21 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal
from src.application.domain.enums import PaymentStatus
@dataclass(slots=True)
class PaymentEntity:
user_id: str | None = None
order_id: str | None = None
status: PaymentStatus | None = None
receipt_cloudekassir_link: str | None = None
itpay_payment_id: str | None = None
transaction_id: str | None = None
web3_transaction_hash: str | None = None
paid_at: str | None = None
expired_date: str | None = None

View File

@@ -1,2 +1,5 @@
from src.application.domain.enums.log_level import LogLevel
from src.application.domain.enums.log_format import LogFormat
from src.application.domain.enums.log_format import LogFormat
from src.application.domain.enums.itpay_payment_status import ItPayPaymentStatus
from src.application.domain.enums.order_status import OrderStatus
from src.application.domain.enums.payment_status import PaymentStatus

View File

@@ -0,0 +1,7 @@
from enum import Enum
class ItPayPaymentStatus(Enum):
COMPLETED = "payment.completed"
REJECTED = "payment.rejected"
CANCELLED = "payment.canceled"

View File

@@ -0,0 +1,10 @@
from enum import Enum
class OrderStatus(str, Enum):
PENDING = 'pending'
REJECTED = 'rejected'
COMPLETED = 'completed'
CANCELLED = 'cancelled'
ERROR = 'error'
CANCELED = 'canceled'

View File

@@ -0,0 +1,12 @@
from enum import Enum
class PaymentStatus(str,Enum):
PENDING='pending'
MONEY_ACCEPTED='money_accepted'
WEB3_HASH_ERROR='web3_hash_error'
WEB3_BALANCE_PROBLEM='web3_balance_problem'
USDT_DELIVERED='usdt_delivered'
RECEIPT_ERROR='receipt_error'
COMPLETED='completed'

View File

@@ -15,4 +15,4 @@ class ApplicationException(Exception):
self.headers = headers
def __str__(self):
return f"{self.status_code}: {self.message}"
return f'{self.status_code}: {self.message}'