feat: add withdraw
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 IOrderRepository,IPaymentRepository,IUserRepository
|
||||
from src.application.abstractions.repositories import IOrderRepository,IPaymentRepository,ISbpWithdrawalRepository,IUserRepository
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
@@ -17,6 +17,9 @@ class IUnitOfWork(Protocol):
|
||||
@property
|
||||
def payment_repository(self) -> IPaymentRepository: ...
|
||||
|
||||
@property
|
||||
def sbp_withdrawal_repository(self) -> ISbpWithdrawalRepository: ...
|
||||
|
||||
@property
|
||||
def user_repository(self) -> IUserRepository: ...
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from src.application.abstractions.repositories.i_order_repository import IOrderRepository
|
||||
from src.application.abstractions.repositories.i_payment_repository import IPaymentRepository
|
||||
from src.application.abstractions.repositories.i_sbp_withdrawal_repository import ISbpWithdrawalRepository
|
||||
from src.application.abstractions.repositories.i_user_repository import IUserRepository
|
||||
@@ -0,0 +1,47 @@
|
||||
from abc import ABC,abstractmethod
|
||||
from src.application.domain.entities import SbpWithdrawalEntity
|
||||
from src.application.domain.enums import SbpWithdrawalStatus
|
||||
|
||||
|
||||
class ISbpWithdrawalRepository(ABC):
|
||||
@abstractmethod
|
||||
async def create(self,withdrawal: SbpWithdrawalEntity) -> SbpWithdrawalEntity:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@abstractmethod
|
||||
async def get_by_id(self,withdraw_id: str) -> SbpWithdrawalEntity | None:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@abstractmethod
|
||||
async def get_by_id_for_user(self,*,withdraw_id: str,user_id: str) -> SbpWithdrawalEntity | None:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@abstractmethod
|
||||
async def list_by_user_id(self,*,user_id: str,limit: int,offset: int) -> list[SbpWithdrawalEntity]:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@abstractmethod
|
||||
async def update_status(
|
||||
self,
|
||||
*,
|
||||
withdraw_id: str,
|
||||
status: SbpWithdrawalStatus,
|
||||
provider_error: str | None = None,
|
||||
) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@abstractmethod
|
||||
async def update_payout_created(
|
||||
self,
|
||||
*,
|
||||
withdraw_id: str,
|
||||
mozen_order_id: str | None,
|
||||
status: SbpWithdrawalStatus,
|
||||
provider_error: str | None,
|
||||
) -> None:
|
||||
raise NotImplementedError
|
||||
@@ -1,4 +1,5 @@
|
||||
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
|
||||
from src.application.commands.payment_read_commands import GetOrderCommand,GetOrderStatusCommand,GetPaymentCommand,GetPaymentConfigCommand,GetPaymentQuoteCommand,GetPaymentQuoteFromRubCommand,ListOrdersCommand,ListPaymentsCommand,OrderPaymentResult
|
||||
from src.application.commands.payment_read_commands import ClientOperationResult,GetOrderCommand,GetOrderStatusCommand,GetPaymentCommand,GetPaymentConfigCommand,GetPaymentQuoteCommand,GetPaymentQuoteFromRubCommand,ListClientOperationsCommand,ListOrdersCommand,ListPaymentsCommand,OrderPaymentResult
|
||||
from src.application.commands.sbp_withdrawal_commands import CreateSbpWithdrawalCommand,GetSbpBanksCommand,GetSbpWithdrawalCommand,HandleSbpWithdrawalWalletEventCommand
|
||||
@@ -1,9 +1,11 @@
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime,timezone
|
||||
from decimal import Decimal
|
||||
from typing import Literal
|
||||
from src.application.abstractions import IUnitOfWork
|
||||
from src.application.contracts import ILogger
|
||||
from src.application.domain.entities import OrderEntity,PaymentEntity
|
||||
from src.application.domain.entities import OrderEntity,PaymentEntity,SbpWithdrawalEntity
|
||||
from src.application.domain.exceptions import NotFoundException
|
||||
from src.application.services import PaymentQuote,PaymentQuoteService
|
||||
from src.infrastructure.database.decorators import transactional
|
||||
@@ -15,6 +17,14 @@ class OrderPaymentResult:
|
||||
payment: PaymentEntity | None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ClientOperationResult:
|
||||
type: Literal['payment','withdrawal']
|
||||
created_at: datetime
|
||||
payment: PaymentEntity | None = None
|
||||
withdrawal: SbpWithdrawalEntity | None = None
|
||||
|
||||
|
||||
class GetPaymentConfigCommand:
|
||||
def __init__(self, *, payment_quote_service: PaymentQuoteService, logger: ILogger):
|
||||
self._payment_quote_service = payment_quote_service
|
||||
@@ -91,6 +101,39 @@ class ListPaymentsCommand:
|
||||
return payments
|
||||
|
||||
|
||||
class ListClientOperationsCommand:
|
||||
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[ClientOperationResult]:
|
||||
fetch_limit=limit+offset
|
||||
payments=await self._unit_of_work.payment_repository.list_by_user_id(
|
||||
user_id=user_id,
|
||||
limit=fetch_limit,
|
||||
offset=0,
|
||||
)
|
||||
withdrawals=await self._unit_of_work.sbp_withdrawal_repository.list_by_user_id(
|
||||
user_id=user_id,
|
||||
limit=fetch_limit,
|
||||
offset=0,
|
||||
)
|
||||
min_dt=datetime.min.replace(tzinfo=timezone.utc)
|
||||
items=[
|
||||
ClientOperationResult(type='payment',created_at=payment.created_at or min_dt,payment=payment)
|
||||
for payment in payments
|
||||
]
|
||||
items.extend(
|
||||
ClientOperationResult(type='withdrawal',created_at=withdrawal.created_at or min_dt,withdrawal=withdrawal)
|
||||
for withdrawal in withdrawals
|
||||
)
|
||||
items.sort(key=lambda item:item.created_at,reverse=True)
|
||||
self._logger.info({'event':'client_operations_list_requested','user_id':user_id,'limit':limit,'offset':offset})
|
||||
return items[offset:offset+limit]
|
||||
|
||||
|
||||
class GetPaymentCommand:
|
||||
def __init__(self, *, unit_of_work: IUnitOfWork, logger: ILogger):
|
||||
self._unit_of_work = unit_of_work
|
||||
|
||||
254
src/application/commands/sbp_withdrawal_commands.py
Normal file
254
src/application/commands/sbp_withdrawal_commands.py
Normal file
@@ -0,0 +1,254 @@
|
||||
from __future__ import annotations
|
||||
from datetime import datetime,timezone
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
from ulid import ULID
|
||||
from src.application.abstractions import IUnitOfWork
|
||||
from src.application.contracts import IQueueMessanger,ILogger,IMozenSbpService,SbpBank
|
||||
from src.application.domain.entities import SbpWithdrawalEntity
|
||||
from src.application.domain.enums import SbpWithdrawalStatus
|
||||
from src.application.domain.exceptions import BadRequestException,NotFoundException,PaymentProviderException,ServiceUnavailableException
|
||||
from src.application.services import SbpWithdrawalQuoteService
|
||||
from src.infrastructure.config import settings
|
||||
from src.infrastructure.database.decorators import transactional
|
||||
|
||||
|
||||
class GetSbpBanksCommand:
|
||||
def __init__(self,*,mozen_sbp_service: IMozenSbpService,logger: ILogger):
|
||||
self._mozen_sbp_service=mozen_sbp_service
|
||||
self._logger=logger
|
||||
|
||||
|
||||
async def __call__(self) -> list[SbpBank]:
|
||||
return await self._mozen_sbp_service.get_banks(self._logger.get_trace_id())
|
||||
|
||||
|
||||
class CreateSbpWithdrawalCommand:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
unit_of_work: IUnitOfWork,
|
||||
logger: ILogger,
|
||||
queue_messanger: IQueueMessanger,
|
||||
mozen_sbp_service: IMozenSbpService,
|
||||
quote_service: SbpWithdrawalQuoteService,
|
||||
):
|
||||
self._unit_of_work=unit_of_work
|
||||
self._logger=logger
|
||||
self._queue_messanger=queue_messanger
|
||||
self._mozen_sbp_service=mozen_sbp_service
|
||||
self._quote_service=quote_service
|
||||
|
||||
|
||||
async def _bank_name(self,bank_id: str) -> str:
|
||||
banks=await self._mozen_sbp_service.get_banks(self._logger.get_trace_id())
|
||||
for bank in banks:
|
||||
if bank.id==bank_id:
|
||||
return bank.name
|
||||
raise BadRequestException(message='SBP bank not found')
|
||||
|
||||
|
||||
@transactional
|
||||
async def __call__(self,*,user_id: str,bank_id: str,phone: str,usdt_amount: Decimal) -> SbpWithdrawalEntity:
|
||||
user=await self._unit_of_work.user_repository.get(user_id)
|
||||
if user is None:
|
||||
raise NotFoundException(message='User not found')
|
||||
wallet_address=str(settings.SBP_WITHDRAWAL_WALLET_ADDRESS or '').strip()
|
||||
if not wallet_address:
|
||||
raise ServiceUnavailableException(message='SBP withdrawal wallet address unavailable')
|
||||
trace_id=self._logger.get_trace_id()
|
||||
withdraw_id=str(ULID())
|
||||
bank_name=await self._bank_name(bank_id)
|
||||
quote=await self._quote_service.get_quote(usdt_amount)
|
||||
withdrawal=SbpWithdrawalEntity(
|
||||
id=withdraw_id,
|
||||
user_id=user_id,
|
||||
bank_id=bank_id,
|
||||
bank_name=bank_name,
|
||||
phone=phone,
|
||||
wallet_address=wallet_address,
|
||||
usdt_amount=quote.usdt_amount,
|
||||
rub_amount=quote.rub_amount,
|
||||
usdt_exchange_rate=quote.usdt_exchange_rate,
|
||||
service_fee_rate=quote.service_fee_rate,
|
||||
service_fee_usdt=quote.service_fee_usdt,
|
||||
trace_id=trace_id,
|
||||
status=SbpWithdrawalStatus.WAITING_USDT,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
saved=await self._unit_of_work.sbp_withdrawal_repository.create(withdrawal)
|
||||
message_id=str(ULID())
|
||||
message: dict[str,Any]={
|
||||
'user_id':user_id,
|
||||
'usdt_amount':str(quote.usdt_amount),
|
||||
'trace_id':trace_id,
|
||||
'wallet_address':wallet_address,
|
||||
'withdraw_id':withdraw_id,
|
||||
'message_id':message_id,
|
||||
}
|
||||
await self._queue_messanger.publish_to_queue(
|
||||
queue=settings.RABBIT_SBP_WITHDRAWAL_USDT_REQUESTED_QUEUE,
|
||||
message=message,
|
||||
message_id=message_id,
|
||||
correlation_id=trace_id,
|
||||
headers={'trace_id':trace_id},
|
||||
)
|
||||
self._logger.info({
|
||||
'event':'sbp_withdrawal_usdt_request_published',
|
||||
'withdraw_id':withdraw_id,
|
||||
'user_id':user_id,
|
||||
'message_id':message_id,
|
||||
})
|
||||
return saved
|
||||
|
||||
|
||||
class GetSbpWithdrawalCommand:
|
||||
def __init__(self,*,unit_of_work: IUnitOfWork,logger: ILogger):
|
||||
self._unit_of_work=unit_of_work
|
||||
self._logger=logger
|
||||
|
||||
|
||||
@transactional
|
||||
async def __call__(self,*,withdraw_id: str,user_id: str) -> SbpWithdrawalEntity:
|
||||
withdrawal=await self._unit_of_work.sbp_withdrawal_repository.get_by_id_for_user(
|
||||
withdraw_id=withdraw_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
if withdrawal is None:
|
||||
raise NotFoundException(message='SBP withdrawal not found')
|
||||
return withdrawal
|
||||
|
||||
|
||||
class HandleSbpWithdrawalWalletEventCommand:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
unit_of_work: IUnitOfWork,
|
||||
logger: ILogger,
|
||||
mozen_sbp_service: IMozenSbpService,
|
||||
):
|
||||
self._unit_of_work=unit_of_work
|
||||
self._logger=logger
|
||||
self._mozen_sbp_service=mozen_sbp_service
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _event_error(
|
||||
*,
|
||||
event_type: str,
|
||||
received_usdt_amount: Decimal | None,
|
||||
tx_hash: str | None,
|
||||
error: str | None,
|
||||
) -> str | None:
|
||||
parts=(
|
||||
f'event_type={event_type}',
|
||||
f'received_usdt_amount={received_usdt_amount}' if received_usdt_amount is not None else '',
|
||||
f'tx_hash={tx_hash}' if tx_hash else '',
|
||||
f'error={error}' if error else '',
|
||||
)
|
||||
value=';'.join(part for part in parts if part)
|
||||
return value or None
|
||||
|
||||
|
||||
@transactional
|
||||
async def __call__(
|
||||
self,
|
||||
*,
|
||||
withdraw_id: str,
|
||||
user_id: str,
|
||||
event_type: str,
|
||||
received_usdt_amount: Decimal | None = None,
|
||||
tx_hash: str | None = None,
|
||||
error: str | None = None,
|
||||
) -> None:
|
||||
withdrawal=await self._unit_of_work.sbp_withdrawal_repository.get_by_id(withdraw_id)
|
||||
if withdrawal is None:
|
||||
raise NotFoundException(message='SBP withdrawal not found')
|
||||
if withdrawal.user_id!=user_id:
|
||||
raise BadRequestException(message='SBP withdrawal user mismatch')
|
||||
normalized_event_type=event_type.strip().lower()
|
||||
if withdrawal.status in (
|
||||
SbpWithdrawalStatus.PAYOUT_PROCESSING,
|
||||
SbpWithdrawalStatus.PAYOUT_COMPLETED,
|
||||
SbpWithdrawalStatus.PAYOUT_FAILED,
|
||||
SbpWithdrawalStatus.CANCELLED,
|
||||
SbpWithdrawalStatus.EXPIRED,
|
||||
):
|
||||
self._logger.info({
|
||||
'event':'sbp_withdrawal_wallet_event_ignored',
|
||||
'withdraw_id':withdraw_id,
|
||||
'user_id':user_id,
|
||||
'wallet_event_type':normalized_event_type,
|
||||
'status':withdrawal.status.value if withdrawal.status is not None else None,
|
||||
})
|
||||
return
|
||||
provider_error=self._event_error(
|
||||
event_type=normalized_event_type,
|
||||
received_usdt_amount=received_usdt_amount,
|
||||
tx_hash=tx_hash,
|
||||
error=error,
|
||||
)
|
||||
if normalized_event_type in ('usdt_underpaid','underpaid'):
|
||||
await self._unit_of_work.sbp_withdrawal_repository.update_status(
|
||||
withdraw_id=withdraw_id,
|
||||
status=SbpWithdrawalStatus.USDT_UNDERPAID,
|
||||
provider_error=provider_error,
|
||||
)
|
||||
return
|
||||
if normalized_event_type in ('expired','usdt_expired'):
|
||||
await self._unit_of_work.sbp_withdrawal_repository.update_status(
|
||||
withdraw_id=withdraw_id,
|
||||
status=SbpWithdrawalStatus.EXPIRED,
|
||||
provider_error=provider_error,
|
||||
)
|
||||
return
|
||||
if normalized_event_type in ('failed','wallet_error','watch_failed','error'):
|
||||
await self._unit_of_work.sbp_withdrawal_repository.update_status(
|
||||
withdraw_id=withdraw_id,
|
||||
status=SbpWithdrawalStatus.WALLET_ERROR,
|
||||
provider_error=provider_error,
|
||||
)
|
||||
return
|
||||
if normalized_event_type not in ('usdt_received','received','confirmed'):
|
||||
raise BadRequestException(message='Unknown SBP withdrawal wallet event type')
|
||||
await self._unit_of_work.sbp_withdrawal_repository.update_status(
|
||||
withdraw_id=withdraw_id,
|
||||
status=SbpWithdrawalStatus.USDT_RECEIVED,
|
||||
provider_error=provider_error,
|
||||
)
|
||||
try:
|
||||
result=await self._mozen_sbp_service.make_withdrawal(
|
||||
user_id=user_id,
|
||||
withdraw_id=withdraw_id,
|
||||
phone=str(withdrawal.phone or ''),
|
||||
bank_id=str(withdrawal.bank_id or ''),
|
||||
# amount=Decimal(str(withdrawal.rub_amount or '0')),
|
||||
amount=Decimal('1'),
|
||||
trace_id=str(withdrawal.trace_id or self._logger.get_trace_id()),
|
||||
)
|
||||
except PaymentProviderException as exception:
|
||||
await self._unit_of_work.sbp_withdrawal_repository.update_status(
|
||||
withdraw_id=withdraw_id,
|
||||
status=SbpWithdrawalStatus.PAYOUT_FAILED,
|
||||
provider_error=exception.message,
|
||||
)
|
||||
return
|
||||
status=SbpWithdrawalStatus.PAYOUT_PROCESSING
|
||||
provider_error=result.error
|
||||
if result.error:
|
||||
status=SbpWithdrawalStatus.PAYOUT_FAILED
|
||||
elif result.status.strip().lower() in ('completed','success','succeeded','paid'):
|
||||
status=SbpWithdrawalStatus.PAYOUT_COMPLETED
|
||||
await self._unit_of_work.sbp_withdrawal_repository.update_payout_created(
|
||||
withdraw_id=withdraw_id,
|
||||
mozen_order_id=result.mozen_order_id,
|
||||
status=status,
|
||||
provider_error=provider_error,
|
||||
)
|
||||
self._logger.info({
|
||||
'event':'sbp_withdrawal_payout_created',
|
||||
'withdraw_id':withdraw_id,
|
||||
'user_id':user_id,
|
||||
'mozen_order_id':result.mozen_order_id,
|
||||
'status':status.value,
|
||||
})
|
||||
@@ -5,4 +5,5 @@ 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_itpay_service import IItPayService
|
||||
from src.application.contracts.i_receipt import IReceipt
|
||||
from src.application.contracts.i_receipt import IReceipt
|
||||
from src.application.contracts.i_mozen_sbp_service import IMozenSbpService,SbpBank,SbpPayoutResult
|
||||
36
src/application/contracts/i_mozen_sbp_service.py
Normal file
36
src/application/contracts/i_mozen_sbp_service.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from abc import ABC,abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SbpBank:
|
||||
id: str
|
||||
name: str
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SbpPayoutResult:
|
||||
status: str
|
||||
mozen_order_id: str | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class IMozenSbpService(ABC):
|
||||
@abstractmethod
|
||||
async def get_banks(self,trace_id: str) -> list[SbpBank]:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@abstractmethod
|
||||
async def make_withdrawal(
|
||||
self,
|
||||
*,
|
||||
user_id: str,
|
||||
withdraw_id: str,
|
||||
phone: str,
|
||||
bank_id: str,
|
||||
amount: Decimal,
|
||||
trace_id: str,
|
||||
) -> SbpPayoutResult:
|
||||
raise NotImplementedError
|
||||
@@ -1,5 +1,6 @@
|
||||
from src.application.domain.entities.order import OrderEntity
|
||||
from src.application.domain.entities.payment import PaymentEntity
|
||||
from src.application.domain.entities.sbp_withdrawal import SbpWithdrawalEntity
|
||||
|
||||
|
||||
__all__ = ['PaymentEntity', 'OrderEntity']
|
||||
__all__ = ['PaymentEntity','OrderEntity','SbpWithdrawalEntity']
|
||||
30
src/application/domain/entities/sbp_withdrawal.py
Normal file
30
src/application/domain/entities/sbp_withdrawal.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from src.application.domain.enums import SbpWithdrawalStatus
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SbpWithdrawalEntity:
|
||||
id: str | None = None
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
user_id: str | None = None
|
||||
bank_id: str | None = None
|
||||
bank_name: str | None = None
|
||||
phone: str | None = None
|
||||
wallet_address: str | None = None
|
||||
usdt_amount: Decimal | None = None
|
||||
rub_amount: Decimal | None = None
|
||||
usdt_exchange_rate: Decimal | None = None
|
||||
service_fee_rate: Decimal | None = None
|
||||
service_fee_usdt: Decimal | None = None
|
||||
mozen_order_id: str | None = None
|
||||
trace_id: str | None = None
|
||||
status: SbpWithdrawalStatus | None = None
|
||||
provider_error: str | None = None
|
||||
usdt_received_at: datetime | None = None
|
||||
payout_created_at: datetime | None = None
|
||||
payout_completed_at: datetime | None = None
|
||||
@@ -2,4 +2,5 @@ from src.application.domain.enums.log_level import LogLevel
|
||||
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
|
||||
from src.application.domain.enums.payment_status import PaymentStatus
|
||||
from src.application.domain.enums.sbp_withdrawal_status import SbpWithdrawalStatus
|
||||
14
src/application/domain/enums/sbp_withdrawal_status.py
Normal file
14
src/application/domain/enums/sbp_withdrawal_status.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class SbpWithdrawalStatus(str,Enum):
|
||||
CREATED='created'
|
||||
WAITING_USDT='waiting_usdt'
|
||||
USDT_UNDERPAID='usdt_underpaid'
|
||||
USDT_RECEIVED='usdt_received'
|
||||
WALLET_ERROR='wallet_error'
|
||||
PAYOUT_PROCESSING='payout_processing'
|
||||
PAYOUT_COMPLETED='payout_completed'
|
||||
PAYOUT_FAILED='payout_failed'
|
||||
CANCELLED='cancelled'
|
||||
EXPIRED='expired'
|
||||
@@ -1,4 +1,5 @@
|
||||
from src.application.services.payment_quote_service import PaymentQuote,PaymentQuoteService
|
||||
from src.application.services.sbp_withdrawal_quote_service import SbpWithdrawalQuote,SbpWithdrawalQuoteService
|
||||
|
||||
|
||||
__all__ = ['PaymentQuote', 'PaymentQuoteService']
|
||||
|
||||
64
src/application/services/sbp_withdrawal_quote_service.py
Normal file
64
src/application/services/sbp_withdrawal_quote_service.py
Normal file
@@ -0,0 +1,64 @@
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal,ROUND_DOWN,ROUND_UP
|
||||
from src.application.contracts import ICache,ILogger
|
||||
from src.application.domain.exceptions import OrderTotalOutOfRangeException,ServiceUnavailableException
|
||||
|
||||
MIN_SBP_WITHDRAWAL_USDT: Decimal=Decimal('100')
|
||||
MAX_SBP_WITHDRAWAL_USDT: Decimal=Decimal('5000')
|
||||
|
||||
_FEE_TIERS: tuple[tuple[Decimal,Decimal,Decimal],...]=(
|
||||
(Decimal('100'),Decimal('2000'),Decimal('0.08')),
|
||||
(Decimal('2000.01'),Decimal('4000'),Decimal('0.06')),
|
||||
(Decimal('4000.01'),Decimal('5000'),Decimal('0.04')),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SbpWithdrawalQuote:
|
||||
usdt_amount: Decimal
|
||||
rub_amount: Decimal
|
||||
usdt_exchange_rate: Decimal
|
||||
service_fee_rate: Decimal
|
||||
service_fee_usdt: Decimal
|
||||
|
||||
|
||||
class SbpWithdrawalQuoteService:
|
||||
def __init__(self,*,remote_cache: ICache,logger: ILogger):
|
||||
self._remote_cache=remote_cache
|
||||
self._logger=logger
|
||||
|
||||
|
||||
async def _load_rate(self) -> Decimal:
|
||||
rate_raw=await self._remote_cache.hget('tradex:rub:rate','value')
|
||||
self._logger.info(f'Actual market value for SBP withdrawal: rate={rate_raw}')
|
||||
if rate_raw is None:
|
||||
raise ServiceUnavailableException(message='Exchange rate unavailable')
|
||||
return Decimal(rate_raw).quantize(Decimal('0.00'),rounding=ROUND_UP)
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _fee_rate(usdt_amount: Decimal) -> Decimal:
|
||||
for lo,hi,rate in _FEE_TIERS:
|
||||
if lo<=usdt_amount<=hi:
|
||||
return rate
|
||||
raise OrderTotalOutOfRangeException(message='Withdrawal amount is out of range')
|
||||
|
||||
|
||||
async def get_quote(self,usdt_amount: Decimal) -> SbpWithdrawalQuote:
|
||||
if usdt_amount<MIN_SBP_WITHDRAWAL_USDT:
|
||||
raise OrderTotalOutOfRangeException(message='Withdrawal amount is below minimum allowed amount')
|
||||
if usdt_amount>MAX_SBP_WITHDRAWAL_USDT:
|
||||
raise OrderTotalOutOfRangeException(message='Withdrawal amount exceeds maximum allowed amount')
|
||||
rate=await self._load_rate()
|
||||
fee_rate=self._fee_rate(usdt_amount)
|
||||
service_fee=(usdt_amount*fee_rate).quantize(Decimal('0.01'))
|
||||
payout_usdt=(usdt_amount-service_fee).quantize(Decimal('0.01'))
|
||||
rub_amount=(payout_usdt*rate).quantize(Decimal('1'),rounding=ROUND_DOWN)
|
||||
return SbpWithdrawalQuote(
|
||||
usdt_amount=usdt_amount,
|
||||
rub_amount=rub_amount,
|
||||
usdt_exchange_rate=rate,
|
||||
service_fee_rate=fee_rate,
|
||||
service_fee_usdt=service_fee,
|
||||
)
|
||||
Reference in New Issue
Block a user