Compare commits

2 Commits

Author SHA1 Message Date
b87db7f864 fix: rollback main 2026-06-29 10:45:48 +03:00
34fbf2e03a feat: add health check endpoint 2026-06-16 11:43:59 +03:00
112 changed files with 655 additions and 4670 deletions

View File

@@ -25,4 +25,4 @@ ENV PATH="/app/.venv/bin:$PATH" \
EXPOSE 8000
CMD ["sh", "-c", "granian --interface asgi ${APP_MODULE:-src.main:app} --host ${APP_HOST:-0.0.0.0} --port ${APP_PORT:-8000} --workers ${APP_WORKERS:-2} --loop uvloop"]
CMD ["sh", "-c", "granian --interface asgi ${APP_MODULE:-src.main:app} --host ${APP_HOST:-0.0.0.0} --port ${APP_PORT:-8000} --workers ${APP_WORKERS:-1} --loop uvloop"]

View File

@@ -1,63 +0,0 @@
services:
pay:
container_name: pay-service
build:
context: .
dockerfile: Dockerfile
ports:
- "8000:8000"
environment:
PYTHONUNBUFFERED: "1"
APP_MODULE: "src.main:app"
APP_HOST: "0.0.0.0"
APP_PORT: "8000"
APP_WORKERS: "2"
KEYDB_HOST: "pay_keydb"
KEYDB_PORT: "${KEYDB_PORT:-6380}"
KEYDB_PASSWORD: "${REDIS_PASSWORD}"
KEYDB_DB: "${REDIS_DB:-0}"
env_file:
- .env
depends_on:
pay_keydb:
condition: service_healthy
restart: no
pay_keydb:
image: eqalpha/keydb
container_name: pay_keydb
restart: no
expose:
- "${KEYDB_PORT:-6380}"
volumes:
- pay_keydb_data:/data
command:
- keydb-server
- --port
- "${KEYDB_PORT:-6380}"
- --requirepass
- ${REDIS_PASSWORD}
- --dir
- /data
- --appendonly
- "yes"
- --appendfsync
- everysec
- --save
- "900"
- "1"
- --save
- "300"
- "10"
- --save
- "60"
- "10000"
healthcheck:
test: ["CMD", "redis-cli", "-p", "${KEYDB_PORT:-6380}", "-a", "${REDIS_PASSWORD}", "ping"]
interval: 5s
timeout: 2s
retries: 20
volumes:
pay_keydb_data:

View File

@@ -1 +0,0 @@
from src.application.abstractions.i_unit_of_work import IUnitOfWork

View File

@@ -1,25 +0,0 @@
from __future__ import annotations
from typing import Protocol, runtime_checkable
from src.application.abstractions.repositories import IOrderRepository,IPaymentRepository,ISbpWithdrawalRepository,IUserRepository
@runtime_checkable
class IUnitOfWork(Protocol):
async def __aenter__(self) -> "IUnitOfWork": ...
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: ...
async def commit(self) -> None: ...
async def rollback(self) -> None: ...
@property
def order_repository(self) -> IOrderRepository: ...
@property
def payment_repository(self) -> IPaymentRepository: ...
@property
def sbp_withdrawal_repository(self) -> ISbpWithdrawalRepository: ...
@property
def user_repository(self) -> IUserRepository: ...

View File

@@ -1,4 +0,0 @@
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

@@ -1,45 +0,0 @@
from abc import ABC,abstractmethod
from src.application.domain.entities.order import OrderEntity
from src.application.domain.enums import OrderStatus
class IOrderRepository(ABC):
@abstractmethod
async def create(self,order: OrderEntity) -> OrderEntity:
raise NotImplementedError
@abstractmethod
async def get_by_client_payment_id(self,client_payment_id: str) -> OrderEntity | None:
raise NotImplementedError
@abstractmethod
async def get_by_id(self,order_id: str) -> OrderEntity | None:
raise NotImplementedError
@abstractmethod
async def get_by_id_for_user(self,*,order_id: str,user_id: str) -> OrderEntity | None:
raise NotImplementedError
@abstractmethod
async def list_by_user_id(self,*,user_id: str,limit: int,offset: int) -> list[OrderEntity]:
raise NotImplementedError
@abstractmethod
async def update_after_itpay_payment_created(self,order: OrderEntity) -> OrderEntity:
raise NotImplementedError
@abstractmethod
async def update_after_itpay_failure(self,order: OrderEntity) -> OrderEntity:
raise NotImplementedError
@abstractmethod
async def update_status(self,*,order_id:str,status:OrderStatus) -> None:
raise NotImplementedError

View File

@@ -1,40 +0,0 @@
from abc import ABC,abstractmethod
from src.application.domain.entities import PaymentEntity
from src.application.domain.enums import PaymentStatus
class IPaymentRepository(ABC):
@abstractmethod
async def create_completed(self,*,user_id:str,order_id:str,itpay_payment_id:str,itpay_paid_amount:str|None,transaction_id:str|None,paid_at:str|None,expired_date:str|None) -> bool:
raise NotImplementedError
@abstractmethod
async def update_crypto_transfer_completed(self,*,order_id:str,web3_transaction_hash:str|None) -> None:
raise NotImplementedError
@abstractmethod
async def update_status(self,*,order_id:str,status:PaymentStatus) -> None:
raise NotImplementedError
@abstractmethod
async def update_receipt(self,*,order_id:str,receipt_cloudekassir_id:str|None,receipt_cloudekassir_link:str|None) -> None:
raise NotImplementedError
@abstractmethod
async def get_by_order_id(self,order_id:str) -> PaymentEntity | None:
raise NotImplementedError
@abstractmethod
async def get_by_id_for_user(self,*,payment_id:str,user_id:str) -> PaymentEntity | None:
raise NotImplementedError
@abstractmethod
async def list_by_user_id(self,*,user_id:str,limit:int,offset:int) -> list[PaymentEntity]:
raise NotImplementedError

View File

@@ -1,47 +0,0 @@
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,9 +0,0 @@
from abc import ABC,abstractmethod
from src.application.domain.entities.user import UserEntity
class IUserRepository(ABC):
@abstractmethod
async def get(self,user_id:str) -> UserEntity|None:
raise NotImplementedError

View File

@@ -1,5 +0,0 @@
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 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,103 +0,0 @@
from __future__ import annotations
from decimal import Decimal
from src.application.abstractions import IUnitOfWork
from src.application.contracts import ILogger,IReceipt
from src.application.domain.enums import OrderStatus,PaymentStatus
from src.application.domain.exceptions import ApplicationException,NotFoundException,PaymentMetadataException,ReceiptDataException
from src.infrastructure.database.decorators import transactional
class CreateCryptoTransferCompletedCommand:
def __init__(self, *, unit_of_work: IUnitOfWork, receipt: IReceipt, logger: ILogger):
self._unit_of_work = unit_of_work
self._receipt = receipt
self._logger = logger
@transactional
async def __call__(self, *, order_id: str, user_id: str, web3_transaction_hash: str | None = None) -> None:
if not order_id:
raise PaymentMetadataException(message='Crypto transfer completed message missing order_id')
if not user_id:
raise PaymentMetadataException(message='Crypto transfer completed message missing user_id')
await self._unit_of_work.payment_repository.update_crypto_transfer_completed(
order_id=order_id,
web3_transaction_hash=web3_transaction_hash,
)
user = await self._unit_of_work.user_repository.get(user_id)
if user is None:
raise NotFoundException(message='User not found')
email = str(user.email or '').strip()
if not email:
raise ReceiptDataException(message='User email missing')
customer_info = ' '.join(
part
for part in (
str(user.last_name or '').strip(),
str(user.first_name or '').strip(),
str(user.middle_name or '').strip(),
)
if part
)
if not customer_info:
raise ReceiptDataException(message='User full name missing')
customer_inn = str(user.inn or '').strip()
if not customer_inn:
raise ReceiptDataException(message='User inn missing')
if user.birth_date is None:
raise ReceiptDataException(message='User birth date missing')
customer_birthday = f'{user.birth_date.isoformat()}T12:00:00.000Z'
order = await self._unit_of_work.order_repository.get_by_id(order_id)
if order is None:
raise NotFoundException(message='Order not found')
if order.total_price is None:
raise ReceiptDataException(message='Order total price missing for receipt')
if order.service_fee is None:
raise ReceiptDataException(message='Order service fee missing for receipt')
total_amount = Decimal(str(order.total_price)).quantize(Decimal('0.01'))
service_fee = Decimal(str(order.service_fee)).quantize(Decimal('0.01'))
principal_amount = (total_amount - service_fee).quantize(Decimal('0.01'))
if principal_amount < 0:
raise ReceiptDataException(message='Invalid receipt amounts: principal negative')
try:
receipt_response = await self._receipt.create_receipt(
order_id=order_id,
user_id=user_id,
email=email,
total_amount=total_amount,
principal_amount=principal_amount,
service_fee=service_fee,
customer_info=customer_info,
customer_inn=customer_inn,
customer_birthday=customer_birthday,
request_id=self._logger.get_trace_id(),
)
except ApplicationException as exception:
self._logger.error({'event':'receipt_create_failed','order_id':order_id,'error':exception.message})
await self._unit_of_work.payment_repository.update_status(
order_id=order_id,
status=PaymentStatus.RECEIPT_ERROR,
)
return
receipt_model = receipt_response.get('Model')
if not isinstance(receipt_model, dict):
receipt_model = {}
await self._unit_of_work.payment_repository.update_receipt(
order_id=order_id,
receipt_cloudekassir_id=str(receipt_model.get('Id') or '') or None,
receipt_cloudekassir_link=str(receipt_model.get('ReceiptLocalUrl') or '') or None,
)
await self._unit_of_work.order_repository.update_status(
order_id=order_id,
status=OrderStatus.COMPLETED,
)
self._logger.info({
'event': 'order_status_changed',
'order_id': order_id,
'user_id': user_id,
'status': OrderStatus.COMPLETED.value,
})

View File

@@ -1,80 +0,0 @@
from __future__ import annotations
from datetime import datetime, timezone
from decimal import Decimal
from ulid import ULID
from src.application.abstractions import IUnitOfWork
from src.application.contracts import ILogger
from src.application.contracts import IItPayService
from src.application.domain.entities.order import OrderEntity
from src.application.domain.enums import OrderStatus
from src.application.domain.exceptions import ApplicationException, ForbiddenException, OrderTotalOutOfRangeException, PriceChangedException
from src.application.services import PaymentQuoteService
from src.application.services.payment_quote_service import MIN_TOTAL_RUB
from src.infrastructure.database.decorators import transactional
from src.presentation.schemas.order import CreateOrder
class CreateOrderCommand:
def __init__(
self,
*,
unit_of_work: IUnitOfWork,
logger: ILogger,
payment_quote_service: PaymentQuoteService,
itpay_service: IItPayService,
) -> None:
self._unit_of_work = unit_of_work
self._logger = logger
self._payment_quote_service = payment_quote_service
self._itpay_service = itpay_service
@transactional
async def __call__(self, payment_data: CreateOrder, user_id: str) -> OrderEntity:
user = await self._unit_of_work.user_repository.get(user_id)
if user is None:
raise ApplicationException(status_code=404, message='User not found')
if user.account_type == 'legal_entity':
raise ForbiddenException(message='USDT purchase orders are not available for legal entity accounts')
client_payment_id = str(ULID())
if payment_data.total_price < MIN_TOTAL_RUB:
raise OrderTotalOutOfRangeException(
message='Order total is below minimum allowed amount',
)
quote = await self._payment_quote_service.get_reference_quote(payment_data.usdt_amount)
actual_gas_fee = quote.gas_fee
actual_usdt_exchange_rate = quote.usdt_exchange_rate
actual_service_fee = quote.service_fee
actual_total_price = quote.total_price
if actual_total_price > payment_data.total_price * Decimal('1.01'):
self._logger.error('Price has changed, please refresh and try again')
raise PriceChangedException()
order = OrderEntity(
user_id=user_id,
usdt_amount=payment_data.usdt_amount,
usdt_exchange_rate=actual_usdt_exchange_rate,
gas_fee=actual_gas_fee,
service_fee=actual_service_fee,
total_price=actual_total_price,
status=OrderStatus.PENDING,
created_at=datetime.now(timezone.utc),
client_payment_id=client_payment_id,
)
saved = await self._unit_of_work.order_repository.create(order)
with_itpay = await self._itpay_service.create_payment(saved,self._logger.get_trace_id())
if with_itpay.status in (
OrderStatus.CANCELLED,
OrderStatus.REJECTED,
OrderStatus.ERROR,
):
await self._unit_of_work.order_repository.update_after_itpay_failure(with_itpay)
else:
await self._unit_of_work.order_repository.update_after_itpay_payment_created(with_itpay)
return with_itpay

View File

@@ -1,94 +0,0 @@
from __future__ import annotations
from ulid import ULID
from src.application.abstractions import IUnitOfWork
from src.application.contracts import ILogger,IQueueMessanger
from src.application.domain.enums import OrderStatus
from src.application.domain.exceptions import PaymentMetadataException
from src.infrastructure.config import settings
from src.infrastructure.database.decorators import transactional
from src.presentation.schemas.itpay_payment_models import ItpayPaymentData
class CreatePaymentCommand:
def __init__(self, *, unit_of_work: IUnitOfWork, logger: ILogger, queue_messanger: IQueueMessanger):
self._unit_of_work = unit_of_work
self._logger = logger
self._queue_messanger = queue_messanger
async def _mark_order_completed(self, order_id: str, user_id: str) -> None:
await self._unit_of_work.order_repository.update_status(
order_id=order_id,
status=OrderStatus.COMPLETED,
)
self._logger.info({
'event': 'order_status_changed',
'order_id': order_id,
'user_id': user_id,
'status': OrderStatus.COMPLETED.value,
})
@transactional
async def __call__(self, payment: ItpayPaymentData) -> None:
if str(payment.status).strip().lower() != 'completed':
return
metadata = payment.metadata or {}
order_id = str(metadata.get('order_id') or '')
user_id = str(metadata.get('user_id') or '')
trace_id = str(metadata.get('trace_id') or self._logger.get_trace_id())
self._logger.set_trace_id(trace_id)
if not order_id:
raise PaymentMetadataException(message='Itpay webhook metadata missing order_id')
if not user_id:
raise PaymentMetadataException(message='Itpay webhook metadata missing user_id')
payment_created = await self._unit_of_work.payment_repository.create_completed(
user_id=user_id,
order_id=order_id,
itpay_payment_id=str(payment.id),
itpay_paid_amount=str(payment.amount) if payment.amount is not None else None,
transaction_id=str(payment.transaction_id) if payment.transaction_id is not None else None,
paid_at=str(payment.paid) if payment.paid is not None else None,
expired_date=str(payment.expired_date) if payment.expired_date is not None else None,
)
if not payment_created:
await self._mark_order_completed(order_id, user_id)
return
await self._mark_order_completed(order_id, user_id)
self._logger.info({
'event': 'payment_money_accepted',
'order_id': order_id,
'user_id': user_id,
})
message_id = str(ULID())
message: dict[str,str] = {
'order_id': order_id,
'user_id': user_id,
'trace_id': trace_id,
'message_id': message_id,
}
try:
await self._queue_messanger.publish_to_queue(
queue=settings.RABBIT_CRYPTO_TRANSFER_QUEUE,
message=message,
message_id=message_id,
correlation_id=message['trace_id'],
headers={'trace_id': trace_id},
)
except Exception as exception:
self._logger.error({
'event': 'crypto_transfer_message_publish_failed',
'queue': settings.RABBIT_CRYPTO_TRANSFER_QUEUE,
'order_id': order_id,
'user_id': user_id,
'message_id': message_id,
'error': str(exception),
})
raise
self._logger.info({
'event': 'crypto_transfer_message_published',
'queue': settings.RABBIT_CRYPTO_TRANSFER_QUEUE,
'order_id': order_id,
'user_id': user_id,
'message_id': message_id,
})

View File

