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