Compare commits

54 Commits

Author SHA1 Message Date
33b8f1ed8a feat: add health check endpoint and change swagger's name 2026-06-29 10:47:05 +03:00
612963a606 fix: harden payment provider handling 2026-06-28 14:12:24 +03:00
0b7bd166c8 feat: add withdraw 2026-06-21 15:05:26 +03:00
39979f9976 feat: add withdraw 2026-06-21 15:00:59 +03:00
56841b83ce feat: add withdraw 2026-06-21 14:16:20 +03:00
8f60637e8d fix: rename minmal total sum 2026-06-17 21:03:26 +03:00
20e0dc6db6 fix: update quote sum 2026-06-16 18:54:21 +03:00
d4c2e7d5be feat: update for b2b 2026-06-02 23:46:29 +03:00
565816e710 featL min 5000 rub 2026-05-22 01:10:52 +03:00
dc213cb9d9 feat: updating order status 2026-05-17 16:23:02 +03:00
e41e89277f feat: add get payments and orders 2026-05-17 15:55:51 +03:00
50bfaa9264 feat: more workers 2026-05-14 23:47:20 +03:00
366bdc9515 feat: add rub quote 2026-05-14 21:53:20 +03:00
6130188a4f feat: update 2026-05-14 01:01:20 +03:00
687076e6dc feat: log message 2026-05-14 00:42:41 +03:00
631cd4861a feat: add 1 usdt 2026-05-14 00:24:01 +03:00
bb89aaeee5 feat: add adaptive fee 2026-05-13 14:22:43 +03:00
4c702b6260 feat: add new column and delete old 2026-05-13 11:18:21 +03:00
20840c95de feat: add csrf 2026-05-12 23:39:57 +03:00
07cd454248 feat: add csrf 2026-05-12 23:37:30 +03:00
d4fe062f72 feat: add cors 2026-05-12 23:24:22 +03:00
e2a1d7e1b4 feat: update users 2026-05-12 21:36:04 +03:00
46b1e336d9 feat: add endpoints desc 2026-05-11 19:50:25 +03:00
852ee9ec2e feat: add mpre endpoints 2026-05-11 19:08:15 +03:00
489c9cb2da feat: add mpre endpoints 2026-05-11 19:04:39 +03:00
42fcfbff34 fix: delete hardcode 2026-05-11 15:57:18 +03:00
1c32bdcb3f feat: update logger logic 2026-05-11 15:33:08 +03:00
3e9625fb86 fix: hardcode user_id 2026-05-11 15:16:48 +03:00
3f13032be1 feat: prod ready 2026-05-11 14:53:28 +03:00
10ff2a510d fix: change itpay webhook 2026-05-11 14:29:17 +03:00
c86c4b451b fix: change itpay webhook 2026-05-11 14:22:29 +03:00
9c8a466789 feat: add dureble true 2026-05-11 14:08:22 +03:00
d7ccddc72c feat: update full path payment 2026-05-11 13:48:02 +03:00
ad51f1220f fix: 409 is exception 2026-05-09 14:57:15 +03:00
766280fd45 feat: add docs 2026-05-09 14:53:44 +03:00
499947e44e feat: add custom exceptions 2026-05-09 14:49:15 +03:00
e929133db8 fix: update recept 2026-05-09 11:26:17 +03:00
3dfde69a3e fix: update recept 2026-05-09 11:22:44 +03:00
ea0ca899ac fix: update recept 2026-05-09 10:57:02 +03:00
195c0a8e53 fix: agent 4 to 6 2026-05-09 10:42:25 +03:00
b6e4f8165f feat: update recipit 2026-05-09 10:30:27 +03:00
be8aee7b73 fix: test command 2026-05-09 00:14:12 +03:00
22f27fa524 feat: add tests command 2026-05-09 00:02:33 +03:00
152a8ed6ac fix: cicle import 2026-05-01 13:29:39 +03:00
3e181dc904 feat: add local keydb 2026-05-01 13:20:12 +03:00
45f2949fbc feat: logs real rate and gas 2026-05-01 13:14:52 +03:00
bf68aca4fa feat: add full pay path 2026-05-01 13:10:13 +03:00
d1ac7e8e84 fix: 20 rub mock 2026-04-22 13:34:55 +03:00
ab772f1f02 fix: mock 20 rub 2026-04-22 13:24:31 +03:00
64125149be feat: add logs in order router 2026-04-22 12:43:54 +03:00
814ba9f318 feat: public id 2026-04-22 12:36:20 +03:00
a146a6a3e9 feat: add itpay creds 2026-04-22 12:15:23 +03:00
2627354673 fix: mvp based 2026-04-22 11:53:01 +03:00
bea79634b5 feat: approle vault 2026-04-22 11:40:25 +03:00
113 changed files with 4612 additions and 625 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:-1} --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:-2} --loop uvloop"]