@@ -1,184 +0,0 @@
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,SbpWithdrawalEntity
from src.application.domain.exceptions import NotFoundException
from src.application.services import PaymentQuote,PaymentQuoteService
from src.infrastructure.database.decorators import transactional
@dataclass(slots=True)
class OrderPaymentResult:
order: OrderEntity
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
self._logger = logger
async def __call__(self) -> PaymentQuote:
quote = await self._payment_quote_service.get_reference_quote(Decimal('1.00'))
self._logger.info({'event':'payment_config_requested'})
return quote
class GetPaymentQuoteCommand:
def __init__(self, *, payment_quote_service: PaymentQuoteService, logger: ILogger):
self._payment_quote_service = payment_quote_service
self._logger = logger
async def __call__(self, usdt_amount: Decimal) -> PaymentQuote:
quote = await self._payment_quote_service.get_quote(usdt_amount)
self._logger.info({'event':'payment_quote_requested','usdt_amount':str(usdt_amount)})
return quote
class GetPaymentQuoteFromRubCommand:
def __init__(self, *, payment_quote_service: PaymentQuoteService, logger: ILogger):
self._payment_quote_service = payment_quote_service
self._logger = logger
async def __call__(self, total_rub: Decimal) -> PaymentQuote:
quote = await self._payment_quote_service.get_quote_from_total_rub(total_rub)
self._logger.info({'event':'payment_quote_from_rub_requested','total_rub':str(total_rub)})
return quote
class ListOrdersCommand:
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[OrderPaymentResult]:
orders = await self._unit_of_work.order_repository.list_by_user_id(
user_id=user_id,
limit=limit,
offset=offset,
)
items: list[OrderPaymentResult] = []
for order in orders:
if order.id is None:
continue
payment = await self._unit_of_work.payment_repository.get_by_order_id(order.id)
items.append(OrderPaymentResult(order=order,payment=payment))
self._logger.info({'event':'orders_list_requested','user_id':user_id,'limit':limit,'offset':offset})
return items
class ListPaymentsCommand:
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[PaymentEntity]:
payments = await self._unit_of_work.payment_repository.list_by_user_id(
user_id=user_id,
limit=limit,
offset=offset,
)
self._logger.info({'event':'payments_list_requested','user_id':user_id,'limit':limit,'offset':offset})
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
self._logger = logger
@transactional
async def __call__(self, *, payment_id: str, user_id: str) -> PaymentEntity:
payment = await self._unit_of_work.payment_repository.get_by_id_for_user(
payment_id=payment_id,
user_id=user_id,
)
if payment is None:
raise NotFoundException(message='Payment not found')
self._logger.info({'event':'payment_detail_requested','payment_id':payment_id,'user_id':user_id})
return payment
class GetOrderCommand:
def __init__(self, *, unit_of_work: IUnitOfWork, logger: ILogger):
self._unit_of_work = unit_of_work
self._logger = logger
@transactional
async def __call__(self, *, order_id: str, user_id: str) -> OrderPaymentResult:
order = await self._unit_of_work.order_repository.get_by_id_for_user(order_id=order_id,user_id=user_id)
if order is None:
raise NotFoundException(message='Order not found')
payment = await self._unit_of_work.payment_repository.get_by_order_id(order_id)
self._logger.info({'event':'order_detail_requested','order_id':order_id,'user_id':user_id})
return OrderPaymentResult(order=order,payment=payment)
class GetOrderStatusCommand:
def __init__(self, *, unit_of_work: IUnitOfWork, logger: ILogger):
self._unit_of_work = unit_of_work
self._logger = logger
@transactional
async def __call__(self, *, order_id: str, user_id: str) -> OrderPaymentResult:
order = await self._unit_of_work.order_repository.get_by_id_for_user(order_id=order_id,user_id=user_id)
if order is None:
raise NotFoundException(message='Order not found')
payment = await self._unit_of_work.payment_repository.get_by_order_id(order_id)
self._logger.info({'event':'order_status_requested','order_id':order_id,'user_id':user_id})
return OrderPaymentResult(order=order,payment=payment)

View File

@@ -1,271 +0,0 @@
from __future__ import annotations
from datetime import datetime,timezone
from decimal import Decimal
from typing import Any
import re
from ulid import ULID
from src.application.abstractions import IUnitOfWork
from src.application.contracts import IQueueMessanger,ILogger,IMozenSbpService,SbpBank
from src.application.domain.entities import SbpWithdrawalEntity
from src.application.domain.enums import SbpWithdrawalStatus
from src.application.domain.exceptions import BadRequestException,NotFoundException,PaymentProviderException,ServiceUnavailableException
from src.application.services import SbpWithdrawalQuoteService
from src.infrastructure.config import settings
from src.infrastructure.database.decorators import transactional
class GetSbpBanksCommand:
def __init__(self,*,mozen_sbp_service: IMozenSbpService,logger: ILogger):
self._mozen_sbp_service=mozen_sbp_service
self._logger=logger
async def __call__(self) -> list[SbpBank]:
return await self._mozen_sbp_service.get_banks(self._logger.get_trace_id())
class CreateSbpWithdrawalCommand:
def __init__(
self,
*,
unit_of_work: IUnitOfWork,
logger: ILogger,
queue_messanger: IQueueMessanger,
mozen_sbp_service: IMozenSbpService,
quote_service: SbpWithdrawalQuoteService,
):
self._unit_of_work=unit_of_work
self._logger=logger
self._queue_messanger=queue_messanger
self._mozen_sbp_service=mozen_sbp_service
self._quote_service=quote_service
async def _bank_name(self,bank_id: str) -> str:
banks=await self._mozen_sbp_service.get_banks(self._logger.get_trace_id())
for bank in banks:
if bank.id==bank_id:
return bank.name
raise BadRequestException(message='SBP bank not found')
@staticmethod
def _normalize_russian_phone(phone: str | None) -> str:
digits=re.sub(r'\D','',phone or '')
if len(digits)!=11:
raise BadRequestException(message='Russian phone number is required for SBP withdrawal')
if digits.startswith('8'):
digits=f'7{digits[1:]}'
if not digits.startswith('7'):
raise BadRequestException(message='Russian phone number is required for SBP withdrawal')
return f'+{digits}'
@transactional
async def __call__(self,*,user_id: str,bank_id: str,usdt_amount: Decimal) -> SbpWithdrawalEntity:
user=await self._unit_of_work.user_repository.get(user_id)
if user is None:
raise NotFoundException(message='User not found')
phone=self._normalize_russian_phone(user.phone)
wallet_address=str(settings.CRYPTO_USDT_CONTRACT_ADDRESS or '').strip()
if not wallet_address:
raise ServiceUnavailableException(message='USDT contract address unavailable')
sender_wallet_address=str(user.erc20 or '').strip()
if not sender_wallet_address:
raise BadRequestException(message='User ERC20 wallet address is required for SBP withdrawal')
trace_id=self._logger.get_trace_id()
withdraw_id=str(ULID())
bank_name=await self._bank_name(bank_id)
quote=await self._quote_service.get_quote(usdt_amount)
withdrawal=SbpWithdrawalEntity(
id=withdraw_id,
user_id=user_id,
bank_id=bank_id,
bank_name=bank_name,
phone=phone,
usdt_amount=quote.usdt_amount,
rub_amount=quote.rub_amount,
usdt_exchange_rate=quote.usdt_exchange_rate,
service_fee_rate=quote.service_fee_rate,
service_fee_usdt=quote.service_fee_usdt,
trace_id=trace_id,
status=SbpWithdrawalStatus.WAITING_USDT,
created_at=datetime.now(timezone.utc),
)
saved=await self._unit_of_work.sbp_withdrawal_repository.create(withdrawal)
message_id=str(ULID())
message: dict[str,Any]={
'user_id':user_id,
'usdt_amount':str(quote.usdt_amount),
'trace_id':trace_id,
'wallet_address':wallet_address,
'sender_wallet_address':sender_wallet_address,
'receiver_wallet_address':wallet_address,
'withdraw_id':withdraw_id,
'message_id':message_id,
}
await self._queue_messanger.publish_to_queue(
queue=settings.RABBIT_SBP_WITHDRAWAL_USDT_REQUESTED_QUEUE,
message=message,
message_id=message_id,
correlation_id=trace_id,
headers={'trace_id':trace_id},
)
self._logger.info({
'event':'sbp_withdrawal_usdt_request_published',
'withdraw_id':withdraw_id,
'user_id':user_id,
'message_id':message_id,
})
return saved
class GetSbpWithdrawalCommand:
def __init__(self,*,unit_of_work: IUnitOfWork,logger: ILogger):
self._unit_of_work=unit_of_work
self._logger=logger
@transactional
async def __call__(self,*,withdraw_id: str,user_id: str) -> SbpWithdrawalEntity:
withdrawal=await self._unit_of_work.sbp_withdrawal_repository.get_by_id_for_user(
withdraw_id=withdraw_id,
user_id=user_id,
)
if withdrawal is None:
raise NotFoundException(message='SBP withdrawal not found')
return withdrawal
class HandleSbpWithdrawalWalletEventCommand:
def __init__(
self,
*,
unit_of_work: IUnitOfWork,
logger: ILogger,
mozen_sbp_service: IMozenSbpService,
):
self._unit_of_work=unit_of_work
self._logger=logger
self._mozen_sbp_service=mozen_sbp_service
@staticmethod
def _event_error(
*,
event_type: str,
received_usdt_amount: Decimal | None,
tx_hash: str | None,
error: str | None,
) -> str | None:
parts=(
f'event_type={event_type}',
f'received_usdt_amount={received_usdt_amount}' if received_usdt_amount is not None else '',
f'tx_hash={tx_hash}' if tx_hash else '',
f'error={error}' if error else '',
)
value=';'.join(part for part in parts if part)
return value or None
@transactional
async def __call__(
self,
*,
withdraw_id: str,
user_id: str,
event_type: str,
received_usdt_amount: Decimal | None = None,
tx_hash: str | None = None,
error: str | None = None,
) -> None:
withdrawal=await self._unit_of_work.sbp_withdrawal_repository.get_by_id(withdraw_id)
if withdrawal is None:
raise NotFoundException(message='SBP withdrawal not found')
if withdrawal.user_id!=user_id:
raise BadRequestException(message='SBP withdrawal user mismatch')
normalized_event_type=event_type.strip().lower()
if withdrawal.status in (
SbpWithdrawalStatus.PAYOUT_PROCESSING,
SbpWithdrawalStatus.PAYOUT_COMPLETED,
SbpWithdrawalStatus.PAYOUT_FAILED,
SbpWithdrawalStatus.CANCELLED,
SbpWithdrawalStatus.EXPIRED,
):
self._logger.info({
'event':'sbp_withdrawal_wallet_event_ignored',
'withdraw_id':withdraw_id,
'user_id':user_id,
'wallet_event_type':normalized_event_type,
'status':withdrawal.status.value if withdrawal.status is not None else None,
})
return
provider_error=self._event_error(
event_type=normalized_event_type,
received_usdt_amount=received_usdt_amount,
tx_hash=tx_hash,
error=error,
)
if normalized_event_type in ('usdt_underpaid','underpaid'):
await self._unit_of_work.sbp_withdrawal_repository.update_status(
withdraw_id=withdraw_id,
status=SbpWithdrawalStatus.USDT_UNDERPAID,
provider_error=provider_error,
)
return
if normalized_event_type in ('expired','usdt_expired'):
await self._unit_of_work.sbp_withdrawal_repository.update_status(
withdraw_id=withdraw_id,
status=SbpWithdrawalStatus.EXPIRED,
provider_error=provider_error,
)
return
if normalized_event_type in ('failed','wallet_error','watch_failed','error'):
await self._unit_of_work.sbp_withdrawal_repository.update_status(
withdraw_id=withdraw_id,
status=SbpWithdrawalStatus.WALLET_ERROR,
provider_error=provider_error,
)
return
if normalized_event_type not in ('usdt_received','received','confirmed'):
raise BadRequestException(message='Unknown SBP withdrawal wallet event type')
await self._unit_of_work.sbp_withdrawal_repository.update_status(
withdraw_id=withdraw_id,
status=SbpWithdrawalStatus.USDT_RECEIVED,
provider_error=provider_error,
)
try:
result=await self._mozen_sbp_service.make_withdrawal(
user_id=user_id,
withdraw_id=withdraw_id,
phone=str(withdrawal.phone or ''),
bank_id=str(withdrawal.bank_id or ''),
amount=Decimal('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

@@ -4,6 +4,3 @@ from src.application.contracts.i_csrf_service import ICsrfService
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_mozen_sbp_service import IMozenSbpService,SbpBank,SbpPayoutResult

View File

@@ -17,10 +17,6 @@ class ICache(ABC):
async def get(self, key: str) -> str | None:
raise NotImplementedError
@abstractmethod
async def hget(self, key: str, field: str) -> str | None:
raise NotImplementedError
@abstractmethod
async def delete(self, key: str) -> bool:
raise NotImplementedError

View File

@@ -1,11 +0,0 @@
from abc import ABC,abstractmethod
from src.application.domain.entities.order import OrderEntity
class IItPayService(ABC):
@abstractmethod
async def create_payment(self,order: OrderEntity,trace_id: str) -> OrderEntity:
pass

View File

@@ -1,4 +1,4 @@
from typing import Any,Callable,Optional,Protocol
from typing import Protocol, Optional, Callable
from src.application.domain.enums.log_format import LogFormat
from src.application.domain.enums.log_level import LogLevel
@@ -43,26 +43,26 @@ class ILogger(Protocol):
"""Get current service instance id"""
...
def debug(self, message: Any) -> None:
def debug(self, message: str) -> None:
"""Log debug message"""
...
def info(self, message: Any) -> None:
def info(self, message: str) -> None:
"""Log info message"""
...
def warning(self, message: Any) -> None:
def warning(self, message: str) -> None:
"""Log warning message"""
...
def error(self, message: Any) -> None:
def error(self, message: str) -> None:
"""Log error message"""
...
def critical(self, message: Any) -> None:
def critical(self, message: str) -> None:
"""Log critical message"""
...
def exception(self, message: Any) -> None:
def exception(self, message: str) -> None:
"""Log exception with traceback"""
...

View File

@@ -1,36 +0,0 @@
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,25 +0,0 @@
from abc import ABC,abstractmethod
from decimal import Decimal
from typing import Any
class IReceipt(ABC):
@abstractmethod
async def create_receipt(
self,
*,
order_id: str,
user_id: str,
email: str,
total_amount: Decimal,
principal_amount: Decimal,
service_fee: Decimal,
customer_info: str = '',
customer_inn: str = '',
customer_birthday: str = '',
success_url: str | None = None,
fail_url: str | None = None,
request_id: str | None = None,
) -> dict[str,Any]:
raise NotImplementedError

View File

@@ -1,6 +1,5 @@
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
from src.application.domain.entities.user import UserEntity
from src.application.domain.entities.session import SessionEntity
__all__ = ['PaymentEntity','OrderEntity','SbpWithdrawalEntity']
__all__ = ['UserEntity', 'SessionEntity']

View File

@@ -1,36 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal
from src.application.domain.enums import OrderStatus
@dataclass(slots=True)
class OrderEntity:
id: str | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
user_id: str | None = None
usdt_amount: Decimal | None = None
usdt_exchange_rate: Decimal | None = None
gas_fee: Decimal | None = None
total_price: Decimal | None = None
service_fee: Decimal | None = None
status: OrderStatus | None = None
client_payment_id: str | None = None
itpay_payment_qr_url_desktop: str | None = None
itpay_payment_qr_url_android: str | None = None
itpay_payment_qr_url_ios: str | None = None
itpay_payment_qr_image_desktop: str | None = None
itpay_payment_qr_image_android: str | None = None
itpay_payment_qr_image_ios: str | None = None
itpay_id: str | None = None
itpay_qr_id: str | None = None
itpay_amount: Decimal | None = None
itpay_created_at: datetime | None = None

View File

@@ -1,27 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal
from src.application.domain.enums import PaymentStatus
@dataclass(slots=True)
class PaymentEntity:
id: str | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
user_id: str | None = None
order_id: str | None = None
status: PaymentStatus | None = None
receipt_cloudekassir_id: str | None = None
receipt_cloudekassir_link: str | None = None
itpay_payment_id: str | None = None
itpay_paid_amount: Decimal | None = None
transaction_id: str | None = None
web3_transaction_hash: str | None = None
paid_at: datetime | None = None
expired_date: datetime | None = None

View File

@@ -1,29 +0,0 @@
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
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

@@ -14,14 +14,13 @@ class UserEntity:
last_name: str | None = None
birth_date: date | None = None
encrypted_mnemonic: str | None = None
crypto_wallet: str | None = None
phone: str | None = None
passport_data: str | None = None
bik: str | None = None
account_number: str | None = None
card_number: str | None = None
inn: str | None = None
erc20: str | None = None
avatar_link: str | None = None
kyc_verified: bool | None = None
is_deleted: bool | None = None
@@ -29,5 +28,3 @@ class UserEntity:
created_at: datetime | None = None
updated_at: datetime | None = None
kyc_verified_at: datetime | None = None
account_type: str = 'individual'

View File

@@ -1,6 +1,2 @@
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.sbp_withdrawal_status import SbpWithdrawalStatus

View File

@@ -1,7 +0,0 @@
from enum import Enum
class ItPayPaymentStatus(Enum):
COMPLETED = "payment.completed"
REJECTED = "payment.rejected"
CANCELLED = "payment.canceled"

View File

@@ -1,9 +0,0 @@
from enum import Enum
class OrderStatus(str, Enum):
PENDING = 'pending'
REJECTED = 'rejected'
COMPLETED = 'completed'
CANCELLED = 'cancelled'
ERROR = 'error'

View File

@@ -1,13 +0,0 @@
from enum import Enum
class PaymentStatus(str,Enum):
PENDING='pending'
MONEY_ACCEPTED='money_accepted'
WEB3_PROCESSING='web3_processing'
WEB3_HASH_ERROR='web3_hash_error'
WEB3_BALANCE_PROBLEM='web3_balance_problem'
USDT_DELIVERED='usdt_delivered'
RECEIPT_ERROR='receipt_error'
COMPLETED='completed'

View File

@@ -1,14 +0,0 @@
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,39 +1 @@
from src.application.domain.exceptions.application_exception import ApplicationException
from src.application.domain.exceptions.bad_gateway_exception import BadGatewayException
from src.application.domain.exceptions.bad_request_exception import BadRequestException
from src.application.domain.exceptions.conflict_exception import ConflictException
from src.application.domain.exceptions.csrf_exception import CsrfException
from src.application.domain.exceptions.forbidden_exception import ForbiddenException
from src.application.domain.exceptions.internal_server_exception import InternalServerException
from src.application.domain.exceptions.jwt_exception import JwtException
from src.application.domain.exceptions.not_found_exception import NotFoundException
from src.application.domain.exceptions.order_total_out_of_range_exception import OrderTotalOutOfRangeException
from src.application.domain.exceptions.payment_metadata_exception import PaymentMetadataException
from src.application.domain.exceptions.payment_provider_exception import PaymentProviderException
from src.application.domain.exceptions.price_changed_exception import PriceChangedException
from src.application.domain.exceptions.receipt_data_exception import ReceiptDataException
from src.application.domain.exceptions.receipt_provider_exception import ReceiptProviderException
from src.application.domain.exceptions.rate_limit_exception import RateLimitException
from src.application.domain.exceptions.service_unavailable_exception import ServiceUnavailableException
from src.application.domain.exceptions.unauthorized_exception import UnauthorizedException
__all__ = [
'ApplicationException',
'BadGatewayException',
'BadRequestException',
'ConflictException',
'CsrfException',
'ForbiddenException',
'InternalServerException',
'JwtException',
'NotFoundException',
'OrderTotalOutOfRangeException',
'PaymentMetadataException',
'PaymentProviderException',
'PriceChangedException',
'ReceiptDataException',
'ReceiptProviderException',
'RateLimitException',
'ServiceUnavailableException',
'UnauthorizedException',
]
from src.application.domain.exceptions.application_exceptions import ApplicationException

View File

@@ -7,13 +7,12 @@ class ApplicationException(Exception):
self,
status_code: int,
message: str,
headers: Mapping[str,str] | None = None,
headers: Mapping[str, str] | None = None,
):
super().__init__(message)
self.status_code = status_code
self.message = message
self.headers = headers
def __str__(self):
return f'{self.status_code}: {self.message}'
return f"{self.status_code}: {self.message}"

View File

@@ -1,12 +0,0 @@
from __future__ import annotations
from typing import Mapping
from src.application.domain.exceptions.application_exception import ApplicationException
class BadGatewayException(ApplicationException):
def __init__(
self,
message: str = 'Bad Gateway',
headers: Mapping[str,str] | None = None,
):
super().__init__(status_code=502,message=message,headers=headers)

View File

@@ -1,12 +0,0 @@
from __future__ import annotations
from typing import Mapping
from src.application.domain.exceptions.application_exception import ApplicationException
class BadRequestException(ApplicationException):
def __init__(
self,
message: str = 'Bad Request',
headers: Mapping[str,str] | None = None,
):
super().__init__(status_code=400,message=message,headers=headers)

View File

@@ -1,12 +0,0 @@
from __future__ import annotations
from typing import Mapping
from src.application.domain.exceptions.application_exception import ApplicationException
class ConflictException(ApplicationException):
def __init__(
self,
message: str = 'Conflict',
headers: Mapping[str,str] | None = None,
):
super().__init__(status_code=409,message=message,headers=headers)

View File

@@ -1,12 +0,0 @@
from __future__ import annotations
from typing import Mapping
from src.application.domain.exceptions.application_exception import ApplicationException
class CsrfException(ApplicationException):
def __init__(
self,
message: str = 'CSRF token invalid',
headers: Mapping[str,str] | None = None,
):
super().__init__(status_code=403,message=message,headers=headers)

View File

@@ -1,12 +0,0 @@
from __future__ import annotations
from typing import Mapping
from src.application.domain.exceptions.application_exception import ApplicationException
class ForbiddenException(ApplicationException):
def __init__(
self,
message: str = 'Forbidden',
headers: Mapping[str,str] | None = None,
):
super().__init__(status_code=403,message=message,headers=headers)

View File

@@ -1,12 +0,0 @@
from __future__ import annotations
from typing import Mapping
from src.application.domain.exceptions.application_exception import ApplicationException
class InternalServerException(ApplicationException):
def __init__(
self,
message: str = 'Internal Server Error',
headers: Mapping[str,str] | None = None,
):
super().__init__(status_code=500,message=message,headers=headers)

View File

@@ -1,13 +0,0 @@
from __future__ import annotations
from typing import Mapping
from src.application.domain.exceptions.application_exception import ApplicationException
class JwtException(ApplicationException):
def __init__(
self,
message: str = 'Invalid token',
status_code: int = 401,
headers: Mapping[str,str] | None = None,
):
super().__init__(status_code=status_code,message=message,headers=headers)

View File

@@ -1,12 +0,0 @@
from __future__ import annotations
from typing import Mapping
from src.application.domain.exceptions.application_exception import ApplicationException
class NotFoundException(ApplicationException):
def __init__(
self,
message: str = 'Not Found',
headers: Mapping[str,str] | None = None,
):
super().__init__(status_code=404,message=message,headers=headers)

View File

@@ -1,12 +0,0 @@
from __future__ import annotations
from typing import Mapping
from src.application.domain.exceptions.bad_request_exception import BadRequestException
class OrderTotalOutOfRangeException(BadRequestException):
def __init__(
self,
message: str = 'Order total is outside allowed range',
headers: Mapping[str,str] | None = None,
) -> None:
super().__init__(message=message, headers=headers)

View File

@@ -1,12 +0,0 @@
from __future__ import annotations
from typing import Mapping
from src.application.domain.exceptions.application_exception import ApplicationException
class PaymentMetadataException(ApplicationException):
def __init__(
self,
message: str = 'Payment metadata invalid',
headers: Mapping[str,str] | None = None,
):
super().__init__(status_code=400,message=message,headers=headers)

View File

@@ -1,12 +0,0 @@
from __future__ import annotations
from typing import Mapping
from src.application.domain.exceptions.application_exception import ApplicationException
class PaymentProviderException(ApplicationException):
def __init__(
self,
message: str = 'Payment provider error',
headers: Mapping[str,str] | None = None,
):
super().__init__(status_code=502,message=message,headers=headers)

View File

@@ -1,12 +0,0 @@
from __future__ import annotations
from typing import Mapping
from src.application.domain.exceptions.application_exception import ApplicationException
class PriceChangedException(ApplicationException):
def __init__(
self,
message: str = 'Price has changed, please refresh and try again',
headers: Mapping[str,str] | None = None,
):
super().__init__(status_code=409,message=message,headers=headers)

View File

@@ -1,13 +0,0 @@
from __future__ import annotations
from typing import Mapping
from src.application.domain.exceptions.application_exception import ApplicationException
class RateLimitException(ApplicationException):
def __init__(
self,
message: str = 'Too Many Requests',
status_code: int = 429,
headers: Mapping[str,str] | None = None,
):
super().__init__(status_code=status_code,message=message,headers=headers)

View File

@@ -1,12 +0,0 @@
from __future__ import annotations
from typing import Mapping
from src.application.domain.exceptions.application_exception import ApplicationException
class ReceiptDataException(ApplicationException):
def __init__(
self,
message: str = 'Receipt data invalid',
headers: Mapping[str,str] | None = None,
):
super().__init__(status_code=400,message=message,headers=headers)

View File

@@ -1,13 +0,0 @@
from __future__ import annotations
from typing import Mapping
from src.application.domain.exceptions.application_exception import ApplicationException
class ReceiptProviderException(ApplicationException):
def __init__(
self,
message: str = 'Receipt provider error',
status_code: int = 502,
headers: Mapping[str,str] | None = None,
):
super().__init__(status_code=status_code,message=message,headers=headers)

View File

@@ -1,12 +0,0 @@
from __future__ import annotations
from typing import Mapping
from src.application.domain.exceptions.application_exception import ApplicationException
class ServiceUnavailableException(ApplicationException):
def __init__(
self,
message: str = 'Service Unavailable',
headers: Mapping[str,str] | None = None,
):
super().__init__(status_code=503,message=message,headers=headers)

View File

@@ -1,12 +0,0 @@
from __future__ import annotations
from typing import Mapping
from src.application.domain.exceptions.application_exception import ApplicationException
class UnauthorizedException(ApplicationException):
def __init__(
self,
message: str = 'Unauthorized',
headers: Mapping[str,str] | None = None,
):
super().__init__(status_code=401,message=message,headers=headers)

View File

@@ -1,5 +0,0 @@
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

@@ -1,184 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
from decimal import Decimal, ROUND_DOWN, ROUND_UP
from src.application.contracts import ICache, ILogger
from src.application.domain.exceptions import OrderTotalOutOfRangeException, ServiceUnavailableException
MIN_TOTAL_RUB: Decimal = Decimal('5000')
_MAX_TOTAL_RUB: Decimal = Decimal('600000')
_FEE_TIERS: tuple[tuple[Decimal, Decimal, Decimal, bool, bool], ...] = (
(Decimal('0.08'), Decimal('0'), Decimal('30000'), True, True),
(Decimal('0.06'), Decimal('30000'), Decimal('100000'), False, True),
(Decimal('0.04'), Decimal('30000'), Decimal('600000'), False, True),
)
def _total_in_bracket(
total: Decimal,
lo: Decimal,
hi: Decimal,
*,
lo_inclusive: bool,
hi_inclusive: bool,
) -> bool:
if lo_inclusive:
ok_lo = total >= lo
else:
ok_lo = total > lo
if hi_inclusive:
ok_hi = total <= hi
else:
ok_hi = total < hi
return ok_lo and ok_hi
@dataclass(slots=True)
class PaymentQuote:
usdt_amount: Decimal
usdt_exchange_rate: Decimal
gas_fee: Decimal
service_fee: Decimal
total_price: Decimal
service_fee_rate: Decimal
created_at: datetime
class PaymentQuoteService:
def __init__(self, *, remote_cache: ICache, logger: ILogger):
self._remote_cache = remote_cache
self._logger = logger
async def _load_prices(self) -> tuple[Decimal, Decimal]:
rate_raw = await self._remote_cache.hget('tradex:rub:rate', 'value')
gas_raw = await self._remote_cache.hget('gwei:eth:last', 'normal_rub')
self._logger.info(f'Actual market values: rate={rate_raw}, gas={gas_raw}')
if rate_raw is None:
self._logger.error('Exchange rate unavailable')
raise ServiceUnavailableException(message='Exchange rate unavailable')
if gas_raw is None:
self._logger.error('Exchange gas unavailable')
raise ServiceUnavailableException(message='Exchange gas unavailable')
gas_fee = Decimal(gas_raw).quantize(Decimal('0.00'), rounding=ROUND_UP)
usdt_exchange_rate = Decimal(rate_raw).quantize(Decimal('0.00'), rounding=ROUND_UP)
return usdt_exchange_rate, gas_fee
def _compose_quote(
self,
usdt_amount: Decimal,
usdt_exchange_rate: Decimal,
gas_fee: Decimal,
) -> PaymentQuote | None:
base_rub = usdt_amount * usdt_exchange_rate
chosen_rate: Decimal | None = None
service_fee: Decimal | None = None
total_price: Decimal | None = None
for fee_rate, lo, hi, li, ri in _FEE_TIERS:
sf = (base_rub * fee_rate).quantize(Decimal('0.01'))
tp = (base_rub + sf + gas_fee).quantize(Decimal('0.01'))
if _total_in_bracket(tp, lo, hi, lo_inclusive=li, hi_inclusive=ri):
chosen_rate = fee_rate
service_fee = sf
total_price = tp
break
if chosen_rate is None or service_fee is None or total_price is None:
return None
return PaymentQuote(
usdt_amount=usdt_amount,
usdt_exchange_rate=usdt_exchange_rate,
gas_fee=gas_fee,
service_fee=service_fee,
total_price=total_price,
service_fee_rate=chosen_rate,
created_at=datetime.now(timezone.utc),
)
async def get_reference_quote(self, usdt_amount: Decimal) -> PaymentQuote:
if usdt_amount <= Decimal('0'):
raise OrderTotalOutOfRangeException(
message='Order total is below minimum allowed amount',
)
usdt_exchange_rate, gas_fee = await self._load_prices()
quote = self._compose_quote(usdt_amount, usdt_exchange_rate, gas_fee)
if quote is None:
raise OrderTotalOutOfRangeException(
message='Order total exceeds maximum allowed amount',
)
return quote
async def get_quote(self, usdt_amount: Decimal) -> PaymentQuote:
quote = await self.get_reference_quote(usdt_amount)
if quote.total_price < MIN_TOTAL_RUB:
raise OrderTotalOutOfRangeException(
message='Order total is below minimum allowed amount',
)
return quote
async def get_quote_from_total_rub(self, total_rub: Decimal) -> PaymentQuote:
if total_rub < MIN_TOTAL_RUB:
raise OrderTotalOutOfRangeException(
message='Order total is below minimum allowed amount',
)
if total_rub > _MAX_TOTAL_RUB:
raise OrderTotalOutOfRangeException(
message='Order total exceeds maximum allowed amount',
)
usdt_exchange_rate, gas_fee = await self._load_prices()
u_cap = ((_MAX_TOTAL_RUB - gas_fee) / (usdt_exchange_rate * Decimal('1.04'))).quantize(
Decimal('0.01'),
rounding=ROUND_DOWN,
)
n_hi = int((u_cap * 100).to_integral_value(rounding=ROUND_DOWN))
if n_hi < 1:
raise OrderTotalOutOfRangeException(
message='Order total is below minimum allowed amount',
)
n_lo = 1
best_cent: int | None = None
while n_lo <= n_hi:
mid = (n_lo + n_hi) // 2
u = Decimal(mid) / Decimal(100)
quote = self._compose_quote(u, usdt_exchange_rate, gas_fee)
if quote is None:
n_hi = mid - 1
continue
if quote.total_price >= total_rub:
best_cent = mid
n_hi = mid - 1
else:
n_lo = mid + 1
if best_cent is None:
raise OrderTotalOutOfRangeException(
message='Order total exceeds maximum allowed amount',
)
final = self._compose_quote(
Decimal(best_cent) / Decimal(100),
usdt_exchange_rate,
gas_fee,
)
if final is None:
raise OrderTotalOutOfRangeException(
message='Order total exceeds maximum allowed amount',
)
return final

View File

@@ -1,64 +0,0 @@
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,
)

