273 lines
11 KiB
Python
273 lines
11 KiB
Python
from __future__ import annotations
|
|
from datetime import datetime,timezone
|
|
from decimal import Decimal
|
|
from typing import Any
|
|
import re
|
|
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')
|
|
|
|
|
|
@staticmethod
|
|
def _normalize_russian_phone(phone: str | None) -> str:
|
|
digits=re.sub(r'\D','',phone or '')
|
|
if len(digits)!=11:
|
|
raise BadRequestException(message='Russian phone number is required for SBP withdrawal')
|
|
if digits.startswith('8'):
|
|
digits=f'7{digits[1:]}'
|
|
if not digits.startswith('7'):
|
|
raise BadRequestException(message='Russian phone number is required for SBP withdrawal')
|
|
return f'+{digits}'
|
|
|
|
|
|
@transactional
|
|
async def __call__(self,*,user_id: str,bank_id: 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')
|
|
phone=self._normalize_russian_phone(user.phone)
|
|
wallet_address=str(settings.CRYPTO_USDT_CONTRACT_ADDRESS or '').strip()
|
|
if not wallet_address:
|
|
raise ServiceUnavailableException(message='USDT contract address unavailable')
|
|
sender_wallet_address=str(user.erc20 or '').strip()
|
|
if not sender_wallet_address:
|
|
raise BadRequestException(message='User ERC20 wallet address is required for SBP withdrawal')
|
|
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,
|
|
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,
|
|
'sender_wallet_address':sender_wallet_address,
|
|
'receiver_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,
|
|
})
|