View File

@@ -0,0 +1,7 @@
BEGIN;
ALTER TABLE sbp_withdrawals
DROP COLUMN IF EXISTS wallet_address,
DROP COLUMN IF EXISTS sender_wallet_address;
COMMIT;

63
docker-compose.yml Normal file
View File

@@ -0,0 +1,63 @@
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_REMOTE_HOST: "pay_keydb"
KEYDB_REMOTE_PORT: "${KEYDB_PORT:-6380}"
KEYDB_REMOTE_PASSWORD: "${REDIS_PASSWORD}"
KEYDB_REMOTE_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

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

View File

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

@@ -0,0 +1,4 @@
from src.application.abstractions.repositories.i_order_repository import IOrderRepository
from src.application.abstractions.repositories.i_payment_repository import IPaymentRepository
from src.application.abstractions.repositories.i_sbp_withdrawal_repository import ISbpWithdrawalRepository
from src.application.abstractions.repositories.i_user_repository import IUserRepository

View File

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

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

@@ -0,0 +1,47 @@
from abc import ABC,abstractmethod
from src.application.domain.entities import SbpWithdrawalEntity
from src.application.domain.enums import SbpWithdrawalStatus
class ISbpWithdrawalRepository(ABC):
@abstractmethod
async def create(self,withdrawal: SbpWithdrawalEntity) -> SbpWithdrawalEntity:
raise NotImplementedError
@abstractmethod
async def get_by_id(self,withdraw_id: str) -> SbpWithdrawalEntity | None:
raise NotImplementedError
@abstractmethod
async def get_by_id_for_user(self,*,withdraw_id: str,user_id: str) -> SbpWithdrawalEntity | None:
raise NotImplementedError
@abstractmethod
async def list_by_user_id(self,*,user_id: str,limit: int,offset: int) -> list[SbpWithdrawalEntity]:
raise NotImplementedError
@abstractmethod
async def update_status(
self,
*,
withdraw_id: str,
status: SbpWithdrawalStatus,
provider_error: str | None = None,
) -> None:
raise NotImplementedError
@abstractmethod
async def update_payout_created(
self,
*,
withdraw_id: str,
mozen_order_id: str | None,
status: SbpWithdrawalStatus,
provider_error: str | None,
) -> None:
raise NotImplementedError

View File

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

@@ -0,0 +1,5 @@
from src.application.commands.create_order_command import CreateOrderCommand
from src.application.commands.create_payment_command import CreatePaymentCommand
from src.application.commands.create_crypto_transfer_completed_command import CreateCryptoTransferCompletedCommand
from src.application.commands.payment_read_commands import 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

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

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

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

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

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

@@ -3,4 +3,7 @@ from src.application.contracts.i_jwt_service import IJwtService
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_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,6 +17,10 @@ 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

@@ -0,0 +1,11 @@
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 Protocol, Optional, Callable
from typing import Any,Callable,Optional,Protocol
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: str) -> None:
def debug(self, message: Any) -> None:
"""Log debug message"""
...
def info(self, message: str) -> None:
def info(self, message: Any) -> None:
"""Log info message"""
...
def warning(self, message: str) -> None:
def warning(self, message: Any) -> None:
"""Log warning message"""
...
def error(self, message: str) -> None:
def error(self, message: Any) -> None:
"""Log error message"""
...
def critical(self, message: str) -> None:
def critical(self, message: Any) -> None:
"""Log critical message"""
...
def exception(self, message: str) -> None:
def exception(self, message: Any) -> None:
"""Log exception with traceback"""
...

View File

@@ -0,0 +1,36 @@
from abc import ABC,abstractmethod
from dataclasses import dataclass
from decimal import Decimal
@dataclass(slots=True)
class SbpBank:
id: str
name: str
@dataclass(slots=True)
class SbpPayoutResult:
status: str
mozen_order_id: str | None = None
error: str | None = None
class IMozenSbpService(ABC):
@abstractmethod
async def get_banks(self,trace_id: str) -> list[SbpBank]:
raise NotImplementedError
@abstractmethod
async def make_withdrawal(
self,
*,
user_id: str,
withdraw_id: str,
phone: str,
bank_id: str,
amount: Decimal,
trace_id: str,
) -> SbpPayoutResult:
raise NotImplementedError

View File

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

View File

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

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

@@ -0,0 +1,29 @@
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,13 +14,14 @@ class UserEntity:
last_name: str | None = None
birth_date: date | None = None
crypto_wallet: str | None = None
encrypted_mnemonic: str | None = None
phone: str | None = None
bik: str | None = None
account_number: str | None = None
card_number: str | None = None
passport_data: 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
@@ -28,3 +29,5 @@ 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,2 +1,6 @@
from src.application.domain.enums.log_level import LogLevel
from src.application.domain.enums.log_format import LogFormat
from src.application.domain.enums.log_format import LogFormat
from src.application.domain.enums.itpay_payment_status import ItPayPaymentStatus
from src.application.domain.enums.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

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