View File

@@ -0,0 +1,29 @@
from src.infrastructure.database.decorators import transactional
from src.presentation.schemas.order import CreateOrder
class UserLoginStartCommand:
def __init__(
self,
hash_service: IHashService,
cache: ICache,
unit_of_work: IUnitOfWork,
logger: ILogger,
messanger: IQueueMessanger,
):
self._hash_service = hash_service
self._unit_of_work = unit_of_work
self._cache = cache
self._logger = logger
self._messanger = messanger
@transactional
async def __call__(self, payment_data: CreateOrder) -> bool:
metadata: dict = {
'user_id': str(payment_data.user_id),
}

View File

@@ -1,5 +1,2 @@
from src.infrastructure.cache.client import create_redis_client
from src.infrastructure.cache.keydb_client import KeydbCache
from src.infrastructure.cache.remote_cache import RemoteCache
__all__ = ['create_redis_client', 'KeydbCache', 'RemoteCache']

View File

@@ -3,10 +3,9 @@ from redis.asyncio.client import Redis
from src.infrastructure.config import settings
def create_redis_client(url:str|None=None) -> Redis:
redis_url = url or settings.KEYDB_MARKET_URL
def create_redis_client() -> Redis:
return redis.from_url(
redis_url,
settings.REDIS_URL,
max_connections=50,
decode_responses=True,
socket_timeout=5,

View File

@@ -20,9 +20,6 @@ class KeydbCache(ICache):
async def get(self, key: str) -> str | None:
return await self._r.get(key)
async def hget(self, key: str, field: str) -> str | None:
return await self._r.hget(key, field)
async def delete(self, key: str) -> bool:
return (await self._r.delete(key)) > 0
@@ -40,12 +37,12 @@ class KeydbCache(ICache):
'middle_name': user.middle_name,
'last_name': user.last_name,
'birth_date': str(user.birth_date) if user.birth_date else None,
'encrypted_mnemonic': user.encrypted_mnemonic,
'crypto_wallet': user.crypto_wallet,
'phone': user.phone,
'passport_data': user.passport_data,
'bik': user.bik,
'account_number': user.account_number,
'card_number': user.card_number,
'inn': user.inn,
'erc20': user.erc20,
'avatar_link': user.avatar_link,
'kyc_verified': user.kyc_verified,
'is_deleted': user.is_deleted,
'created_at': user.created_at.isoformat() if user.created_at else None,

View File

@@ -1,68 +0,0 @@
from __future__ import annotations
import orjson
from redis.asyncio.client import Redis
from src.application.contracts import ICache
from src.application.domain.entities.user import UserEntity
class RemoteCache(ICache):
USER_PREFIX = 'user:me'
def __init__(self,redis_client: Redis) -> None:
self._r = redis_client
async def set(self,key: str,value: str,ttl: int) -> bool:
return bool(await self._r.set(key,value,ex=ttl))
async def set_nx(self,key: str,value: str,ttl: int) -> bool:
return bool(await self._r.set(key,value,ex=ttl,nx=True))
async def get(self,key: str) -> str | None:
mapping = await self._r.hgetall(key)
if not mapping:
return None
return mapping.get('usdt_rub')
async def hget(self,key: str,field: str) -> str | None:
return await self._r.hget(key,field)
async def delete(self,key: str) -> bool:
return (await self._r.delete(key)) > 0
async def get_user(self,user_id: str) -> dict | None:
raw = await self._r.get(f'{self.USER_PREFIX}:{user_id}')
if raw is None:
return None
return orjson.loads(raw)
async def set_user(self,user_id: str,user: UserEntity,ttl: int = 300) -> None:
data = orjson.dumps({
'id': user.id,
'email': user.email,
'first_name': user.first_name,
'middle_name': user.middle_name,
'last_name': user.last_name,
'birth_date': str(user.birth_date) if user.birth_date else None,
'encrypted_mnemonic': user.encrypted_mnemonic,
'phone': user.phone,
'passport_data': user.passport_data,
'inn': user.inn,
'erc20': user.erc20,
'avatar_link': user.avatar_link,
'kyc_verified': user.kyc_verified,
'is_deleted': user.is_deleted,
'created_at': user.created_at.isoformat() if user.created_at else None,
'updated_at': user.updated_at.isoformat() if user.updated_at else None,
'kyc_verified_at': user.kyc_verified_at.isoformat() if user.kyc_verified_at else None,
})
await self._r.set(f'{self.USER_PREFIX}:{user_id}',data,ex=ttl)

View File

@@ -1,5 +0,0 @@
from src.infrastructure.cloud_kassir.client import ClaudeKassirClient
__all__=['ClaudeKassirClient']

View File

@@ -1,151 +0,0 @@
from __future__ import annotations
from decimal import Decimal
from typing import Any
from ulid import ULID
import aiohttp
import orjson
from aiohttp import BasicAuth, ClientTimeout
from src.application.contracts import IReceipt
from src.application.domain.exceptions import ApplicationException,ReceiptProviderException
from src.infrastructure.cloud_kassir.constants import CLOUD_KASSIR_API_BASE_URL
class ClaudeKassirClient(IReceipt):
def __init__(
self,
*,
public_id: str,
api_secret: str,
inn: str,
api_base_url: str = CLOUD_KASSIR_API_BASE_URL,
success_url: str | None = None,
fail_url: str | None = None,
timeout_seconds: float = 30,
) -> None:
self._public_id = public_id
self._api_secret = api_secret
self._inn = inn
self._api_base_url = api_base_url.rstrip('/')
self._success_url = success_url
self._fail_url = fail_url
self._timeout = ClientTimeout(total=timeout_seconds)
async def create_receipt(
self,
*,
order_id: str,
user_id: str,
email: str,
total_amount: Decimal,
principal_amount: Decimal,
service_fee: Decimal,
customer_info: str = '',
customer_inn: str = '',
customer_birthday: str = '',
success_url: str | None = None,
fail_url: str | None = None,
request_id: str | None = None,
) -> dict[str, Any]:
total = total_amount.quantize(Decimal('0.01'))
principal = principal_amount.quantize(Decimal('0.01'))
fee = service_fee.quantize(Decimal('0.01'))
description = f'Оплата по заявке №{order_id}'
principal_description = f'Исполнение поручения принципала по заявке №{order_id}'
fee_description = f'Агентское вознаграждение по заявке №{order_id}'
payload: dict[str, Any] = {
'Inn': self._inn,
'Type': 'Income',
'InvoiceId': order_id,
'AccountId': user_id,
'Description': description,
'CustomerReceipt': {
'TaxationSystem': 2,
'Items': [
{
'label': principal_description,
'price': float(principal),
'quantity': 1,
'amount': float(principal),
'vat': 0,
'method': 4,
'object': 4,
'measurement_unit': 'шт',
'agentSign': 6,
'agentData': {
'agentOperationName': 'Исполнение поручения принципала',
},
'purveyorData': {
'name': 'Elcsa',
'inn': self._inn,
},
},
{
'label': fee_description,
'price': float(fee),
'quantity': 1,
'amount': float(fee),
'vat': 0,
'method': 4,
'object': 11,
'measurement_unit': 'шт',
},
],
'amounts': {
'electronic': float(total),
'advancePayment': 0,
'credit': 0,
'provision': 0,
},
'email': email,
'customerInfo': customer_info,
'customerInn': customer_inn,
'customerBirthday': customer_birthday,
},
'Email': email,
'SuccessUrl': success_url or self._success_url,
'FailUrl': fail_url or self._fail_url,
}
if payload['SuccessUrl'] is None:
payload.pop('SuccessUrl')
if payload['FailUrl'] is None:
payload.pop('FailUrl')
url = f'{self._api_base_url}/kkt/receipt'
headers = {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-Request-ID': request_id or str(ULID()),
}
try:
async with aiohttp.ClientSession(timeout=self._timeout) as session:
auth = BasicAuth(self._public_id, self._api_secret)
async with session.post(url, json=payload, headers=headers, auth=auth) as resp:
raw = (await resp.text()).strip()
if not raw:
raise ReceiptProviderException(
message=f'Receipt provider empty response (HTTP {resp.status})',
)
try:
parsed: Any = orjson.loads(raw)
except orjson.JSONDecodeError:
preview = raw[:240].replace('\n', ' ')
raise ReceiptProviderException(
message=f'Receipt provider non-JSON response (HTTP {resp.status}): {preview}',
)
if not isinstance(parsed, dict):
raise ReceiptProviderException(message='Receipt provider invalid response')
body = parsed
if resp.status >= 400:
raise ReceiptProviderException(
message=str(body.get('Message') or 'Receipt provider error'),
)
if body.get('Success') is False:
raise ReceiptProviderException(
status_code=409,
message=str(body.get('Message') or 'Receipt provider rejected receipt'),
)
return body
except ApplicationException:
raise
except (TimeoutError,aiohttp.ClientError):
raise ReceiptProviderException(message='Receipt provider unreachable')

View File

@@ -1,4 +0,0 @@
CLOUD_KASSIR_API_BASE_URL = 'https://api.cloudpayments.ru'
CLOUD_KASSIR_INN = '9810001062'
CLOUD_KASSIR_SUCCESS_URL = 'https://yourdomain.com/success'
CLOUD_KASSIR_FAIL_URL = 'https://yourdomain.com/fail'

View File

@@ -1,37 +1,22 @@
from __future__ import annotations
import os
from functools import lru_cache
from typing import List,Literal
from dotenv import find_dotenv,load_dotenv
from pydantic import AliasChoices,Field,field_validator,model_validator
from pydantic_settings import BaseSettings,SettingsConfigDict
from src.infrastructure.vault import create_hvac_client_from_approle, read_kv2_secret
from typing import List, Literal
import os
from dotenv import load_dotenv, find_dotenv
from pydantic import Field, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from src.infrastructure.vault import create_hvac_client, read_kv2_secret
env_file = find_dotenv('.env')
env_file = find_dotenv(".env")
if env_file:
load_dotenv(env_file)
def normalize_vault_base_url(raw: str) -> str:
u = raw.strip().rstrip('/')
if not u:
return raw.strip()
if '://' not in u:
return f'https://{u}'
return u
class Settings(BaseSettings):
VAULT_ADDR: str = Field(default='http://localhost:8200')
VAULT_ROLE_ID: str = Field(..., description='AppRole role_id')
VAULT_SECRET_ID: str = Field(
...,
description='AppRole secret_id',
validation_alias=AliasChoices('VAULT_SECRET_ID', 'VAULT_SECRET_TOKEN'),
)
VAULT_NAMESPACE: str | None = Field(default=None)
VAULT_MOUNT_POINT: str = Field(default='secrets')
VAULT_ADDR: str = Field(default="http://localhost:8200")
VAULT_TOKEN: str = Field(..., description="Vault token is required")
VAULT_MOUNT_POINT: str = Field(default="secrets")
VAULT_JWT_KID_PATH: str = "jwt/kid"
VAULT_JWT_KIDS_PREFIX: str = "jwt/kids"
@@ -60,8 +45,6 @@ class Settings(BaseSettings):
CSRF_COOKIE_PATH: str = "/"
CSRF_COOKIE_DOMAIN: str | None = None
CORS_ALLOW_ORIGIN_REGEX: str = r'https?://([a-z0-9-]+\.)*elcsa\.ru(:\d+)?$'
DOCS_USERNAME: str = "admin"
DOCS_PASSWORD: str = "admin"
@@ -76,15 +59,6 @@ class Settings(BaseSettings):
REDIS_PASSWORD: str | None = None
REDIS_DB: int = 0
KEYDB_REMOTE_HOST: str | None = None
KEYDB_REMOTE_PORT: int | None = None
KEYDB_REMOTE_PASSWORD: str | None = None
KEYDB_REMOTE_DB: int | None = None
KEYDB_HOST: str | None = Field(default=None,validation_alias=AliasChoices('KEYDB_HOST','KEYDB_CACHE_HOST'))
KEYDB_PORT: int | None = Field(default=None,validation_alias=AliasChoices('KEYDB_PORT','KEYDB_CACHE_PORT'))
KEYDB_PASSWORD: str | None = Field(default=None,validation_alias=AliasChoices('KEYDB_PASSWORD','KEYDB_CACHE_PASSWORD'))
KEYDB_DB: int | None = Field(default=None,validation_alias=AliasChoices('KEYDB_DB','KEYDB_CACHE_DB'))
RABBIT_HOST: str = "localhost"
RABBIT_PORT: int = 5672
RABBIT_USER: str = "guest"
@@ -94,25 +68,6 @@ class Settings(BaseSettings):
RABBIT_PUBLISH_PERSIST: bool = True
RABBIT_CONNECT_TIMEOUT: int = 5
RABBIT_EMAIL_CODE_QUEUE: str = "email.verification_code"
RABBIT_CRYPTO_TRANSFER_QUEUE: str = "crypto.transfer.requested"
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_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 = ''
CRYPTO_USDT_CONTRACT_ADDRESS: str = ''
CLOUD_KASSIR_PUBLIC_ID: str = ''
CLOUD_KASSIR_API_SECRET: str = ''
LOG_LEVEL: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO"
LOG_FORMAT: Literal["JSON", "TEXT"] = "TEXT"
@@ -122,211 +77,51 @@ class Settings(BaseSettings):
env_file_encoding="utf-8",
case_sensitive=True,
extra="ignore",
populate_by_name=True,
)
@field_validator('VAULT_ADDR', mode='before')
@classmethod
def vault_addr_scheme(cls, v):
if v is None or not isinstance(v, str):
return v
return normalize_vault_base_url(v)
@model_validator(mode='before')
@model_validator(mode="before")
@classmethod
def load_from_vault(cls, data: dict):
if not isinstance(data, dict):
return data
addr_raw = data.get('VAULT_ADDR') or os.getenv('VAULT_ADDR') or 'http://localhost:8200'
addr = normalize_vault_base_url(addr_raw)
data['VAULT_ADDR'] = addr
role_id = data.get('VAULT_ROLE_ID') or os.getenv('VAULT_ROLE_ID')
secret_id = (
data.get('VAULT_SECRET_ID')
or data.get('VAULT_SECRET_TOKEN')
or os.getenv('VAULT_SECRET_ID')
or os.getenv('VAULT_SECRET_TOKEN')
)
namespace = data.get('VAULT_NAMESPACE')
if namespace is None:
namespace = os.getenv('VAULT_NAMESPACE')
namespace = namespace if namespace else None
mount = data.get('VAULT_MOUNT_POINT') or os.getenv('VAULT_MOUNT_POINT') or 'secrets'
addr = data.get("VAULT_ADDR") or os.getenv("VAULT_ADDR") or "http://localhost:8200"
token = data.get("VAULT_TOKEN") or os.getenv("VAULT_TOKEN")
mount = data.get("VAULT_MOUNT_POINT") or os.getenv("VAULT_MOUNT_POINT") or "secrets"
if not role_id or not secret_id:
raise RuntimeError('VAULT_ROLE_ID and VAULT_SECRET_ID (or VAULT_SECRET_TOKEN) are required for Vault AppRole')
if not token:
raise RuntimeError("VAULT_TOKEN is required")
data['VAULT_ROLE_ID'] = str(role_id).strip()
data['VAULT_SECRET_ID'] = str(secret_id).strip()
client = create_hvac_client(url=addr, token=token, timeout=5)
client = create_hvac_client_from_approle(
url=addr,
role_id=role_id,
secret_id=secret_id,
namespace=namespace,
timeout=5,
)
def read_secret(path: str) -> dict:
return read_kv2_secret(client=client, mount_point=mount, path=path)
def read_secret_optional(path: str) -> dict:
def safe_read(path: str) -> dict:
try:
return read_secret(path)
return read_kv2_secret(client=client, mount_point=mount, path=path)
except Exception:
return {}
database = read_secret('database')
csrf = read_secret_optional('csrf')
rabbitmq = read_secret_optional('rabbitmq')
database = safe_read("database")
rabbitmq = safe_read("rabbitmq")
csrf = safe_read("csrf")
db_ci = {str(k).lower(): v for k, v in database.items()}
if database:
required = ["HOST", "NAME", "USER", "PASSWORD", "PORT"]
missing = [k for k in required if k not in database]
if missing:
raise RuntimeError(f"Vault database secret missing keys {missing}")
def db_nonempty(key: str) -> bool:
v = db_ci.get(key)
if v is None:
return False
if isinstance(v, str) and not v.strip():
return False
return True
required_db = ['host', 'name', 'user', 'password', 'port']
missing_db = [k for k in required_db if not db_nonempty(k)]
if missing_db:
raise RuntimeError(f'Vault secret database missing non-empty keys: {missing_db}')
data['DATABASE_HOST'] = str(db_ci['host']).strip()
data['DATABASE_PORT'] = int(db_ci['port'])
data['DATABASE_NAME'] = str(db_ci['name']).strip()
data['DATABASE_USER'] = str(db_ci['user']).strip()
data['DATABASE_PASSWORD'] = str(db_ci['password']).strip()
if csrf:
csrf_secret = None
for entry_key, entry_val in csrf.items():
if str(entry_key).lower() == 'key' and entry_val is not None and str(entry_val).strip():
csrf_secret = str(entry_val).strip()
break
if csrf_secret:
data['CSRF_SECRET_KEY'] = csrf_secret
data["DATABASE_HOST"] = database["HOST"]
data["DATABASE_PORT"] = database["PORT"]
data["DATABASE_NAME"] = database["NAME"]
data["DATABASE_USER"] = database["USER"]
data["DATABASE_PASSWORD"] = database["PASSWORD"]
if rabbitmq:
r_ci = {str(k).lower(): v for k, v in rabbitmq.items()}
data["RABBIT_HOST"] = rabbitmq.get("HOST", data.get("RABBIT_HOST"))
data["RABBIT_PORT"] = rabbitmq.get("PORT", data.get("RABBIT_PORT"))
data["RABBIT_USER"] = rabbitmq.get("USER", data.get("RABBIT_USER"))
data["RABBIT_PASSWORD"] = rabbitmq.get("PASSWORD", data.get("RABBIT_PASSWORD"))
data["RABBIT_VHOST"] = rabbitmq.get("VHOST", data.get("RABBIT_VHOST"))
def rb_set(field: str, env_key: str, *, as_int: bool = False) -> None:
v = r_ci.get(field)
if v is None:
return
if isinstance(v, str) and not v.strip():
return
data[env_key] = int(v) if as_int else str(v).strip()
rb_set('host', 'RABBIT_HOST')
rb_set('port', 'RABBIT_PORT', as_int=True)
rb_set('user', 'RABBIT_USER')
rb_set('password', 'RABBIT_PASSWORD')
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')
crypto = read_secret_optional('crypto')
if crypto:
crypto_ci = {str(k).lower(): v for k, v in crypto.items()}
usdt_contract_address = crypto_ci.get('usdt_contract_address')
if usdt_contract_address is not None and str(usdt_contract_address).strip():
data['CRYPTO_USDT_CONTRACT_ADDRESS'] = str(usdt_contract_address).strip()
keydb = read_secret('keydb')
k_ci = {str(k).lower(): v for k, v in keydb.items()}
def keydb_nonempty(key: str) -> bool:
v = k_ci.get(key)
if v is None:
return False
if isinstance(v, str) and not v.strip():
return False
return True
missing_keydb = []
for req in ('host', 'port'):
if not keydb_nonempty(req):
missing_keydb.append(req)
db_raw = k_ci.get('database')
if db_raw is None:
db_raw = k_ci.get('db')
if db_raw is None or (isinstance(db_raw, str) and not str(db_raw).strip()):
missing_keydb.append('database')
if missing_keydb:
raise RuntimeError(
f'Vault secret keydb missing non-empty keys: {missing_keydb} (mount={mount},path=keydb)'
)
data['KEYDB_REMOTE_HOST'] = str(k_ci['host']).strip()
data['KEYDB_REMOTE_PORT'] = int(k_ci['port'])
data['KEYDB_REMOTE_DB'] = int(db_raw)
pw_raw = k_ci.get('password')
if pw_raw is not None and str(pw_raw).strip():
data['KEYDB_REMOTE_PASSWORD'] = str(pw_raw).strip()
else:
data['KEYDB_REMOTE_PASSWORD'] = None
itpay_public_id = data.get('ITPAY_PUBLIC_ID') or os.getenv('ITPAY_PUBLIC_ID')
itpay_api_secret = data.get('ITPAY_API_SECRET') or os.getenv('ITPAY_API_SECRET')
if itpay_public_id is not None and str(itpay_public_id).strip() and itpay_api_secret is not None and str(itpay_api_secret).strip():
data['ITPAY_PUBLIC_ID'] = str(itpay_public_id).strip()
data['ITPAY_API_SECRET'] = str(itpay_api_secret).strip()
else:
itpay = read_secret('itpay')
itpay_ci = {str(k).lower(): v for k, v in itpay.items()}
public_id = itpay_ci.get('public_id')
api_secret = itpay_ci.get('api_secret')
if api_secret is None:
api_secret = itpay_ci.get('secret')
missing = []
if public_id is None or not str(public_id).strip():
missing.append('public_id')
if api_secret is None or not str(api_secret).strip():
missing.append('api_secret')
if missing:
raise RuntimeError(f'Vault secret itpay missing non-empty keys: {missing} (mount={mount},path=itpay)')
data['ITPAY_PUBLIC_ID'] = str(public_id).strip()
data['ITPAY_API_SECRET'] = str(api_secret).strip()
ck_public = data.get('CLOUD_KASSIR_PUBLIC_ID') or os.getenv('CLOUD_KASSIR_PUBLIC_ID')
ck_secret = data.get('CLOUD_KASSIR_API_SECRET') or os.getenv('CLOUD_KASSIR_API_SECRET')
if ck_public is not None and str(ck_public).strip() and ck_secret is not None and str(ck_secret).strip():
data['CLOUD_KASSIR_PUBLIC_ID'] = str(ck_public).strip()
data['CLOUD_KASSIR_API_SECRET'] = str(ck_secret).strip()
else:
cloudkassir = read_secret_optional('cloudkassir')
if cloudkassir:
ck_ci = {str(k).lower(): v for k, v in cloudkassir.items()}
public_id_ck = ck_ci.get('public_id')
api_secret_ck = ck_ci.get('api_secret')
if api_secret_ck is None:
api_secret_ck = ck_ci.get('secret')
if public_id_ck is not None and str(public_id_ck).strip():
data['CLOUD_KASSIR_PUBLIC_ID'] = str(public_id_ck).strip()
if api_secret_ck is not None and str(api_secret_ck).strip():
data['CLOUD_KASSIR_API_SECRET'] = str(api_secret_ck).strip()
if csrf:
data["CSRF_SECRET_KEY"] = csrf.get("KEY", data.get("CSRF_SECRET_KEY"))
return data
@@ -339,41 +134,8 @@ class Settings(BaseSettings):
@property
def REDIS_URL(self) -> str:
return self.KEYDB_MARKET_URL
@staticmethod
def _redis_url(*, host: str, port: int, password: str | None, db: int) -> str:
auth = f':{password}@' if password else ''
return f'redis://{auth}{host}:{port}/{db}'
@property
def KEYDB_MARKET_URL(self) -> str:
if self.KEYDB_REMOTE_HOST is None or self.KEYDB_REMOTE_PORT is None or self.KEYDB_REMOTE_DB is None:
raise RuntimeError('Vault KeyDB settings are required for market data')
return self._redis_url(
host=self.KEYDB_REMOTE_HOST,
port=int(self.KEYDB_REMOTE_PORT),
password=self.KEYDB_REMOTE_PASSWORD,
db=int(self.KEYDB_REMOTE_DB),
)
@property
def KEYDB_REMOTE_URL(self) -> str:
return self.KEYDB_MARKET_URL
@property
def KEYDB_CACHE_URL(self) -> str | None:
if self.KEYDB_HOST is None or not self.KEYDB_HOST.strip():
return None
if self.KEYDB_PORT is None:
return None
db = int(self.KEYDB_DB) if self.KEYDB_DB is not None else 0
return self._redis_url(
host=self.KEYDB_HOST.strip(),
port=int(self.KEYDB_PORT),
password=self.KEYDB_PASSWORD,
db=db,
)
auth = f":{self.REDIS_PASSWORD}@" if self.REDIS_PASSWORD else ""
return f"redis://{auth}{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"
@property
def RABBIT_URL(self) -> str:

