feat: add withdraw
This commit is contained in:
@@ -14,4 +14,44 @@ services:
|
|||||||
APP_WORKERS: "2"
|
APP_WORKERS: "2"
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
restart: no
|
depends_on:
|
||||||
|
pay_keydb:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: no
|
||||||
|
|
||||||
|
pay_keydb:
|
||||||
|
image: eqalpha/keydb
|
||||||
|
container_name: pay_keydb
|
||||||
|
restart: no
|
||||||
|
expose:
|
||||||
|
- "6379"
|
||||||
|
volumes:
|
||||||
|
- pay_keydb_data:/data
|
||||||
|
command:
|
||||||
|
- keydb-server
|
||||||
|
- --requirepass
|
||||||
|
- ${REDIS_PASSWORD}
|
||||||
|
- --dir
|
||||||
|
- /data
|
||||||
|
- --appendonly
|
||||||
|
- "yes"
|
||||||
|
- --appendfsync
|
||||||
|
- everysec
|
||||||
|
- --save
|
||||||
|
- "900"
|
||||||
|
- "1"
|
||||||
|
- --save
|
||||||
|
- "300"
|
||||||
|
- "10"
|
||||||
|
- --save
|
||||||
|
- "60"
|
||||||
|
- "10000"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 2s
|
||||||
|
retries: 20
|
||||||
|
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pay_keydb_data:
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from typing import Protocol, runtime_checkable
|
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
|
@runtime_checkable
|
||||||
@@ -17,6 +17,9 @@ class IUnitOfWork(Protocol):
|
|||||||
@property
|
@property
|
||||||
def payment_repository(self) -> IPaymentRepository: ...
|
def payment_repository(self) -> IPaymentRepository: ...
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sbp_withdrawal_repository(self) -> ISbpWithdrawalRepository: ...
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def user_repository(self) -> IUserRepository: ...
|
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_order_repository import IOrderRepository
|
||||||
from src.application.abstractions.repositories.i_payment_repository import IPaymentRepository
|
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
|
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_order_command import CreateOrderCommand
|
||||||
from src.application.commands.create_payment_command import CreatePaymentCommand
|
from src.application.commands.create_payment_command import CreatePaymentCommand
|
||||||
from src.application.commands.create_crypto_transfer_completed_command import CreateCryptoTransferCompletedCommand
|
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 __future__ import annotations
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime,timezone
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
from typing import Literal
|
||||||
from src.application.abstractions import IUnitOfWork
|
from src.application.abstractions import IUnitOfWork
|
||||||
from src.application.contracts import ILogger
|
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.domain.exceptions import NotFoundException
|
||||||
from src.application.services import PaymentQuote,PaymentQuoteService
|
from src.application.services import PaymentQuote,PaymentQuoteService
|
||||||
from src.infrastructure.database.decorators import transactional
|
from src.infrastructure.database.decorators import transactional
|
||||||
@@ -15,6 +17,14 @@ class OrderPaymentResult:
|
|||||||
payment: PaymentEntity | None
|
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:
|
class GetPaymentConfigCommand:
|
||||||
def __init__(self, *, payment_quote_service: PaymentQuoteService, logger: ILogger):
|
def __init__(self, *, payment_quote_service: PaymentQuoteService, logger: ILogger):
|
||||||
self._payment_quote_service = payment_quote_service
|
self._payment_quote_service = payment_quote_service
|
||||||
@@ -91,6 +101,39 @@ class ListPaymentsCommand:
|
|||||||
return payments
|
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:
|
class GetPaymentCommand:
|
||||||
def __init__(self, *, unit_of_work: IUnitOfWork, logger: ILogger):
|
def __init__(self, *, unit_of_work: IUnitOfWork, logger: ILogger):
|
||||||
self._unit_of_work = unit_of_work
|
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_hash_service import IHashService
|
||||||
from src.application.contracts.i_queue_messanger import IQueueMessanger
|
from src.application.contracts.i_queue_messanger import IQueueMessanger
|
||||||
from src.application.contracts.i_itpay_service import IItPayService
|
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.order import OrderEntity
|
||||||
from src.application.domain.entities.payment import PaymentEntity
|
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.log_format import LogFormat
|
||||||
from src.application.domain.enums.itpay_payment_status import ItPayPaymentStatus
|
from src.application.domain.enums.itpay_payment_status import ItPayPaymentStatus
|
||||||
from src.application.domain.enums.order_status import OrderStatus
|
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.payment_quote_service import PaymentQuote,PaymentQuoteService
|
||||||
|
from src.application.services.sbp_withdrawal_quote_service import SbpWithdrawalQuote,SbpWithdrawalQuoteService
|
||||||
|
|
||||||
|
|
||||||
__all__ = ['PaymentQuote', 'PaymentQuoteService']
|
__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,
|
||||||
|
)
|
||||||
@@ -90,10 +90,21 @@ class Settings(BaseSettings):
|
|||||||
RABBIT_EMAIL_CODE_QUEUE: str = "email.verification_code"
|
RABBIT_EMAIL_CODE_QUEUE: str = "email.verification_code"
|
||||||
RABBIT_CRYPTO_TRANSFER_QUEUE: str = "crypto.transfer.requested"
|
RABBIT_CRYPTO_TRANSFER_QUEUE: str = "crypto.transfer.requested"
|
||||||
RABBIT_CRYPTO_TRANSFER_COMPLETED_QUEUE: str = "crypto.transfer.completed"
|
RABBIT_CRYPTO_TRANSFER_COMPLETED_QUEUE: str = "crypto.transfer.completed"
|
||||||
|
RABBIT_SBP_WITHDRAWAL_USDT_REQUESTED_QUEUE: str = 'sbp.withdrawal.usdt.requested'
|
||||||
|
RABBIT_SBP_WITHDRAWAL_WALLET_EVENTS_QUEUE: str = 'sbp.withdrawal.wallet.events'
|
||||||
|
|
||||||
ITPAY_PUBLIC_ID: str
|
ITPAY_PUBLIC_ID: str
|
||||||
ITPAY_API_SECRET: str
|
ITPAY_API_SECRET: str
|
||||||
|
|
||||||
|
MOZEN_API_BASE_URL: str = 'https://extgw.mozen.io'
|
||||||
|
MOZEN_API_SECRET: str = ''
|
||||||
|
MOZEN_CLIENT_ID: str = ''
|
||||||
|
MOZEN_ENDPOINT_ID: str = ''
|
||||||
|
MOZEN_MERCHANT_ID: str = ''
|
||||||
|
MOZEN_PARK_ID: str = ''
|
||||||
|
MOZEN_PARK_TOKEN: str = ''
|
||||||
|
SBP_WITHDRAWAL_WALLET_ADDRESS: str = ''
|
||||||
|
|
||||||
CLOUD_KASSIR_PUBLIC_ID: str = ''
|
CLOUD_KASSIR_PUBLIC_ID: str = ''
|
||||||
CLOUD_KASSIR_API_SECRET: str = ''
|
CLOUD_KASSIR_API_SECRET: str = ''
|
||||||
|
|
||||||
@@ -210,6 +221,28 @@ class Settings(BaseSettings):
|
|||||||
rb_set('password', 'RABBIT_PASSWORD')
|
rb_set('password', 'RABBIT_PASSWORD')
|
||||||
rb_set('vhost', 'RABBIT_VHOST')
|
rb_set('vhost', 'RABBIT_VHOST')
|
||||||
|
|
||||||
|
extgw = read_secret_optional('extgw')
|
||||||
|
if extgw:
|
||||||
|
extgw_ci = {str(k).lower(): v for k, v in extgw.items()}
|
||||||
|
|
||||||
|
def extgw_set(field: str, env_key: str) -> None:
|
||||||
|
v = extgw_ci.get(field)
|
||||||
|
if v is None:
|
||||||
|
return
|
||||||
|
if isinstance(v, str) and not v.strip():
|
||||||
|
return
|
||||||
|
data[env_key] = str(v).strip()
|
||||||
|
|
||||||
|
extgw_set('api_base_url', 'MOZEN_API_BASE_URL')
|
||||||
|
extgw_set('api_secret', 'MOZEN_API_SECRET')
|
||||||
|
extgw_set('client_id', 'MOZEN_CLIENT_ID')
|
||||||
|
extgw_set('endpoint_id', 'MOZEN_ENDPOINT_ID')
|
||||||
|
extgw_set('merchant_id', 'MOZEN_MERCHANT_ID')
|
||||||
|
extgw_set('park_id', 'MOZEN_PARK_ID')
|
||||||
|
extgw_set('park_token', 'MOZEN_PARK_TOKEN')
|
||||||
|
extgw_set('wallet_address', 'SBP_WITHDRAWAL_WALLET_ADDRESS')
|
||||||
|
extgw_set('usdt_wallet_address', 'SBP_WITHDRAWAL_WALLET_ADDRESS')
|
||||||
|
|
||||||
keydb = read_secret('keydb')
|
keydb = read_secret('keydb')
|
||||||
k_ci = {str(k).lower(): v for k, v in keydb.items()}
|
k_ci = {str(k).lower(): v for k, v in keydb.items()}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
from src.infrastructure.database.models.base import Base
|
from src.infrastructure.database.models.base import Base
|
||||||
from src.infrastructure.database.models.order import Order
|
from src.infrastructure.database.models.order import Order
|
||||||
from src.infrastructure.database.models.payment import Payment
|
from src.infrastructure.database.models.payment import Payment
|
||||||
|
from src.infrastructure.database.models.sbp_withdrawal import SbpWithdrawal
|
||||||
from src.infrastructure.database.models.user import UserModel
|
from src.infrastructure.database.models.user import UserModel
|
||||||
|
|
||||||
|
|
||||||
__all__ = ['Base','Order','Payment','UserModel']
|
__all__ = ['Base','Order','Payment','SbpWithdrawal','UserModel']
|
||||||
|
|||||||
40
src/infrastructure/database/models/sbp_withdrawal.py
Normal file
40
src/infrastructure/database/models/sbp_withdrawal.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
from sqlalchemy import DateTime,Enum as SAEnum,ForeignKey,Numeric,String,Text
|
||||||
|
from sqlalchemy.orm import Mapped,mapped_column
|
||||||
|
from src.application.domain.enums import SbpWithdrawalStatus
|
||||||
|
from src.infrastructure.database.models.base import Base
|
||||||
|
from src.infrastructure.database.models.mixins import AuditTimestampsMixin,UlidPrimaryKeyMixin
|
||||||
|
|
||||||
|
|
||||||
|
class SbpWithdrawal(Base,UlidPrimaryKeyMixin,AuditTimestampsMixin):
|
||||||
|
__tablename__='sbp_withdrawals'
|
||||||
|
|
||||||
|
user_id: Mapped[str]=mapped_column(
|
||||||
|
String(26),
|
||||||
|
ForeignKey('users.id',ondelete='RESTRICT'),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
bank_id: Mapped[str]=mapped_column(String(32),nullable=False,index=True)
|
||||||
|
bank_name: Mapped[str]=mapped_column(String(255),nullable=False)
|
||||||
|
phone: Mapped[str]=mapped_column(String(32),nullable=False)
|
||||||
|
wallet_address: Mapped[str]=mapped_column(String(255),nullable=False)
|
||||||
|
usdt_amount: Mapped[Decimal]=mapped_column(Numeric(38,2),nullable=False)
|
||||||
|
rub_amount: Mapped[Decimal]=mapped_column(Numeric(38,2),nullable=False)
|
||||||
|
usdt_exchange_rate: Mapped[Decimal]=mapped_column(Numeric(38,2),nullable=False)
|
||||||
|
service_fee_rate: Mapped[Decimal]=mapped_column(Numeric(8,4),nullable=False)
|
||||||
|
service_fee_usdt: Mapped[Decimal]=mapped_column(Numeric(38,2),nullable=False)
|
||||||
|
mozen_order_id: Mapped[str | None]=mapped_column(String(128),nullable=True,index=True)
|
||||||
|
trace_id: Mapped[str]=mapped_column(String(64),nullable=False,index=True)
|
||||||
|
status: Mapped[SbpWithdrawalStatus]=mapped_column(
|
||||||
|
SAEnum(SbpWithdrawalStatus,name='sbp_withdrawal_status_enum',values_callable=lambda x:[e.value for e in x]),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
default=SbpWithdrawalStatus.CREATED,
|
||||||
|
)
|
||||||
|
provider_error: Mapped[str | None]=mapped_column(Text,nullable=True)
|
||||||
|
usdt_received_at: Mapped[datetime | None]=mapped_column(DateTime(timezone=True),nullable=True)
|
||||||
|
payout_created_at: Mapped[datetime | None]=mapped_column(DateTime(timezone=True),nullable=True)
|
||||||
|
payout_completed_at: Mapped[datetime | None]=mapped_column(DateTime(timezone=True),nullable=True)
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from dataclasses import replace
|
||||||
|
from datetime import datetime,timezone
|
||||||
|
from sqlalchemy import desc,select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from src.application.abstractions.repositories import ISbpWithdrawalRepository
|
||||||
|
from src.application.contracts import ILogger
|
||||||
|
from src.application.domain.entities import SbpWithdrawalEntity
|
||||||
|
from src.application.domain.enums import SbpWithdrawalStatus
|
||||||
|
from src.infrastructure.database.models.sbp_withdrawal import SbpWithdrawal
|
||||||
|
|
||||||
|
|
||||||
|
class SbpWithdrawalRepository(ISbpWithdrawalRepository):
|
||||||
|
def __init__(self,session: AsyncSession,logger: ILogger):
|
||||||
|
self._session=session
|
||||||
|
self._logger=logger
|
||||||
|
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _to_entity(model: SbpWithdrawal) -> SbpWithdrawalEntity:
|
||||||
|
return SbpWithdrawalEntity(
|
||||||
|
id=model.id,
|
||||||
|
created_at=model.created_at,
|
||||||
|
updated_at=model.updated_at,
|
||||||
|
user_id=model.user_id,
|
||||||
|
bank_id=model.bank_id,
|
||||||
|
bank_name=model.bank_name,
|
||||||
|
phone=model.phone,
|
||||||
|
wallet_address=model.wallet_address,
|
||||||
|
usdt_amount=model.usdt_amount,
|
||||||
|
rub_amount=model.rub_amount,
|
||||||
|
usdt_exchange_rate=model.usdt_exchange_rate,
|
||||||
|
service_fee_rate=model.service_fee_rate,
|
||||||
|
service_fee_usdt=model.service_fee_usdt,
|
||||||
|
mozen_order_id=model.mozen_order_id,
|
||||||
|
trace_id=model.trace_id,
|
||||||
|
status=model.status,
|
||||||
|
provider_error=model.provider_error,
|
||||||
|
usdt_received_at=model.usdt_received_at,
|
||||||
|
payout_created_at=model.payout_created_at,
|
||||||
|
payout_completed_at=model.payout_completed_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def create(self,withdrawal: SbpWithdrawalEntity) -> SbpWithdrawalEntity:
|
||||||
|
values={
|
||||||
|
'user_id':withdrawal.user_id,
|
||||||
|
'bank_id':withdrawal.bank_id,
|
||||||
|
'bank_name':withdrawal.bank_name,
|
||||||
|
'phone':withdrawal.phone,
|
||||||
|
'wallet_address':withdrawal.wallet_address,
|
||||||
|
'usdt_amount':withdrawal.usdt_amount,
|
||||||
|
'rub_amount':withdrawal.rub_amount,
|
||||||
|
'usdt_exchange_rate':withdrawal.usdt_exchange_rate,
|
||||||
|
'service_fee_rate':withdrawal.service_fee_rate,
|
||||||
|
'service_fee_usdt':withdrawal.service_fee_usdt,
|
||||||
|
'mozen_order_id':withdrawal.mozen_order_id,
|
||||||
|
'trace_id':withdrawal.trace_id,
|
||||||
|
'status':withdrawal.status or SbpWithdrawalStatus.CREATED,
|
||||||
|
'provider_error':withdrawal.provider_error,
|
||||||
|
'usdt_received_at':withdrawal.usdt_received_at,
|
||||||
|
'payout_created_at':withdrawal.payout_created_at,
|
||||||
|
'payout_completed_at':withdrawal.payout_completed_at,
|
||||||
|
}
|
||||||
|
if withdrawal.id is not None:
|
||||||
|
values['id']=withdrawal.id
|
||||||
|
model=SbpWithdrawal(**values)
|
||||||
|
self._session.add(model)
|
||||||
|
await self._session.flush()
|
||||||
|
return replace(withdrawal,id=model.id,created_at=model.created_at,updated_at=model.updated_at)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_by_id(self,withdraw_id: str) -> SbpWithdrawalEntity | None:
|
||||||
|
stmt=select(SbpWithdrawal).where(SbpWithdrawal.id==withdraw_id)
|
||||||
|
model=await self._session.scalar(stmt)
|
||||||
|
if model is None:
|
||||||
|
return None
|
||||||
|
return self._to_entity(model)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_by_id_for_user(self,*,withdraw_id: str,user_id: str) -> SbpWithdrawalEntity | None:
|
||||||
|
stmt=select(SbpWithdrawal).where(SbpWithdrawal.id==withdraw_id,SbpWithdrawal.user_id==user_id)
|
||||||
|
model=await self._session.scalar(stmt)
|
||||||
|
if model is None:
|
||||||
|
return None
|
||||||
|
return self._to_entity(model)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_by_user_id(self,*,user_id: str,limit: int,offset: int) -> list[SbpWithdrawalEntity]:
|
||||||
|
stmt=(
|
||||||
|
select(SbpWithdrawal)
|
||||||
|
.where(SbpWithdrawal.user_id==user_id)
|
||||||
|
.order_by(desc(SbpWithdrawal.created_at))
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset)
|
||||||
|
)
|
||||||
|
result=await self._session.scalars(stmt)
|
||||||
|
return [self._to_entity(model) for model in result.all()]
|
||||||
|
|
||||||
|
|
||||||
|
async def update_status(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
withdraw_id: str,
|
||||||
|
status: SbpWithdrawalStatus,
|
||||||
|
provider_error: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
model=await self._session.get(SbpWithdrawal,withdraw_id)
|
||||||
|
if model is None:
|
||||||
|
return
|
||||||
|
model.status=status
|
||||||
|
model.provider_error=provider_error
|
||||||
|
if status==SbpWithdrawalStatus.USDT_RECEIVED:
|
||||||
|
model.usdt_received_at=datetime.now(timezone.utc)
|
||||||
|
if status==SbpWithdrawalStatus.PAYOUT_PROCESSING:
|
||||||
|
model.payout_created_at=datetime.now(timezone.utc)
|
||||||
|
if status==SbpWithdrawalStatus.PAYOUT_COMPLETED:
|
||||||
|
model.payout_completed_at=datetime.now(timezone.utc)
|
||||||
|
await self._session.flush()
|
||||||
|
|
||||||
|
|
||||||
|
async def update_payout_created(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
withdraw_id: str,
|
||||||
|
mozen_order_id: str | None,
|
||||||
|
status: SbpWithdrawalStatus,
|
||||||
|
provider_error: str | None,
|
||||||
|
) -> None:
|
||||||
|
model=await self._session.get(SbpWithdrawal,withdraw_id)
|
||||||
|
if model is None:
|
||||||
|
return
|
||||||
|
model.mozen_order_id=mozen_order_id
|
||||||
|
model.status=status
|
||||||
|
model.provider_error=provider_error
|
||||||
|
model.payout_created_at=datetime.now(timezone.utc)
|
||||||
|
if status==SbpWithdrawalStatus.PAYOUT_COMPLETED:
|
||||||
|
model.payout_completed_at=datetime.now(timezone.utc)
|
||||||
|
await self._session.flush()
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||||
from src.application.abstractions import IUnitOfWork
|
from src.application.abstractions import IUnitOfWork
|
||||||
from src.application.abstractions.repositories import IOrderRepository,IPaymentRepository,IUserRepository
|
from src.application.abstractions.repositories import IOrderRepository,IPaymentRepository,ISbpWithdrawalRepository,IUserRepository
|
||||||
from src.application.contracts import ILogger
|
from src.application.contracts import ILogger
|
||||||
from src.infrastructure.database.repositories.order_repository import OrderRepository
|
from src.infrastructure.database.repositories.order_repository import OrderRepository
|
||||||
from src.infrastructure.database.repositories.payment_repository import PaymentRepository
|
from src.infrastructure.database.repositories.payment_repository import PaymentRepository
|
||||||
|
from src.infrastructure.database.repositories.sbp_withdrawal_repository import SbpWithdrawalRepository
|
||||||
from src.infrastructure.database.repositories.user_repository import UserRepository
|
from src.infrastructure.database.repositories.user_repository import UserRepository
|
||||||
|
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ class UnitOfWork(IUnitOfWork):
|
|||||||
self._session: AsyncSession = None
|
self._session: AsyncSession = None
|
||||||
self._order_repository: IOrderRepository | None = None
|
self._order_repository: IOrderRepository | None = None
|
||||||
self._payment_repository: IPaymentRepository | None = None
|
self._payment_repository: IPaymentRepository | None = None
|
||||||
|
self._sbp_withdrawal_repository: ISbpWithdrawalRepository | None = None
|
||||||
self._user_repository: IUserRepository | None = None
|
self._user_repository: IUserRepository | None = None
|
||||||
self._logger: ILogger = logger
|
self._logger: ILogger = logger
|
||||||
|
|
||||||
@@ -21,6 +23,7 @@ class UnitOfWork(IUnitOfWork):
|
|||||||
self._session = self.session_factory()
|
self._session = self.session_factory()
|
||||||
self._order_repository = None
|
self._order_repository = None
|
||||||
self._payment_repository = None
|
self._payment_repository = None
|
||||||
|
self._sbp_withdrawal_repository = None
|
||||||
self._user_repository = None
|
self._user_repository = None
|
||||||
return self
|
return self
|
||||||
|
|
||||||
@@ -49,6 +52,13 @@ class UnitOfWork(IUnitOfWork):
|
|||||||
return self._payment_repository
|
return self._payment_repository
|
||||||
|
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sbp_withdrawal_repository(self) -> ISbpWithdrawalRepository:
|
||||||
|
if self._sbp_withdrawal_repository is None:
|
||||||
|
self._sbp_withdrawal_repository = SbpWithdrawalRepository(session=self._session,logger=self._logger)
|
||||||
|
return self._sbp_withdrawal_repository
|
||||||
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def user_repository(self) -> IUserRepository:
|
def user_repository(self) -> IUserRepository:
|
||||||
if self._user_repository is None:
|
if self._user_repository is None:
|
||||||
|
|||||||
4
src/infrastructure/mozen/__init__.py
Normal file
4
src/infrastructure/mozen/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
from src.infrastructure.mozen.sbp_client import MozenSbpClient
|
||||||
|
|
||||||
|
|
||||||
|
__all__=['MozenSbpClient']
|
||||||
115
src/infrastructure/mozen/sbp_client.py
Normal file
115
src/infrastructure/mozen/sbp_client.py
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from decimal import Decimal,ROUND_DOWN
|
||||||
|
from typing import Any
|
||||||
|
import aiohttp
|
||||||
|
import orjson
|
||||||
|
from aiohttp import ClientTimeout
|
||||||
|
from src.application.contracts import IMozenSbpService,SbpBank,SbpPayoutResult
|
||||||
|
from src.application.domain.exceptions import PaymentProviderException,ServiceUnavailableException
|
||||||
|
|
||||||
|
|
||||||
|
class MozenSbpClient(IMozenSbpService):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
api_base_url: str,
|
||||||
|
client_id: str,
|
||||||
|
park_token: str,
|
||||||
|
timeout_seconds: float = 30,
|
||||||
|
) -> None:
|
||||||
|
self._api_base_url=api_base_url.rstrip('/')
|
||||||
|
self._client_id=client_id
|
||||||
|
self._park_token=park_token
|
||||||
|
self._timeout=ClientTimeout(total=timeout_seconds)
|
||||||
|
|
||||||
|
|
||||||
|
def _auth_headers(self,trace_id: str) -> dict[str,str]:
|
||||||
|
if not self._client_id or not self._park_token:
|
||||||
|
raise ServiceUnavailableException(message='Mozen SBP credentials unavailable')
|
||||||
|
return {
|
||||||
|
'Accept':'application/json',
|
||||||
|
'Content-Type':'application/json',
|
||||||
|
'x-trace-id':trace_id,
|
||||||
|
'x-client-id':self._client_id,
|
||||||
|
'park-token':self._park_token,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_banks(self,trace_id: str) -> list[SbpBank]:
|
||||||
|
url=f'{self._api_base_url}/api/v2/payout/sbp/banks'
|
||||||
|
headers={'Accept':'application/json','x-trace-id':trace_id}
|
||||||
|
try:
|
||||||
|
async with aiohttp.ClientSession(timeout=self._timeout) as session:
|
||||||
|
async with session.get(url,headers=headers) as resp:
|
||||||
|
response_text=await resp.text()
|
||||||
|
try:
|
||||||
|
response_json=orjson.loads(response_text)
|
||||||
|
except orjson.JSONDecodeError:
|
||||||
|
response_json=[]
|
||||||
|
if resp.status>=400:
|
||||||
|
raise PaymentProviderException(message='Mozen SBP banks provider error')
|
||||||
|
if not isinstance(response_json,list):
|
||||||
|
raise PaymentProviderException(message='Mozen SBP banks response invalid')
|
||||||
|
banks: list[SbpBank]=[]
|
||||||
|
for item in response_json:
|
||||||
|
if not isinstance(item,dict):
|
||||||
|
continue
|
||||||
|
bank_id=str(item.get('id') or '').strip()
|
||||||
|
name=str(item.get('name') or '').strip()
|
||||||
|
if bank_id and name:
|
||||||
|
banks.append(SbpBank(id=bank_id,name=name))
|
||||||
|
return banks
|
||||||
|
except PaymentProviderException:
|
||||||
|
raise
|
||||||
|
except aiohttp.ClientError:
|
||||||
|
raise PaymentProviderException(message='Mozen SBP banks provider unreachable')
|
||||||
|
|
||||||
|
|
||||||
|
async def make_withdrawal(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
user_id: str,
|
||||||
|
withdraw_id: str,
|
||||||
|
phone: str,
|
||||||
|
bank_id: str,
|
||||||
|
amount: Decimal,
|
||||||
|
trace_id: str,
|
||||||
|
) -> SbpPayoutResult:
|
||||||
|
amount_int=int(amount.quantize(Decimal('1'),rounding=ROUND_DOWN))
|
||||||
|
payload: dict[str,Any]={
|
||||||
|
'amount':amount_int,
|
||||||
|
'order_id':withdraw_id,
|
||||||
|
'phone':phone,
|
||||||
|
'bank_id':bank_id,
|
||||||
|
'description':'SBP withdrawal',
|
||||||
|
'user_id':user_id,
|
||||||
|
}
|
||||||
|
url=f'{self._api_base_url}/api/v2/payout/sbp/withdrawal'
|
||||||
|
try:
|
||||||
|
async with aiohttp.ClientSession(timeout=self._timeout) as session:
|
||||||
|
async with session.post(url,json=payload,headers=self._auth_headers(trace_id)) as resp:
|
||||||
|
response_text=await resp.text()
|
||||||
|
try:
|
||||||
|
response_json=orjson.loads(response_text)
|
||||||
|
except orjson.JSONDecodeError:
|
||||||
|
response_json={'raw':response_text}
|
||||||
|
if resp.status>=400:
|
||||||
|
raise PaymentProviderException(message='Mozen SBP withdrawal provider error')
|
||||||
|
if not isinstance(response_json,dict):
|
||||||
|
raise PaymentProviderException(message='Mozen SBP withdrawal response invalid')
|
||||||
|
error=response_json.get('error')
|
||||||
|
if isinstance(error,dict):
|
||||||
|
error_message=str(error.get('message') or error.get('code') or '').strip() or 'Mozen SBP withdrawal rejected'
|
||||||
|
return SbpPayoutResult(status=str(response_json.get('status') or 'error'),error=error_message)
|
||||||
|
request=response_json.get('withdrawal_request')
|
||||||
|
mozen_order_id=None
|
||||||
|
if isinstance(request,dict):
|
||||||
|
mozen_order_id=str(request.get('id') or '').strip() or None
|
||||||
|
return SbpPayoutResult(
|
||||||
|
status=str(response_json.get('status') or '').strip(),
|
||||||
|
mozen_order_id=mozen_order_id,
|
||||||
|
)
|
||||||
|
except PaymentProviderException:
|
||||||
|
raise
|
||||||
|
except aiohttp.ClientError:
|
||||||
|
raise PaymentProviderException(message='Mozen SBP withdrawal provider unreachable')
|
||||||
@@ -15,9 +15,9 @@ from src.infrastructure.utils import generate_instance_id
|
|||||||
from src.infrastructure.logger import logger
|
from src.infrastructure.logger import logger
|
||||||
from src.infrastructure.config import settings
|
from src.infrastructure.config import settings
|
||||||
from src.presentation.handler import application_exception_handler, unhandled_exception_handler
|
from src.presentation.handler import application_exception_handler, unhandled_exception_handler
|
||||||
from src.presentation.messaging import crypto_transfer_router
|
from src.presentation.messaging import crypto_transfer_router,sbp_withdrawal_messaging_router
|
||||||
from src.presentation.middleware import TraceIDMiddleware, SecurityHeadersMiddleware
|
from src.presentation.middleware import TraceIDMiddleware, SecurityHeadersMiddleware
|
||||||
from src.presentation.routing import order_router,orders_router,payment_router,payments_router
|
from src.presentation.routing import order_router,orders_router,payment_router,payments_router,sbp_withdrawal_router
|
||||||
|
|
||||||
security = HTTPBasic()
|
security = HTTPBasic()
|
||||||
|
|
||||||
@@ -78,7 +78,9 @@ app.include_router(order_router)
|
|||||||
app.include_router(orders_router)
|
app.include_router(orders_router)
|
||||||
app.include_router(payment_router)
|
app.include_router(payment_router)
|
||||||
app.include_router(payments_router)
|
app.include_router(payments_router)
|
||||||
|
app.include_router(sbp_withdrawal_router)
|
||||||
app.include_router(crypto_transfer_router)
|
app.include_router(crypto_transfer_router)
|
||||||
|
app.include_router(sbp_withdrawal_messaging_router)
|
||||||
|
|
||||||
|
|
||||||
# Added middleware
|
# Added middleware
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
import functools
|
import functools
|
||||||
|
import orjson
|
||||||
from typing import Any,Awaitable,Callable
|
from typing import Any,Awaitable,Callable
|
||||||
|
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
from fastapi.responses import ORJSONResponse
|
from fastapi.responses import ORJSONResponse
|
||||||
|
|
||||||
from src.infrastructure.logger import get_logger
|
from src.infrastructure.logger import get_logger
|
||||||
from src.presentation.dependencies.cache import get_cache_remote
|
from src.presentation.dependencies.cache import get_cache_remote
|
||||||
|
|
||||||
|
|
||||||
def cached(*, prefix: str) -> Callable:
|
def _to_cache_payload(value: Any) -> Any:
|
||||||
|
if hasattr(value,'model_dump'):
|
||||||
|
return value.model_dump(mode='json')
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def cached(*,prefix: str,ttl: int = 300,user_scoped: bool = True) -> Callable:
|
||||||
def decorator(func: Callable[..., Awaitable[Any]]):
|
def decorator(func: Callable[..., Awaitable[Any]]):
|
||||||
@functools.wraps(func)
|
@functools.wraps(func)
|
||||||
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||||
@@ -29,21 +30,34 @@ def cached(*, prefix: str) -> Callable:
|
|||||||
auth = kwargs.get('auth')
|
auth = kwargs.get('auth')
|
||||||
user_id = getattr(auth, 'user_id', None) if auth else None
|
user_id = getattr(auth, 'user_id', None) if auth else None
|
||||||
|
|
||||||
if request is None or user_id is None:
|
if request is None:
|
||||||
return await func(*args, **kwargs)
|
return await func(*args, **kwargs)
|
||||||
|
|
||||||
cache_key = f'{prefix}:{user_id}'
|
if user_scoped:
|
||||||
|
if user_id is None:
|
||||||
|
return await func(*args, **kwargs)
|
||||||
|
cache_key = f'{prefix}:{user_id}'
|
||||||
|
else:
|
||||||
|
cache_key = prefix
|
||||||
|
|
||||||
try:
|
try:
|
||||||
cache = get_cache_remote(request)
|
cache = get_cache_remote(request)
|
||||||
hit = await cache.get_user(user_id)
|
hit = await cache.get(cache_key)
|
||||||
if hit is not None:
|
if hit is not None:
|
||||||
logger.debug(f'Cache hit key={cache_key}')
|
logger.debug(f'Cache hit key={cache_key}')
|
||||||
return ORJSONResponse(status_code=200, content=hit)
|
return ORJSONResponse(status_code=200,content=orjson.loads(hit))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f'Cache read failed key={cache_key} error={e}')
|
logger.warning(f'Cache read failed key={cache_key} error={e}')
|
||||||
|
|
||||||
return await func(*args, **kwargs)
|
result = await func(*args, **kwargs)
|
||||||
|
try:
|
||||||
|
cache = get_cache_remote(request)
|
||||||
|
payload = _to_cache_payload(result)
|
||||||
|
await cache.set(cache_key,orjson.dumps(payload).decode(),ttl)
|
||||||
|
logger.debug(f'Cache set key={cache_key} ttl={ttl}')
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f'Cache write failed key={cache_key} error={e}')
|
||||||
|
return result
|
||||||
|
|
||||||
return wrapper
|
return wrapper
|
||||||
return decorator
|
return decorator
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from fastapi import Depends
|
from fastapi import Depends
|
||||||
from src.application.abstractions import IUnitOfWork
|
from src.application.abstractions import IUnitOfWork
|
||||||
from src.application.commands import CreateCryptoTransferCompletedCommand,CreateOrderCommand,CreatePaymentCommand,GetOrderCommand,GetOrderStatusCommand,GetPaymentCommand,GetPaymentConfigCommand,GetPaymentQuoteCommand,GetPaymentQuoteFromRubCommand,ListOrdersCommand,ListPaymentsCommand
|
from src.application.commands import CreateCryptoTransferCompletedCommand,CreateOrderCommand,CreatePaymentCommand,CreateSbpWithdrawalCommand,GetOrderCommand,GetOrderStatusCommand,GetPaymentCommand,GetPaymentConfigCommand,GetPaymentQuoteCommand,GetPaymentQuoteFromRubCommand,GetSbpBanksCommand,GetSbpWithdrawalCommand,HandleSbpWithdrawalWalletEventCommand,ListClientOperationsCommand,ListOrdersCommand,ListPaymentsCommand
|
||||||
from src.application.contracts import ICache,ILogger,IQueueMessanger,IReceipt
|
from src.application.contracts import ICache,ILogger,IMozenSbpService,IQueueMessanger,IReceipt
|
||||||
from src.application.contracts.i_itpay_service import IItPayService
|
from src.application.contracts.i_itpay_service import IItPayService
|
||||||
from src.application.services import PaymentQuoteService
|
from src.application.services import PaymentQuoteService,SbpWithdrawalQuoteService
|
||||||
from src.infrastructure.cloud_kassir import ClaudeKassirClient
|
from src.infrastructure.cloud_kassir import ClaudeKassirClient
|
||||||
from src.infrastructure.cloud_kassir.constants import CLOUD_KASSIR_API_BASE_URL,CLOUD_KASSIR_FAIL_URL,CLOUD_KASSIR_INN,CLOUD_KASSIR_SUCCESS_URL
|
from src.infrastructure.cloud_kassir.constants import CLOUD_KASSIR_API_BASE_URL,CLOUD_KASSIR_FAIL_URL,CLOUD_KASSIR_INN,CLOUD_KASSIR_SUCCESS_URL
|
||||||
from src.infrastructure.config import settings
|
from src.infrastructure.config import settings
|
||||||
from src.infrastructure.itpay.client import ItPayClient
|
from src.infrastructure.itpay.client import ItPayClient
|
||||||
|
from src.infrastructure.mozen import MozenSbpClient
|
||||||
from src.presentation.dependencies.cache import get_remote_cache
|
from src.presentation.dependencies.cache import get_remote_cache
|
||||||
from src.presentation.dependencies.logger import get_logger
|
from src.presentation.dependencies.logger import get_logger
|
||||||
from src.presentation.dependencies.queue_messanger import get_rabbit
|
from src.presentation.dependencies.queue_messanger import get_rabbit
|
||||||
@@ -23,6 +24,14 @@ def get_itpay_service() -> IItPayService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_mozen_sbp_service() -> IMozenSbpService:
|
||||||
|
return MozenSbpClient(
|
||||||
|
api_base_url=settings.MOZEN_API_BASE_URL,
|
||||||
|
client_id=settings.MOZEN_CLIENT_ID,
|
||||||
|
park_token=settings.MOZEN_PARK_TOKEN,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_create_order_command(
|
def get_create_order_command(
|
||||||
logger: ILogger = Depends(get_logger),
|
logger: ILogger = Depends(get_logger),
|
||||||
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
|
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
|
||||||
@@ -45,6 +54,43 @@ def get_payment_quote_service(
|
|||||||
return PaymentQuoteService(remote_cache=remote_cache,logger=logger)
|
return PaymentQuoteService(remote_cache=remote_cache,logger=logger)
|
||||||
|
|
||||||
|
|
||||||
|
def get_sbp_withdrawal_quote_service(
|
||||||
|
logger: ILogger = Depends(get_logger),
|
||||||
|
remote_cache: ICache = Depends(get_remote_cache),
|
||||||
|
) -> SbpWithdrawalQuoteService:
|
||||||
|
return SbpWithdrawalQuoteService(remote_cache=remote_cache,logger=logger)
|
||||||
|
|
||||||
|
|
||||||
|
def get_sbp_banks_command(
|
||||||
|
logger: ILogger = Depends(get_logger),
|
||||||
|
mozen_sbp_service: IMozenSbpService = Depends(get_mozen_sbp_service),
|
||||||
|
) -> GetSbpBanksCommand:
|
||||||
|
return GetSbpBanksCommand(mozen_sbp_service=mozen_sbp_service,logger=logger)
|
||||||
|
|
||||||
|
|
||||||
|
def get_create_sbp_withdrawal_command(
|
||||||
|
logger: ILogger = Depends(get_logger),
|
||||||
|
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
|
||||||
|
queue_messanger: IQueueMessanger = Depends(get_rabbit),
|
||||||
|
mozen_sbp_service: IMozenSbpService = Depends(get_mozen_sbp_service),
|
||||||
|
quote_service: SbpWithdrawalQuoteService = Depends(get_sbp_withdrawal_quote_service),
|
||||||
|
) -> CreateSbpWithdrawalCommand:
|
||||||
|
return CreateSbpWithdrawalCommand(
|
||||||
|
unit_of_work=unit_of_work,
|
||||||
|
logger=logger,
|
||||||
|
queue_messanger=queue_messanger,
|
||||||
|
mozen_sbp_service=mozen_sbp_service,
|
||||||
|
quote_service=quote_service,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_sbp_withdrawal_command(
|
||||||
|
logger: ILogger = Depends(get_logger),
|
||||||
|
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
|
||||||
|
) -> GetSbpWithdrawalCommand:
|
||||||
|
return GetSbpWithdrawalCommand(unit_of_work=unit_of_work,logger=logger)
|
||||||
|
|
||||||
|
|
||||||
def get_payment_config_command(
|
def get_payment_config_command(
|
||||||
logger: ILogger = Depends(get_logger),
|
logger: ILogger = Depends(get_logger),
|
||||||
payment_quote_service: PaymentQuoteService = Depends(get_payment_quote_service),
|
payment_quote_service: PaymentQuoteService = Depends(get_payment_quote_service),
|
||||||
@@ -80,6 +126,13 @@ def get_list_payments_command(
|
|||||||
return ListPaymentsCommand(unit_of_work=unit_of_work,logger=logger)
|
return ListPaymentsCommand(unit_of_work=unit_of_work,logger=logger)
|
||||||
|
|
||||||
|
|
||||||
|
def get_list_client_operations_command(
|
||||||
|
logger: ILogger = Depends(get_logger),
|
||||||
|
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
|
||||||
|
) -> ListClientOperationsCommand:
|
||||||
|
return ListClientOperationsCommand(unit_of_work=unit_of_work,logger=logger)
|
||||||
|
|
||||||
|
|
||||||
def get_payment_command(
|
def get_payment_command(
|
||||||
logger: ILogger = Depends(get_logger),
|
logger: ILogger = Depends(get_logger),
|
||||||
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
|
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
|
||||||
@@ -125,4 +178,16 @@ def get_crypto_transfer_completed_command(
|
|||||||
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
|
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
|
||||||
receipt: IReceipt = Depends(get_cloud_kassir_receipt),
|
receipt: IReceipt = Depends(get_cloud_kassir_receipt),
|
||||||
) -> CreateCryptoTransferCompletedCommand:
|
) -> CreateCryptoTransferCompletedCommand:
|
||||||
return CreateCryptoTransferCompletedCommand(unit_of_work=unit_of_work,receipt=receipt,logger=logger)
|
return CreateCryptoTransferCompletedCommand(unit_of_work=unit_of_work,receipt=receipt,logger=logger)
|
||||||
|
|
||||||
|
|
||||||
|
def get_sbp_withdrawal_wallet_event_command(
|
||||||
|
logger: ILogger = Depends(get_logger),
|
||||||
|
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
|
||||||
|
mozen_sbp_service: IMozenSbpService = Depends(get_mozen_sbp_service),
|
||||||
|
) -> HandleSbpWithdrawalWalletEventCommand:
|
||||||
|
return HandleSbpWithdrawalWalletEventCommand(
|
||||||
|
unit_of_work=unit_of_work,
|
||||||
|
logger=logger,
|
||||||
|
mozen_sbp_service=mozen_sbp_service,
|
||||||
|
)
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
from src.presentation.messaging.crypto_transfer import crypto_transfer_router
|
from src.presentation.messaging.crypto_transfer import crypto_transfer_router
|
||||||
|
from src.presentation.messaging.sbp_withdrawal import sbp_withdrawal_messaging_router
|
||||||
|
|
||||||
|
|
||||||
__all__=['crypto_transfer_router']
|
__all__=['crypto_transfer_router','sbp_withdrawal_messaging_router']
|
||||||
|
|
||||||
|
|||||||
55
src/presentation/messaging/sbp_withdrawal.py
Normal file
55
src/presentation/messaging/sbp_withdrawal.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
from fastapi import Depends
|
||||||
|
from faststream.rabbit import RabbitQueue
|
||||||
|
from faststream.rabbit.fastapi import RabbitMessage,RabbitRouter
|
||||||
|
from decimal import Decimal
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from src.application.commands import HandleSbpWithdrawalWalletEventCommand
|
||||||
|
from src.application.contracts import ILogger
|
||||||
|
from src.infrastructure.config import settings
|
||||||
|
from src.infrastructure.context_vars import trace_id_var
|
||||||
|
from src.presentation.dependencies.commands import get_sbp_withdrawal_wallet_event_command
|
||||||
|
from src.presentation.dependencies.logger import get_logger
|
||||||
|
|
||||||
|
|
||||||
|
sbp_withdrawal_messaging_router=RabbitRouter(settings.RABBIT_URL)
|
||||||
|
|
||||||
|
|
||||||
|
class SbpWithdrawalWalletEventMessage(BaseModel):
|
||||||
|
user_id: str
|
||||||
|
withdraw_id: str
|
||||||
|
event_type: str
|
||||||
|
trace_id: str | None = None
|
||||||
|
received_usdt_amount: Decimal | None = None
|
||||||
|
tx_hash: str | None = None
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@sbp_withdrawal_messaging_router.subscriber(RabbitQueue(settings.RABBIT_SBP_WITHDRAWAL_WALLET_EVENTS_QUEUE,durable=True))
|
||||||
|
async def sbp_withdrawal_wallet_event_handler(
|
||||||
|
msg_body: SbpWithdrawalWalletEventMessage,
|
||||||
|
message: RabbitMessage,
|
||||||
|
command: HandleSbpWithdrawalWalletEventCommand = Depends(get_sbp_withdrawal_wallet_event_command),
|
||||||
|
logger: ILogger = Depends(get_logger),
|
||||||
|
) -> None:
|
||||||
|
headers=message.headers or {}
|
||||||
|
trace_id=msg_body.trace_id or message.correlation_id or str(headers.get('trace_id') or '')
|
||||||
|
token=trace_id_var.set(trace_id)
|
||||||
|
try:
|
||||||
|
logger.info({
|
||||||
|
'event':'sbp_withdrawal_wallet_event_received',
|
||||||
|
'withdraw_id':msg_body.withdraw_id,
|
||||||
|
'user_id':msg_body.user_id,
|
||||||
|
'wallet_event_type':msg_body.event_type,
|
||||||
|
'rabbit_message_id':message.message_id,
|
||||||
|
'rabbit_correlation_id':message.correlation_id,
|
||||||
|
})
|
||||||
|
await command(
|
||||||
|
withdraw_id=msg_body.withdraw_id,
|
||||||
|
user_id=msg_body.user_id,
|
||||||
|
event_type=msg_body.event_type,
|
||||||
|
received_usdt_amount=msg_body.received_usdt_amount,
|
||||||
|
tx_hash=msg_body.tx_hash,
|
||||||
|
error=msg_body.error,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
trace_id_var.reset(token)
|
||||||
@@ -1 +1,2 @@
|
|||||||
from src.presentation.routing.order import order_router,orders_router,payment_router,payments_router
|
from src.presentation.routing.order import order_router,orders_router,payment_router,payments_router
|
||||||
|
from src.presentation.routing.sbp_withdrawal import sbp_withdrawal_router
|
||||||
@@ -6,19 +6,19 @@ from fastapi import APIRouter,Depends,Query,Request,WebSocket,WebSocketDisconnec
|
|||||||
from fastapi.responses import ORJSONResponse
|
from fastapi.responses import ORJSONResponse
|
||||||
from fastapi.security.utils import get_authorization_scheme_param
|
from fastapi.security.utils import get_authorization_scheme_param
|
||||||
from ulid import ULID
|
from ulid import ULID
|
||||||
from src.application.commands import CreateOrderCommand,CreatePaymentCommand,GetOrderCommand,GetOrderStatusCommand,GetPaymentCommand,GetPaymentConfigCommand,GetPaymentQuoteCommand,GetPaymentQuoteFromRubCommand,ListOrdersCommand,ListPaymentsCommand
|
from src.application.commands import ClientOperationResult,CreateOrderCommand,CreatePaymentCommand,GetOrderCommand,GetOrderStatusCommand,GetPaymentCommand,GetPaymentConfigCommand,GetPaymentQuoteCommand,GetPaymentQuoteFromRubCommand,ListClientOperationsCommand,ListOrdersCommand
|
||||||
from src.application.contracts import IJwtService,ILogger
|
from src.application.contracts import IJwtService,ILogger
|
||||||
from src.application.domain.dto import AccessTokenPayload,AuthContext
|
from src.application.domain.dto import AccessTokenPayload,AuthContext
|
||||||
from src.application.domain.entities import OrderEntity,PaymentEntity
|
from src.application.domain.entities import OrderEntity,PaymentEntity,SbpWithdrawalEntity
|
||||||
from src.application.domain.enums import OrderStatus
|
from src.application.domain.enums import OrderStatus
|
||||||
from src.application.domain.exceptions import ApplicationException,ConflictException
|
from src.application.domain.exceptions import ApplicationException,ConflictException
|
||||||
from src.application.services import PaymentQuote
|
from src.application.services import PaymentQuote
|
||||||
from src.infrastructure.context_vars import trace_id_var
|
from src.infrastructure.context_vars import trace_id_var
|
||||||
from src.presentation.decorators import require_access_token, csrf_protect
|
from src.presentation.decorators import require_access_token, csrf_protect
|
||||||
from src.presentation.dependencies.commands import get_create_order_command,get_create_payment_command,get_list_orders_command,get_list_payments_command,get_order_command,get_order_status_command,get_payment_command,get_payment_config_command,get_payment_quote_command,get_payment_quote_from_rub_command
|
from src.presentation.dependencies.commands import get_create_order_command,get_create_payment_command,get_list_client_operations_command,get_list_orders_command,get_order_command,get_order_status_command,get_payment_command,get_payment_config_command,get_payment_quote_command,get_payment_quote_from_rub_command
|
||||||
from src.presentation.dependencies.logger import get_logger
|
from src.presentation.dependencies.logger import get_logger
|
||||||
from src.presentation.dependencies.security import get_jwt_service
|
from src.presentation.dependencies.security import get_jwt_service
|
||||||
from src.presentation.schemas.order import CreateOrder,CreateOrderResponse,ErrorResponse,OrderDetailResponse,OrderPaymentResponse,OrdersResponse,OrderStatusResponse,OrderWithPaymentResponse,PaymentConfigResponse,PaymentDetailResponse,PaymentQuoteResponse,PaymentResponse,PaymentsResponse
|
from src.presentation.schemas.order import ClientOperationResponse,ClientOperationsResponse,CreateOrder,CreateOrderResponse,ErrorResponse,OrderDetailResponse,OrderPaymentResponse,OrdersResponse,OrderStatusResponse,OrderWithPaymentResponse,PaymentConfigResponse,PaymentDetailResponse,PaymentQuoteResponse,PaymentResponse,SbpWithdrawalOperationResponse
|
||||||
from src.presentation.schemas.itpay_payment_models import ItpayPaymentData
|
from src.presentation.schemas.itpay_payment_models import ItpayPaymentData
|
||||||
|
|
||||||
order_router = APIRouter(prefix='/order', tags=['orders'])
|
order_router = APIRouter(prefix='/order', tags=['orders'])
|
||||||
@@ -110,6 +110,40 @@ def _payment_response(payment: PaymentEntity) -> PaymentResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _withdrawal_operation_response(withdrawal: SbpWithdrawalEntity) -> SbpWithdrawalOperationResponse:
|
||||||
|
return SbpWithdrawalOperationResponse(
|
||||||
|
id=withdrawal.id,
|
||||||
|
created_at=withdrawal.created_at.isoformat() if withdrawal.created_at is not None else None,
|
||||||
|
updated_at=withdrawal.updated_at.isoformat() if withdrawal.updated_at is not None else None,
|
||||||
|
user_id=withdrawal.user_id,
|
||||||
|
bank_id=withdrawal.bank_id,
|
||||||
|
bank_name=withdrawal.bank_name,
|
||||||
|
phone=withdrawal.phone,
|
||||||
|
wallet_address=withdrawal.wallet_address,
|
||||||
|
usdt_amount=str(withdrawal.usdt_amount) if withdrawal.usdt_amount is not None else None,
|
||||||
|
rub_amount=str(withdrawal.rub_amount) if withdrawal.rub_amount is not None else None,
|
||||||
|
usdt_exchange_rate=str(withdrawal.usdt_exchange_rate) if withdrawal.usdt_exchange_rate is not None else None,
|
||||||
|
service_fee_rate=str(withdrawal.service_fee_rate) if withdrawal.service_fee_rate is not None else None,
|
||||||
|
service_fee_usdt=str(withdrawal.service_fee_usdt) if withdrawal.service_fee_usdt is not None else None,
|
||||||
|
mozen_order_id=withdrawal.mozen_order_id,
|
||||||
|
trace_id=withdrawal.trace_id,
|
||||||
|
status=withdrawal.status,
|
||||||
|
provider_error=withdrawal.provider_error,
|
||||||
|
usdt_received_at=withdrawal.usdt_received_at.isoformat() if withdrawal.usdt_received_at is not None else None,
|
||||||
|
payout_created_at=withdrawal.payout_created_at.isoformat() if withdrawal.payout_created_at is not None else None,
|
||||||
|
payout_completed_at=withdrawal.payout_completed_at.isoformat() if withdrawal.payout_completed_at is not None else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _client_operation_response(operation: ClientOperationResult) -> ClientOperationResponse:
|
||||||
|
return ClientOperationResponse(
|
||||||
|
type=operation.type,
|
||||||
|
created_at=operation.created_at.isoformat(),
|
||||||
|
payment=_payment_response(operation.payment) if operation.payment is not None else None,
|
||||||
|
withdrawal=_withdrawal_operation_response(operation.withdrawal) if operation.withdrawal is not None else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _order_detail_response(order: OrderEntity, payment: PaymentEntity | None) -> OrderDetailResponse:
|
def _order_detail_response(order: OrderEntity, payment: PaymentEntity | None) -> OrderDetailResponse:
|
||||||
return OrderDetailResponse(
|
return OrderDetailResponse(
|
||||||
status_code=200,
|
status_code=200,
|
||||||
@@ -266,7 +300,7 @@ async def payment_list_orders(
|
|||||||
|
|
||||||
@payment_router.get(
|
@payment_router.get(
|
||||||
'/payments',
|
'/payments',
|
||||||
response_model=PaymentsResponse,
|
response_model=ClientOperationsResponse,
|
||||||
status_code=200,
|
status_code=200,
|
||||||
responses=ERROR_RESPONSES,
|
responses=ERROR_RESPONSES,
|
||||||
)
|
)
|
||||||
@@ -276,12 +310,12 @@ async def payment_list_payments(
|
|||||||
limit: int = Query(default=20, ge=1, le=100),
|
limit: int = Query(default=20, ge=1, le=100),
|
||||||
offset: int = Query(default=0, ge=0),
|
offset: int = Query(default=0, ge=0),
|
||||||
auth: AuthContext = Depends(require_access_token),
|
auth: AuthContext = Depends(require_access_token),
|
||||||
command: ListPaymentsCommand = Depends(get_list_payments_command),
|
command: ListClientOperationsCommand = Depends(get_list_client_operations_command),
|
||||||
) -> PaymentsResponse:
|
) -> ClientOperationsResponse:
|
||||||
payments = await command(user_id=auth.user_id,limit=limit,offset=offset)
|
operations = await command(user_id=auth.user_id,limit=limit,offset=offset)
|
||||||
return PaymentsResponse(
|
return ClientOperationsResponse(
|
||||||
status_code=200,
|
status_code=200,
|
||||||
payments=[_payment_response(payment) for payment in payments],
|
operations=[_client_operation_response(operation) for operation in operations],
|
||||||
limit=limit,
|
limit=limit,
|
||||||
offset=offset,
|
offset=offset,
|
||||||
)
|
)
|
||||||
@@ -325,7 +359,7 @@ async def list_orders(
|
|||||||
|
|
||||||
@payments_router.get(
|
@payments_router.get(
|
||||||
'/payments',
|
'/payments',
|
||||||
response_model=PaymentsResponse,
|
response_model=ClientOperationsResponse,
|
||||||
status_code=200,
|
status_code=200,
|
||||||
responses=ERROR_RESPONSES,
|
responses=ERROR_RESPONSES,
|
||||||
)
|
)
|
||||||
@@ -335,12 +369,12 @@ async def list_payments(
|
|||||||
limit: int = Query(default=20, ge=1, le=100),
|
limit: int = Query(default=20, ge=1, le=100),
|
||||||
offset: int = Query(default=0, ge=0),
|
offset: int = Query(default=0, ge=0),
|
||||||
auth: AuthContext = Depends(require_access_token),
|
auth: AuthContext = Depends(require_access_token),
|
||||||
command: ListPaymentsCommand = Depends(get_list_payments_command),
|
command: ListClientOperationsCommand = Depends(get_list_client_operations_command),
|
||||||
) -> PaymentsResponse:
|
) -> ClientOperationsResponse:
|
||||||
payments = await command(user_id=auth.user_id,limit=limit,offset=offset)
|
operations = await command(user_id=auth.user_id,limit=limit,offset=offset)
|
||||||
return PaymentsResponse(
|
return ClientOperationsResponse(
|
||||||
status_code=200,
|
status_code=200,
|
||||||
payments=[_payment_response(payment) for payment in payments],
|
operations=[_client_operation_response(operation) for operation in operations],
|
||||||
limit=limit,
|
limit=limit,
|
||||||
offset=offset,
|
offset=offset,
|
||||||
)
|
)
|
||||||
|
|||||||
107
src/presentation/routing/sbp_withdrawal.py
Normal file
107
src/presentation/routing/sbp_withdrawal.py
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
from fastapi import APIRouter,Depends,Request
|
||||||
|
from src.application.commands import CreateSbpWithdrawalCommand,GetSbpBanksCommand,GetSbpWithdrawalCommand
|
||||||
|
from src.application.domain.dto import AuthContext
|
||||||
|
from src.application.domain.entities import SbpWithdrawalEntity
|
||||||
|
from src.presentation.decorators import cached,csrf_protect,require_access_token
|
||||||
|
from src.presentation.dependencies.commands import get_create_sbp_withdrawal_command,get_sbp_banks_command,get_sbp_withdrawal_command
|
||||||
|
from src.presentation.schemas.order import ErrorResponse
|
||||||
|
from src.presentation.schemas.sbp_withdrawal import CreateSbpWithdrawal,CreateSbpWithdrawalResponse,SbpBankResponse,SbpBanksResponse,SbpWithdrawalDetailResponse,SbpWithdrawalResponse
|
||||||
|
|
||||||
|
|
||||||
|
sbp_withdrawal_router=APIRouter(prefix='/sbp-payout',tags=['sbp payouts'])
|
||||||
|
|
||||||
|
|
||||||
|
ERROR_RESPONSES={
|
||||||
|
400:{'model':ErrorResponse,'description':'Bad Request'},
|
||||||
|
401:{'model':ErrorResponse,'description':'Unauthorized'},
|
||||||
|
403:{'model':ErrorResponse,'description':'Forbidden'},
|
||||||
|
404:{'model':ErrorResponse,'description':'Not Found'},
|
||||||
|
409:{'model':ErrorResponse,'description':'Conflict'},
|
||||||
|
422:{'model':ErrorResponse,'description':'Validation Error'},
|
||||||
|
429:{'model':ErrorResponse,'description':'Too Many Requests'},
|
||||||
|
500:{'model':ErrorResponse,'description':'Internal Server Error'},
|
||||||
|
502:{'model':ErrorResponse,'description':'Bad Gateway'},
|
||||||
|
503:{'model':ErrorResponse,'description':'Service Unavailable'},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _withdrawal_response(withdrawal: SbpWithdrawalEntity) -> SbpWithdrawalResponse:
|
||||||
|
return SbpWithdrawalResponse(
|
||||||
|
id=withdrawal.id,
|
||||||
|
created_at=withdrawal.created_at.isoformat() if withdrawal.created_at is not None else None,
|
||||||
|
updated_at=withdrawal.updated_at.isoformat() if withdrawal.updated_at is not None else None,
|
||||||
|
user_id=withdrawal.user_id,
|
||||||
|
bank_id=withdrawal.bank_id,
|
||||||
|
bank_name=withdrawal.bank_name,
|
||||||
|
phone=withdrawal.phone,
|
||||||
|
wallet_address=withdrawal.wallet_address,
|
||||||
|
usdt_amount=str(withdrawal.usdt_amount) if withdrawal.usdt_amount is not None else None,
|
||||||
|
rub_amount=str(withdrawal.rub_amount) if withdrawal.rub_amount is not None else None,
|
||||||
|
usdt_exchange_rate=str(withdrawal.usdt_exchange_rate) if withdrawal.usdt_exchange_rate is not None else None,
|
||||||
|
service_fee_rate=str(withdrawal.service_fee_rate) if withdrawal.service_fee_rate is not None else None,
|
||||||
|
service_fee_usdt=str(withdrawal.service_fee_usdt) if withdrawal.service_fee_usdt is not None else None,
|
||||||
|
mozen_order_id=withdrawal.mozen_order_id,
|
||||||
|
trace_id=withdrawal.trace_id,
|
||||||
|
status=withdrawal.status,
|
||||||
|
provider_error=withdrawal.provider_error,
|
||||||
|
usdt_received_at=withdrawal.usdt_received_at.isoformat() if withdrawal.usdt_received_at is not None else None,
|
||||||
|
payout_created_at=withdrawal.payout_created_at.isoformat() if withdrawal.payout_created_at is not None else None,
|
||||||
|
payout_completed_at=withdrawal.payout_completed_at.isoformat() if withdrawal.payout_completed_at is not None else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@sbp_withdrawal_router.get(
|
||||||
|
'/banks',
|
||||||
|
response_model=SbpBanksResponse,
|
||||||
|
status_code=200,
|
||||||
|
responses=ERROR_RESPONSES,
|
||||||
|
)
|
||||||
|
@cached(prefix='sbp:payout:banks',ttl=3600,user_scoped=False)
|
||||||
|
async def sbp_banks(
|
||||||
|
request: Request,
|
||||||
|
command: GetSbpBanksCommand = Depends(get_sbp_banks_command),
|
||||||
|
) -> SbpBanksResponse:
|
||||||
|
banks=await command()
|
||||||
|
return SbpBanksResponse(
|
||||||
|
status_code=200,
|
||||||
|
banks=[SbpBankResponse(id=bank.id,name=bank.name) for bank in banks],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@sbp_withdrawal_router.post(
|
||||||
|
'/withdrawals',
|
||||||
|
response_model=CreateSbpWithdrawalResponse,
|
||||||
|
status_code=201,
|
||||||
|
responses=ERROR_RESPONSES,
|
||||||
|
)
|
||||||
|
@csrf_protect()
|
||||||
|
async def create_sbp_withdrawal(
|
||||||
|
request: Request,
|
||||||
|
payload: CreateSbpWithdrawal,
|
||||||
|
auth: AuthContext = Depends(require_access_token),
|
||||||
|
command: CreateSbpWithdrawalCommand = Depends(get_create_sbp_withdrawal_command),
|
||||||
|
) -> CreateSbpWithdrawalResponse:
|
||||||
|
withdrawal=await command(
|
||||||
|
user_id=auth.user_id,
|
||||||
|
bank_id=payload.bank_id,
|
||||||
|
phone=payload.phone,
|
||||||
|
usdt_amount=payload.usdt_amount,
|
||||||
|
)
|
||||||
|
return CreateSbpWithdrawalResponse(status_code=201,withdrawal=_withdrawal_response(withdrawal))
|
||||||
|
|
||||||
|
|
||||||
|
@sbp_withdrawal_router.get(
|
||||||
|
'/withdrawals/{withdraw_id}',
|
||||||
|
response_model=SbpWithdrawalDetailResponse,
|
||||||
|
status_code=200,
|
||||||
|
responses=ERROR_RESPONSES,
|
||||||
|
)
|
||||||
|
@csrf_protect()
|
||||||
|
async def sbp_withdrawal_detail(
|
||||||
|
request: Request,
|
||||||
|
withdraw_id: str,
|
||||||
|
auth: AuthContext = Depends(require_access_token),
|
||||||
|
command: GetSbpWithdrawalCommand = Depends(get_sbp_withdrawal_command),
|
||||||
|
) -> SbpWithdrawalDetailResponse:
|
||||||
|
withdrawal=await command(withdraw_id=withdraw_id,user_id=auth.user_id)
|
||||||
|
return SbpWithdrawalDetailResponse(status_code=200,withdrawal=_withdrawal_response(withdrawal))
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from pydantic import BaseModel,Field
|
from pydantic import BaseModel,Field
|
||||||
from src.application.domain.enums import OrderStatus,PaymentStatus
|
from src.application.domain.enums import OrderStatus,PaymentStatus,SbpWithdrawalStatus
|
||||||
|
|
||||||
|
|
||||||
class CreateOrder(BaseModel):
|
class CreateOrder(BaseModel):
|
||||||
@@ -51,6 +51,36 @@ class PaymentResponse(BaseModel):
|
|||||||
expired_date: str | None = None
|
expired_date: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SbpWithdrawalOperationResponse(BaseModel):
|
||||||
|
id: str | None = None
|
||||||
|
created_at: str | None = None
|
||||||
|
updated_at: str | 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: str | None = None
|
||||||
|
rub_amount: str | None = None
|
||||||
|
usdt_exchange_rate: str | None = None
|
||||||
|
service_fee_rate: str | None = None
|
||||||
|
service_fee_usdt: str | 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: str | None = None
|
||||||
|
payout_created_at: str | None = None
|
||||||
|
payout_completed_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ClientOperationResponse(BaseModel):
|
||||||
|
type: str
|
||||||
|
created_at: str
|
||||||
|
payment: PaymentResponse | None = None
|
||||||
|
withdrawal: SbpWithdrawalOperationResponse | None = None
|
||||||
|
|
||||||
|
|
||||||
class CreateOrderResponse(BaseModel):
|
class CreateOrderResponse(BaseModel):
|
||||||
status_code: int
|
status_code: int
|
||||||
order: OrderPaymentResponse
|
order: OrderPaymentResponse
|
||||||
@@ -100,6 +130,13 @@ class PaymentsResponse(BaseModel):
|
|||||||
offset: int
|
offset: int
|
||||||
|
|
||||||
|
|
||||||
|
class ClientOperationsResponse(BaseModel):
|
||||||
|
status_code: int
|
||||||
|
operations: list[ClientOperationResponse]
|
||||||
|
limit: int
|
||||||
|
offset: int
|
||||||
|
|
||||||
|
|
||||||
class PaymentDetailResponse(BaseModel):
|
class PaymentDetailResponse(BaseModel):
|
||||||
status_code: int
|
status_code: int
|
||||||
payment: PaymentResponse
|
payment: PaymentResponse
|
||||||
|
|||||||
52
src/presentation/schemas/sbp_withdrawal.py
Normal file
52
src/presentation/schemas/sbp_withdrawal.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
from decimal import Decimal
|
||||||
|
from pydantic import BaseModel,Field
|
||||||
|
from src.application.domain.enums import SbpWithdrawalStatus
|
||||||
|
|
||||||
|
|
||||||
|
class SbpBankResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class SbpBanksResponse(BaseModel):
|
||||||
|
status_code: int
|
||||||
|
banks: list[SbpBankResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class CreateSbpWithdrawal(BaseModel):
|
||||||
|
bank_id: str = Field(min_length=1,max_length=32)
|
||||||
|
phone: str = Field(min_length=5,max_length=32)
|
||||||
|
usdt_amount: Decimal = Field(ge=Decimal('100'),le=Decimal('5000'),decimal_places=2,max_digits=20)
|
||||||
|
|
||||||
|
|
||||||
|
class SbpWithdrawalResponse(BaseModel):
|
||||||
|
id: str | None = None
|
||||||
|
created_at: str | None = None
|
||||||
|
updated_at: str | 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: str | None = None
|
||||||
|
rub_amount: str | None = None
|
||||||
|
usdt_exchange_rate: str | None = None
|
||||||
|
service_fee_rate: str | None = None
|
||||||
|
service_fee_usdt: str | 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: str | None = None
|
||||||
|
payout_created_at: str | None = None
|
||||||
|
payout_completed_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CreateSbpWithdrawalResponse(BaseModel):
|
||||||
|
status_code: int
|
||||||
|
withdrawal: SbpWithdrawalResponse
|
||||||
|
|
||||||
|
|
||||||
|
class SbpWithdrawalDetailResponse(BaseModel):
|
||||||
|
status_code: int
|
||||||
|
withdrawal: SbpWithdrawalResponse
|
||||||
Reference in New Issue
Block a user