feat: add full pay path
This commit is contained in:
@@ -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: ...
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
2
src/application/commands/__init__.py
Normal file
2
src/application/commands/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from src.application.commands.create_order_command import CreateOrderCommand
|
||||
from src.application.commands.create_payment_command import CreatePaymentCommand
|
||||
81
src/application/commands/create_order_command.py
Normal file
81
src/application/commands/create_order_command.py
Normal 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
|
||||
|
||||
49
src/application/commands/create_payment_command.py
Normal file
49
src/application/commands/create_payment_command.py
Normal 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'],
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
11
src/application/contracts/i_itpay_service.py
Normal file
11
src/application/contracts/i_itpay_service.py
Normal 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
|
||||
24
src/application/contracts/i_receipt.py
Normal file
24
src/application/contracts/i_receipt.py
Normal 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
|
||||
|
||||
@@ -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']
|
||||
36
src/application/domain/entities/order.py
Normal file
36
src/application/domain/entities/order.py
Normal 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
|
||||
|
||||
21
src/application/domain/entities/payment.py
Normal file
21
src/application/domain/entities/payment.py
Normal 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
|
||||
@@ -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
|
||||
7
src/application/domain/enums/itpay_payment_status.py
Normal file
7
src/application/domain/enums/itpay_payment_status.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class ItPayPaymentStatus(Enum):
|
||||
COMPLETED = "payment.completed"
|
||||
REJECTED = "payment.rejected"
|
||||
CANCELLED = "payment.canceled"
|
||||
10
src/application/domain/enums/order_status.py
Normal file
10
src/application/domain/enums/order_status.py
Normal 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'
|
||||
12
src/application/domain/enums/payment_status.py
Normal file
12
src/application/domain/enums/payment_status.py
Normal 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'
|
||||
|
||||
@@ -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}'
|
||||
|
||||
Reference in New Issue
Block a user