View File

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

View File

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

@@ -0,0 +1,14 @@
from enum import Enum
class SbpWithdrawalStatus(str,Enum):
CREATED='created'
WAITING_USDT='waiting_usdt'
USDT_UNDERPAID='usdt_underpaid'
USDT_RECEIVED='usdt_received'
WALLET_ERROR='wallet_error'
PAYOUT_PROCESSING='payout_processing'
PAYOUT_COMPLETED='payout_completed'
PAYOUT_FAILED='payout_failed'
CANCELLED='cancelled'
EXPIRED='expired'

View File

@@ -1 +1,39 @@
from src.application.domain.exceptions.application_exceptions import ApplicationException
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',
]

View File

@@ -7,12 +7,13 @@ 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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -0,0 +1,5 @@
from src.application.services.payment_quote_service import PaymentQuote,PaymentQuoteService
from src.application.services.sbp_withdrawal_quote_service import SbpWithdrawalQuote,SbpWithdrawalQuoteService
__all__ = ['PaymentQuote', 'PaymentQuoteService']

View File

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

@@ -0,0 +1,64 @@
from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal,ROUND_DOWN,ROUND_UP
from src.application.contracts import ICache,ILogger
from src.application.domain.exceptions import OrderTotalOutOfRangeException,ServiceUnavailableException
MIN_SBP_WITHDRAWAL_USDT: Decimal=Decimal('100')
MAX_SBP_WITHDRAWAL_USDT: Decimal=Decimal('5000')
_FEE_TIERS: tuple[tuple[Decimal,Decimal,Decimal],...]=(
(Decimal('100'),Decimal('2000'),Decimal('0.08')),
(Decimal('2000.01'),Decimal('4000'),Decimal('0.06')),
(Decimal('4000.01'),Decimal('5000'),Decimal('0.04')),
)
@dataclass(slots=True)
class SbpWithdrawalQuote:
usdt_amount: Decimal
rub_amount: Decimal
usdt_exchange_rate: Decimal
service_fee_rate: Decimal
service_fee_usdt: Decimal
class SbpWithdrawalQuoteService:
def __init__(self,*,remote_cache: ICache,logger: ILogger):
self._remote_cache=remote_cache
self._logger=logger
async def _load_rate(self) -> Decimal:
rate_raw=await self._remote_cache.hget('tradex:rub:rate','value')
self._logger.info(f'Actual market value for SBP withdrawal: rate={rate_raw}')
if rate_raw is None:
raise ServiceUnavailableException(message='Exchange rate unavailable')
return Decimal(rate_raw).quantize(Decimal('0.00'),rounding=ROUND_UP)
@staticmethod
def _fee_rate(usdt_amount: Decimal) -> Decimal:
for lo,hi,rate in _FEE_TIERS:
if lo<=usdt_amount<=hi:
return rate
raise OrderTotalOutOfRangeException(message='Withdrawal amount is out of range')
async def get_quote(self,usdt_amount: Decimal) -> SbpWithdrawalQuote:
if usdt_amount<MIN_SBP_WITHDRAWAL_USDT:
raise OrderTotalOutOfRangeException(message='Withdrawal amount is below minimum allowed amount')
if usdt_amount>MAX_SBP_WITHDRAWAL_USDT:
raise OrderTotalOutOfRangeException(message='Withdrawal amount exceeds maximum allowed amount')
rate=await self._load_rate()
fee_rate=self._fee_rate(usdt_amount)
service_fee=(usdt_amount*fee_rate).quantize(Decimal('0.01'))
payout_usdt=(usdt_amount-service_fee).quantize(Decimal('0.01'))
rub_amount=(payout_usdt*rate).quantize(Decimal('1'),rounding=ROUND_DOWN)
return SbpWithdrawalQuote(
usdt_amount=usdt_amount,
rub_amount=rub_amount,
usdt_exchange_rate=rate,
service_fee_rate=fee_rate,
service_fee_usdt=service_fee,
)

View File

