Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d4c2e7d5be | |||
| 565816e710 | |||
| dc213cb9d9 | |||
| e41e89277f | |||
| 50bfaa9264 | |||
| 366bdc9515 | |||
| 6130188a4f | |||
| 687076e6dc | |||
| 631cd4861a | |||
| bb89aaeee5 | |||
| 4c702b6260 | |||
| 20840c95de | |||
| 07cd454248 | |||
| d4fe062f72 | |||
| e2a1d7e1b4 | |||
| 46b1e336d9 | |||
| 852ee9ec2e | |||
| 489c9cb2da | |||
| 42fcfbff34 | |||
| 1c32bdcb3f | |||
| 3e9625fb86 | |||
| 3f13032be1 | |||
| 10ff2a510d | |||
| c86c4b451b | |||
| 9c8a466789 | |||
| d7ccddc72c | |||
| ad51f1220f | |||
| 766280fd45 | |||
| 499947e44e | |||
| e929133db8 | |||
| 3dfde69a3e | |||
| ea0ca899ac | |||
| 195c0a8e53 | |||
| b6e4f8165f | |||
| be8aee7b73 | |||
| 22f27fa524 | |||
| 152a8ed6ac | |||
| 3e181dc904 | |||
| 45f2949fbc | |||
| bf68aca4fa | |||
| d1ac7e8e84 | |||
| ab772f1f02 | |||
| 64125149be | |||
| 814ba9f318 | |||
| a146a6a3e9 | |||
| 2627354673 | |||
| bea79634b5 |
@@ -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"]
|
||||
|
||||
17
docker-compose.yml
Normal file
17
docker-compose.yml
Normal file
@@ -0,0 +1,17 @@
|
||||
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"
|
||||
env_file:
|
||||
- .env
|
||||
restart: no
|
||||
1
src/application/abstractions/__init__.py
Normal file
1
src/application/abstractions/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from src.application.abstractions.i_unit_of_work import IUnitOfWork
|
||||
22
src/application/abstractions/i_unit_of_work.py
Normal file
22
src/application/abstractions/i_unit_of_work.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from __future__ import annotations
|
||||
from typing import Protocol, runtime_checkable
|
||||
from src.application.abstractions.repositories import IOrderRepository,IPaymentRepository,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 user_repository(self) -> IUserRepository: ...
|
||||
|
||||
3
src/application/abstractions/repositories/__init__.py
Normal file
3
src/application/abstractions/repositories/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
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_user_repository import IUserRepository
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
4
src/application/commands/__init__.py
Normal file
4
src/application/commands/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from src.application.commands.create_order_command import CreateOrderCommand
|
||||
from src.application.commands.create_payment_command import CreatePaymentCommand
|
||||
from src.application.commands.create_crypto_transfer_completed_command import CreateCryptoTransferCompletedCommand
|
||||
from src.application.commands.payment_read_commands import GetOrderCommand,GetOrderStatusCommand,GetPaymentCommand,GetPaymentConfigCommand,GetPaymentQuoteCommand,GetPaymentQuoteFromRubCommand,ListOrdersCommand,ListPaymentsCommand,OrderPaymentResult
|
||||
@@ -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,
|
||||
})
|
||||
74
src/application/commands/create_order_command.py
Normal file
74
src/application/commands/create_order_command.py
Normal file
@@ -0,0 +1,74 @@
|
||||
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, PriceChangedException
|
||||
from src.application.services import PaymentQuoteService
|
||||
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())
|
||||
|
||||
quote = await self._payment_quote_service.get_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
|
||||
|
||||
94
src/application/commands/create_payment_command.py
Normal file
94
src/application/commands/create_payment_command.py
Normal 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,
|
||||
})
|
||||
141
src/application/commands/payment_read_commands.py
Normal file
141
src/application/commands/payment_read_commands.py
Normal file
@@ -0,0 +1,141 @@
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
from src.application.abstractions import IUnitOfWork
|
||||
from src.application.contracts import ILogger
|
||||
from src.application.domain.entities import OrderEntity,PaymentEntity
|
||||
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
|
||||
|
||||
|
||||
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 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)
|
||||
@@ -4,3 +4,5 @@ from src.application.contracts.i_csrf_service import ICsrfService
|
||||
from src.application.contracts.i_cache import ICache
|
||||
from src.application.contracts.i_hash_service import IHashService
|
||||
from src.application.contracts.i_queue_messanger import IQueueMessanger
|
||||
from src.application.contracts.i_itpay_service import IItPayService
|
||||
from src.application.contracts.i_receipt import IReceipt
|
||||
@@ -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
|
||||
|
||||
11
src/application/contracts/i_itpay_service.py
Normal file
11
src/application/contracts/i_itpay_service.py
Normal 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
|
||||
@@ -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"""
|
||||
...
|
||||
25
src/application/contracts/i_receipt.py
Normal file
25
src/application/contracts/i_receipt.py
Normal 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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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
|
||||
|
||||
|
||||
__all__ = ['UserEntity', 'SessionEntity']
|
||||
__all__ = ['PaymentEntity', 'OrderEntity']
|
||||
36
src/application/domain/entities/order.py
Normal file
36
src/application/domain/entities/order.py
Normal 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
|
||||
|
||||
27
src/application/domain/entities/payment.py
Normal file
27
src/application/domain/entities/payment.py
Normal 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
|
||||
@@ -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'
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
from src.application.domain.enums.log_level import LogLevel
|
||||
from src.application.domain.enums.log_format import LogFormat
|
||||
from src.application.domain.enums.itpay_payment_status import ItPayPaymentStatus
|
||||
from src.application.domain.enums.order_status import OrderStatus
|
||||
from src.application.domain.enums.payment_status import PaymentStatus
|
||||
7
src/application/domain/enums/itpay_payment_status.py
Normal file
7
src/application/domain/enums/itpay_payment_status.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class ItPayPaymentStatus(Enum):
|
||||
COMPLETED = "payment.completed"
|
||||
REJECTED = "payment.rejected"
|
||||
CANCELLED = "payment.canceled"
|
||||
9
src/application/domain/enums/order_status.py
Normal file
9
src/application/domain/enums/order_status.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class OrderStatus(str, Enum):
|
||||
PENDING = 'pending'
|
||||
REJECTED = 'rejected'
|
||||
COMPLETED = 'completed'
|
||||
CANCELLED = 'cancelled'
|
||||
ERROR = 'error'
|
||||
13
src/application/domain/enums/payment_status.py
Normal file
13
src/application/domain/enums/payment_status.py
Normal 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'
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
@@ -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}'
|
||||
12
src/application/domain/exceptions/bad_gateway_exception.py
Normal file
12
src/application/domain/exceptions/bad_gateway_exception.py
Normal 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)
|
||||
12
src/application/domain/exceptions/bad_request_exception.py
Normal file
12
src/application/domain/exceptions/bad_request_exception.py
Normal 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)
|
||||
12
src/application/domain/exceptions/conflict_exception.py
Normal file
12
src/application/domain/exceptions/conflict_exception.py
Normal 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)
|
||||
12
src/application/domain/exceptions/csrf_exception.py
Normal file
12
src/application/domain/exceptions/csrf_exception.py
Normal 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)
|
||||
12
src/application/domain/exceptions/forbidden_exception.py
Normal file
12
src/application/domain/exceptions/forbidden_exception.py
Normal 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)
|
||||
@@ -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)
|
||||
13
src/application/domain/exceptions/jwt_exception.py
Normal file
13
src/application/domain/exceptions/jwt_exception.py
Normal 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)
|
||||
12
src/application/domain/exceptions/not_found_exception.py
Normal file
12
src/application/domain/exceptions/not_found_exception.py
Normal 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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
12
src/application/domain/exceptions/price_changed_exception.py
Normal file
12
src/application/domain/exceptions/price_changed_exception.py
Normal 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)
|
||||
13
src/application/domain/exceptions/rate_limit_exception.py
Normal file
13
src/application/domain/exceptions/rate_limit_exception.py
Normal 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)
|
||||
12
src/application/domain/exceptions/receipt_data_exception.py
Normal file
12
src/application/domain/exceptions/receipt_data_exception.py
Normal 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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
12
src/application/domain/exceptions/unauthorized_exception.py
Normal file
12
src/application/domain/exceptions/unauthorized_exception.py
Normal 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)
|
||||
4
src/application/services/__init__.py
Normal file
4
src/application/services/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from src.application.services.payment_quote_service import PaymentQuote,PaymentQuoteService
|
||||
|
||||
|
||||
__all__ = ['PaymentQuote', 'PaymentQuoteService']
|
||||
186
src/application/services/payment_quote_service.py
Normal file
186
src/application/services/payment_quote_service.py
Normal file
@@ -0,0 +1,186 @@
|
||||
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_budget = ((total_rub - gas_fee) / usdt_exchange_rate).quantize(Decimal('0.01'), rounding=ROUND_DOWN)
|
||||
u_cap = ((_MAX_TOTAL_RUB - gas_fee) / (usdt_exchange_rate * Decimal('1.04'))).quantize(
|
||||
Decimal('0.01'),
|
||||
rounding=ROUND_DOWN,
|
||||
)
|
||||
u_upper = min(u_budget, u_cap)
|
||||
n_hi = int((u_upper * 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_lo = mid + 1
|
||||
else:
|
||||
n_hi = mid - 1
|
||||
|
||||
if best_cent is None:
|
||||
raise OrderTotalOutOfRangeException(
|
||||
message='Order total is below minimum 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
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
3
src/infrastructure/cache/__init__.py
vendored
3
src/infrastructure/cache/__init__.py
vendored
@@ -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.remote_cache import RemoteCache
|
||||
|
||||
__all__ = ['create_redis_client', 'KeydbCache', 'RemoteCache']
|
||||
|
||||
5
src/infrastructure/cache/client.py
vendored
5
src/infrastructure/cache/client.py
vendored
@@ -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,
|
||||
|
||||
11
src/infrastructure/cache/keydb_client.py
vendored
11
src/infrastructure/cache/keydb_client.py
vendored
@@ -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,
|
||||
|
||||
68
src/infrastructure/cache/remote_cache.py
vendored
Normal file
68
src/infrastructure/cache/remote_cache.py
vendored
Normal 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)
|
||||
5
src/infrastructure/cloud_kassir/__init__.py
Normal file
5
src/infrastructure/cloud_kassir/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from src.infrastructure.cloud_kassir.client import ClaudeKassirClient
|
||||
|
||||
|
||||
__all__=['ClaudeKassirClient']
|
||||
|
||||
151
src/infrastructure/cloud_kassir/client.py
Normal file
151
src/infrastructure/cloud_kassir/client.py
Normal 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 aiohttp.ClientError:
|
||||
raise ReceiptProviderException(message='Receipt provider unreachable')
|
||||
4
src/infrastructure/cloud_kassir/constants.py
Normal file
4
src/infrastructure/cloud_kassir/constants.py
Normal 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'
|
||||
@@ -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"
|
||||
@@ -59,6 +74,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 +88,14 @@ 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"
|
||||
|
||||
ITPAY_PUBLIC_ID: str
|
||||
ITPAY_API_SECRET: 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 +105,184 @@ 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')
|
||||
|
||||
keydb = read_secret('keydb')
|
||||
k_ci = {str(k).lower(): v for k, v in keydb.items()}
|
||||
|
||||
def keydb_nonempty(key: str) -> bool:
|
||||
v = k_ci.get(key)
|
||||
if v is None:
|
||||
return False
|
||||
if isinstance(v, str) and not v.strip():
|
||||
return False
|
||||
return True
|
||||
|
||||
missing_keydb = []
|
||||
for req in ('host', 'port'):
|
||||
if not keydb_nonempty(req):
|
||||
missing_keydb.append(req)
|
||||
db_raw = k_ci.get('database')
|
||||
if db_raw is None:
|
||||
db_raw = k_ci.get('db')
|
||||
if db_raw is None or (isinstance(db_raw, str) and not str(db_raw).strip()):
|
||||
missing_keydb.append('database')
|
||||
if missing_keydb:
|
||||
raise RuntimeError(
|
||||
f'Vault secret keydb missing non-empty keys: {missing_keydb} (mount={mount},path=keydb)'
|
||||
)
|
||||
|
||||
data['KEYDB_REMOTE_HOST'] = str(k_ci['host']).strip()
|
||||
data['KEYDB_REMOTE_PORT'] = int(k_ci['port'])
|
||||
data['KEYDB_REMOTE_DB'] = int(db_raw)
|
||||
pw_raw = k_ci.get('password')
|
||||
if pw_raw is not None and str(pw_raw).strip():
|
||||
data['KEYDB_REMOTE_PASSWORD'] = str(pw_raw).strip()
|
||||
else:
|
||||
data['KEYDB_REMOTE_PASSWORD'] = None
|
||||
|
||||
itpay_public_id = data.get('ITPAY_PUBLIC_ID') or os.getenv('ITPAY_PUBLIC_ID')
|
||||
itpay_api_secret = data.get('ITPAY_API_SECRET') or os.getenv('ITPAY_API_SECRET')
|
||||
if itpay_public_id is not None and str(itpay_public_id).strip() and itpay_api_secret is not None and str(itpay_api_secret).strip():
|
||||
data['ITPAY_PUBLIC_ID'] = str(itpay_public_id).strip()
|
||||
data['ITPAY_API_SECRET'] = str(itpay_api_secret).strip()
|
||||
else:
|
||||
itpay = read_secret('itpay')
|
||||
itpay_ci = {str(k).lower(): v for k, v in itpay.items()}
|
||||
public_id = itpay_ci.get('public_id')
|
||||
api_secret = itpay_ci.get('api_secret')
|
||||
if api_secret is None:
|
||||
api_secret = itpay_ci.get('secret')
|
||||
missing = []
|
||||
if public_id is None or not str(public_id).strip():
|
||||
missing.append('public_id')
|
||||
if api_secret is None or not str(api_secret).strip():
|
||||
missing.append('api_secret')
|
||||
if missing:
|
||||
raise RuntimeError(f'Vault secret itpay missing non-empty keys: {missing} (mount={mount},path=itpay)')
|
||||
data['ITPAY_PUBLIC_ID'] = str(public_id).strip()
|
||||
data['ITPAY_API_SECRET'] = str(api_secret).strip()
|
||||
|
||||
ck_public = data.get('CLOUD_KASSIR_PUBLIC_ID') or os.getenv('CLOUD_KASSIR_PUBLIC_ID')
|
||||
ck_secret = data.get('CLOUD_KASSIR_API_SECRET') or os.getenv('CLOUD_KASSIR_API_SECRET')
|
||||
if ck_public is not None and str(ck_public).strip() and ck_secret is not None and str(ck_secret).strip():
|
||||
data['CLOUD_KASSIR_PUBLIC_ID'] = str(ck_public).strip()
|
||||
data['CLOUD_KASSIR_API_SECRET'] = str(ck_secret).strip()
|
||||
else:
|
||||
cloudkassir = read_secret_optional('cloudkassir')
|
||||
if cloudkassir:
|
||||
ck_ci = {str(k).lower(): v for k, v in cloudkassir.items()}
|
||||
public_id_ck = ck_ci.get('public_id')
|
||||
api_secret_ck = ck_ci.get('api_secret')
|
||||
if api_secret_ck is None:
|
||||
api_secret_ck = ck_ci.get('secret')
|
||||
if public_id_ck is not None and str(public_id_ck).strip():
|
||||
data['CLOUD_KASSIR_PUBLIC_ID'] = str(public_id_ck).strip()
|
||||
if api_secret_ck is not None and str(api_secret_ck).strip():
|
||||
data['CLOUD_KASSIR_API_SECRET'] = str(api_secret_ck).strip()
|
||||
|
||||
return data
|
||||
|
||||
@@ -134,8 +295,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:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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.user import UserModel
|
||||
from src.infrastructure.database.models.sessions import Session
|
||||
|
||||
__all__ = ['Base', 'UserModel', 'Session']
|
||||
|
||||
__all__ = ['Base','Order','Payment','UserModel']
|
||||
|
||||
54
src/infrastructure/database/models/order.py
Normal file
54
src/infrastructure/database/models/order.py
Normal 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
|
||||
)
|
||||
|
||||
47
src/infrastructure/database/models/payment.py
Normal file
47
src/infrastructure/database/models/payment.py
Normal 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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
from src.infrastructure.database.repositories.user_repository import UserRepository
|
||||
173
src/infrastructure/database/repositories/order_repository.py
Normal file
173
src/infrastructure/database/repositories/order_repository.py
Normal 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()
|
||||
124
src/infrastructure/database/repositories/payment_repository.py
Normal file
124
src/infrastructure/database/repositories/payment_repository.py
Normal 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()]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
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,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.user_repository import UserRepository
|
||||
|
||||
|
||||
|
||||
@@ -10,12 +12,16 @@ 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._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._user_repository = None
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
@@ -29,14 +35,22 @@ 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 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
|
||||
|
||||
115
src/infrastructure/itpay/client.py
Normal file
115
src/infrastructure/itpay/client.py
Normal file
@@ -0,0 +1,115 @@
|
||||
import orjson
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
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['status']).strip().lower()
|
||||
itpay_id = str(body['id'])
|
||||
|
||||
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['qrc_id'])
|
||||
itpay_amount = Decimal(str(body['amount']))
|
||||
|
||||
created_norm = str(body['created']).replace('Z', '+00:00')
|
||||
itpay_created_at = datetime.fromisoformat(created_norm)
|
||||
|
||||
payment_qr_urls = body['payment_qr_urls']
|
||||
if isinstance(payment_qr_urls, str):
|
||||
payment_qr_urls = orjson.loads(payment_qr_urls)
|
||||
|
||||
payment_qr_images = body['payment_qr_images']
|
||||
if isinstance(payment_qr_images, str):
|
||||
payment_qr_images = orjson.loads(payment_qr_images)
|
||||
|
||||
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 aiohttp.ClientError:
|
||||
raise PaymentProviderException(message='Payment provider unreachable')
|
||||
@@ -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)
|
||||
1
src/infrastructure/messanger/__init__.py
Normal file
1
src/infrastructure/messanger/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from src.infrastructure.messanger.rabbit_client import RabbitClient
|
||||
72
src/infrastructure/messanger/rabbit_client.py
Normal file
72
src/infrastructure/messanger/rabbit_client.py
Normal 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,
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
@@ -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
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
36
src/main.py
36
src/main.py
@@ -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
|
||||
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
|
||||
|
||||
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,14 @@ 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
|
||||
|
||||
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,
|
||||
@@ -56,7 +60,7 @@ 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()
|
||||
await app.state.redis_remote.aclose()
|
||||
logger.info(f'Users service instance ended with id {instance_id}')
|
||||
|
||||
|
||||
@@ -71,9 +75,21 @@ 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(crypto_transfer_router)
|
||||
|
||||
|
||||
# Added middleware
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[],
|
||||
allow_origin_regex='.*',
|
||||
allow_credentials=True,
|
||||
allow_methods=['*'],
|
||||
allow_headers=['*'],
|
||||
)
|
||||
app.add_middleware(TraceIDMiddleware, logger=logger)
|
||||
app.add_middleware(
|
||||
SecurityHeadersMiddleware,
|
||||
@@ -81,7 +97,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'",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
import functools
|
||||
from typing import Any, Awaitable, Callable
|
||||
from typing import Any,Awaitable,Callable
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.responses import ORJSONResponse
|
||||
from src.infrastructure.cache import KeydbCache
|
||||
|
||||
from src.infrastructure.logger import get_logger
|
||||
from src.presentation.dependencies.cache import get_redis
|
||||
from src.presentation.dependencies.cache import get_cache_remote
|
||||
|
||||
|
||||
def cached(*, prefix: str) -> Callable:
|
||||
|
||||
|
||||
def decorator(func: Callable[..., Awaitable[Any]]):
|
||||
@functools.wraps(func)
|
||||
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
@@ -31,8 +35,7 @@ def cached(*, prefix: str) -> Callable:
|
||||
cache_key = f'{prefix}:{user_id}'
|
||||
|
||||
try:
|
||||
redis = get_redis(request)
|
||||
cache = KeydbCache(redis)
|
||||
cache = get_cache_remote(request)
|
||||
hit = await cache.get_user(user_id)
|
||||
if hit is not None:
|
||||
logger.debug(f'Cache hit key={cache_key}')
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)},
|
||||
)
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,161 +1,128 @@
|
||||
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,GetOrderCommand,GetOrderStatusCommand,GetPaymentCommand,GetPaymentConfigCommand,GetPaymentQuoteCommand,GetPaymentQuoteFromRubCommand,ListOrdersCommand,ListPaymentsCommand
|
||||
from src.application.contracts import ICache,ILogger,IQueueMessanger,IReceipt
|
||||
from src.application.contracts.i_itpay_service import IItPayService
|
||||
from src.application.services import PaymentQuoteService
|
||||
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.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(
|
||||
def get_create_order_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,
|
||||
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,
|
||||
hash_service=hash_service,
|
||||
logger=logger,
|
||||
payment_quote_service=payment_quote_service,
|
||||
itpay_service=itpay_service,
|
||||
)
|
||||
|
||||
|
||||
def get_change_password_start_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_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),
|
||||
cache: ICache = Depends(get_cache),
|
||||
messanger: IQueueMessanger = Depends(get_rabbit),
|
||||
hash_service: IHashService = Depends(get_hash_service),
|
||||
) -> ChangePasswordStartCommand:
|
||||
return ChangePasswordStartCommand(
|
||||
logger=logger,
|
||||
unit_of_work=unit_of_work,
|
||||
cache=cache,
|
||||
messanger=messanger,
|
||||
hash_service=hash_service,
|
||||
) -> 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_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_password_complete_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),
|
||||
hash_service: IHashService = Depends(get_hash_service),
|
||||
) -> ChangePasswordCompleteCommand:
|
||||
return ChangePasswordCompleteCommand(
|
||||
logger=logger,
|
||||
unit_of_work=unit_of_work,
|
||||
cache=cache,
|
||||
hash_service=hash_service,
|
||||
)
|
||||
|
||||
|
||||
def get_change_email_start_command(
|
||||
logger: ILogger = Depends(get_logger),
|
||||
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
|
||||
cache: ICache = Depends(get_cache),
|
||||
messanger: IQueueMessanger = Depends(get_rabbit),
|
||||
hash_service: IHashService = Depends(get_hash_service),
|
||||
) -> ChangeEmailStartCommand:
|
||||
return ChangeEmailStartCommand(
|
||||
logger=logger,
|
||||
unit_of_work=unit_of_work,
|
||||
cache=cache,
|
||||
messanger=messanger,
|
||||
hash_service=hash_service,
|
||||
)
|
||||
|
||||
|
||||
def get_change_email_confirm_old_command(
|
||||
logger: ILogger = Depends(get_logger),
|
||||
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
|
||||
cache: ICache = Depends(get_cache),
|
||||
messanger: IQueueMessanger = Depends(get_rabbit),
|
||||
hash_service: IHashService = Depends(get_hash_service),
|
||||
) -> ChangeEmailConfirmOldCommand:
|
||||
return ChangeEmailConfirmOldCommand(
|
||||
logger=logger,
|
||||
unit_of_work=unit_of_work,
|
||||
cache=cache,
|
||||
messanger=messanger,
|
||||
hash_service=hash_service,
|
||||
)
|
||||
|
||||
|
||||
def get_change_email_complete_command(
|
||||
logger: ILogger = Depends(get_logger),
|
||||
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
|
||||
cache: ICache = Depends(get_cache),
|
||||
hash_service: IHashService = Depends(get_hash_service),
|
||||
) -> ChangeEmailCompleteCommand:
|
||||
return ChangeEmailCompleteCommand(
|
||||
logger=logger,
|
||||
unit_of_work=unit_of_work,
|
||||
cache=cache,
|
||||
hash_service=hash_service,
|
||||
)
|
||||
|
||||
|
||||
def get_update_bank_details_start_command(
|
||||
logger: ILogger = Depends(get_logger),
|
||||
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
|
||||
cache: ICache = Depends(get_cache),
|
||||
messanger: IQueueMessanger = Depends(get_rabbit),
|
||||
hash_service: IHashService = Depends(get_hash_service),
|
||||
) -> UpdateBankDetailsStartCommand:
|
||||
return UpdateBankDetailsStartCommand(
|
||||
logger=logger,
|
||||
unit_of_work=unit_of_work,
|
||||
cache=cache,
|
||||
messanger=messanger,
|
||||
hash_service=hash_service,
|
||||
)
|
||||
|
||||
|
||||
def get_update_bank_details_complete_command(
|
||||
logger: ILogger = Depends(get_logger),
|
||||
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
|
||||
cache: ICache = Depends(get_cache),
|
||||
hash_service: IHashService = Depends(get_hash_service),
|
||||
) -> UpdateBankDetailsCompleteCommand:
|
||||
return UpdateBankDetailsCompleteCommand(
|
||||
logger=logger,
|
||||
unit_of_work=unit_of_work,
|
||||
cache=cache,
|
||||
hash_service=hash_service,
|
||||
)
|
||||
receipt: IReceipt = Depends(get_cloud_kassir_receipt),
|
||||
) -> CreateCryptoTransferCompletedCommand:
|
||||
return CreateCryptoTransferCompletedCommand(unit_of_work=unit_of_work,receipt=receipt,logger=logger)
|
||||
7
src/presentation/handler/__init__.py
Normal file
7
src/presentation/handler/__init__.py
Normal 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',
|
||||
]
|
||||
22
src/presentation/handler/application_exception_handler.py
Normal file
22
src/presentation/handler/application_exception_handler.py
Normal 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,
|
||||
)
|
||||
@@ -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},
|
||||
)
|
||||
@@ -1,2 +0,0 @@
|
||||
from src.presentation.handlers.unhandled_handler import unhandled_exception_handler
|
||||
from src.presentation.handlers.application_handler import application_exception_handler
|
||||
@@ -1,17 +0,0 @@
|
||||
from fastapi.responses import ORJSONResponse
|
||||
from fastapi import Request
|
||||
from src.application.domain.exceptions import ApplicationException
|
||||
|
||||
|
||||
async def application_exception_handler(_request: Request, exc: ApplicationException) -> ORJSONResponse:
|
||||
detail = exc.message
|
||||
if 500 <= exc.status_code:
|
||||
detail = "Internal Server Error"
|
||||
|
||||
return ORJSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={"detail": detail},
|
||||
headers=dict(exc.headers) if exc.headers else None,
|
||||
)
|
||||
|
||||
|
||||
5
src/presentation/messaging/__init__.py
Normal file
5
src/presentation/messaging/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from src.presentation.messaging.crypto_transfer import crypto_transfer_router
|
||||
|
||||
|
||||
__all__=['crypto_transfer_router']
|
||||
|
||||
52
src/presentation/messaging/crypto_transfer.py
Normal file
52
src/presentation/messaging/crypto_transfer.py
Normal file
@@ -0,0 +1,52 @@
|
||||
from fastapi import Depends
|
||||
from faststream.rabbit import RabbitQueue
|
||||
from faststream.rabbit.fastapi import RabbitMessage,RabbitRouter
|
||||
from pydantic import BaseModel
|
||||
from src.application.commands import CreateCryptoTransferCompletedCommand
|
||||
from src.application.contracts import ILogger
|
||||
from src.infrastructure.config import settings
|
||||
from src.infrastructure.context_vars import trace_id_var
|
||||
from src.presentation.dependencies.commands import get_crypto_transfer_completed_command
|
||||
from src.presentation.dependencies.logger import get_logger
|
||||
|
||||
|
||||
crypto_transfer_router=RabbitRouter(settings.RABBIT_URL)
|
||||
|
||||
|
||||
class CryptoTransferCompletedMessage(BaseModel):
|
||||
user_id: str
|
||||
order_id: str
|
||||
trace_id: str | None = None
|
||||
message_id: str
|
||||
web3_transaction_hash: str | None = None
|
||||
transaction_hash: str | None = None
|
||||
tx_hash: str | None = None
|
||||
|
||||
|
||||
@crypto_transfer_router.subscriber(RabbitQueue(settings.RABBIT_CRYPTO_TRANSFER_COMPLETED_QUEUE,durable=True))
|
||||
async def crypto_transfer_completed_handler(
|
||||
msg_body: CryptoTransferCompletedMessage,
|
||||
message: RabbitMessage,
|
||||
command: CreateCryptoTransferCompletedCommand = Depends(get_crypto_transfer_completed_command),
|
||||
logger: ILogger = Depends(get_logger),
|
||||
) -> None:
|
||||
headers=message.headers or {}
|
||||
trace_id=msg_body.trace_id or message.correlation_id or str(headers.get('trace_id') or '')
|
||||
token=trace_id_var.set(trace_id)
|
||||
try:
|
||||
payload=msg_body.model_dump(mode='json')
|
||||
logger.info({
|
||||
'event':'crypto_transfer_completed_received',
|
||||
'payload':payload,
|
||||
'rabbit_message_id':message.message_id,
|
||||
'rabbit_correlation_id':message.correlation_id,
|
||||
})
|
||||
web3_transaction_hash=msg_body.web3_transaction_hash or msg_body.transaction_hash or msg_body.tx_hash
|
||||
await command(
|
||||
order_id=msg_body.order_id,
|
||||
user_id=msg_body.user_id,
|
||||
web3_transaction_hash=web3_transaction_hash,
|
||||
)
|
||||
finally:
|
||||
trace_id_var.reset(token)
|
||||
|
||||
@@ -1 +1 @@
|
||||
from src.presentation.routing.order import order_router
|
||||
from src.presentation.routing.order import order_router,orders_router,payment_router,payments_router
|
||||
@@ -1,114 +1,459 @@
|
||||
import json
|
||||
import asyncio
|
||||
from decimal import Decimal
|
||||
from urllib.parse import parse_qs
|
||||
import aiohttp
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
import orjson
|
||||
from fastapi import APIRouter,Depends,Query,Request,WebSocket,WebSocketDisconnect
|
||||
from fastapi.responses import ORJSONResponse
|
||||
from fastapi.security.utils import get_authorization_scheme_param
|
||||
from ulid import ULID
|
||||
from src.application.contracts import ILogger
|
||||
from src.application.domain.dto import AuthContext
|
||||
from src.application.domain.exceptions import ApplicationException
|
||||
from src.presentation.decorators import csrf_protect, require_access_token
|
||||
from src.application.commands import CreateOrderCommand,CreatePaymentCommand,GetOrderCommand,GetOrderStatusCommand,GetPaymentCommand,GetPaymentConfigCommand,GetPaymentQuoteCommand,GetPaymentQuoteFromRubCommand,ListOrdersCommand,ListPaymentsCommand
|
||||
from src.application.contracts import IJwtService,ILogger
|
||||
from src.application.domain.dto import AccessTokenPayload,AuthContext
|
||||
from src.application.domain.entities import OrderEntity,PaymentEntity
|
||||
from src.application.domain.enums import OrderStatus
|
||||
from src.application.domain.exceptions import ApplicationException,ConflictException
|
||||
from src.application.services import PaymentQuote
|
||||
from src.infrastructure.context_vars import trace_id_var
|
||||
from src.presentation.decorators import require_access_token, csrf_protect
|
||||
from src.presentation.dependencies.commands import get_create_order_command,get_create_payment_command,get_list_orders_command,get_list_payments_command,get_order_command,get_order_status_command,get_payment_command,get_payment_config_command,get_payment_quote_command,get_payment_quote_from_rub_command
|
||||
from src.presentation.dependencies.logger import get_logger
|
||||
from src.presentation.schemas.order import CreateOrder
|
||||
|
||||
from src.presentation.dependencies.security import get_jwt_service
|
||||
from src.presentation.schemas.order import CreateOrder,CreateOrderResponse,ErrorResponse,OrderDetailResponse,OrderPaymentResponse,OrdersResponse,OrderStatusResponse,OrderWithPaymentResponse,PaymentConfigResponse,PaymentDetailResponse,PaymentQuoteResponse,PaymentResponse,PaymentsResponse
|
||||
from src.presentation.schemas.itpay_payment_models import ItpayPaymentData
|
||||
|
||||
order_router = APIRouter(prefix='/order', tags=['orders'])
|
||||
orders_router = APIRouter(tags=['orders'])
|
||||
payment_router = APIRouter(prefix='/payment', tags=['payments'])
|
||||
payments_router = APIRouter(tags=['payments'])
|
||||
|
||||
ITPAY_API_BASE = 'https://api.gw.itpay.ru'
|
||||
ITPAY_AUTHORIZATION = 'Token REPLACE_WITH_JWT_FROM_ITPAY_DASHBOARD'
|
||||
HARDCODED_USDT_TO_RUB = Decimal('100')
|
||||
HARDCODED_GAS_RUB = Decimal('15')
|
||||
HARDCODED_OUR_COMMISSION_RUB = Decimal('25')
|
||||
ERROR_RESPONSES = {
|
||||
400: {'model': ErrorResponse, 'description': 'Bad Request'},
|
||||
401: {'model': ErrorResponse, 'description': 'Unauthorized'},
|
||||
403: {'model': ErrorResponse, 'description': 'Forbidden'},
|
||||
404: {'model': ErrorResponse, 'description': 'Not Found'},
|
||||
409: {'model': ErrorResponse, 'description': 'Conflict'},
|
||||
422: {'model': ErrorResponse, 'description': 'Validation Error'},
|
||||
429: {'model': ErrorResponse, 'description': 'Too Many Requests'},
|
||||
500: {'model': ErrorResponse, 'description': 'Internal Server Error'},
|
||||
502: {'model': ErrorResponse, 'description': 'Bad Gateway'},
|
||||
503: {'model': ErrorResponse, 'description': 'Service Unavailable'},
|
||||
}
|
||||
|
||||
|
||||
def _amount_rub_for_itpay(amount_usdt: Decimal) -> Decimal:
|
||||
return (amount_usdt * HARDCODED_USDT_TO_RUB + HARDCODED_GAS_RUB + HARDCODED_OUR_COMMISSION_RUB).quantize(Decimal('0.01'))
|
||||
|
||||
|
||||
|
||||
@order_router.post('/create')
|
||||
#@csrf_protect()
|
||||
async def create_order(
|
||||
request: Request,
|
||||
body: CreateOrder,
|
||||
#auth: AuthContext = Depends(require_access_token),
|
||||
logger: ILogger = Depends(get_logger),
|
||||
) -> ORJSONResponse:
|
||||
amount_rub = _amount_rub_for_itpay(body.amount_usdt)
|
||||
amount_str = str(amount_rub)
|
||||
client_payment_id = str(ULID())
|
||||
payload = {
|
||||
'amount': amount_str,
|
||||
'client_payment_id': client_payment_id,
|
||||
'description': f'USDT {body.amount_usdt}',
|
||||
'metadata': {
|
||||
'user_id': '01KPSYW27JZ26HBDR3QS5J6VMS',
|
||||
'amount_usdt': str(body.amount_usdt),
|
||||
'rate': str(HARDCODED_USDT_TO_RUB),
|
||||
'gas_rub': str(HARDCODED_GAS_RUB),
|
||||
'commission_rub': str(HARDCODED_OUR_COMMISSION_RUB),
|
||||
},
|
||||
}
|
||||
url = f'{ITPAY_API_BASE}/v1/payments'
|
||||
headers = {
|
||||
'Authorization': ITPAY_AUTHORIZATION,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
}
|
||||
try:
|
||||
timeout = aiohttp.ClientTimeout(total=30)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.post(url, json=payload, headers=headers) as resp:
|
||||
response_text = await resp.text()
|
||||
try:
|
||||
response_json = json.loads(response_text)
|
||||
except json.JSONDecodeError:
|
||||
response_json = {'raw': response_text}
|
||||
if resp.status >= 400:
|
||||
logger.warning(f'itpay payments POST {resp.status} {response_text}')
|
||||
raise ApplicationException(status_code=502, message='Payment provider error')
|
||||
except ApplicationException:
|
||||
raise
|
||||
except aiohttp.ClientError as e:
|
||||
logger.error(str(e))
|
||||
raise ApplicationException(status_code=502, message='Payment provider unreachable')
|
||||
return ORJSONResponse(
|
||||
content={
|
||||
'itpay': response_json,
|
||||
'client_payment_id': client_payment_id,
|
||||
'amount_usdt': str(body.amount_usdt),
|
||||
'amount_rub': amount_str,
|
||||
'hardcoded': {
|
||||
'usdt_to_rub': str(HARDCODED_USDT_TO_RUB),
|
||||
'gas_rub': str(HARDCODED_GAS_RUB),
|
||||
'commission_rub': str(HARDCODED_OUR_COMMISSION_RUB),
|
||||
},
|
||||
}
|
||||
def _payment_config_response(quote: PaymentQuote) -> PaymentConfigResponse:
|
||||
return PaymentConfigResponse(
|
||||
status_code=200,
|
||||
usdt_exchange_rate=str(quote.usdt_exchange_rate),
|
||||
gas_fee=str(quote.gas_fee),
|
||||
service_fee_rate=str(quote.service_fee_rate),
|
||||
one_usdt_service_fee=str(quote.service_fee),
|
||||
one_usdt_total_price=str(quote.total_price),
|
||||
created_at=quote.created_at.isoformat(),
|
||||
)
|
||||
|
||||
|
||||
def _payment_quote_response(quote: PaymentQuote) -> PaymentQuoteResponse:
|
||||
return PaymentQuoteResponse(
|
||||
status_code=200,
|
||||
usdt_amount=str(quote.usdt_amount),
|
||||
usdt_exchange_rate=str(quote.usdt_exchange_rate),
|
||||
gas_fee=str(quote.gas_fee),
|
||||
service_fee=str(quote.service_fee),
|
||||
total_price=str(quote.total_price),
|
||||
service_fee_rate=str(quote.service_fee_rate),
|
||||
created_at=quote.created_at.isoformat(),
|
||||
)
|
||||
|
||||
@order_router.post('/webhook/itpay')
|
||||
async def itpay_webhook(request: Request, logger: ILogger = Depends(get_logger)) -> ORJSONResponse:
|
||||
|
||||
def _order_response(o: OrderEntity) -> OrderPaymentResponse:
|
||||
return OrderPaymentResponse(
|
||||
id=o.id,
|
||||
created_at=o.created_at.isoformat() if o.created_at is not None else None,
|
||||
updated_at=o.updated_at.isoformat() if o.updated_at is not None else None,
|
||||
user_id=o.user_id,
|
||||
usdt_amount=str(o.usdt_amount) if o.usdt_amount is not None else None,
|
||||
usdt_exchange_rate=str(o.usdt_exchange_rate) if o.usdt_exchange_rate is not None else None,
|
||||
gas_fee=str(o.gas_fee) if o.gas_fee is not None else None,
|
||||
total_price=str(o.total_price) if o.total_price is not None else None,
|
||||
service_fee=str(o.service_fee) if o.service_fee is not None else None,
|
||||
status=o.status,
|
||||
client_payment_id=o.client_payment_id,
|
||||
itpay_payment_qr_url_desktop=o.itpay_payment_qr_url_desktop,
|
||||
itpay_payment_qr_url_android=o.itpay_payment_qr_url_android,
|
||||
itpay_payment_qr_url_ios=o.itpay_payment_qr_url_ios,
|
||||
itpay_payment_qr_image_desktop=o.itpay_payment_qr_image_desktop,
|
||||
itpay_payment_qr_image_android=o.itpay_payment_qr_image_android,
|
||||
itpay_payment_qr_image_ios=o.itpay_payment_qr_image_ios,
|
||||
itpay_id=o.itpay_id,
|
||||
itpay_qr_id=o.itpay_qr_id,
|
||||
itpay_amount=str(o.itpay_amount) if o.itpay_amount is not None else None,
|
||||
itpay_created_at=o.itpay_created_at.isoformat() if o.itpay_created_at is not None else None,
|
||||
)
|
||||
|
||||
|
||||
def _payment_response(payment: PaymentEntity) -> PaymentResponse:
|
||||
return PaymentResponse(
|
||||
id=payment.id,
|
||||
created_at=payment.created_at.isoformat() if payment.created_at is not None else None,
|
||||
updated_at=payment.updated_at.isoformat() if payment.updated_at is not None else None,
|
||||
user_id=payment.user_id,
|
||||
order_id=payment.order_id,
|
||||
status=payment.status,
|
||||
receipt_cloudekassir_id=payment.receipt_cloudekassir_id,
|
||||
receipt_cloudekassir_link=payment.receipt_cloudekassir_link,
|
||||
itpay_payment_id=payment.itpay_payment_id,
|
||||
itpay_paid_amount=str(payment.itpay_paid_amount) if payment.itpay_paid_amount is not None else None,
|
||||
transaction_id=payment.transaction_id,
|
||||
web3_transaction_hash=payment.web3_transaction_hash,
|
||||
paid_at=payment.paid_at.isoformat() if payment.paid_at is not None else None,
|
||||
expired_date=payment.expired_date.isoformat() if payment.expired_date is not None else None,
|
||||
)
|
||||
|
||||
|
||||
def _order_detail_response(order: OrderEntity, payment: PaymentEntity | None) -> OrderDetailResponse:
|
||||
return OrderDetailResponse(
|
||||
status_code=200,
|
||||
order=_order_response(order),
|
||||
payment=_payment_response(payment) if payment is not None else None,
|
||||
)
|
||||
|
||||
|
||||
def _order_with_payment_response(order: OrderEntity, payment: PaymentEntity | None) -> OrderWithPaymentResponse:
|
||||
return OrderWithPaymentResponse(
|
||||
order=_order_response(order),
|
||||
payment=_payment_response(payment) if payment is not None else None,
|
||||
)
|
||||
|
||||
|
||||
def _order_status_response(order: OrderEntity, payment: PaymentEntity | None) -> OrderStatusResponse:
|
||||
return OrderStatusResponse(
|
||||
status_code=200,
|
||||
order_id=str(order.id),
|
||||
order_status=order.status,
|
||||
payment_status=payment.status if payment is not None else None,
|
||||
receipt_cloudekassir_link=payment.receipt_cloudekassir_link if payment is not None else None,
|
||||
web3_transaction_hash=payment.web3_transaction_hash if payment is not None else None,
|
||||
updated_at=payment.updated_at.isoformat() if payment is not None and payment.updated_at is not None else order.updated_at.isoformat() if order.updated_at is not None else None,
|
||||
)
|
||||
|
||||
|
||||
def _extract_websocket_access_token(websocket: WebSocket) -> str | None:
|
||||
token = websocket.cookies.get('access_token')
|
||||
if token:
|
||||
return token
|
||||
auth = websocket.headers.get('Authorization')
|
||||
if auth:
|
||||
scheme,param = get_authorization_scheme_param(auth)
|
||||
if scheme.lower() == 'bearer' and param:
|
||||
return param
|
||||
query_token = websocket.query_params.get('access_token') or websocket.query_params.get('token')
|
||||
if query_token:
|
||||
return str(query_token)
|
||||
return None
|
||||
|
||||
|
||||
async def _websocket_auth_context(websocket: WebSocket, jwt_service: IJwtService) -> AuthContext | None:
|
||||
token = _extract_websocket_access_token(websocket)
|
||||
if not token:
|
||||
return None
|
||||
try:
|
||||
payload: AccessTokenPayload = await jwt_service.decode_access_token(token)
|
||||
except ApplicationException:
|
||||
return None
|
||||
if payload.type != 'access':
|
||||
return None
|
||||
return AuthContext(user_id=payload.sub, sid=payload.sid, token=payload)
|
||||
|
||||
|
||||
@order_router.post(
|
||||
'/create',
|
||||
response_model=CreateOrderResponse,
|
||||
status_code=201,
|
||||
responses=ERROR_RESPONSES,
|
||||
)
|
||||
@csrf_protect()
|
||||
async def create_order(
|
||||
request: Request,
|
||||
payment_data: CreateOrder,
|
||||
auth: AuthContext = Depends(require_access_token),
|
||||
command: CreateOrderCommand = Depends(get_create_order_command),
|
||||
logger: ILogger = Depends(get_logger),
|
||||
) -> CreateOrderResponse:
|
||||
o = await command(payment_data, auth.user_id)
|
||||
|
||||
itpay_error = o.status in (
|
||||
OrderStatus.CANCELLED,
|
||||
OrderStatus.REJECTED,
|
||||
OrderStatus.ERROR,
|
||||
)
|
||||
log_ids = {
|
||||
'event': 'order_create_itpay_failed' if itpay_error else 'order_created',
|
||||
'order_id': o.id,
|
||||
'user_id': o.user_id,
|
||||
'client_payment_id': o.client_payment_id,
|
||||
'itpay_id': o.itpay_id,
|
||||
'order_status': o.status.value if o.status is not None else None,
|
||||
}
|
||||
logger.info(log_ids)
|
||||
if itpay_error:
|
||||
raise ConflictException(message='Payment provider rejected order')
|
||||
content = CreateOrderResponse(
|
||||
status_code=201,
|
||||
order=_order_response(o),
|
||||
)
|
||||
return content
|
||||
|
||||
|
||||
@payment_router.get(
|
||||
'/config',
|
||||
response_model=PaymentConfigResponse,
|
||||
status_code=200,
|
||||
responses=ERROR_RESPONSES,
|
||||
)
|
||||
async def payment_config(
|
||||
command: GetPaymentConfigCommand = Depends(get_payment_config_command),
|
||||
) -> PaymentConfigResponse:
|
||||
quote = await command()
|
||||
return _payment_config_response(quote)
|
||||
|
||||
|
||||
@payment_router.get(
|
||||
'/quote',
|
||||
response_model=PaymentQuoteResponse,
|
||||
status_code=200,
|
||||
responses=ERROR_RESPONSES,
|
||||
)
|
||||
async def payment_quote(
|
||||
usdt_amount: Decimal = Query(gt=0, decimal_places=2, max_digits=20),
|
||||
command: GetPaymentQuoteCommand = Depends(get_payment_quote_command),
|
||||
) -> PaymentQuoteResponse:
|
||||
quote = await command(usdt_amount)
|
||||
return _payment_quote_response(quote)
|
||||
|
||||
|
||||
@payment_router.get(
|
||||
'/quote/rub',
|
||||
response_model=PaymentQuoteResponse,
|
||||
status_code=200,
|
||||
responses=ERROR_RESPONSES,
|
||||
)
|
||||
async def payment_quote_from_rub(
|
||||
total_rub: Decimal = Query(gt=0, decimal_places=2, max_digits=20),
|
||||
command: GetPaymentQuoteFromRubCommand = Depends(get_payment_quote_from_rub_command),
|
||||
) -> PaymentQuoteResponse:
|
||||
quote = await command(total_rub)
|
||||
return _payment_quote_response(quote)
|
||||
|
||||
|
||||
@payment_router.get(
|
||||
'/orders',
|
||||
response_model=OrdersResponse,
|
||||
status_code=200,
|
||||
responses=ERROR_RESPONSES,
|
||||
)
|
||||
@csrf_protect()
|
||||
async def payment_list_orders(
|
||||
request: Request,
|
||||
limit: int = Query(default=20, ge=1, le=100),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
auth: AuthContext = Depends(require_access_token),
|
||||
command: ListOrdersCommand = Depends(get_list_orders_command),
|
||||
) -> OrdersResponse:
|
||||
orders = await command(user_id=auth.user_id,limit=limit,offset=offset)
|
||||
items = [_order_with_payment_response(item.order,item.payment) for item in orders]
|
||||
return OrdersResponse(status_code=200,orders=items,limit=limit,offset=offset)
|
||||
|
||||
|
||||
@payment_router.get(
|
||||
'/payments',
|
||||
response_model=PaymentsResponse,
|
||||
status_code=200,
|
||||
responses=ERROR_RESPONSES,
|
||||
)
|
||||
@csrf_protect()
|
||||
async def payment_list_payments(
|
||||
request: Request,
|
||||
limit: int = Query(default=20, ge=1, le=100),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
auth: AuthContext = Depends(require_access_token),
|
||||
command: ListPaymentsCommand = Depends(get_list_payments_command),
|
||||
) -> PaymentsResponse:
|
||||
payments = await command(user_id=auth.user_id,limit=limit,offset=offset)
|
||||
return PaymentsResponse(
|
||||
status_code=200,
|
||||
payments=[_payment_response(payment) for payment in payments],
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@payment_router.get(
|
||||
'/payments/{payment_id}',
|
||||
response_model=PaymentDetailResponse,
|
||||
status_code=200,
|
||||
responses=ERROR_RESPONSES,
|
||||
)
|
||||
@csrf_protect()
|
||||
async def payment_detail(
|
||||
request: Request,
|
||||
payment_id: str,
|
||||
auth: AuthContext = Depends(require_access_token),
|
||||
command: GetPaymentCommand = Depends(get_payment_command),
|
||||
) -> PaymentDetailResponse:
|
||||
payment = await command(payment_id=payment_id,user_id=auth.user_id)
|
||||
return PaymentDetailResponse(status_code=200,payment=_payment_response(payment))
|
||||
|
||||
|
||||
@orders_router.get(
|
||||
'/orders',
|
||||
response_model=OrdersResponse,
|
||||
status_code=200,
|
||||
responses=ERROR_RESPONSES,
|
||||
)
|
||||
@csrf_protect()
|
||||
async def list_orders(
|
||||
request: Request,
|
||||
limit: int = Query(default=20, ge=1, le=100),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
auth: AuthContext = Depends(require_access_token),
|
||||
command: ListOrdersCommand = Depends(get_list_orders_command),
|
||||
) -> OrdersResponse:
|
||||
orders = await command(user_id=auth.user_id,limit=limit,offset=offset)
|
||||
items = [_order_with_payment_response(item.order,item.payment) for item in orders]
|
||||
return OrdersResponse(status_code=200,orders=items,limit=limit,offset=offset)
|
||||
|
||||
|
||||
@payments_router.get(
|
||||
'/payments',
|
||||
response_model=PaymentsResponse,
|
||||
status_code=200,
|
||||
responses=ERROR_RESPONSES,
|
||||
)
|
||||
@csrf_protect()
|
||||
async def list_payments(
|
||||
request: Request,
|
||||
limit: int = Query(default=20, ge=1, le=100),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
auth: AuthContext = Depends(require_access_token),
|
||||
command: ListPaymentsCommand = Depends(get_list_payments_command),
|
||||
) -> PaymentsResponse:
|
||||
payments = await command(user_id=auth.user_id,limit=limit,offset=offset)
|
||||
return PaymentsResponse(
|
||||
status_code=200,
|
||||
payments=[_payment_response(payment) for payment in payments],
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@order_router.get(
|
||||
'/{order_id}/status',
|
||||
response_model=OrderStatusResponse,
|
||||
status_code=200,
|
||||
responses=ERROR_RESPONSES,
|
||||
)
|
||||
@csrf_protect()
|
||||
async def order_status(
|
||||
request: Request,
|
||||
order_id: str,
|
||||
auth: AuthContext = Depends(require_access_token),
|
||||
command: GetOrderStatusCommand = Depends(get_order_status_command),
|
||||
) -> OrderStatusResponse:
|
||||
result = await command(order_id=order_id,user_id=auth.user_id)
|
||||
return _order_status_response(result.order,result.payment)
|
||||
|
||||
|
||||
@order_router.websocket('/{order_id}/events')
|
||||
async def order_events(
|
||||
websocket: WebSocket,
|
||||
order_id: str,
|
||||
command: GetOrderStatusCommand = Depends(get_order_status_command),
|
||||
jwt_service: IJwtService = Depends(get_jwt_service),
|
||||
logger: ILogger = Depends(get_logger),
|
||||
) -> None:
|
||||
trace_id = websocket.headers.get('X-Trace-ID') or websocket.headers.get('X-Request-ID') or str(ULID())
|
||||
token = trace_id_var.set(trace_id)
|
||||
try:
|
||||
auth = await _websocket_auth_context(websocket, jwt_service)
|
||||
if auth is None:
|
||||
await websocket.close(code=1008)
|
||||
return
|
||||
await websocket.accept()
|
||||
logger.info({'event':'order_events_connected','order_id':order_id,'user_id':auth.user_id})
|
||||
last_payload: dict | None = None
|
||||
while True:
|
||||
try:
|
||||
result = await command(order_id=order_id,user_id=auth.user_id)
|
||||
except ApplicationException as exception:
|
||||
await websocket.send_json({'event':'order_events_error','detail':exception.message,'status_code':exception.status_code})
|
||||
await websocket.close(code=1008)
|
||||
return
|
||||
status_payload = _order_status_response(result.order,result.payment).model_dump(mode='json')
|
||||
payload = {'event':'order_status','data':status_payload}
|
||||
if payload != last_payload:
|
||||
await websocket.send_text(orjson.dumps(payload).decode())
|
||||
last_payload = payload
|
||||
await asyncio.sleep(2)
|
||||
except WebSocketDisconnect:
|
||||
logger.info({'event':'order_events_disconnected','order_id':order_id})
|
||||
finally:
|
||||
trace_id_var.reset(token)
|
||||
|
||||
|
||||
@order_router.get(
|
||||
'/{order_id}',
|
||||
response_model=OrderDetailResponse,
|
||||
status_code=200,
|
||||
responses=ERROR_RESPONSES,
|
||||
)
|
||||
@csrf_protect()
|
||||
async def order_detail(
|
||||
request: Request,
|
||||
order_id: str,
|
||||
auth: AuthContext = Depends(require_access_token),
|
||||
command: GetOrderCommand = Depends(get_order_command),
|
||||
) -> OrderDetailResponse:
|
||||
result = await command(order_id=order_id,user_id=auth.user_id)
|
||||
return _order_detail_response(result.order,result.payment)
|
||||
|
||||
|
||||
@order_router.post(
|
||||
'/webhook/itpay',
|
||||
status_code=200,
|
||||
responses=ERROR_RESPONSES,
|
||||
)
|
||||
async def itpay_webhook(
|
||||
request: Request,
|
||||
payment_command: CreatePaymentCommand = Depends(get_create_payment_command),
|
||||
logger: ILogger = Depends(get_logger),
|
||||
) -> ORJSONResponse:
|
||||
raw = await request.body()
|
||||
ct = (request.headers.get('content-type') or '').lower()
|
||||
if 'application/json' in ct:
|
||||
try:
|
||||
parsed = json.loads(raw.decode('utf-8'))
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
parsed = raw.decode('utf-8', errors='replace')
|
||||
payload = orjson.loads(raw)
|
||||
elif 'application/x-www-form-urlencoded' in ct:
|
||||
decoded = raw.decode('utf-8', errors='replace')
|
||||
qs = parse_qs(decoded, keep_blank_values=True)
|
||||
parsed = {k: (vals[0] if len(vals) == 1 else vals) for k, vals in qs.items()}
|
||||
payload = {k: (vals[0] if len(vals) == 1 else vals) for k, vals in qs.items()}
|
||||
else:
|
||||
parsed = raw.decode('utf-8', errors='replace')
|
||||
payload = orjson.loads(raw)
|
||||
data = payload.get('data') if isinstance(payload.get('data'), dict) else {}
|
||||
metadata = data.get('metadata') if isinstance(data.get('metadata'), dict) else {}
|
||||
trace_id = str(metadata.get('trace_id') or '').strip()
|
||||
if trace_id:
|
||||
logger.set_trace_id(trace_id)
|
||||
status = str(data.get('status') or '').strip().lower()
|
||||
log_payload = {
|
||||
'method': request.method,
|
||||
'url': str(request.url),
|
||||
'headers': {k: v for k, v in request.headers.items()},
|
||||
'body': parsed,
|
||||
'event': 'itpay_webhook_received',
|
||||
'webhook_id': payload.get('id'),
|
||||
'webhook_type': payload.get('type'),
|
||||
'payment_id': data.get('id'),
|
||||
'client_payment_id': data.get('client_payment_id'),
|
||||
'payment_status': status,
|
||||
'itpay_metadata': metadata,
|
||||
}
|
||||
logger.info(json.dumps(log_payload, ensure_ascii=False, default=str))
|
||||
logger.info(log_payload)
|
||||
if status == 'completed':
|
||||
payment = ItpayPaymentData.model_validate(data)
|
||||
await payment_command(payment)
|
||||
return ORJSONResponse(content={'status': 0})
|
||||
|
||||
15
src/presentation/schemas/itpay_payment_base.py
Normal file
15
src/presentation/schemas/itpay_payment_base.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any,Literal
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ItpayEventBase(BaseModel):
|
||||
id:str
|
||||
object:Literal['event']
|
||||
type:str
|
||||
created:datetime
|
||||
data:Any
|
||||
|
||||
17
src/presentation/schemas/itpay_payment_completed_event.py
Normal file
17
src/presentation/schemas/itpay_payment_completed_event.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from src.presentation.schemas.itpay_payment_models import ItpayPaymentData
|
||||
|
||||
|
||||
class ItpayPaymentCompletedEvent(BaseModel):
|
||||
id:str
|
||||
object:Literal['event']
|
||||
type:Literal['payment.completed']
|
||||
created:datetime
|
||||
data:ItpayPaymentData
|
||||
|
||||
93
src/presentation/schemas/itpay_payment_models.py
Normal file
93
src/presentation/schemas/itpay_payment_models.py
Normal file
@@ -0,0 +1,93 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ItpayPaymentShopLegalEntity(BaseModel):
|
||||
id:int|None=None
|
||||
name:str|None=None
|
||||
theme:str|None=None
|
||||
opf_short:str|None=None
|
||||
short_name:str|None=None
|
||||
taxation_system:int|None=None
|
||||
|
||||
|
||||
class ItpayPaymentShopPaymentMethod(BaseModel):
|
||||
id:int|None=None
|
||||
method:str|None=None
|
||||
status:str|None=None
|
||||
order:int|None=None
|
||||
shop_uuid:str|None=None
|
||||
is_savable:bool|None=None
|
||||
payment_agent_percent:str|None=None
|
||||
|
||||
|
||||
class ItpayPaymentShop(BaseModel):
|
||||
id:int|None=None
|
||||
name:str|None=None
|
||||
description:str|None=None
|
||||
legal_entity:ItpayPaymentShopLegalEntity|None=None
|
||||
type:str|None=None
|
||||
address:str|None=None
|
||||
timezone:str|None=None
|
||||
payout_currency:str|None=None
|
||||
payment_methods:list[ItpayPaymentShopPaymentMethod]|None=None
|
||||
|
||||
|
||||
class ItpayPaymentOrderPlace(BaseModel):
|
||||
name:str|None=None
|
||||
number:int|None=None
|
||||
|
||||
|
||||
class ItpayPaymentOrder(BaseModel):
|
||||
state:Any=None
|
||||
external_id:str|None=None
|
||||
number:str|None=None
|
||||
place:ItpayPaymentOrderPlace|None=None
|
||||
total_amount:Any=None
|
||||
paid_amount:Any=None
|
||||
external_updated:Any=None
|
||||
external_created:Any=None
|
||||
|
||||
|
||||
class ItpayPaymentQrUrls(BaseModel):
|
||||
desktop:str|None=None
|
||||
android:str|None=None
|
||||
ios:str|None=None
|
||||
|
||||
|
||||
class ItpayPaymentQrImages(BaseModel):
|
||||
desktop:str|None=None
|
||||
android:str|None=None
|
||||
ios:str|None=None
|
||||
|
||||
|
||||
class ItpayPaymentData(BaseModel):
|
||||
id:str
|
||||
amount:float|int|str|None=None
|
||||
currency:str|None=None
|
||||
payout_amount:float|int|str|None=None
|
||||
payout_currency:str|None=None
|
||||
created:datetime|str|None=None
|
||||
updated:datetime|str|None=None
|
||||
paid:datetime|str|None=None
|
||||
status:str|None=None
|
||||
status_code_error:Any=None
|
||||
metadata:dict[str,Any]|None=None
|
||||
success_url:str|None=None
|
||||
success_url_description:str|None=None
|
||||
qrc_id:str|None=None
|
||||
transaction_id:str|None=None
|
||||
description:str|None=None
|
||||
shop:ItpayPaymentShop|None=None
|
||||
order:ItpayPaymentOrder|None=None
|
||||
method:str|None=None
|
||||
payment_qr_urls:ItpayPaymentQrUrls|None=None
|
||||
payment_qr_images:ItpayPaymentQrImages|None=None
|
||||
client_payment_id:str|None=None
|
||||
expired_date:datetime|str|None=None
|
||||
payer:Any=None
|
||||
|
||||
17
src/presentation/schemas/itpay_payment_pay_event.py
Normal file
17
src/presentation/schemas/itpay_payment_pay_event.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from src.presentation.schemas.itpay_payment_models import ItpayPaymentData
|
||||
|
||||
|
||||
class ItpayPaymentPayEvent(BaseModel):
|
||||
id:str
|
||||
object:Literal['event']
|
||||
type:Literal['payment.pay']
|
||||
created:datetime
|
||||
data:ItpayPaymentData
|
||||
|
||||
@@ -1,6 +1,121 @@
|
||||
from decimal import Decimal
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel,Field
|
||||
from src.application.domain.enums import OrderStatus,PaymentStatus
|
||||
|
||||
|
||||
class CreateOrder(BaseModel):
|
||||
amount_usdt: Decimal = Field(gt=0)
|
||||
usdt_amount: Decimal = Field(gt=0, decimal_places=2, max_digits=20)
|
||||
usdt_exchange_rate: Decimal = Field(gt=0, decimal_places=2, max_digits=20)
|
||||
gas_fee: Decimal = Field(gt=0, decimal_places=2, max_digits=20)
|
||||
total_price: Decimal = Field(gt=0, decimal_places=2, max_digits=20)
|
||||
|
||||
|
||||
class OrderPaymentResponse(BaseModel):
|
||||
id: str | None = None
|
||||
created_at: str | None = None
|
||||
updated_at: str | None = None
|
||||
user_id: str | None = None
|
||||
usdt_amount: str | None = None
|
||||
usdt_exchange_rate: str | None = None
|
||||
gas_fee: str | None = None
|
||||
total_price: str | None = None
|
||||
service_fee: str | 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: str | None = None
|
||||
itpay_created_at: str | None = None
|
||||
|
||||
|
||||
class PaymentResponse(BaseModel):
|
||||
id: str | None = None
|
||||
created_at: str | None = None
|
||||
updated_at: str | 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: str | None = None
|
||||
transaction_id: str | None = None
|
||||
web3_transaction_hash: str | None = None
|
||||
paid_at: str | None = None
|
||||
expired_date: str | None = None
|
||||
|
||||
|
||||
class CreateOrderResponse(BaseModel):
|
||||
status_code: int
|
||||
order: OrderPaymentResponse
|
||||
|
||||
|
||||
class PaymentConfigResponse(BaseModel):
|
||||
status_code: int
|
||||
usdt_exchange_rate: str
|
||||
gas_fee: str
|
||||
service_fee_rate: str
|
||||
one_usdt_service_fee: str
|
||||
one_usdt_total_price: str
|
||||
created_at: str
|
||||
|
||||
|
||||
class PaymentQuoteResponse(BaseModel):
|
||||
status_code: int
|
||||
usdt_amount: str
|
||||
usdt_exchange_rate: str
|
||||
gas_fee: str
|
||||
service_fee: str
|
||||
total_price: str
|
||||
service_fee_rate: str
|
||||
created_at: str
|
||||
|
||||
|
||||
class OrderWithPaymentResponse(BaseModel):
|
||||
order: OrderPaymentResponse
|
||||
payment: PaymentResponse | None = None
|
||||
|
||||
|
||||
class OrderDetailResponse(OrderWithPaymentResponse):
|
||||
status_code: int
|
||||
|
||||
|
||||
class OrdersResponse(BaseModel):
|
||||
status_code: int
|
||||
orders: list[OrderWithPaymentResponse]
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
class PaymentsResponse(BaseModel):
|
||||
status_code: int
|
||||
payments: list[PaymentResponse]
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
class PaymentDetailResponse(BaseModel):
|
||||
status_code: int
|
||||
payment: PaymentResponse
|
||||
|
||||
|
||||
class OrderStatusResponse(BaseModel):
|
||||
status_code: int
|
||||
order_id: str
|
||||
order_status: OrderStatus | None = None
|
||||
payment_status: PaymentStatus | None = None
|
||||
receipt_cloudekassir_link: str | None = None
|
||||
web3_transaction_hash: str | None = None
|
||||
updated_at: str | None = None
|
||||
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
detail: str
|
||||
status_code: int
|
||||
|
||||
|
||||
Reference in New Issue
Block a user