View File

@@ -1,8 +1,6 @@
from src.infrastructure.database.models.base import Base
from src.infrastructure.database.models.order import Order
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.sessions import Session
__all__ = ['Base', 'UserModel', 'Session']
__all__ = ['Base','Order','Payment','SbpWithdrawal','UserModel']

View File

@@ -1,54 +0,0 @@
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 OrderStatus
from src.infrastructure.database.models.base import Base
from src.infrastructure.database.models.mixins import AuditTimestampsMixin, UlidPrimaryKeyMixin
class Order(Base, UlidPrimaryKeyMixin, AuditTimestampsMixin):
__tablename__ = 'orders'
user_id: Mapped[str] = mapped_column(
String(26),
ForeignKey('users.id', ondelete='RESTRICT'),
nullable=False,
index=True,
)
usdt_amount: Mapped[Decimal] = mapped_column(Numeric(38, 2), nullable=False)
usdt_exchange_rate: Mapped[Decimal] = mapped_column(Numeric(38, 2), nullable=False)
gas_fee: Mapped[Decimal] = mapped_column(Numeric(38, 2), nullable=False)
total_price: Mapped[Decimal] = mapped_column(Numeric(38, 2), nullable=False)
service_fee: Mapped[Decimal] = mapped_column(Numeric(38, 2), nullable=False)
status: Mapped[OrderStatus] = mapped_column(
SAEnum(OrderStatus,name='order_status_enum',values_callable=lambda x:[e.value for e in x]),
nullable=False,
index=True,
default=OrderStatus.PENDING,
)
client_payment_id: Mapped[str] = mapped_column(
String(26),
nullable=False,
unique=True,
index=True
)
itpay_payment_qr_url_desktop: Mapped[str | None] = mapped_column(Text, nullable=True)
itpay_payment_qr_url_android: Mapped[str | None] = mapped_column(Text, nullable=True)
itpay_payment_qr_url_ios: Mapped[str | None] = mapped_column(Text, nullable=True)
itpay_payment_qr_image_desktop: Mapped[str | None] = mapped_column(Text, nullable=True)
itpay_payment_qr_image_android: Mapped[str | None] = mapped_column(Text, nullable=True)
itpay_payment_qr_image_ios: Mapped[str | None] = mapped_column(Text, nullable=True)
itpay_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
itpay_qr_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
itpay_amount: Mapped[Decimal] = mapped_column(Numeric(38, 2), nullable=False)
itpay_created_at: Mapped[DateTime] = mapped_column(
DateTime(timezone=True),
nullable=False
)