@@ -1,29 +0,0 @@
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,2 +1,5 @@
from src.infrastructure.cache.client import create_redis_client
from src.infrastructure.cache.keydb_client import KeydbCache
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,9 +3,10 @@ from redis.asyncio.client import Redis
from src.infrastructure.config import settings
def create_redis_client() -> Redis:
def create_redis_client(url:str|None=None) -> Redis:
redis_url = url or settings.KEYDB_REMOTE_URL
return redis.from_url(
settings.REDIS_URL,
redis_url,
max_connections=50,
decode_responses=True,
socket_timeout=5,

View File

@@ -20,6 +20,9 @@ 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
@@ -37,12 +40,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,
'crypto_wallet': user.crypto_wallet,
'encrypted_mnemonic': user.encrypted_mnemonic,
'phone': user.phone,
'bik': user.bik,
'account_number': user.account_number,
'card_number': user.card_number,
'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,

View File

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

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

View File

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

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

@@ -4,19 +4,34 @@ from functools import lru_cache
from typing import List, Literal
import os
from dotenv import load_dotenv, find_dotenv
from pydantic import Field, model_validator
from pydantic import AliasChoices, Field, field_validator, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from src.infrastructure.vault import create_hvac_client, read_kv2_secret
from src.infrastructure.vault import create_hvac_client_from_approle, read_kv2_secret
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_TOKEN: str = Field(..., description="Vault token is required")
VAULT_MOUNT_POINT: str = Field(default="secrets")
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_JWT_KID_PATH: str = "jwt/kid"
VAULT_JWT_KIDS_PREFIX: str = "jwt/kids"
@@ -45,6 +60,8 @@ 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"
@@ -59,6 +76,11 @@ 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
RABBIT_HOST: str = "localhost"
RABBIT_PORT: int = 5672
RABBIT_USER: str = "guest"
@@ -68,6 +90,25 @@ 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"
@@ -77,51 +118,224 @@ 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")
@classmethod
def load_from_vault(cls, data: dict):
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 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'
if not token:
raise RuntimeError("VAULT_TOKEN is required")
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')
client = create_hvac_client(url=addr, token=token, timeout=5)
data['VAULT_ROLE_ID'] = str(role_id).strip()
data['VAULT_SECRET_ID'] = str(secret_id).strip()
def safe_read(path: str) -> dict:
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:
try:
return read_kv2_secret(client=client, mount_point=mount, path=path)
return read_secret(path)
except Exception:
return {}
database = safe_read("database")
rabbitmq = safe_read("rabbitmq")
csrf = safe_read("csrf")
database = read_secret('database')
csrf = read_secret_optional('csrf')
rabbitmq = read_secret_optional('rabbitmq')
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}")
db_ci = {str(k).lower(): v for k, v in database.items()}
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"]
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
if rabbitmq:
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"))
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:
data["CSRF_SECRET_KEY"] = csrf.get("KEY", data.get("CSRF_SECRET_KEY"))
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
if rabbitmq:
r_ci = {str(k).lower(): v for k, v in rabbitmq.items()}
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
keydb_host_override = os.getenv('KEYDB_REMOTE_HOST') or data.get('KEYDB_REMOTE_HOST')
keydb_port_override = os.getenv('KEYDB_REMOTE_PORT') or data.get('KEYDB_REMOTE_PORT')
keydb_db_override = os.getenv('KEYDB_REMOTE_DB') or data.get('KEYDB_REMOTE_DB')
keydb_password_override = os.getenv('KEYDB_REMOTE_PASSWORD') or data.get('KEYDB_REMOTE_PASSWORD')
if keydb_host_override is not None and str(keydb_host_override).strip():
data['KEYDB_REMOTE_HOST'] = str(keydb_host_override).strip()
if keydb_port_override is not None and str(keydb_port_override).strip():
data['KEYDB_REMOTE_PORT'] = int(keydb_port_override)
if keydb_db_override is not None and str(keydb_db_override).strip():
data['KEYDB_REMOTE_DB'] = int(keydb_db_override)
if keydb_password_override is not None and str(keydb_password_override).strip():
data['KEYDB_REMOTE_PASSWORD'] = str(keydb_password_override).strip()
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()
return data
@@ -134,8 +348,20 @@ class Settings(BaseSettings):
@property
def REDIS_URL(self) -> str:
auth = f":{self.REDIS_PASSWORD}@" if self.REDIS_PASSWORD else ""
return f"redis://{auth}{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"
return self.KEYDB_REMOTE_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_REMOTE_URL(self) -> str:
host = self.KEYDB_REMOTE_HOST or self.REDIS_HOST
port = int(self.KEYDB_REMOTE_PORT) if self.KEYDB_REMOTE_PORT is not None else int(self.REDIS_PORT)
password = self.KEYDB_REMOTE_PASSWORD if self.KEYDB_REMOTE_PASSWORD is not None else self.REDIS_PASSWORD
db = int(self.KEYDB_REMOTE_DB) if self.KEYDB_REMOTE_DB is not None else int(self.REDIS_DB)
return self._redis_url(host=host, port=port, password=password, db=db)
@property
def RABBIT_URL(self) -> str:
@@ -152,4 +378,4 @@ def get_settings() -> Settings:
return Settings()
settings = get_settings()
settings = get_settings()

View File

@@ -1,6 +1,8 @@
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

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

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

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

