feat: add withdraw

This commit is contained in:
2026-06-21 14:16:20 +03:00
parent 8f60637e8d
commit 56841b83ce
32 changed files with 1291 additions and 44 deletions

View File

@@ -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: ...

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

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

View File

@@ -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

View 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

View File

@@ -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']

View 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

View File

@@ -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

View 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'

View File

@@ -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']

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