View File

@@ -1,47 +0,0 @@
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from sqlalchemy import DateTime,Enum as SAEnum,ForeignKey,Numeric,String,UniqueConstraint,Text
from sqlalchemy.orm import Mapped, mapped_column
from src.application.domain.enums import PaymentStatus
from src.infrastructure.database.models.base import Base
from src.infrastructure.database.models.mixins import AuditTimestampsMixin, UlidPrimaryKeyMixin
class Payment(Base, UlidPrimaryKeyMixin, AuditTimestampsMixin):
__tablename__ = 'payments'
__table_args__ = (
UniqueConstraint('order_id', name='uq_payments_order_id'),
)
user_id: Mapped[str] = mapped_column(
String(26),
ForeignKey('users.id', ondelete='RESTRICT'),
nullable=False,
index=True,
)
order_id: Mapped[str] = mapped_column(
String(26),
ForeignKey('orders.id', ondelete='RESTRICT'),
nullable=False,
index=True,
)
status: Mapped[PaymentStatus] = mapped_column(
SAEnum(PaymentStatus,name='payment_status_enum',values_callable=lambda x:[e.value for e in x]),
nullable=False,
index=True,
default=PaymentStatus.PENDING,
)
receipt_cloudekassir_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
receipt_cloudekassir_link: Mapped[str | None] = mapped_column(nullable=True)
itpay_payment_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
itpay_paid_amount: Mapped[Decimal | None] = mapped_column(Numeric(38, 2), nullable=True)
transaction_id: Mapped[str | None] = mapped_column(String(200), nullable=True)
web3_transaction_hash: Mapped[str | None] = mapped_column(String(128), nullable=True)
paid_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
expired_date: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)

View File

@@ -1,39 +0,0 @@
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)
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)

View File

@@ -0,0 +1,50 @@
from datetime import datetime, timezone
from sqlalchemy import String, DateTime, ForeignKey, Index
from sqlalchemy.orm import Mapped, mapped_column
from ulid import ULID
from src.infrastructure.database.models import Base
from src.infrastructure.database.models.mixins import UlidPrimaryKeyMixin, AuditTimestampsMixin
class Session(Base, UlidPrimaryKeyMixin, AuditTimestampsMixin):
__tablename__ = "sessions"
sid: Mapped[str] = mapped_column(
String(26),
unique=True,
index=True,
nullable=False,
default=lambda: str(ULID()),
)
user_id: Mapped[str] = mapped_column(
String(26),
ForeignKey("users.id", ondelete="CASCADE"),
index=True,
nullable=False,
)
device_id: Mapped[str] = mapped_column(
String(26),
nullable=False,
index=True,
)
user_agent: Mapped[str | None] = mapped_column(String(500))
first_ip: Mapped[str | None] = mapped_column(String(64))
last_ip: Mapped[str | None] = mapped_column(String(64))
last_seen_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=lambda: datetime.now(timezone.utc),
)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
refresh_jti_hash: Mapped[str | None] = mapped_column(String(255))
refresh_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
Index("ux_sessions_user_device", Session.user_id, Session.device_id, unique=True)
Index("ix_sessions_user_active", Session.user_id, Session.revoked_at)

View File

@@ -1,9 +1,8 @@
from __future__ import annotations
from sqlalchemy import Boolean,Date,String,DateTime,Text
from sqlalchemy.orm import Mapped,mapped_column
from sqlalchemy import Boolean, Date, String, DateTime
from sqlalchemy.orm import Mapped, mapped_column
from src.infrastructure.database.models.base import Base
from src.infrastructure.database.models.mixins import UlidPrimaryKeyMixin,AuditTimestampsMixin,SoftDeleteMixin
from src.infrastructure.database.models.mixins import UlidPrimaryKeyMixin, AuditTimestampsMixin, SoftDeleteMixin
class UserModel(Base, UlidPrimaryKeyMixin, AuditTimestampsMixin, SoftDeleteMixin):
@@ -17,17 +16,13 @@ class UserModel(Base, UlidPrimaryKeyMixin, AuditTimestampsMixin, SoftDeleteMixin
middle_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
birth_date: Mapped[Date | None] = mapped_column(Date, nullable=True)
encrypted_mnemonic: Mapped[str | None] = mapped_column(Text, nullable=True)
crypto_wallet: Mapped[str | None] = mapped_column(String(255), nullable=True)
phone: Mapped[str | None] = mapped_column(String(16), nullable=True)
passport_data: Mapped[str | None] = mapped_column(String(255), nullable=True)
bik: Mapped[str | None] = mapped_column(String(9), nullable=True)
account_number: Mapped[str | None] = mapped_column(String(20), nullable=True)
card_number: Mapped[str | None] = mapped_column(String(19), nullable=True)
inn: Mapped[str | None] = mapped_column(String(12), nullable=True)
erc20: Mapped[str | None] = mapped_column(String(255), nullable=True)
avatar_link: Mapped[str | None] = mapped_column(String(2048), nullable=True)
kyc_verified: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default='false', default=False)
kyc_verified_at: Mapped[DateTime | None] = mapped_column(DateTime(timezone=True), nullable=True)
account_type: Mapped[str] = mapped_column(String(20), nullable=False, server_default='individual', default='individual')

View File

@@ -0,0 +1 @@
from src.infrastructure.database.repositories.user_repository import UserRepository

View File