@@ -1,50 +0,0 @@
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,8 +1,9 @@
from __future__ import annotations
from sqlalchemy import Boolean, Date, String, DateTime
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import Boolean,Date,String,DateTime,Text
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):
@@ -16,13 +17,17 @@ 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)
crypto_wallet: Mapped[str | None] = mapped_column(String(255), nullable=True)
encrypted_mnemonic: Mapped[str | None] = mapped_column(Text, nullable=True)
phone: Mapped[str | None] = mapped_column(String(16), 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)
passport_data: Mapped[str | None] = mapped_column(String(255), 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

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

View File

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

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

@@ -0,0 +1,137 @@
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,114 +1,51 @@
from __future__ import annotations
from fastapi import status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
from src.application.abstractions.repositories.i_user_repository import IUserRepository
from src.application.contracts import ILogger
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
from src.application.domain.entities.user import UserEntity
from src.infrastructure.database.models.user import UserModel
class UserRepository(IUserRepository):
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)}',
)
def __init__(self,session:AsyncSession,logger:ILogger):
self._session=session
self._logger=logger
@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,8 +1,11 @@
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from src.application.abstractions import IUnitOfWork
from src.application.abstractions.repositories import IUserRepository, ISessionRepository
from src.application.abstractions.repositories import IOrderRepository,IPaymentRepository,ISbpWithdrawalRepository,IUserRepository
from src.application.contracts import ILogger
from src.infrastructure.database.repositories import UserRepository, SessionRepository
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
@@ -10,12 +13,18 @@ class UnitOfWork(IUnitOfWork):
def __init__(self, session_factory: async_sessionmaker[AsyncSession], logger: ILogger):
self.session_factory = session_factory
self._session: AsyncSession = None
self._user_repository: IUserRepository = None
self._session_repository: ISessionRepository = 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._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):
@@ -29,14 +38,29 @@ 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

@@ -0,0 +1,123 @@
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 json
import orjson
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: str) -> dict[str, Any]:
def _prepare_log_data(self, level: LogLevel, message: Any) -> dict[str, Any]:
current_frame = inspect.currentframe()
if (
current_frame
@@ -75,34 +75,37 @@ 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(),
"message": message,
'timestamp': datetime.now().isoformat(),
'level': level.name,
'instance_id': self.instance_id,
'file': filename,
'line': line_number,
'trace_id': trace_id_var.get(),
}
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: str) -> None:
def _log(self, level: LogLevel, message: Any) -> None:
if level >= self.min_level:
log_data = self._prepare_log_data(level, message)
if self.log_format == LogFormat.JSON:
log_message = json.dumps(log_data, ensure_ascii=False)
log_message = orjson.dumps(log_data).decode()
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['message']}"
f"{log_data.get('message', log_data.get('event', ''))}"
)
if "exception" in log_data:
if 'exception' in log_data:
log_message += f"\nTraceback:\n{log_data['exception']}"
self._write(log_message)
@@ -110,20 +113,20 @@ class Logger(ILogger):
def _write(self, message: str) -> None:
sys.stdout.write(message + "\n")
def debug(self, message: str) -> None:
def debug(self, message: Any) -> None:
self._log(LogLevel.DEBUG, message)
def info(self, message: str) -> None:
def info(self, message: Any) -> None:
self._log(LogLevel.INFO, message)
def warning(self, message: str) -> None:
def warning(self, message: Any) -> None:
self._log(LogLevel.WARNING, message)
def error(self, message: str) -> None:
def error(self, message: Any) -> None:
self._log(LogLevel.ERROR, message)
def critical(self, message: str) -> None:
def critical(self, message: Any) -> None:
self._log(LogLevel.CRITICAL, message)
def exception(self, message: str) -> None:
def exception(self, message: Any) -> None:
self._log(LogLevel.EXCEPTION, message)

View File

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

View File

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

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

View File

