Files
pay-service/src/application/commands/sbp_withdrawal_commands.py
2026-06-21 15:00:59 +03:00

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