@@ -1,173 +0,0 @@
from __future__ import annotations
from dataclasses import replace
from datetime import datetime,timezone
from decimal import Decimal
from sqlalchemy import desc,select,update
from sqlalchemy.ext.asyncio import AsyncSession
from src.application.abstractions.repositories.i_order_repository import IOrderRepository
from src.application.contracts import ILogger
from src.application.domain.entities.order import OrderEntity
from src.application.domain.enums import OrderStatus
from src.infrastructure.database.models.order import Order
class OrderRepository(IOrderRepository):
def __init__(self, session: AsyncSession, logger: ILogger):
self._session = session
self._logger = logger
@staticmethod
def _to_entity(model: Order) -> OrderEntity:
return OrderEntity(
id=model.id,
created_at=model.created_at,
updated_at=model.updated_at,
user_id=model.user_id,
usdt_amount=model.usdt_amount,
usdt_exchange_rate=model.usdt_exchange_rate,
gas_fee=model.gas_fee,
total_price=model.total_price,
service_fee=model.service_fee,
status=model.status,
client_payment_id=model.client_payment_id,
itpay_payment_qr_url_desktop=model.itpay_payment_qr_url_desktop,
itpay_payment_qr_url_android=model.itpay_payment_qr_url_android,
itpay_payment_qr_url_ios=model.itpay_payment_qr_url_ios,
itpay_payment_qr_image_desktop=model.itpay_payment_qr_image_desktop,
itpay_payment_qr_image_android=model.itpay_payment_qr_image_android,
itpay_payment_qr_image_ios=model.itpay_payment_qr_image_ios,
itpay_id=model.itpay_id,
itpay_qr_id=model.itpay_qr_id,
itpay_amount=model.itpay_amount,
itpay_created_at=model.itpay_created_at,
)
async def create(self,order: OrderEntity) -> OrderEntity:
model = Order(
user_id=order.user_id,
usdt_amount=order.usdt_amount,
usdt_exchange_rate=order.usdt_exchange_rate,
gas_fee=order.gas_fee,
total_price=order.total_price,
service_fee=order.service_fee,
status=order.status,
client_payment_id=order.client_payment_id,
itpay_payment_qr_url_desktop=None,
itpay_payment_qr_url_android=None,
itpay_payment_qr_url_ios=None,
itpay_payment_qr_image_desktop=None,
itpay_payment_qr_image_android=None,
itpay_payment_qr_image_ios=None,
itpay_id=None,
itpay_qr_id=None,
itpay_amount=Decimal('0.00'),
itpay_created_at=datetime.now(timezone.utc),
)
self._session.add(model)
await self._session.flush()
return replace(order,id=model.id)
async def get_by_client_payment_id(self,client_payment_id: str) -> OrderEntity | None:
stmt=select(Order).where(Order.client_payment_id==client_payment_id)
model=await self._session.scalar(stmt)
if model is None:
return None
return self._to_entity(model)
async def get_by_id(self,order_id: str) -> OrderEntity | None:
stmt=select(Order).where(Order.id==order_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,*,order_id: str,user_id: str) -> OrderEntity | None:
stmt=select(Order).where(Order.id==order_id,Order.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[OrderEntity]:
stmt=(
select(Order)
.where(Order.user_id==user_id)
.order_by(desc(Order.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_after_itpay_payment_created(self,order: OrderEntity) -> OrderEntity:
if not order.id:
raise ValueError('OrderEntity.id is required')
itpay_amount: Decimal
if order.itpay_amount is None:
itpay_amount = Decimal('0.00')
else:
itpay_amount = Decimal(str(order.itpay_amount))
itpay_created_at = order.itpay_created_at or datetime.now(timezone.utc)
stmt = (
update(Order)
.where(Order.id == order.id)
.values(
itpay_payment_qr_url_desktop=order.itpay_payment_qr_url_desktop,
itpay_payment_qr_url_android=order.itpay_payment_qr_url_android,
itpay_payment_qr_url_ios=order.itpay_payment_qr_url_ios,
itpay_payment_qr_image_desktop=order.itpay_payment_qr_image_desktop,
itpay_payment_qr_image_android=order.itpay_payment_qr_image_android,
itpay_payment_qr_image_ios=order.itpay_payment_qr_image_ios,
itpay_id=order.itpay_id,
itpay_qr_id=order.itpay_qr_id,
itpay_amount=itpay_amount,
itpay_created_at=itpay_created_at,
)
)
await self._session.execute(stmt)
await self._session.flush()
return order
async def update_after_itpay_failure(self,order: OrderEntity) -> OrderEntity:
if not order.id:
raise ValueError('OrderEntity.id is required')
if order.status is None:
raise ValueError('OrderEntity.status is required')
itpay_amount: Decimal
if order.itpay_amount is None:
itpay_amount = Decimal('0.00')
else:
itpay_amount = Decimal(str(order.itpay_amount))
itpay_created_at = order.itpay_created_at or datetime.now(timezone.utc)
stmt = (
update(Order)
.where(Order.id == order.id)
.values(
status=order.status,
itpay_id=order.itpay_id,
itpay_qr_id=order.itpay_qr_id,
itpay_amount=itpay_amount,
itpay_created_at=itpay_created_at,
)
)
await self._session.execute(stmt)
await self._session.flush()
return order
async def update_status(self,*,order_id:str,status:OrderStatus) -> None:
stmt=(
update(Order)
.where(Order.id==order_id)
.values(status=status)
)
await self._session.execute(stmt)
await self._session.flush()

View File

@@ -1,124 +0,0 @@
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from sqlalchemy import desc,select
from sqlalchemy.ext.asyncio import AsyncSession
from src.application.abstractions.repositories.i_payment_repository import IPaymentRepository
from src.application.contracts import ILogger
from src.application.domain.entities import PaymentEntity
from src.application.domain.enums import PaymentStatus
from src.infrastructure.database.models.payment import Payment
class PaymentRepository(IPaymentRepository):
def __init__(self,session: AsyncSession,logger: ILogger):
self._session=session
self._logger=logger
@staticmethod
def _to_entity(model: Payment) -> PaymentEntity:
return PaymentEntity(
id=model.id,
created_at=model.created_at,
updated_at=model.updated_at,
user_id=model.user_id,
order_id=model.order_id,
status=model.status,
receipt_cloudekassir_id=model.receipt_cloudekassir_id,
receipt_cloudekassir_link=model.receipt_cloudekassir_link,
itpay_payment_id=model.itpay_payment_id,
itpay_paid_amount=model.itpay_paid_amount,
transaction_id=model.transaction_id,
web3_transaction_hash=model.web3_transaction_hash,
paid_at=model.paid_at,
expired_date=model.expired_date,
)
async def create_completed(self,*,user_id:str,order_id:str,itpay_payment_id:str,itpay_paid_amount:str|None,transaction_id:str|None,paid_at:str|None,expired_date:str|None) -> bool:
stmt=select(Payment).where(Payment.order_id==order_id)
existing=await self._session.scalar(stmt)
if existing is not None:
return False
paid_at_dt=datetime.fromisoformat(paid_at.replace('Z','+00:00')) if paid_at else None
expired_dt=datetime.fromisoformat(expired_date.replace('Z','+00:00')) if expired_date else None
paid_amount_dec=Decimal(str(itpay_paid_amount)) if itpay_paid_amount is not None else None
model=Payment(
user_id=user_id,
order_id=order_id,
status=PaymentStatus.MONEY_ACCEPTED,
receipt_cloudekassir_id=None,
receipt_cloudekassir_link=None,
itpay_payment_id=itpay_payment_id,
itpay_paid_amount=paid_amount_dec,
transaction_id=transaction_id,
paid_at=paid_at_dt,
expired_date=expired_dt,
)
self._session.add(model)
await self._session.flush()
return True
async def update_crypto_transfer_completed(self,*,order_id:str,web3_transaction_hash:str|None) -> None:
stmt=select(Payment).where(Payment.order_id==order_id)
model=await self._session.scalar(stmt)
if model is None:
return
model.status=PaymentStatus.USDT_DELIVERED
model.web3_transaction_hash=web3_transaction_hash
await self._session.flush()
return
async def update_status(self,*,order_id:str,status:PaymentStatus) -> None:
stmt=select(Payment).where(Payment.order_id==order_id)
model=await self._session.scalar(stmt)
if model is None:
return
model.status=status
await self._session.flush()
return
async def update_receipt(self,*,order_id:str,receipt_cloudekassir_id:str|None,receipt_cloudekassir_link:str|None) -> None:
stmt=select(Payment).where(Payment.order_id==order_id)
model=await self._session.scalar(stmt)
if model is None:
return
model.status=PaymentStatus.COMPLETED
model.receipt_cloudekassir_id=receipt_cloudekassir_id
model.receipt_cloudekassir_link=receipt_cloudekassir_link
await self._session.flush()
return
async def get_by_order_id(self,order_id:str) -> PaymentEntity | None:
stmt=select(Payment).where(Payment.order_id==order_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,*,payment_id:str,user_id:str) -> PaymentEntity | None:
stmt=select(Payment).where(Payment.id==payment_id,Payment.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[PaymentEntity]:
stmt=(
select(Payment)
.where(Payment.user_id==user_id)
.order_by(desc(Payment.created_at))
.limit(limit)
.offset(offset)
)
result=await self._session.scalars(stmt)
return [self._to_entity(model) for model in result.all()]

View File

@@ -1,137 +0,0 @@
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,
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,
'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()

View File

@@ -1,51 +1,114 @@
from __future__ import annotations
from fastapi import status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.application.abstractions.repositories.i_user_repository import IUserRepository
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
from src.application.contracts import ILogger
from src.application.domain.entities.user import UserEntity
from src.infrastructure.database.models.user import UserModel
from src.application.domain.exceptions import ApplicationException
from src.application.abstractions.repositories import IUserRepository
from src.application.domain.entities import UserEntity
from src.infrastructure.database.models import UserModel
class UserRepository(IUserRepository):
def __init__(self,session:AsyncSession,logger:ILogger):
self._session=session
self._logger=logger
def __init__(self, session: AsyncSession, logger: ILogger):
self._session = session
self._logger = logger
async def create_user(self, email: str, password_hash: str) -> UserEntity:
user = UserModel(email=email, password_hash=password_hash)
self._session.add(user)
try:
await self._session.flush()
return UserEntity(
id=user.id,
email=user.email,
created_at=user.created_at,
kyc_verified=user.kyc_verified,
is_deleted=user.is_deleted
)
except IntegrityError:
self._logger.error(f'User already exists with email {user.email}')
raise ApplicationException(
status_code=status.HTTP_409_CONFLICT,
message='User with this email already exists',
)
except SQLAlchemyError as exception:
self._logger.exception(str(exception))
raise ApplicationException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
message=f'Database error: {str(exception)}',
)
async def get_user_by_email(self, email: str) -> UserEntity:
try:
stmt = (
select(UserModel)
.where(
UserModel.email == email,
UserModel.is_deleted.is_(False),
)
)
result = await self._session.execute(stmt)
user: UserModel | None = result.scalar_one_or_none()
if user is None:
self._logger.warning(f'User not found with email {email}')
raise ApplicationException(status_code=status.HTTP_404_NOT_FOUND, message='User not found',)
return UserEntity(
id=user.id,
email=user.email,
password_hash=user.password_hash,
first_name=user.first_name,
middle_name=user.middle_name,
last_name=user.last_name,
birth_date=user.birth_date,
crypto_wallet=user.crypto_wallet,
phone=user.phone,
bik=user.bik,
account_number=user.account_number,
card_number=user.card_number,
inn=user.inn,
kyc_verified_at=user.kyc_verified_at,
kyc_verified=user.kyc_verified,
is_deleted=user.is_deleted,
created_at=user.created_at,
updated_at=user.updated_at,
)
except ApplicationException:
raise
except SQLAlchemyError as exception:
self._logger.exception(str(exception))
raise ApplicationException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
message=f'Database error: {str(exception)}',
)
async def exists_by_email(self, email: str) -> bool:
try:
stmt = (
select(UserModel.id)
.where(
UserModel.email == email,
UserModel.is_deleted.is_(False),
)
.limit(1)
)
result = await self._session.execute(stmt)
return result.scalar_one_or_none() is not None
except SQLAlchemyError as exception:
self._logger.exception(str(exception))
raise ApplicationException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
message=f'Database error: {str(exception)}',
)
@staticmethod
def _to_entity(model:UserModel) -> UserEntity:
return UserEntity(
id=model.id,
email=model.email,
password_hash=model.password_hash,
first_name=model.first_name,
middle_name=model.middle_name,
last_name=model.last_name,
birth_date=model.birth_date,
encrypted_mnemonic=model.encrypted_mnemonic,
phone=model.phone,
passport_data=model.passport_data,
inn=model.inn,
erc20=model.erc20,
avatar_link=model.avatar_link,
kyc_verified=model.kyc_verified,
is_deleted=model.is_deleted,
created_at=model.created_at,
updated_at=model.updated_at,
kyc_verified_at=model.kyc_verified_at,
account_type=model.account_type,
)
async def get(self,user_id:str) -> UserEntity|None:
stmt=(
select(UserModel)
.where(UserModel.id==user_id)
.where(UserModel.is_deleted.is_(False))
)
model=await self._session.scalar(stmt)
if model is None:
return None
return self._to_entity(model)

View File

@@ -1,11 +1,8 @@
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from src.application.abstractions import IUnitOfWork
from src.application.abstractions.repositories import IOrderRepository,IPaymentRepository,ISbpWithdrawalRepository,IUserRepository
from src.application.abstractions.repositories import IUserRepository, ISessionRepository
from src.application.contracts import ILogger
from src.infrastructure.database.repositories.order_repository import OrderRepository
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 import UserRepository, SessionRepository
@@ -13,18 +10,12 @@ class UnitOfWork(IUnitOfWork):
def __init__(self, session_factory: async_sessionmaker[AsyncSession], logger: ILogger):
self.session_factory = session_factory
self._session: AsyncSession = None
self._order_repository: IOrderRepository | 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
self._session_repository: ISessionRepository = None
self._logger: ILogger = logger
async def __aenter__(self):
self._session = self.session_factory()
self._order_repository = None
self._payment_repository = None
self._sbp_withdrawal_repository = None
self._user_repository = None
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
@@ -38,29 +29,14 @@ class UnitOfWork(IUnitOfWork):
self._logger.debug('Commit')
await self._session.close()
@property
def order_repository(self) -> IOrderRepository:
if self._order_repository is None:
self._order_repository = OrderRepository(session=self._session, logger=self._logger)
return self._order_repository
@property
def payment_repository(self) -> IPaymentRepository:
if self._payment_repository is None:
self._payment_repository = PaymentRepository(session=self._session, logger=self._logger)
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
def user_repository(self) -> IUserRepository:
if self._user_repository is None:
self._user_repository = UserRepository(session=self._session, logger=self._logger)
return self._user_repository
@property
def session_repository(self) -> ISessionRepository:
if self._session_repository is None:
self._session_repository = SessionRepository(session=self._session, logger=self._logger)
return self._session_repository

View File

@@ -1,123 +0,0 @@
import orjson
from dataclasses import replace
from datetime import datetime, timezone
from decimal import Decimal,InvalidOperation
from typing import Any
import aiohttp
from aiohttp import BasicAuth, ClientTimeout
from src.application.contracts.i_itpay_service import IItPayService
from src.application.domain.entities.order import OrderEntity
from src.application.domain.enums import OrderStatus
from src.application.domain.exceptions import ApplicationException,PaymentProviderException
class ItPayClient(IItPayService):
def __init__(
self,
*,
public_id: str,
api_secret: str,
api_base_url: str = 'https://api.gw.itpay.ru',
timeout_seconds: float = 30,
) -> None:
self._api_base_url = api_base_url.rstrip('/')
self._public_id = public_id
self._api_secret = api_secret
self._timeout = ClientTimeout(total=timeout_seconds)
async def create_payment(self, order: OrderEntity, trace_id: str) -> OrderEntity:
total = order.total_price if order.total_price is not None else Decimal('0')
amount = total if isinstance(total, Decimal) else Decimal(str(total))
amount_str = str(amount.quantize(Decimal('0.01')))
metadata: dict[str,Any] = {
'trace_id': trace_id,
'order_id': order.id,
'user_id': order.user_id,
'usdt_amount': str(order.usdt_amount) if order.usdt_amount is not None else None,
'usdt_exchange_rate': str(order.usdt_exchange_rate) if order.usdt_exchange_rate is not None else None,
'gas_fee': str(order.gas_fee) if order.gas_fee is not None else None,
'service_fee': str(order.service_fee) if order.service_fee is not None else None,
'agent_fee': str(order.service_fee) if order.service_fee is not None else None,
'amount': amount_str,
'total_amount': amount_str,
}
if order.total_price is not None and order.service_fee is not None:
principal = (Decimal(str(order.total_price)) - Decimal(str(order.service_fee))).quantize(Decimal('0.01'))
metadata['principal_amount'] = str(principal)
metadata = {k:v for k,v in metadata.items() if v is not None and v != ''}
payload: dict[str, Any] = {
'amount': amount_str,
'client_payment_id': order.client_payment_id or '',
'method': 'sbp',
'description': 'CFU',
'metadata': metadata,
}
url = f'{self._api_base_url}/v1/payments'
headers = {
'Content-Type': 'application/json',
'Accept': 'application/json',
}
try:
async with aiohttp.ClientSession(timeout=self._timeout) as session:
auth = BasicAuth(self._public_id, self._api_secret)
async with session.post(url, json=payload, headers=headers, auth=auth) as resp:
response_text = await resp.text()
try:
response_json: dict[str, Any] = orjson.loads(response_text)
except orjson.JSONDecodeError:
response_json = {'raw': response_text}
if resp.status >= 400:
raise PaymentProviderException(message='Payment provider error')
body_raw = response_json.get('data')
body = body_raw if isinstance(body_raw, dict) else response_json
status = str(body.get('status') or '').strip().lower()
itpay_id = str(body.get('id') or '').strip()
if not status or not itpay_id:
raise PaymentProviderException(message='Payment provider response invalid')
if status == 'cancelled':
return replace(order, status=OrderStatus.CANCELLED, itpay_id=itpay_id)
if status == 'rejected':
return replace(order, status=OrderStatus.REJECTED, itpay_id=itpay_id)
if status == 'error':
return replace(order, status=OrderStatus.ERROR, itpay_id=itpay_id)
qrc_id = str(body.get('qrc_id') or '').strip()
if not qrc_id:
raise PaymentProviderException(message='Payment provider response invalid')
itpay_amount = Decimal(str(body.get('amount')))
created_norm = str(body.get('created') or '').replace('Z', '+00:00')
itpay_created_at = datetime.fromisoformat(created_norm)
payment_qr_urls = body.get('payment_qr_urls')
if isinstance(payment_qr_urls, str):
payment_qr_urls = orjson.loads(payment_qr_urls)
payment_qr_images = body.get('payment_qr_images')
if isinstance(payment_qr_images, str):
payment_qr_images = orjson.loads(payment_qr_images)
if not isinstance(payment_qr_urls,dict) or not isinstance(payment_qr_images,dict):
raise PaymentProviderException(message='Payment provider response invalid')
return replace(
order,
itpay_id=itpay_id,
itpay_qr_id=qrc_id,
itpay_created_at=itpay_created_at,
itpay_amount=itpay_amount,
itpay_payment_qr_url_android=str(payment_qr_urls['android']),
itpay_payment_qr_url_ios=str(payment_qr_urls['ios']),
itpay_payment_qr_url_desktop=str(payment_qr_urls['desktop']),
itpay_payment_qr_image_android=str(payment_qr_images['android']),
itpay_payment_qr_image_ios=str(payment_qr_images['ios']),
itpay_payment_qr_image_desktop=str(payment_qr_images['desktop']),
)
except ApplicationException:
raise
except (TimeoutError,aiohttp.ClientError):
raise PaymentProviderException(message='Payment provider unreachable')
except (KeyError,ValueError,TypeError,InvalidOperation,orjson.JSONDecodeError) as exception:
raise PaymentProviderException(message='Payment provider response invalid') from exception

View File

@@ -1,7 +1,7 @@
import traceback
import inspect
import sys
import orjson
import json
from datetime import datetime
from typing import Callable, Optional, Any
from ulid import ULID
@@ -59,7 +59,7 @@ class Logger(ILogger):
def clear_trace_id(self) -> None:
trace_id_var.set("N/A")
def _prepare_log_data(self, level: LogLevel, message: Any) -> dict[str, Any]:
def _prepare_log_data(self, level: LogLevel, message: str) -> dict[str, Any]:
current_frame = inspect.currentframe()
if (
current_frame
@@ -75,37 +75,34 @@ class Logger(ILogger):
line_number = 0
log_data = {
'timestamp': datetime.now().isoformat(),
'level': level.name,
'instance_id': self.instance_id,
'file': filename,
'line': line_number,
'trace_id': trace_id_var.get(),
"timestamp": datetime.now().isoformat(),
"level": level.name,
"instance_id": self.instance_id,
"file": filename,
"line": line_number,
"trace_id": trace_id_var.get(),
"message": message,
}
if isinstance(message, dict):
log_data.update(message)
else:
log_data['message'] = message
if level == LogLevel.EXCEPTION:
log_data['exception'] = traceback.format_exc()
log_data["exception"] = traceback.format_exc()
return log_data
def _log(self, level: LogLevel, message: Any) -> None:
def _log(self, level: LogLevel, message: str) -> None:
if level >= self.min_level:
log_data = self._prepare_log_data(level, message)
if self.log_format == LogFormat.JSON:
log_message = orjson.dumps(log_data).decode()
log_message = json.dumps(log_data, ensure_ascii=False)
else:
log_message = (
f"{log_data['timestamp']} - {log_data['level']} - "
f"{log_data['instance_id']} - {log_data['trace_id']} - "
f"{log_data['file']}:{log_data['line']} - "
f"{log_data.get('message', log_data.get('event', ''))}"
f"{log_data['message']}"
)
if 'exception' in log_data:
if "exception" in log_data:
log_message += f"\nTraceback:\n{log_data['exception']}"
self._write(log_message)
@@ -113,20 +110,20 @@ class Logger(ILogger):
def _write(self, message: str) -> None:
sys.stdout.write(message + "\n")
def debug(self, message: Any) -> None:
def debug(self, message: str) -> None:
self._log(LogLevel.DEBUG, message)
def info(self, message: Any) -> None:
def info(self, message: str) -> None:
self._log(LogLevel.INFO, message)
def warning(self, message: Any) -> None:
def warning(self, message: str) -> None:
self._log(LogLevel.WARNING, message)
def error(self, message: Any) -> None:
def error(self, message: str) -> None:
self._log(LogLevel.ERROR, message)
def critical(self, message: Any) -> None:
def critical(self, message: str) -> None:
self._log(LogLevel.CRITICAL, message)
def exception(self, message: Any) -> None:
def exception(self, message: str) -> None:
self._log(LogLevel.EXCEPTION, message)

View File

@@ -1 +0,0 @@
from src.infrastructure.messanger.rabbit_client import RabbitClient

View File

@@ -1,72 +0,0 @@
from typing import Any, Mapping
from faststream.rabbit import RabbitBroker
from src.application.contracts import IQueueMessanger
from src.infrastructure.config import settings
class RabbitClient(IQueueMessanger):
def __init__(self) -> None:
self._broker = RabbitBroker(
settings.RABBIT_URL,
)
self._connected = False
async def connect(self) -> None:
if self._connected:
return
await self._broker.connect()
self._connected = True
async def close(self) -> None:
if not self._connected:
return
await self._broker.close()
self._connected = False
async def _ensure_connected(self) -> None:
if not self._connected:
await self.connect()
async def publish_to_queue(
self,
queue: str,
message: Any,
*,
persist: bool | None = None,
headers: Mapping[str, Any] | None = None,
correlation_id: str | None = None,
message_id: str | None = None,
) -> None:
await self._ensure_connected()
await self._broker.publish(
message,
queue=queue,
persist=settings.RABBIT_PUBLISH_PERSIST if persist is None else persist,
headers=headers,
correlation_id=correlation_id,
message_id=message_id,
)
async def publish(
self,
message: Any,
*,
exchange: str,
routing_key: str,
persist: bool | None = None,
headers: Mapping[str, Any] | None = None,
correlation_id: str | None = None,
message_id: str | None = None,
) -> None:
await self._ensure_connected()
await self._broker.publish(
message,
exchange=exchange,
routing_key=routing_key,
persist=settings.RABBIT_PUBLISH_PERSIST if persist is None else persist,
headers=headers,
correlation_id=correlation_id,
message_id=message_id,
)

View File

@@ -1,4 +0,0 @@
from src.infrastructure.mozen.sbp_client import MozenSbpClient
__all__=['MozenSbpClient']

View File

@@ -1,115 +0,0 @@
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 (TimeoutError,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 (TimeoutError,aiohttp.ClientError):
raise PaymentProviderException(message='Mozen SBP withdrawal provider unreachable')

View File

@@ -3,7 +3,7 @@ import secrets
from typing import Any, Optional, Mapping
from itsdangerous import URLSafeTimedSerializer, SignatureExpired, BadSignature
from src.application.contracts import ICsrfService
from src.application.domain.exceptions import CsrfException
from src.application.domain.exceptions import ApplicationException
from src.infrastructure.config.settings import settings
@@ -42,12 +42,21 @@ class CsrfService(ICsrfService):
try:
data = self._serializer.loads(token, max_age=self.TTL_SECONDS)
except SignatureExpired:
raise CsrfException(message='CSRF token expired')
raise ApplicationException(
status_code=403,
message='CSRF token expired',
)
except BadSignature:
raise CsrfException(message='CSRF token invalid')
raise ApplicationException(
status_code=403,
message='CSRF token invalid',
)
if expected_subject is not None and data.get('sub') != expected_subject:
raise CsrfException(message='CSRF token subject mismatch')
raise ApplicationException(
status_code=403,
message='CSRF token subject mismatch',
)
return data
@@ -58,9 +67,15 @@ class CsrfService(ICsrfService):
def verify_pair(self, cookie_token: Optional[str], header_token: Optional[str], expected_subject: Optional[str] = None) -> None:
if not cookie_token or not header_token:
raise CsrfException(message='CSRF token missing')
raise ApplicationException(
status_code=403,
message='CSRF token missing',
)
if not secrets.compare_digest(cookie_token, header_token):
raise CsrfException(message='CSRF token mismatch')
raise ApplicationException(
status_code=403,
message='CSRF token mismatch',
)
self.verify(cookie_token, expected_subject=expected_subject)

View File

@@ -9,7 +9,9 @@ class HashService(IHashService):
async def hash(self, value: str) -> str:
hashed_value = bcrypt.hashpw(value.encode(), bcrypt.gensalt())
self._logger.info(f'Hash value {hashed_value.decode()}')
return hashed_value.decode()
async def verify(self, hashed_value: str, plain_value: str) -> bool:
self._logger.info(f'Hash value {hashed_value[:10]}')
return bcrypt.checkpw(plain_value.encode(), hashed_value.encode())

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from jose import jwt, ExpiredSignatureError, JWTError
from src.application.contracts import ILogger, IJwtService
from src.application.domain.dto import AccessTokenPayload
from src.application.domain.exceptions import ApplicationException,InternalServerException,JwtException
from src.application.domain.exceptions import ApplicationException
from src.infrastructure.config.settings import settings
from src.infrastructure.vault import JwtKeyStore
@@ -17,7 +17,7 @@ class JwtService(IJwtService):
if payload.get('type') != 'access':
self._logger.warning(f'Access token invalid type received_type={payload.get('type')}')
raise JwtException(message='Invalid token type')
raise ApplicationException(status_code=401, message='Invalid token type')
try:
return AccessTokenPayload(
@@ -32,7 +32,7 @@ class JwtService(IJwtService):
)
except KeyError as exception:
self._logger.warning(f'Access token missing claim error={str(exception)}')
raise JwtException(message=f'Missing token claim: {exception}')
raise ApplicationException(status_code=401, message=f'Missing token claim: {exception}')
async def _decode_and_verify(self, token: str) -> dict:
kid: str | None = None
@@ -42,12 +42,12 @@ class JwtService(IJwtService):
kid = header.get('kid')
if not kid:
self._logger.warning(f'JWT header missing kid header={header}')
raise JwtException(message='Missing token header: kid')
raise ApplicationException(status_code=401, message='Missing token header: kid')
received_alg = header.get('alg')
if received_alg != settings.JWT_ALGORITHM:
self._logger.warning(f'JWT invalid algorithm kid={kid} received_alg={received_alg} expected_alg={settings.JWT_ALGORITHM}')
raise JwtException(message='Invalid token algorithm')
raise ApplicationException(status_code=401, message='Invalid token algorithm')
public_pem = await self._key_store.get_public_key_for_kid(str(kid))
@@ -58,7 +58,7 @@ class JwtService(IJwtService):
if not public_pem:
self._logger.warning(f'JWT unknown kid kid={kid}')
raise JwtException(message='Unknown token kid')
raise ApplicationException(status_code=401, message='Unknown token kid')
options = {
'verify_signature': True,
@@ -85,25 +85,25 @@ class JwtService(IJwtService):
if 'sid' not in payload:
self._logger.warning(f'JWT missing sid claim kid={kid}')
raise JwtException(message='Missing token claim: sid')
raise ApplicationException(status_code=401, message='Missing token claim: sid')
if 'type' not in payload:
self._logger.warning(f'JWT missing type claim kid={kid}')
raise JwtException(message='Missing token claim: type')
raise ApplicationException(status_code=401, message='Missing token claim: type')
return payload
except ExpiredSignatureError as exception:
self._logger.info(f'JWT expired kid={kid} error={str(exception)}')
raise JwtException(message='Token expired')
raise ApplicationException(status_code=401, message='Token expired')
except ApplicationException:
raise
except JWTError as exception:
self._logger.warning(f'JWT decode failed kid={kid} error={str(exception)}')
raise JwtException(message='Invalid token')
raise ApplicationException(status_code=401, message='Invalid token')
except Exception as exception:
self._logger.error(f'Unexpected JWT decode error kid={kid} error={str(exception)}')
raise InternalServerException(message='JWT decode failed')
raise ApplicationException(status_code=500, message='JWT decode failed')

View File

@@ -1,3 +1,3 @@
from src.infrastructure.vault.utils import read_kv2_secret,create_hvac_client_from_approle
from src.infrastructure.vault.utils import read_kv2_secret, create_hvac_client
from src.infrastructure.vault.keys import JwtKeyStore
from src.infrastructure.vault.scheduler import start_jwt_keys_scheduler

View File

@@ -2,8 +2,8 @@ from __future__ import annotations
import asyncio
from datetime import datetime, timezone
from src.application.domain.dto import JwtPublicKeySet, JwtPublicKey
from src.application.domain.exceptions import InternalServerException
from src.infrastructure.vault import create_hvac_client_from_approle,read_kv2_secret
from src.application.domain.exceptions import ApplicationException
from src.infrastructure.vault import create_hvac_client, read_kv2_secret
class JwtKeyStore:
@@ -19,9 +19,7 @@ class JwtKeyStore:
self,
*,
vault_addr: str,
vault_role_id: str,
vault_secret_id: str,
vault_namespace: str | None,
vault_token: str,
mount_point: str,
kid_path: str = 'jwt/kid',
kids_prefix: str = 'jwt/kids',
@@ -32,9 +30,7 @@ class JwtKeyStore:
return
self._vault_addr = vault_addr
self._vault_role_id = vault_role_id
self._vault_secret_id = vault_secret_id
self._vault_namespace = vault_namespace
self._vault_token = vault_token
self._timeout = timeout_seconds
self._mount = mount_point
@@ -52,17 +48,11 @@ class JwtKeyStore:
@classmethod
def get_instance(cls) -> 'JwtKeyStore':
if cls._instance is None:
raise InternalServerException(message='JwtKeyStore not initialized')
raise ApplicationException(status_code=500, message='JwtKeyStore not initialized')
return cls._instance
def _read_keyset_sync(self) -> JwtPublicKeySet:
client = create_hvac_client_from_approle(
url=self._vault_addr,
role_id=self._vault_role_id,
secret_id=self._vault_secret_id,
namespace=self._vault_namespace,
timeout=self._timeout,
)
client = create_hvac_client(url=self._vault_addr, token=self._vault_token, timeout=self._timeout)
kids = read_kv2_secret(client=client, mount_point=self._mount, path=self._kid_path)
active_kid = kids.get('active')

View File

@@ -2,23 +2,10 @@ from __future__ import annotations
import hvac
def create_hvac_client_from_approle(
*,
url: str,
role_id: str,
secret_id: str,
namespace: str | None = None,
timeout: int = 5,
) -> hvac.Client:
kwargs: dict = {'url': url, 'timeout': timeout}
if namespace:
kwargs['namespace'] = namespace
client = hvac.Client(**kwargs)
client.auth.approle.login(role_id=role_id, secret_id=secret_id)
def create_hvac_client(*, url: str, token: str, timeout: int = 5) -> hvac.Client:
client = hvac.Client(url=url, token=token, timeout=timeout)
if not client.is_authenticated():
raise RuntimeError(
'Vault AppRole authentication failed. Check VAULT_ADDR, VAULT_ROLE_ID, VAULT_SECRET_ID'
)
raise RuntimeError("Vault authentication failed. Check VAULT_ADDR / VAULT_TOKEN")
return client

View File

@@ -2,22 +2,20 @@ from __future__ import annotations
from contextlib import asynccontextmanager
import secrets
from typing import AsyncGenerator
from fastapi import Depends, FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi import Depends, FastAPI, status
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
from fastapi.responses import HTMLResponse
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from src.application.domain.exceptions import ApplicationException,UnauthorizedException
from src.application.domain.exceptions import ApplicationException
from src.infrastructure.cache import create_redis_client
from src.infrastructure.config.settings import get_settings
from src.infrastructure.vault import JwtKeyStore, start_jwt_keys_scheduler
from src.infrastructure.utils import generate_instance_id
from src.infrastructure.logger import logger
from src.infrastructure.config import settings
from src.presentation.handler import application_exception_handler, unhandled_exception_handler
from src.presentation.messaging import crypto_transfer_router,sbp_withdrawal_messaging_router
from src.presentation.handlers import application_exception_handler, unhandled_exception_handler
from src.presentation.middleware import TraceIDMiddleware, SecurityHeadersMiddleware
from src.presentation.routing import order_router,orders_router,payment_router,payments_router,sbp_withdrawal_router
from src.presentation.routing import order_router
security = HTTPBasic()
@@ -26,7 +24,8 @@ async def verify_credentials(credentials: HTTPBasicCredentials = Depends(securit
user_ok = secrets.compare_digest(credentials.username, settings.DOCS_USERNAME)
pass_ok = secrets.compare_digest(credentials.password, settings.DOCS_PASSWORD)
if not (user_ok and pass_ok):
raise UnauthorizedException(
raise ApplicationException(
status_code=status.HTTP_401_UNAUTHORIZED,
message='Unauthorized',
headers={'WWW-Authenticate': 'Basic'},
)
@@ -40,38 +39,11 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
logger.set_instance_id(instance_id)
logger.info(f'Users service instance started with id {instance_id}')
app.state.redis_market = create_redis_client(settings.KEYDB_MARKET_URL)
app.state.redis_remote = app.state.redis_market
app.state.redis_cache = None
app.state.redis = None
try:
await app.state.redis_market.ping()
logger.info('Market KeyDB connection established')
except Exception as exception:
logger.error(f'Market KeyDB connection failed: {exception}')
await app.state.redis_market.aclose()
raise
cache_url = settings.KEYDB_CACHE_URL
if cache_url:
app.state.redis_cache = create_redis_client(cache_url)
app.state.redis = app.state.redis_cache
try:
await app.state.redis_cache.ping()
logger.info('Cache KeyDB connection established')
except Exception as exception:
logger.error(f'Cache KeyDB connection failed: {exception}')
await app.state.redis_cache.aclose()
await app.state.redis_market.aclose()
raise
else:
logger.info('Cache KeyDB is not configured')
app.state.redis = create_redis_client()
jwt_store = JwtKeyStore(
vault_addr=settings.VAULT_ADDR,
vault_role_id=settings.VAULT_ROLE_ID,
vault_secret_id=settings.VAULT_SECRET_ID,
vault_namespace=settings.VAULT_NAMESPACE,
vault_token=settings.VAULT_TOKEN,
mount_point=settings.VAULT_MOUNT_POINT,
kid_path=settings.VAULT_JWT_KID_PATH,
kids_prefix=settings.VAULT_JWT_KIDS_PREFIX,
@@ -83,47 +55,25 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.jwt_key_store = jwt_store
app.state.jwt_keys_scheduler = jwt_scheduler
try:
yield
finally:
sched = getattr(app.state,'jwt_keys_scheduler',None)
if sched:
sched.shutdown(wait=False)
redis_cache = getattr(app.state,'redis_cache',None)
if redis_cache is not None:
await redis_cache.aclose()
await app.state.redis_market.aclose()
logger.info(f'Pay service instance ended with id {instance_id}')
yield
await app.state.redis.aclose()
logger.info(f'Users service instance ended with id {instance_id}')
app: FastAPI = FastAPI(
redoc_url=None,
docs_url=None,
lifespan=lifespan,
title='Elcsa. Pay service Service'
title='Elcsa Users Service'
)
app.add_exception_handler(ApplicationException, application_exception_handler)
app.add_exception_handler(Exception, unhandled_exception_handler)
app.include_router(order_router)
app.include_router(orders_router)
app.include_router(payment_router)
app.include_router(payments_router)
app.include_router(sbp_withdrawal_router)
app.include_router(crypto_transfer_router)
app.include_router(sbp_withdrawal_messaging_router)
# Added middleware
app.add_middleware(
CORSMiddleware,
allow_origins=[],
allow_origin_regex=settings.CORS_ALLOW_ORIGIN_REGEX,
allow_credentials=True,
allow_methods=['*'],
allow_headers=['*'],
)
app.add_middleware(TraceIDMiddleware, logger=logger)
app.add_middleware(
SecurityHeadersMiddleware,
@@ -131,7 +81,7 @@ app.add_middleware(
hsts_preload=False,
frame_options='DENY',
referrer_policy='strict-origin-when-cross-origin',
content_security_policy="default-src 'self'; script-src 'self' 'unsafe-inline' https://unpkg.com https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://unpkg.com; img-src 'self' data: https://fastapi.tiangolo.com; frame-ancestors 'none'; base-uri 'self'; object-src 'none'",
content_security_policy="default-src 'self'; frame-ancestors 'none'; base-uri 'self'; object-src 'none'",
)
@@ -163,10 +113,3 @@ async def ping() -> dict[str, str]:
'message': 'pong',
'status': 'ok',
}
@app.get('/health')
async def health() -> dict[str, str]:
return {
'status': 'ok',
}

View File

@@ -1,7 +1,7 @@
from fastapi import Depends, Request
from fastapi.security.utils import get_authorization_scheme_param
from src.application.contracts import IJwtService
from src.application.domain.exceptions import UnauthorizedException
from src.application.domain.exceptions import ApplicationException
from src.application.domain.dto import AccessTokenPayload, AuthContext
from src.presentation.dependencies import get_jwt_service
@@ -27,10 +27,10 @@ async def require_access_token(
) -> AuthContext:
token = _extract_access_token(request)
if not token:
raise UnauthorizedException(message='Not authenticated')
raise ApplicationException(status_code=401, message='Not authenticated')
payload: AccessTokenPayload = await jwt_service.decode_access_token(token)
if payload.type != 'access':
raise UnauthorizedException(message='Invalid token type')
raise ApplicationException(status_code=401, message='Invalid token type')
return AuthContext(user_id=payload.sub, sid=payload.sid, token=payload)

View File

@@ -1,20 +1,15 @@
from __future__ import annotations
import functools
import orjson
from typing import Any,Awaitable,Callable
from typing import Any, Awaitable, Callable
from fastapi import Request
from fastapi.responses import ORJSONResponse
from src.infrastructure.cache import KeydbCache
from src.infrastructure.logger import get_logger
from src.presentation.dependencies.cache import get_cache_remote
from src.presentation.dependencies.cache import get_redis
def _to_cache_payload(value: Any) -> Any:
if hasattr(value,'model_dump'):
return value.model_dump(mode='json')
return value
def cached(*, prefix: str) -> Callable:
def cached(*,prefix: str,ttl: int = 300,user_scoped: bool = True) -> Callable:
def decorator(func: Callable[..., Awaitable[Any]]):
@functools.wraps(func)
async def wrapper(*args: Any, **kwargs: Any) -> Any:
@@ -30,34 +25,22 @@ def cached(*,prefix: str,ttl: int = 300,user_scoped: bool = True) -> Callable:
auth = kwargs.get('auth')
user_id = getattr(auth, 'user_id', None) if auth else None
if request is None:
if request is None or user_id is None:
return await func(*args, **kwargs)
if user_scoped:
if user_id is None:
return await func(*args, **kwargs)
cache_key = f'{prefix}:{user_id}'
else:
cache_key = prefix
cache_key = f'{prefix}:{user_id}'
try:
cache = get_cache_remote(request)
hit = await cache.get(cache_key)
redis = get_redis(request)
cache = KeydbCache(redis)
hit = await cache.get_user(user_id)
if hit is not None:
logger.debug(f'Cache hit key={cache_key}')
return ORJSONResponse(status_code=200,content=orjson.loads(hit))
return ORJSONResponse(status_code=200, content=hit)
except Exception as e:
logger.warning(f'Cache read failed key={cache_key} error={e}')
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 await func(*args, **kwargs)
return wrapper
return decorator

View File

@@ -3,7 +3,7 @@ import inspect
from functools import wraps
from typing import Callable, Awaitable, Any, Optional, Annotated
from fastapi import Request, Header
from src.application.domain.exceptions import InternalServerException
from src.application.domain.exceptions import ApplicationException
from src.infrastructure.security import CsrfService
@@ -39,7 +39,10 @@ def csrf_protect(
break
if request is None:
raise InternalServerException(message='Request is required for CSRF protection')
raise ApplicationException(
status_code=500,
message='Request is required for CSRF protection',
)
csrf = CsrfService()

View File

@@ -6,7 +6,7 @@ from typing import Any, Awaitable, Callable, Literal, Optional, Protocol, runtim
from fastapi import Request
from redis.asyncio.client import Redis
from src.application.contracts import ILogger
from src.application.domain.exceptions import InternalServerException,RateLimitException
from src.application.domain.exceptions import ApplicationException
from src.infrastructure.logger import get_logger
from src.presentation.dependencies import get_redis
@@ -124,7 +124,7 @@ def rate_limit(
ident = _call_key_builder(key_builder, request, args, kwargs) # type: ignore[arg-type]
except Exception as e:
logger.error(f'RateLimit key_builder failed error={str(e)}')
raise InternalServerException(message='Rate limiter key_builder failed')
raise ApplicationException(500, 'Rate limiter key_builder failed')
route = request.url.path
method = request.method
@@ -153,12 +153,13 @@ def rate_limit(
logger.warning(f'RateLimit fail-open activated key={redis_key}')
return await func(*args, **kwargs)
raise RateLimitException(status_code=503,message='Rate limiter unavailable')
raise ApplicationException(503, 'Rate limiter unavailable')
if count > limit:
retry_after = max(ttl, 0)
logger.warning(f'RateLimit exceeded key={redis_key} count={count} limit={limit} retry_after={retry_after}')
raise RateLimitException(
raise ApplicationException(
status_code=429,
message='Too Many Requests',
headers={'Retry-After': str(retry_after)},
)

View File

@@ -1,9 +1,16 @@
from __future__ import annotations
from src.presentation.dependencies.cache import get_cache,get_cache_remote,get_redis,get_redis_remote
from src.presentation.dependencies.queue_messanger import get_rabbit
from src.presentation.dependencies.commands import (
get_get_me_command,
get_set_phone_command,
get_set_crypto_wallet_start_command,
get_set_crypto_wallet_complete_command,
get_update_bank_details_start_command,
get_update_bank_details_complete_command,
get_change_password_start_command,
get_change_password_complete_command,
get_change_email_start_command,
get_change_email_confirm_old_command,
get_change_email_complete_command,
)
from src.presentation.dependencies.security import get_jwt_service
__all__=['get_jwt_service','get_redis','get_redis_remote','get_cache','get_cache_remote','get_rabbit']
from src.presentation.dependencies.cache import get_redis, get_cache
from src.presentation.dependencies.queue_messanger import get_rabbit

View File

@@ -1,31 +1,12 @@
from __future__ import annotations
from fastapi import Depends,Request
from fastapi import Depends, Request
from redis.asyncio.client import Redis
from src.application.contracts import ICache
from src.infrastructure.cache import KeydbCache,RemoteCache
def get_redis_remote(request: Request) -> Redis:
return request.app.state.redis_market
from src.infrastructure.cache import KeydbCache
def get_redis(request: Request) -> Redis:
redis_cache = getattr(request.app.state,'redis_cache',None)
if redis_cache is None:
raise RuntimeError('Cache KeyDB is not configured')
return redis_cache
return request.app.state.redis
def get_cache_remote(request: Request) -> ICache:
return KeydbCache(get_redis(request))
def get_remote_cache(request: Request) -> ICache:
return RemoteCache(get_redis_remote(request))
def get_cache(cache: ICache = Depends(get_cache_remote)) -> ICache:
return cache
def get_cache(redis_client: Redis = Depends(get_redis)) -> ICache:
return KeydbCache(redis_client)

View File

@@ -1,193 +1,161 @@
from __future__ import annotations
from fastapi import Depends
from src.application.abstractions import IUnitOfWork
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,IMozenSbpService,IQueueMessanger,IReceipt
from src.application.contracts.i_itpay_service import IItPayService
from src.application.services import PaymentQuoteService,SbpWithdrawalQuoteService
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.config import settings
from src.infrastructure.itpay.client import ItPayClient
from src.infrastructure.mozen import MozenSbpClient
from src.presentation.dependencies.cache import get_remote_cache
from src.application.commands import GetMeCommand, SetPhoneCommand, SetCryptoWalletStartCommand, SetCryptoWalletCompleteCommand, UpdateBankDetailsStartCommand, UpdateBankDetailsCompleteCommand, ChangePasswordStartCommand, ChangePasswordCompleteCommand, ChangeEmailStartCommand, ChangeEmailConfirmOldCommand, ChangeEmailCompleteCommand
from src.application.contracts import ILogger, ICache, IQueueMessanger, IHashService
from src.presentation.dependencies.cache import get_cache
from src.presentation.dependencies.logger import get_logger
from src.presentation.dependencies.queue_messanger import get_rabbit
from src.presentation.dependencies.security import get_hash_service
from src.presentation.dependencies.unit_of_work import get_unit_of_work
def get_itpay_service() -> IItPayService:
return ItPayClient(
public_id=settings.ITPAY_PUBLIC_ID,
api_secret=settings.ITPAY_API_SECRET,
api_base_url='https://api.gw.itpay.ru',
)
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_get_me_command(
logger: ILogger = Depends(get_logger),
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
remote_cache: ICache = Depends(get_remote_cache),
itpay_service: IItPayService = Depends(get_itpay_service),
) -> CreateOrderCommand:
payment_quote_service = PaymentQuoteService(remote_cache=remote_cache,logger=logger)
return CreateOrderCommand(
unit_of_work=unit_of_work,
cache: ICache = Depends(get_cache),
) -> GetMeCommand:
return GetMeCommand(logger=logger, unit_of_work=unit_of_work, cache=cache)
def get_set_phone_command(
logger: ILogger = Depends(get_logger),
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
cache: ICache = Depends(get_cache),
) -> SetPhoneCommand:
return SetPhoneCommand(logger=logger, unit_of_work=unit_of_work, cache=cache)
def get_set_crypto_wallet_start_command(
logger: ILogger = Depends(get_logger),
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
cache: ICache = Depends(get_cache),
messanger: IQueueMessanger = Depends(get_rabbit),
hash_service: IHashService = Depends(get_hash_service),
) -> SetCryptoWalletStartCommand:
return SetCryptoWalletStartCommand(
logger=logger,
payment_quote_service=payment_quote_service,
itpay_service=itpay_service,
)
def get_payment_quote_service(
logger: ILogger = Depends(get_logger),
remote_cache: ICache = Depends(get_remote_cache),
) -> PaymentQuoteService:
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,
cache=cache,
messanger=messanger,
hash_service=hash_service,
)
def get_set_crypto_wallet_complete_command(
logger: ILogger = Depends(get_logger),
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
cache: ICache = Depends(get_cache),
hash_service: IHashService = Depends(get_hash_service),
) -> SetCryptoWalletCompleteCommand:
return SetCryptoWalletCompleteCommand(
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(
logger: ILogger = Depends(get_logger),
payment_quote_service: PaymentQuoteService = Depends(get_payment_quote_service),
) -> GetPaymentConfigCommand:
return GetPaymentConfigCommand(payment_quote_service=payment_quote_service,logger=logger)
def get_payment_quote_command(
logger: ILogger = Depends(get_logger),
payment_quote_service: PaymentQuoteService = Depends(get_payment_quote_service),
) -> GetPaymentQuoteCommand:
return GetPaymentQuoteCommand(payment_quote_service=payment_quote_service,logger=logger)
def get_payment_quote_from_rub_command(
logger: ILogger = Depends(get_logger),
payment_quote_service: PaymentQuoteService = Depends(get_payment_quote_service),
) -> GetPaymentQuoteFromRubCommand:
return GetPaymentQuoteFromRubCommand(payment_quote_service=payment_quote_service,logger=logger)
def get_list_orders_command(
logger: ILogger = Depends(get_logger),
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
) -> ListOrdersCommand:
return ListOrdersCommand(unit_of_work=unit_of_work,logger=logger)
def get_list_payments_command(
logger: ILogger = Depends(get_logger),
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
) -> ListPaymentsCommand:
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(
logger: ILogger = Depends(get_logger),
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
) -> GetPaymentCommand:
return GetPaymentCommand(unit_of_work=unit_of_work,logger=logger)
def get_order_command(
logger: ILogger = Depends(get_logger),
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
) -> GetOrderCommand:
return GetOrderCommand(unit_of_work=unit_of_work,logger=logger)
def get_order_status_command(
logger: ILogger = Depends(get_logger),
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
) -> GetOrderStatusCommand:
return GetOrderStatusCommand(unit_of_work=unit_of_work,logger=logger)
def get_create_payment_command(
logger: ILogger = Depends(get_logger),
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
queue_messanger: IQueueMessanger = Depends(get_rabbit),
) -> CreatePaymentCommand:
return CreatePaymentCommand(unit_of_work=unit_of_work,logger=logger,queue_messanger=queue_messanger)
def get_cloud_kassir_receipt() -> IReceipt:
return ClaudeKassirClient(
public_id=settings.CLOUD_KASSIR_PUBLIC_ID,
api_secret=settings.CLOUD_KASSIR_API_SECRET,
inn=CLOUD_KASSIR_INN,
api_base_url=CLOUD_KASSIR_API_BASE_URL,
success_url=CLOUD_KASSIR_SUCCESS_URL,
fail_url=CLOUD_KASSIR_FAIL_URL,
)
def get_crypto_transfer_completed_command(
logger: ILogger = Depends(get_logger),
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
receipt: IReceipt = Depends(get_cloud_kassir_receipt),
) -> CreateCryptoTransferCompletedCommand:
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,
cache=cache,
hash_service=hash_service,
)
def get_change_password_start_command(
logger: ILogger = Depends(get_logger),
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
cache: ICache = Depends(get_cache),
messanger: IQueueMessanger = Depends(get_rabbit),
hash_service: IHashService = Depends(get_hash_service),
) -> ChangePasswordStartCommand:
return ChangePasswordStartCommand(
logger=logger,
unit_of_work=unit_of_work,
cache=cache,
messanger=messanger,
hash_service=hash_service,
)
def get_change_password_complete_command(
logger: ILogger = Depends(get_logger),
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
cache: ICache = Depends(get_cache),
hash_service: IHashService = Depends(get_hash_service),
) -> ChangePasswordCompleteCommand:
return ChangePasswordCompleteCommand(
logger=logger,
unit_of_work=unit_of_work,
cache=cache,
hash_service=hash_service,
)
def get_change_email_start_command(
logger: ILogger = Depends(get_logger),
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
cache: ICache = Depends(get_cache),
messanger: IQueueMessanger = Depends(get_rabbit),
hash_service: IHashService = Depends(get_hash_service),
) -> ChangeEmailStartCommand:
return ChangeEmailStartCommand(
logger=logger,
unit_of_work=unit_of_work,
cache=cache,
messanger=messanger,
hash_service=hash_service,
)
def get_change_email_confirm_old_command(
logger: ILogger = Depends(get_logger),
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
cache: ICache = Depends(get_cache),
messanger: IQueueMessanger = Depends(get_rabbit),
hash_service: IHashService = Depends(get_hash_service),
) -> ChangeEmailConfirmOldCommand:
return ChangeEmailConfirmOldCommand(
logger=logger,
unit_of_work=unit_of_work,
cache=cache,
messanger=messanger,
hash_service=hash_service,
)
def get_change_email_complete_command(
logger: ILogger = Depends(get_logger),
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
cache: ICache = Depends(get_cache),
hash_service: IHashService = Depends(get_hash_service),
) -> ChangeEmailCompleteCommand:
return ChangeEmailCompleteCommand(
logger=logger,
unit_of_work=unit_of_work,
cache=cache,
hash_service=hash_service,
)
def get_update_bank_details_start_command(
logger: ILogger = Depends(get_logger),
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
cache: ICache = Depends(get_cache),
messanger: IQueueMessanger = Depends(get_rabbit),
hash_service: IHashService = Depends(get_hash_service),
) -> UpdateBankDetailsStartCommand:
return UpdateBankDetailsStartCommand(
logger=logger,
unit_of_work=unit_of_work,
cache=cache,
messanger=messanger,
hash_service=hash_service,
)
def get_update_bank_details_complete_command(
logger: ILogger = Depends(get_logger),
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
cache: ICache = Depends(get_cache),
hash_service: IHashService = Depends(get_hash_service),
) -> UpdateBankDetailsCompleteCommand:
return UpdateBankDetailsCompleteCommand(
logger=logger,
unit_of_work=unit_of_work,
cache=cache,
hash_service=hash_service,
)

View File

@@ -1,7 +0,0 @@
from src.presentation.handler.application_exception_handler import application_exception_handler
from src.presentation.handler.unhandled_exception_handler import unhandled_exception_handler
__all__ = [
'application_exception_handler',
'unhandled_exception_handler',
]

View File

@@ -1,22 +0,0 @@
from fastapi import Request
from fastapi.responses import ORJSONResponse
from src.application.domain.exceptions import ApplicationException
from src.infrastructure.logger import logger
async def application_exception_handler(request: Request, exc: ApplicationException) -> ORJSONResponse:
logger.warning({
'event':'application_exception',
'path':request.url.path,
'status_code':exc.status_code,
'detail':exc.message,
})
detail = exc.message
if 500 <= exc.status_code:
detail = 'Internal Server Error'
return ORJSONResponse(
status_code=exc.status_code,
content={'detail': detail,'status_code': exc.status_code},
headers=dict(exc.headers) if exc.headers else None,
)

View File

@@ -0,0 +1,2 @@
from src.presentation.handlers.unhandled_handler import unhandled_exception_handler
from src.presentation.handlers.application_handler import application_exception_handler

View File

@@ -0,0 +1,17 @@
from fastapi.responses import ORJSONResponse
from fastapi import Request
from src.application.domain.exceptions import ApplicationException
async def application_exception_handler(_request: Request, exc: ApplicationException) -> ORJSONResponse:
detail = exc.message
if 500 <= exc.status_code:
detail = "Internal Server Error"
return ORJSONResponse(
status_code=exc.status_code,
content={"detail": detail},
headers=dict(exc.headers) if exc.headers else None,
)

View File

@@ -1,5 +1,5 @@
from fastapi import Request
from fastapi.responses import ORJSONResponse
from fastapi import Request
from starlette import status
from src.infrastructure.logger import logger
@@ -8,5 +8,5 @@ async def unhandled_exception_handler(_request: Request, exc: Exception) -> ORJS
logger.exception(f'Unhandled exception: {type(exc).__name__}')
return ORJSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={'detail': 'Internal Server Error','status_code': status.HTTP_500_INTERNAL_SERVER_ERROR},
content={'detail': 'Internal Server Error'},
)

Some files were not shown because too many files have changed in this diff Show More