@@ -0,0 +1,115 @@
from __future__ import annotations
from decimal import Decimal,ROUND_DOWN
from typing import Any
import aiohttp
import orjson
from aiohttp import ClientTimeout
from src.application.contracts import IMozenSbpService,SbpBank,SbpPayoutResult
from src.application.domain.exceptions import PaymentProviderException,ServiceUnavailableException
class MozenSbpClient(IMozenSbpService):
def __init__(
self,
*,
api_base_url: str,
client_id: str,
park_token: str,
timeout_seconds: float = 30,
) -> None:
self._api_base_url=api_base_url.rstrip('/')
self._client_id=client_id
self._park_token=park_token
self._timeout=ClientTimeout(total=timeout_seconds)
def _auth_headers(self,trace_id: str) -> dict[str,str]:
if not self._client_id or not self._park_token:
raise ServiceUnavailableException(message='Mozen SBP credentials unavailable')
return {
'Accept':'application/json',
'Content-Type':'application/json',
'x-trace-id':trace_id,
'x-client-id':self._client_id,
'park-token':self._park_token,
}
async def get_banks(self,trace_id: str) -> list[SbpBank]:
url=f'{self._api_base_url}/api/v2/payout/sbp/banks'
headers={'Accept':'application/json','x-trace-id':trace_id}
try:
async with aiohttp.ClientSession(timeout=self._timeout) as session:
async with session.get(url,headers=headers) as resp:
response_text=await resp.text()
try:
response_json=orjson.loads(response_text)
except orjson.JSONDecodeError:
response_json=[]
if resp.status>=400:
raise PaymentProviderException(message='Mozen SBP banks provider error')
if not isinstance(response_json,list):
raise PaymentProviderException(message='Mozen SBP banks response invalid')
banks: list[SbpBank]=[]
for item in response_json:
if not isinstance(item,dict):
continue
bank_id=str(item.get('id') or '').strip()
name=str(item.get('name') or '').strip()
if bank_id and name:
banks.append(SbpBank(id=bank_id,name=name))
return banks
except PaymentProviderException:
raise
except (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 ApplicationException
from src.application.domain.exceptions import CsrfException
from src.infrastructure.config.settings import settings
@@ -42,21 +42,12 @@ class CsrfService(ICsrfService):
try:
data = self._serializer.loads(token, max_age=self.TTL_SECONDS)
except SignatureExpired:
raise ApplicationException(
status_code=403,
message='CSRF token expired',
)
raise CsrfException(message='CSRF token expired')
except BadSignature:
raise ApplicationException(
status_code=403,
message='CSRF token invalid',
)
raise CsrfException(message='CSRF token invalid')
if expected_subject is not None and data.get('sub') != expected_subject:
raise ApplicationException(
status_code=403,
message='CSRF token subject mismatch',
)
raise CsrfException(message='CSRF token subject mismatch')
return data
@@ -67,15 +58,9 @@ 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 ApplicationException(
status_code=403,
message='CSRF token missing',
)
raise CsrfException(message='CSRF token missing')
if not secrets.compare_digest(cookie_token, header_token):
raise ApplicationException(
status_code=403,
message='CSRF token mismatch',
)
raise CsrfException(message='CSRF token mismatch')
self.verify(cookie_token, expected_subject=expected_subject)

View File

@@ -9,9 +9,7 @@ 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
from src.application.domain.exceptions import ApplicationException,InternalServerException,JwtException
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 ApplicationException(status_code=401, message='Invalid token type')
raise JwtException(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 ApplicationException(status_code=401, message=f'Missing token claim: {exception}')
raise JwtException(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 ApplicationException(status_code=401, message='Missing token header: kid')
raise JwtException(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 ApplicationException(status_code=401, message='Invalid token algorithm')
raise JwtException(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 ApplicationException(status_code=401, message='Unknown token kid')
raise JwtException(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 ApplicationException(status_code=401, message='Missing token claim: sid')
raise JwtException(message='Missing token claim: sid')
if 'type' not in payload:
self._logger.warning(f'JWT missing type claim kid={kid}')
raise ApplicationException(status_code=401, message='Missing token claim: type')
raise JwtException(message='Missing token claim: type')
return payload
except ExpiredSignatureError as exception:
self._logger.info(f'JWT expired kid={kid} error={str(exception)}')
raise ApplicationException(status_code=401, message='Token expired')
raise JwtException(message='Token expired')
except ApplicationException:
raise
except JWTError as exception:
self._logger.warning(f'JWT decode failed kid={kid} error={str(exception)}')
raise ApplicationException(status_code=401, message='Invalid token')
raise JwtException(message='Invalid token')
except Exception as exception:
self._logger.error(f'Unexpected JWT decode error kid={kid} error={str(exception)}')
raise ApplicationException(status_code=500, message='JWT decode failed')
raise InternalServerException(message='JWT decode failed')

View File

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

View File

@@ -2,10 +2,23 @@ from __future__ import annotations
import hvac
def create_hvac_client(*, url: str, token: str, timeout: int = 5) -> hvac.Client:
client = hvac.Client(url=url, token=token, timeout=timeout)
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)
if not client.is_authenticated():
raise RuntimeError("Vault authentication failed. Check VAULT_ADDR / VAULT_TOKEN")
raise RuntimeError(
'Vault AppRole authentication failed. Check VAULT_ADDR, VAULT_ROLE_ID, VAULT_SECRET_ID'
)
return client

View File

@@ -2,20 +2,22 @@ from __future__ import annotations
from contextlib import asynccontextmanager
import secrets
from typing import AsyncGenerator
from fastapi import Depends, FastAPI, status
from fastapi import Depends, FastAPI
from fastapi.middleware.cors import CORSMiddleware
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
from src.application.domain.exceptions import ApplicationException,UnauthorizedException
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.handlers import application_exception_handler, unhandled_exception_handler
from src.presentation.handler import application_exception_handler, unhandled_exception_handler
from src.presentation.messaging import crypto_transfer_router,sbp_withdrawal_messaging_router
from src.presentation.middleware import TraceIDMiddleware, SecurityHeadersMiddleware
from src.presentation.routing import order_router
from src.presentation.routing import order_router,orders_router,payment_router,payments_router,sbp_withdrawal_router
security = HTTPBasic()
@@ -24,8 +26,7 @@ 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 ApplicationException(
status_code=status.HTTP_401_UNAUTHORIZED,
raise UnauthorizedException(
message='Unauthorized',
headers={'WWW-Authenticate': 'Basic'},
)
@@ -39,11 +40,21 @@ 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 = create_redis_client()
app.state.redis_remote = create_redis_client(settings.KEYDB_REMOTE_URL)
app.state.redis = app.state.redis_remote
try:
await app.state.redis_remote.ping()
logger.info('KeyDB connection established')
except Exception as exception:
logger.error(f'KeyDB connection failed: {exception}')
await app.state.redis_remote.aclose()
raise
jwt_store = JwtKeyStore(
vault_addr=settings.VAULT_ADDR,
vault_token=settings.VAULT_TOKEN,
vault_role_id=settings.VAULT_ROLE_ID,
vault_secret_id=settings.VAULT_SECRET_ID,
vault_namespace=settings.VAULT_NAMESPACE,
mount_point=settings.VAULT_MOUNT_POINT,
kid_path=settings.VAULT_JWT_KID_PATH,
kids_prefix=settings.VAULT_JWT_KIDS_PREFIX,
@@ -55,25 +66,44 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.jwt_key_store = jwt_store
app.state.jwt_keys_scheduler = jwt_scheduler
yield
await app.state.redis.aclose()
logger.info(f'Users service instance ended with id {instance_id}')
try:
yield
finally:
sched = getattr(app.state,'jwt_keys_scheduler',None)
if sched:
sched.shutdown(wait=False)
await app.state.redis_remote.aclose()
logger.info(f'Pay service instance ended with id {instance_id}')
app: FastAPI = FastAPI(
redoc_url=None,
docs_url=None,
lifespan=lifespan,
title='Elcsa Users Service'
title='Elcsa. Pay service 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,
@@ -81,7 +111,7 @@ app.add_middleware(
hsts_preload=False,
frame_options='DENY',
referrer_policy='strict-origin-when-cross-origin',
content_security_policy="default-src 'self'; frame-ancestors 'none'; base-uri 'self'; object-src 'none'",
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'",
)
@@ -113,3 +143,10 @@ 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 ApplicationException
from src.application.domain.exceptions import UnauthorizedException
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 ApplicationException(status_code=401, message='Not authenticated')
raise UnauthorizedException(message='Not authenticated')
payload: AccessTokenPayload = await jwt_service.decode_access_token(token)
if payload.type != 'access':
raise ApplicationException(status_code=401, message='Invalid token type')
raise UnauthorizedException(message='Invalid token type')
return AuthContext(user_id=payload.sub, sid=payload.sid, token=payload)

View File

@@ -1,15 +1,20 @@
from __future__ import annotations
import functools
from typing import Any, Awaitable, Callable
import orjson
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_redis
from src.presentation.dependencies.cache import get_cache_remote
def cached(*, prefix: str) -> Callable:
def _to_cache_payload(value: Any) -> Any:
if hasattr(value,'model_dump'):
return value.model_dump(mode='json')
return value
def cached(*,prefix: str,ttl: int = 300,user_scoped: bool = True) -> Callable:
def decorator(func: Callable[..., Awaitable[Any]]):
@functools.wraps(func)
async def wrapper(*args: Any, **kwargs: Any) -> Any:
@@ -25,22 +30,34 @@ def cached(*, prefix: str) -> Callable:
auth = kwargs.get('auth')
user_id = getattr(auth, 'user_id', None) if auth else None
if request is None or user_id is None:
if request is None:
return await func(*args, **kwargs)
cache_key = f'{prefix}:{user_id}'
if user_scoped:
if user_id is None:
return await func(*args, **kwargs)
cache_key = f'{prefix}:{user_id}'
else:
cache_key = prefix
try:
redis = get_redis(request)
cache = KeydbCache(redis)
hit = await cache.get_user(user_id)
cache = get_cache_remote(request)
hit = await cache.get(cache_key)
if hit is not None:
logger.debug(f'Cache hit key={cache_key}')
return ORJSONResponse(status_code=200, content=hit)
return ORJSONResponse(status_code=200,content=orjson.loads(hit))
except Exception as e:
logger.warning(f'Cache read failed key={cache_key} error={e}')
return await func(*args, **kwargs)
result = await func(*args, **kwargs)
try:
cache = get_cache_remote(request)
payload = _to_cache_payload(result)
await cache.set(cache_key,orjson.dumps(payload).decode(),ttl)
logger.debug(f'Cache set key={cache_key} ttl={ttl}')
except Exception as e:
logger.warning(f'Cache write failed key={cache_key} error={e}')
return result
return wrapper
return 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 ApplicationException
from src.application.domain.exceptions import InternalServerException
from src.infrastructure.security import CsrfService
@@ -39,10 +39,7 @@ def csrf_protect(
break
if request is None:
raise ApplicationException(
status_code=500,
message='Request is required for CSRF protection',
)
raise InternalServerException(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 ApplicationException
from src.application.domain.exceptions import InternalServerException,RateLimitException
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 ApplicationException(500, 'Rate limiter key_builder failed')
raise InternalServerException(message='Rate limiter key_builder failed')
route = request.url.path
method = request.method
@@ -153,13 +153,12 @@ def rate_limit(
logger.warning(f'RateLimit fail-open activated key={redis_key}')
return await func(*args, **kwargs)
raise ApplicationException(503, 'Rate limiter unavailable')
raise RateLimitException(status_code=503,message='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 ApplicationException(
status_code=429,
raise RateLimitException(
message='Too Many Requests',
headers={'Retry-After': str(retry_after)},
)

View File

@@ -1,16 +1,9 @@
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
from src.presentation.dependencies.cache import get_redis, get_cache
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.security import get_jwt_service
__all__=['get_jwt_service','get_redis','get_redis_remote','get_cache','get_cache_remote','get_rabbit']

View File

@@ -1,12 +1,28 @@
from fastapi import Depends, Request
from __future__ import annotations
from fastapi import Depends,Request
from redis.asyncio.client import Redis
from src.application.contracts import ICache
from src.infrastructure.cache import KeydbCache
from src.infrastructure.cache import KeydbCache,RemoteCache
def get_redis_remote(request: Request) -> Redis:
return request.app.state.redis_remote
def get_redis(request: Request) -> Redis:
return request.app.state.redis
return request.app.state.redis_remote
def get_cache(redis_client: Redis = Depends(get_redis)) -> ICache:
def get_cache_remote(redis_client: Redis = Depends(get_redis_remote)) -> ICache:
return KeydbCache(redis_client)
def get_remote_cache(redis_client: Redis = Depends(get_redis_remote)) -> ICache:
return RemoteCache(redis_client)
def get_cache(cache: ICache = Depends(get_cache_remote)) -> ICache:
return cache

View File

@@ -1,161 +1,193 @@
from __future__ import annotations
from fastapi import Depends
from src.application.abstractions import IUnitOfWork
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.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.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_get_me_command(
logger: ILogger = Depends(get_logger),
unit_of_work: IUnitOfWork = Depends(get_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,
unit_of_work=unit_of_work,
cache=cache,
messanger=messanger,
hash_service=hash_service,
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_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,
unit_of_work=unit_of_work,
cache=cache,
hash_service=hash_service,
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_change_password_start_command(
def get_create_order_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,
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=cache,
messanger=messanger,
hash_service=hash_service,
logger=logger,
payment_quote_service=payment_quote_service,
itpay_service=itpay_service,
)
def get_change_password_complete_command(
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),
cache: ICache = Depends(get_cache),
hash_service: IHashService = Depends(get_hash_service),
) -> ChangePasswordCompleteCommand:
return ChangePasswordCompleteCommand(
logger=logger,
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,
hash_service=hash_service,
logger=logger,
queue_messanger=queue_messanger,
mozen_sbp_service=mozen_sbp_service,
quote_service=quote_service,
)
def get_change_email_start_command(
def get_sbp_withdrawal_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,
) -> 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_change_email_confirm_old_command(
def get_crypto_transfer_completed_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,
)
receipt: IReceipt = Depends(get_cloud_kassir_receipt),
) -> CreateCryptoTransferCompletedCommand:
return CreateCryptoTransferCompletedCommand(unit_of_work=unit_of_work,receipt=receipt,logger=logger)
def get_change_email_complete_command(
def get_sbp_withdrawal_wallet_event_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,
mozen_sbp_service: IMozenSbpService = Depends(get_mozen_sbp_service),
) -> HandleSbpWithdrawalWalletEventCommand:
return HandleSbpWithdrawalWalletEventCommand(
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,
)
mozen_sbp_service=mozen_sbp_service,
)

View File

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

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

@@ -1,5 +1,5 @@
from fastapi.responses import ORJSONResponse
from fastapi import Request
from fastapi.responses import ORJSONResponse
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'},
)
content={'detail': 'Internal Server Error','status_code': status.HTTP_500_INTERNAL_SERVER_ERROR},
)

View File

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

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