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
|
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_cache import ICache
|
||||||
from src.application.contracts.i_hash_service import IHashService
|
from src.application.contracts.i_hash_service import IHashService
|
||||||
from src.application.contracts.i_queue_messanger import IQueueMessanger
|
from src.application.contracts.i_queue_messanger import IQueueMessanger
|
||||||
|
from src.application.contracts.i_itpay_service import IItPayService
|
||||||
|
from src.application.contracts.i_receipt import IReceipt
|
||||||
@@ -17,6 +17,10 @@ class ICache(ABC):
|
|||||||
async def get(self, key: str) -> str | None:
|
async def get(self, key: str) -> str | None:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def hget(self, key: str, field: str) -> str | None:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def delete(self, key: str) -> bool:
|
async def delete(self, key: str) -> bool:
|
||||||
raise NotImplementedError
|
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_format import LogFormat
|
||||||
from src.application.domain.enums.log_level import LogLevel
|
from src.application.domain.enums.log_level import LogLevel
|
||||||
|
|
||||||
@@ -43,26 +43,26 @@ class ILogger(Protocol):
|
|||||||
"""Get current service instance id"""
|
"""Get current service instance id"""
|
||||||
...
|
...
|
||||||
|
|
||||||
def debug(self, message: str) -> None:
|
def debug(self, message: Any) -> None:
|
||||||
"""Log debug message"""
|
"""Log debug message"""
|
||||||
...
|
...
|
||||||
|
|
||||||
def info(self, message: str) -> None:
|
def info(self, message: Any) -> None:
|
||||||
"""Log info message"""
|
"""Log info message"""
|
||||||
...
|
...
|
||||||
|
|
||||||
def warning(self, message: str) -> None:
|
def warning(self, message: Any) -> None:
|
||||||
"""Log warning message"""
|
"""Log warning message"""
|
||||||
...
|
...
|
||||||
|
|
||||||
def error(self, message: str) -> None:
|
def error(self, message: Any) -> None:
|
||||||
"""Log error message"""
|
"""Log error message"""
|
||||||
...
|
...
|
||||||
|
|
||||||
def critical(self, message: str) -> None:
|
def critical(self, message: Any) -> None:
|
||||||
"""Log critical message"""
|
"""Log critical message"""
|
||||||
...
|
...
|
||||||
|
|
||||||
def exception(self, message: str) -> None:
|
def exception(self, message: Any) -> None:
|
||||||
"""Log exception with traceback"""
|
"""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.order import OrderEntity
|
||||||
from src.application.domain.entities.session import SessionEntity
|
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
|
last_name: str | None = None
|
||||||
birth_date: date | None = None
|
birth_date: date | None = None
|
||||||
|
|
||||||
crypto_wallet: str | None = None
|
encrypted_mnemonic: str | None = None
|
||||||
phone: str | None = None
|
phone: str | None = None
|
||||||
|
|
||||||
bik: str | None = None
|
passport_data: str | None = None
|
||||||
account_number: str | None = None
|
|
||||||
card_number: str | None = None
|
|
||||||
inn: str | None = None
|
inn: str | None = None
|
||||||
|
erc20: str | None = None
|
||||||
|
|
||||||
|
avatar_link: str | None = None
|
||||||
|
|
||||||
kyc_verified: bool | None = None
|
kyc_verified: bool | None = None
|
||||||
is_deleted: bool | None = None
|
is_deleted: bool | None = None
|
||||||
@@ -28,3 +29,5 @@ class UserEntity:
|
|||||||
created_at: datetime | None = None
|
created_at: datetime | None = None
|
||||||
updated_at: datetime | None = None
|
updated_at: datetime | None = None
|
||||||
kyc_verified_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_level import LogLevel
|
||||||
from src.application.domain.enums.log_format import LogFormat
|
from src.application.domain.enums.log_format import LogFormat
|
||||||
|
from src.application.domain.enums.itpay_payment_status import ItPayPaymentStatus
|
||||||
|
from src.application.domain.enums.order_status import OrderStatus
|
||||||
|
from src.application.domain.enums.payment_status import PaymentStatus
|
||||||
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,
|
self,
|
||||||
status_code: int,
|
status_code: int,
|
||||||
message: str,
|
message: str,
|
||||||
headers: Mapping[str, str] | None = None,
|
headers: Mapping[str,str] | None = None,
|
||||||
):
|
):
|
||||||
super().__init__(message)
|
super().__init__(message)
|
||||||
self.status_code = status_code
|
self.status_code = status_code
|
||||||
self.message = message
|
self.message = message
|
||||||
self.headers = headers
|
self.headers = headers
|
||||||
|
|
||||||
|
|
||||||
def __str__(self):
|
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.client import create_redis_client
|
||||||
from src.infrastructure.cache.keydb_client import KeydbCache
|
from src.infrastructure.cache.keydb_client import KeydbCache
|
||||||
|
from src.infrastructure.cache.remote_cache import RemoteCache
|
||||||
|
|
||||||
|
__all__ = ['create_redis_client', 'KeydbCache', 'RemoteCache']
|
||||||
|
|||||||
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
|
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(
|
return redis.from_url(
|
||||||
settings.REDIS_URL,
|
redis_url,
|
||||||
max_connections=50,
|
max_connections=50,
|
||||||
decode_responses=True,
|
decode_responses=True,
|
||||||
socket_timeout=5,
|
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:
|
async def get(self, key: str) -> str | None:
|
||||||
return await self._r.get(key)
|
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:
|
async def delete(self, key: str) -> bool:
|
||||||
return (await self._r.delete(key)) > 0
|
return (await self._r.delete(key)) > 0
|
||||||
|
|
||||||
@@ -37,12 +40,12 @@ class KeydbCache(ICache):
|
|||||||
'middle_name': user.middle_name,
|
'middle_name': user.middle_name,
|
||||||
'last_name': user.last_name,
|
'last_name': user.last_name,
|
||||||
'birth_date': str(user.birth_date) if user.birth_date else None,
|
'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,
|
'phone': user.phone,
|
||||||
'bik': user.bik,
|
'passport_data': user.passport_data,
|
||||||
'account_number': user.account_number,
|
|
||||||
'card_number': user.card_number,
|
|
||||||
'inn': user.inn,
|
'inn': user.inn,
|
||||||
|
'erc20': user.erc20,
|
||||||
|
'avatar_link': user.avatar_link,
|
||||||
'kyc_verified': user.kyc_verified,
|
'kyc_verified': user.kyc_verified,
|
||||||
'is_deleted': user.is_deleted,
|
'is_deleted': user.is_deleted,
|
||||||
'created_at': user.created_at.isoformat() if user.created_at else None,
|
'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
|
from typing import List, Literal
|
||||||
import os
|
import os
|
||||||
from dotenv import load_dotenv, find_dotenv
|
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 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")
|
env_file = find_dotenv(".env")
|
||||||
if env_file:
|
if env_file:
|
||||||
load_dotenv(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):
|
class Settings(BaseSettings):
|
||||||
VAULT_ADDR: str = Field(default="http://localhost:8200")
|
VAULT_ADDR: str = Field(default='http://localhost:8200')
|
||||||
VAULT_TOKEN: str = Field(..., description="Vault token is required")
|
VAULT_ROLE_ID: str = Field(..., description='AppRole role_id')
|
||||||
VAULT_MOUNT_POINT: str = Field(default="secrets")
|
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_KID_PATH: str = "jwt/kid"
|
||||||
VAULT_JWT_KIDS_PREFIX: str = "jwt/kids"
|
VAULT_JWT_KIDS_PREFIX: str = "jwt/kids"
|
||||||
@@ -59,6 +74,11 @@ class Settings(BaseSettings):
|
|||||||
REDIS_PASSWORD: str | None = None
|
REDIS_PASSWORD: str | None = None
|
||||||
REDIS_DB: int = 0
|
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_HOST: str = "localhost"
|
||||||
RABBIT_PORT: int = 5672
|
RABBIT_PORT: int = 5672
|
||||||
RABBIT_USER: str = "guest"
|
RABBIT_USER: str = "guest"
|
||||||
@@ -68,6 +88,14 @@ class Settings(BaseSettings):
|
|||||||
RABBIT_PUBLISH_PERSIST: bool = True
|
RABBIT_PUBLISH_PERSIST: bool = True
|
||||||
RABBIT_CONNECT_TIMEOUT: int = 5
|
RABBIT_CONNECT_TIMEOUT: int = 5
|
||||||
RABBIT_EMAIL_CODE_QUEUE: str = "email.verification_code"
|
RABBIT_EMAIL_CODE_QUEUE: str = "email.verification_code"
|
||||||
|
RABBIT_CRYPTO_TRANSFER_QUEUE: str = "crypto.transfer.requested"
|
||||||
|
RABBIT_CRYPTO_TRANSFER_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_LEVEL: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO"
|
||||||
LOG_FORMAT: Literal["JSON", "TEXT"] = "TEXT"
|
LOG_FORMAT: Literal["JSON", "TEXT"] = "TEXT"
|
||||||
@@ -77,51 +105,184 @@ class Settings(BaseSettings):
|
|||||||
env_file_encoding="utf-8",
|
env_file_encoding="utf-8",
|
||||||
case_sensitive=True,
|
case_sensitive=True,
|
||||||
extra="ignore",
|
extra="ignore",
|
||||||
|
populate_by_name=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@field_validator('VAULT_ADDR', mode='before')
|
||||||
|
@classmethod
|
||||||
|
def vault_addr_scheme(cls, v):
|
||||||
|
if v is None or not isinstance(v, str):
|
||||||
|
return v
|
||||||
|
return normalize_vault_base_url(v)
|
||||||
|
|
||||||
@model_validator(mode="before")
|
@model_validator(mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
def load_from_vault(cls, data: dict):
|
def load_from_vault(cls, data: dict):
|
||||||
addr = data.get("VAULT_ADDR") or os.getenv("VAULT_ADDR") or "http://localhost:8200"
|
if not isinstance(data, dict):
|
||||||
token = data.get("VAULT_TOKEN") or os.getenv("VAULT_TOKEN")
|
return data
|
||||||
mount = data.get("VAULT_MOUNT_POINT") or os.getenv("VAULT_MOUNT_POINT") or "secrets"
|
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:
|
if not role_id or not secret_id:
|
||||||
raise RuntimeError("VAULT_TOKEN is required")
|
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(
|
||||||
try:
|
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)
|
return read_kv2_secret(client=client, mount_point=mount, path=path)
|
||||||
|
|
||||||
|
def read_secret_optional(path: str) -> dict:
|
||||||
|
try:
|
||||||
|
return read_secret(path)
|
||||||
except Exception:
|
except Exception:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
database = safe_read("database")
|
database = read_secret('database')
|
||||||
rabbitmq = safe_read("rabbitmq")
|
csrf = read_secret_optional('csrf')
|
||||||
csrf = safe_read("csrf")
|
rabbitmq = read_secret_optional('rabbitmq')
|
||||||
|
|
||||||
if database:
|
db_ci = {str(k).lower(): v for k, v in database.items()}
|
||||||
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}")
|
|
||||||
|
|
||||||
data["DATABASE_HOST"] = database["HOST"]
|
def db_nonempty(key: str) -> bool:
|
||||||
data["DATABASE_PORT"] = database["PORT"]
|
v = db_ci.get(key)
|
||||||
data["DATABASE_NAME"] = database["NAME"]
|
if v is None:
|
||||||
data["DATABASE_USER"] = database["USER"]
|
return False
|
||||||
data["DATABASE_PASSWORD"] = database["PASSWORD"]
|
if isinstance(v, str) and not v.strip():
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
if rabbitmq:
|
required_db = ['host', 'name', 'user', 'password', 'port']
|
||||||
data["RABBIT_HOST"] = rabbitmq.get("HOST", data.get("RABBIT_HOST"))
|
missing_db = [k for k in required_db if not db_nonempty(k)]
|
||||||
data["RABBIT_PORT"] = rabbitmq.get("PORT", data.get("RABBIT_PORT"))
|
if missing_db:
|
||||||
data["RABBIT_USER"] = rabbitmq.get("USER", data.get("RABBIT_USER"))
|
raise RuntimeError(f'Vault secret database missing non-empty keys: {missing_db}')
|
||||||
data["RABBIT_PASSWORD"] = rabbitmq.get("PASSWORD", data.get("RABBIT_PASSWORD"))
|
|
||||||
data["RABBIT_VHOST"] = rabbitmq.get("VHOST", data.get("RABBIT_VHOST"))
|
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:
|
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
|
return data
|
||||||
|
|
||||||
@@ -134,8 +295,20 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def REDIS_URL(self) -> str:
|
def REDIS_URL(self) -> str:
|
||||||
auth = f":{self.REDIS_PASSWORD}@" if self.REDIS_PASSWORD else ""
|
return self.KEYDB_REMOTE_URL
|
||||||
return f"redis://{auth}{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"
|
|
||||||
|
@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
|
@property
|
||||||
def RABBIT_URL(self) -> str:
|
def RABBIT_URL(self) -> str:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from src.infrastructure.database.models.base import Base
|
from src.infrastructure.database.models.base import Base
|
||||||
|
from src.infrastructure.database.models.order import Order
|
||||||
|
from src.infrastructure.database.models.payment import Payment
|
||||||
from src.infrastructure.database.models.user import UserModel
|
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 __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.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):
|
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)
|
middle_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||||
birth_date: Mapped[Date | None] = mapped_column(Date, 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)
|
phone: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||||
|
|
||||||
bik: Mapped[str | None] = mapped_column(String(9), nullable=True)
|
passport_data: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
account_number: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
|
||||||
card_number: Mapped[str | None] = mapped_column(String(19), nullable=True)
|
|
||||||
inn: Mapped[str | None] = mapped_column(String(12), nullable=True)
|
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: 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)
|
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 __future__ import annotations
|
||||||
from fastapi import status
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.contracts import ILogger
|
||||||
from src.application.domain.exceptions import ApplicationException
|
from src.application.domain.entities.user import UserEntity
|
||||||
from src.application.abstractions.repositories import IUserRepository
|
from src.infrastructure.database.models.user import UserModel
|
||||||
from src.application.domain.entities import UserEntity
|
|
||||||
from src.infrastructure.database.models import UserModel
|
|
||||||
|
|
||||||
|
|
||||||
class UserRepository(IUserRepository):
|
class UserRepository(IUserRepository):
|
||||||
def __init__(self, session: AsyncSession, logger: ILogger):
|
def __init__(self,session:AsyncSession,logger:ILogger):
|
||||||
self._session = session
|
self._session=session
|
||||||
self._logger = logger
|
self._logger=logger
|
||||||
|
|
||||||
async def create_user(self, email: str, password_hash: str) -> UserEntity:
|
|
||||||
user = UserModel(email=email, password_hash=password_hash)
|
@staticmethod
|
||||||
self._session.add(user)
|
def _to_entity(model:UserModel) -> UserEntity:
|
||||||
try:
|
|
||||||
await self._session.flush()
|
|
||||||
return UserEntity(
|
return UserEntity(
|
||||||
id=user.id,
|
id=model.id,
|
||||||
email=user.email,
|
email=model.email,
|
||||||
created_at=user.created_at,
|
password_hash=model.password_hash,
|
||||||
kyc_verified=user.kyc_verified,
|
first_name=model.first_name,
|
||||||
is_deleted=user.is_deleted
|
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
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:
|
async def get(self,user_id:str) -> UserEntity|None:
|
||||||
self._logger.exception(str(exception))
|
stmt=(
|
||||||
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)
|
select(UserModel)
|
||||||
.where(
|
.where(UserModel.id==user_id)
|
||||||
UserModel.email == email,
|
.where(UserModel.is_deleted.is_(False))
|
||||||
UserModel.is_deleted.is_(False),
|
|
||||||
)
|
)
|
||||||
)
|
model=await self._session.scalar(stmt)
|
||||||
|
if model is None:
|
||||||
result = await self._session.execute(stmt)
|
return None
|
||||||
user: UserModel | None = result.scalar_one_or_none()
|
return self._to_entity(model)
|
||||||
|
|
||||||
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)}',
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||||
from src.application.abstractions import IUnitOfWork
|
from src.application.abstractions import IUnitOfWork
|
||||||
from src.application.abstractions.repositories import IUserRepository, ISessionRepository
|
from src.application.abstractions.repositories import IOrderRepository,IPaymentRepository,IUserRepository
|
||||||
from src.application.contracts import ILogger
|
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):
|
def __init__(self, session_factory: async_sessionmaker[AsyncSession], logger: ILogger):
|
||||||
self.session_factory = session_factory
|
self.session_factory = session_factory
|
||||||
self._session: AsyncSession = None
|
self._session: AsyncSession = None
|
||||||
self._user_repository: IUserRepository = None
|
self._order_repository: IOrderRepository | None = None
|
||||||
self._session_repository: ISessionRepository = None
|
self._payment_repository: IPaymentRepository | None = None
|
||||||
|
self._user_repository: IUserRepository | None = None
|
||||||
self._logger: ILogger = logger
|
self._logger: ILogger = logger
|
||||||
|
|
||||||
async def __aenter__(self):
|
async def __aenter__(self):
|
||||||
self._session = self.session_factory()
|
self._session = self.session_factory()
|
||||||
|
self._order_repository = None
|
||||||
|
self._payment_repository = None
|
||||||
|
self._user_repository = None
|
||||||
return self
|
return self
|
||||||
|
|
||||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||||
@@ -29,14 +35,22 @@ class UnitOfWork(IUnitOfWork):
|
|||||||
self._logger.debug('Commit')
|
self._logger.debug('Commit')
|
||||||
await self._session.close()
|
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
|
@property
|
||||||
def user_repository(self) -> IUserRepository:
|
def user_repository(self) -> IUserRepository:
|
||||||
if self._user_repository is None:
|
if self._user_repository is None:
|
||||||
self._user_repository = UserRepository(session=self._session, logger=self._logger)
|
self._user_repository = UserRepository(session=self._session, logger=self._logger)
|
||||||
return self._user_repository
|
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 traceback
|
||||||
import inspect
|
import inspect
|
||||||
import sys
|
import sys
|
||||||
import json
|
import orjson
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Callable, Optional, Any
|
from typing import Callable, Optional, Any
|
||||||
from ulid import ULID
|
from ulid import ULID
|
||||||
@@ -59,7 +59,7 @@ class Logger(ILogger):
|
|||||||
def clear_trace_id(self) -> None:
|
def clear_trace_id(self) -> None:
|
||||||
trace_id_var.set("N/A")
|
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()
|
current_frame = inspect.currentframe()
|
||||||
if (
|
if (
|
||||||
current_frame
|
current_frame
|
||||||
@@ -75,34 +75,37 @@ class Logger(ILogger):
|
|||||||
line_number = 0
|
line_number = 0
|
||||||
|
|
||||||
log_data = {
|
log_data = {
|
||||||
"timestamp": datetime.now().isoformat(),
|
'timestamp': datetime.now().isoformat(),
|
||||||
"level": level.name,
|
'level': level.name,
|
||||||
"instance_id": self.instance_id,
|
'instance_id': self.instance_id,
|
||||||
"file": filename,
|
'file': filename,
|
||||||
"line": line_number,
|
'line': line_number,
|
||||||
"trace_id": trace_id_var.get(),
|
'trace_id': trace_id_var.get(),
|
||||||
"message": message,
|
|
||||||
}
|
}
|
||||||
|
if isinstance(message, dict):
|
||||||
|
log_data.update(message)
|
||||||
|
else:
|
||||||
|
log_data['message'] = message
|
||||||
|
|
||||||
if level == LogLevel.EXCEPTION:
|
if level == LogLevel.EXCEPTION:
|
||||||
log_data["exception"] = traceback.format_exc()
|
log_data['exception'] = traceback.format_exc()
|
||||||
|
|
||||||
return log_data
|
return log_data
|
||||||
|
|
||||||
def _log(self, level: LogLevel, message: str) -> None:
|
def _log(self, level: LogLevel, message: Any) -> None:
|
||||||
if level >= self.min_level:
|
if level >= self.min_level:
|
||||||
log_data = self._prepare_log_data(level, message)
|
log_data = self._prepare_log_data(level, message)
|
||||||
|
|
||||||
if self.log_format == LogFormat.JSON:
|
if self.log_format == LogFormat.JSON:
|
||||||
log_message = json.dumps(log_data, ensure_ascii=False)
|
log_message = orjson.dumps(log_data).decode()
|
||||||
else:
|
else:
|
||||||
log_message = (
|
log_message = (
|
||||||
f"{log_data['timestamp']} - {log_data['level']} - "
|
f"{log_data['timestamp']} - {log_data['level']} - "
|
||||||
f"{log_data['instance_id']} - {log_data['trace_id']} - "
|
f"{log_data['instance_id']} - {log_data['trace_id']} - "
|
||||||
f"{log_data['file']}:{log_data['line']} - "
|
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']}"
|
log_message += f"\nTraceback:\n{log_data['exception']}"
|
||||||
|
|
||||||
self._write(log_message)
|
self._write(log_message)
|
||||||
@@ -110,20 +113,20 @@ class Logger(ILogger):
|
|||||||
def _write(self, message: str) -> None:
|
def _write(self, message: str) -> None:
|
||||||
sys.stdout.write(message + "\n")
|
sys.stdout.write(message + "\n")
|
||||||
|
|
||||||
def debug(self, message: str) -> None:
|
def debug(self, message: Any) -> None:
|
||||||
self._log(LogLevel.DEBUG, message)
|
self._log(LogLevel.DEBUG, message)
|
||||||
|
|
||||||
def info(self, message: str) -> None:
|
def info(self, message: Any) -> None:
|
||||||
self._log(LogLevel.INFO, message)
|
self._log(LogLevel.INFO, message)
|
||||||
|
|
||||||
def warning(self, message: str) -> None:
|
def warning(self, message: Any) -> None:
|
||||||
self._log(LogLevel.WARNING, message)
|
self._log(LogLevel.WARNING, message)
|
||||||
|
|
||||||
def error(self, message: str) -> None:
|
def error(self, message: Any) -> None:
|
||||||
self._log(LogLevel.ERROR, message)
|
self._log(LogLevel.ERROR, message)
|
||||||
|
|
||||||
def critical(self, message: str) -> None:
|
def critical(self, message: Any) -> None:
|
||||||
self._log(LogLevel.CRITICAL, message)
|
self._log(LogLevel.CRITICAL, message)
|
||||||
|
|
||||||
def exception(self, message: str) -> None:
|
def exception(self, message: Any) -> None:
|
||||||
self._log(LogLevel.EXCEPTION, message)
|
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 typing import Any, Optional, Mapping
|
||||||
from itsdangerous import URLSafeTimedSerializer, SignatureExpired, BadSignature
|
from itsdangerous import URLSafeTimedSerializer, SignatureExpired, BadSignature
|
||||||
from src.application.contracts import ICsrfService
|
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
|
from src.infrastructure.config.settings import settings
|
||||||
|
|
||||||
|
|
||||||
@@ -42,21 +42,12 @@ class CsrfService(ICsrfService):
|
|||||||
try:
|
try:
|
||||||
data = self._serializer.loads(token, max_age=self.TTL_SECONDS)
|
data = self._serializer.loads(token, max_age=self.TTL_SECONDS)
|
||||||
except SignatureExpired:
|
except SignatureExpired:
|
||||||
raise ApplicationException(
|
raise CsrfException(message='CSRF token expired')
|
||||||
status_code=403,
|
|
||||||
message='CSRF token expired',
|
|
||||||
)
|
|
||||||
except BadSignature:
|
except BadSignature:
|
||||||
raise ApplicationException(
|
raise CsrfException(message='CSRF token invalid')
|
||||||
status_code=403,
|
|
||||||
message='CSRF token invalid',
|
|
||||||
)
|
|
||||||
|
|
||||||
if expected_subject is not None and data.get('sub') != expected_subject:
|
if expected_subject is not None and data.get('sub') != expected_subject:
|
||||||
raise ApplicationException(
|
raise CsrfException(message='CSRF token subject mismatch')
|
||||||
status_code=403,
|
|
||||||
message='CSRF token subject mismatch',
|
|
||||||
)
|
|
||||||
|
|
||||||
return data
|
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:
|
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:
|
if not cookie_token or not header_token:
|
||||||
raise ApplicationException(
|
raise CsrfException(message='CSRF token missing')
|
||||||
status_code=403,
|
|
||||||
message='CSRF token missing',
|
|
||||||
)
|
|
||||||
|
|
||||||
if not secrets.compare_digest(cookie_token, header_token):
|
if not secrets.compare_digest(cookie_token, header_token):
|
||||||
raise ApplicationException(
|
raise CsrfException(message='CSRF token mismatch')
|
||||||
status_code=403,
|
|
||||||
message='CSRF token mismatch',
|
|
||||||
)
|
|
||||||
|
|
||||||
self.verify(cookie_token, expected_subject=expected_subject)
|
self.verify(cookie_token, expected_subject=expected_subject)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
from jose import jwt, ExpiredSignatureError, JWTError
|
from jose import jwt, ExpiredSignatureError, JWTError
|
||||||
from src.application.contracts import ILogger, IJwtService
|
from src.application.contracts import ILogger, IJwtService
|
||||||
from src.application.domain.dto import AccessTokenPayload
|
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.config.settings import settings
|
||||||
from src.infrastructure.vault import JwtKeyStore
|
from src.infrastructure.vault import JwtKeyStore
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ class JwtService(IJwtService):
|
|||||||
|
|
||||||
if payload.get('type') != 'access':
|
if payload.get('type') != 'access':
|
||||||
self._logger.warning(f'Access token invalid type received_type={payload.get('type')}')
|
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:
|
try:
|
||||||
return AccessTokenPayload(
|
return AccessTokenPayload(
|
||||||
@@ -32,7 +32,7 @@ class JwtService(IJwtService):
|
|||||||
)
|
)
|
||||||
except KeyError as exception:
|
except KeyError as exception:
|
||||||
self._logger.warning(f'Access token missing claim error={str(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:
|
async def _decode_and_verify(self, token: str) -> dict:
|
||||||
kid: str | None = None
|
kid: str | None = None
|
||||||
@@ -42,12 +42,12 @@ class JwtService(IJwtService):
|
|||||||
kid = header.get('kid')
|
kid = header.get('kid')
|
||||||
if not kid:
|
if not kid:
|
||||||
self._logger.warning(f'JWT header missing kid header={header}')
|
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')
|
received_alg = header.get('alg')
|
||||||
if received_alg != settings.JWT_ALGORITHM:
|
if received_alg != settings.JWT_ALGORITHM:
|
||||||
self._logger.warning(f'JWT invalid algorithm kid={kid} received_alg={received_alg} expected_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))
|
public_pem = await self._key_store.get_public_key_for_kid(str(kid))
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@ class JwtService(IJwtService):
|
|||||||
|
|
||||||
if not public_pem:
|
if not public_pem:
|
||||||
self._logger.warning(f'JWT unknown kid kid={kid}')
|
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 = {
|
options = {
|
||||||
'verify_signature': True,
|
'verify_signature': True,
|
||||||
@@ -85,25 +85,25 @@ class JwtService(IJwtService):
|
|||||||
|
|
||||||
if 'sid' not in payload:
|
if 'sid' not in payload:
|
||||||
self._logger.warning(f'JWT missing sid claim kid={kid}')
|
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:
|
if 'type' not in payload:
|
||||||
self._logger.warning(f'JWT missing type claim kid={kid}')
|
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
|
return payload
|
||||||
|
|
||||||
except ExpiredSignatureError as exception:
|
except ExpiredSignatureError as exception:
|
||||||
self._logger.info(f'JWT expired kid={kid} error={str(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:
|
except ApplicationException:
|
||||||
raise
|
raise
|
||||||
|
|
||||||
except JWTError as exception:
|
except JWTError as exception:
|
||||||
self._logger.warning(f'JWT decode failed kid={kid} error={str(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:
|
except Exception as exception:
|
||||||
self._logger.error(f'Unexpected JWT decode error kid={kid} error={str(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.keys import JwtKeyStore
|
||||||
from src.infrastructure.vault.scheduler import start_jwt_keys_scheduler
|
from src.infrastructure.vault.scheduler import start_jwt_keys_scheduler
|
||||||
@@ -2,8 +2,8 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from src.application.domain.dto import JwtPublicKeySet, JwtPublicKey
|
from src.application.domain.dto import JwtPublicKeySet, JwtPublicKey
|
||||||
from src.application.domain.exceptions import ApplicationException
|
from src.application.domain.exceptions import InternalServerException
|
||||||
from src.infrastructure.vault import create_hvac_client, read_kv2_secret
|
from src.infrastructure.vault import create_hvac_client_from_approle,read_kv2_secret
|
||||||
|
|
||||||
|
|
||||||
class JwtKeyStore:
|
class JwtKeyStore:
|
||||||
@@ -19,7 +19,9 @@ class JwtKeyStore:
|
|||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
vault_addr: str,
|
vault_addr: str,
|
||||||
vault_token: str,
|
vault_role_id: str,
|
||||||
|
vault_secret_id: str,
|
||||||
|
vault_namespace: str | None,
|
||||||
mount_point: str,
|
mount_point: str,
|
||||||
kid_path: str = 'jwt/kid',
|
kid_path: str = 'jwt/kid',
|
||||||
kids_prefix: str = 'jwt/kids',
|
kids_prefix: str = 'jwt/kids',
|
||||||
@@ -30,7 +32,9 @@ class JwtKeyStore:
|
|||||||
return
|
return
|
||||||
|
|
||||||
self._vault_addr = vault_addr
|
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._timeout = timeout_seconds
|
||||||
|
|
||||||
self._mount = mount_point
|
self._mount = mount_point
|
||||||
@@ -48,11 +52,17 @@ class JwtKeyStore:
|
|||||||
@classmethod
|
@classmethod
|
||||||
def get_instance(cls) -> 'JwtKeyStore':
|
def get_instance(cls) -> 'JwtKeyStore':
|
||||||
if cls._instance is None:
|
if cls._instance is None:
|
||||||
raise ApplicationException(status_code=500, message='JwtKeyStore not initialized')
|
raise InternalServerException(message='JwtKeyStore not initialized')
|
||||||
return cls._instance
|
return cls._instance
|
||||||
|
|
||||||
def _read_keyset_sync(self) -> JwtPublicKeySet:
|
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)
|
kids = read_kv2_secret(client=client, mount_point=self._mount, path=self._kid_path)
|
||||||
active_kid = kids.get('active')
|
active_kid = kids.get('active')
|
||||||
|
|||||||
@@ -2,10 +2,23 @@ from __future__ import annotations
|
|||||||
import hvac
|
import hvac
|
||||||
|
|
||||||
|
|
||||||
def create_hvac_client(*, url: str, token: str, timeout: int = 5) -> hvac.Client:
|
def create_hvac_client_from_approle(
|
||||||
client = hvac.Client(url=url, token=token, timeout=timeout)
|
*,
|
||||||
|
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():
|
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
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
36
src/main.py
36
src/main.py
@@ -2,20 +2,22 @@ from __future__ import annotations
|
|||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
import secrets
|
import secrets
|
||||||
from typing import AsyncGenerator
|
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.openapi.docs import get_redoc_html, get_swagger_ui_html
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
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.cache import create_redis_client
|
||||||
from src.infrastructure.config.settings import get_settings
|
from src.infrastructure.config.settings import get_settings
|
||||||
from src.infrastructure.vault import JwtKeyStore, start_jwt_keys_scheduler
|
from src.infrastructure.vault import JwtKeyStore, start_jwt_keys_scheduler
|
||||||
from src.infrastructure.utils import generate_instance_id
|
from src.infrastructure.utils import generate_instance_id
|
||||||
from src.infrastructure.logger import logger
|
from src.infrastructure.logger import logger
|
||||||
from src.infrastructure.config import settings
|
from src.infrastructure.config import settings
|
||||||
from src.presentation.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.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()
|
security = HTTPBasic()
|
||||||
|
|
||||||
@@ -24,8 +26,7 @@ async def verify_credentials(credentials: HTTPBasicCredentials = Depends(securit
|
|||||||
user_ok = secrets.compare_digest(credentials.username, settings.DOCS_USERNAME)
|
user_ok = secrets.compare_digest(credentials.username, settings.DOCS_USERNAME)
|
||||||
pass_ok = secrets.compare_digest(credentials.password, settings.DOCS_PASSWORD)
|
pass_ok = secrets.compare_digest(credentials.password, settings.DOCS_PASSWORD)
|
||||||
if not (user_ok and pass_ok):
|
if not (user_ok and pass_ok):
|
||||||
raise ApplicationException(
|
raise UnauthorizedException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
message='Unauthorized',
|
message='Unauthorized',
|
||||||
headers={'WWW-Authenticate': 'Basic'},
|
headers={'WWW-Authenticate': 'Basic'},
|
||||||
)
|
)
|
||||||
@@ -39,11 +40,14 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
logger.set_instance_id(instance_id)
|
logger.set_instance_id(instance_id)
|
||||||
logger.info(f'Users service instance started with 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(
|
jwt_store = JwtKeyStore(
|
||||||
vault_addr=settings.VAULT_ADDR,
|
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,
|
mount_point=settings.VAULT_MOUNT_POINT,
|
||||||
kid_path=settings.VAULT_JWT_KID_PATH,
|
kid_path=settings.VAULT_JWT_KID_PATH,
|
||||||
kids_prefix=settings.VAULT_JWT_KIDS_PREFIX,
|
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_key_store = jwt_store
|
||||||
app.state.jwt_keys_scheduler = jwt_scheduler
|
app.state.jwt_keys_scheduler = jwt_scheduler
|
||||||
yield
|
yield
|
||||||
await app.state.redis.aclose()
|
await app.state.redis_remote.aclose()
|
||||||
logger.info(f'Users service instance ended with id {instance_id}')
|
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.add_exception_handler(Exception, unhandled_exception_handler)
|
||||||
|
|
||||||
app.include_router(order_router)
|
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
|
# 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(TraceIDMiddleware, logger=logger)
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
SecurityHeadersMiddleware,
|
SecurityHeadersMiddleware,
|
||||||
@@ -81,7 +97,7 @@ app.add_middleware(
|
|||||||
hsts_preload=False,
|
hsts_preload=False,
|
||||||
frame_options='DENY',
|
frame_options='DENY',
|
||||||
referrer_policy='strict-origin-when-cross-origin',
|
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 import Depends, Request
|
||||||
from fastapi.security.utils import get_authorization_scheme_param
|
from fastapi.security.utils import get_authorization_scheme_param
|
||||||
from src.application.contracts import IJwtService
|
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.application.domain.dto import AccessTokenPayload, AuthContext
|
||||||
from src.presentation.dependencies import get_jwt_service
|
from src.presentation.dependencies import get_jwt_service
|
||||||
|
|
||||||
@@ -27,10 +27,10 @@ async def require_access_token(
|
|||||||
) -> AuthContext:
|
) -> AuthContext:
|
||||||
token = _extract_access_token(request)
|
token = _extract_access_token(request)
|
||||||
if not token:
|
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)
|
payload: AccessTokenPayload = await jwt_service.decode_access_token(token)
|
||||||
if payload.type != 'access':
|
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)
|
return AuthContext(user_id=payload.sub, sid=payload.sid, token=payload)
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
import functools
|
import functools
|
||||||
from typing import Any, Awaitable, Callable
|
from typing import Any,Awaitable,Callable
|
||||||
|
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
from fastapi.responses import ORJSONResponse
|
from fastapi.responses import ORJSONResponse
|
||||||
from src.infrastructure.cache import KeydbCache
|
|
||||||
from src.infrastructure.logger import get_logger
|
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 cached(*, prefix: str) -> Callable:
|
||||||
|
|
||||||
|
|
||||||
def decorator(func: Callable[..., Awaitable[Any]]):
|
def decorator(func: Callable[..., Awaitable[Any]]):
|
||||||
@functools.wraps(func)
|
@functools.wraps(func)
|
||||||
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||||
@@ -31,8 +35,7 @@ def cached(*, prefix: str) -> Callable:
|
|||||||
cache_key = f'{prefix}:{user_id}'
|
cache_key = f'{prefix}:{user_id}'
|
||||||
|
|
||||||
try:
|
try:
|
||||||
redis = get_redis(request)
|
cache = get_cache_remote(request)
|
||||||
cache = KeydbCache(redis)
|
|
||||||
hit = await cache.get_user(user_id)
|
hit = await cache.get_user(user_id)
|
||||||
if hit is not None:
|
if hit is not None:
|
||||||
logger.debug(f'Cache hit key={cache_key}')
|
logger.debug(f'Cache hit key={cache_key}')
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import inspect
|
|||||||
from functools import wraps
|
from functools import wraps
|
||||||
from typing import Callable, Awaitable, Any, Optional, Annotated
|
from typing import Callable, Awaitable, Any, Optional, Annotated
|
||||||
from fastapi import Request, Header
|
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
|
from src.infrastructure.security import CsrfService
|
||||||
|
|
||||||
|
|
||||||
@@ -39,10 +39,7 @@ def csrf_protect(
|
|||||||
break
|
break
|
||||||
|
|
||||||
if request is None:
|
if request is None:
|
||||||
raise ApplicationException(
|
raise InternalServerException(message='Request is required for CSRF protection')
|
||||||
status_code=500,
|
|
||||||
message='Request is required for CSRF protection',
|
|
||||||
)
|
|
||||||
|
|
||||||
csrf = CsrfService()
|
csrf = CsrfService()
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from typing import Any, Awaitable, Callable, Literal, Optional, Protocol, runtim
|
|||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
from redis.asyncio.client import Redis
|
from redis.asyncio.client import Redis
|
||||||
from src.application.contracts import ILogger
|
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.infrastructure.logger import get_logger
|
||||||
from src.presentation.dependencies import get_redis
|
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]
|
ident = _call_key_builder(key_builder, request, args, kwargs) # type: ignore[arg-type]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f'RateLimit key_builder failed error={str(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
|
route = request.url.path
|
||||||
method = request.method
|
method = request.method
|
||||||
@@ -153,13 +153,12 @@ def rate_limit(
|
|||||||
logger.warning(f'RateLimit fail-open activated key={redis_key}')
|
logger.warning(f'RateLimit fail-open activated key={redis_key}')
|
||||||
return await func(*args, **kwargs)
|
return await func(*args, **kwargs)
|
||||||
|
|
||||||
raise ApplicationException(503, 'Rate limiter unavailable')
|
raise RateLimitException(status_code=503,message='Rate limiter unavailable')
|
||||||
|
|
||||||
if count > limit:
|
if count > limit:
|
||||||
retry_after = max(ttl, 0)
|
retry_after = max(ttl, 0)
|
||||||
logger.warning(f'RateLimit exceeded key={redis_key} count={count} limit={limit} retry_after={retry_after}')
|
logger.warning(f'RateLimit exceeded key={redis_key} count={count} limit={limit} retry_after={retry_after}')
|
||||||
raise ApplicationException(
|
raise RateLimitException(
|
||||||
status_code=429,
|
|
||||||
message='Too Many Requests',
|
message='Too Many Requests',
|
||||||
headers={'Retry-After': str(retry_after)},
|
headers={'Retry-After': str(retry_after)},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,16 +1,9 @@
|
|||||||
from src.presentation.dependencies.commands import (
|
from __future__ import annotations
|
||||||
get_get_me_command,
|
|
||||||
get_set_phone_command,
|
|
||||||
get_set_crypto_wallet_start_command,
|
from src.presentation.dependencies.cache import get_cache,get_cache_remote,get_redis,get_redis_remote
|
||||||
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 src.presentation.dependencies.queue_messanger import get_rabbit
|
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 redis.asyncio.client import Redis
|
||||||
|
|
||||||
from src.application.contracts import ICache
|
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:
|
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)
|
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 fastapi import Depends
|
||||||
from src.application.abstractions import IUnitOfWork
|
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.commands import CreateCryptoTransferCompletedCommand,CreateOrderCommand,CreatePaymentCommand,GetOrderCommand,GetOrderStatusCommand,GetPaymentCommand,GetPaymentConfigCommand,GetPaymentQuoteCommand,GetPaymentQuoteFromRubCommand,ListOrdersCommand,ListPaymentsCommand
|
||||||
from src.application.contracts import ILogger, ICache, IQueueMessanger, IHashService
|
from src.application.contracts import ICache,ILogger,IQueueMessanger,IReceipt
|
||||||
from src.presentation.dependencies.cache import get_cache
|
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.logger import get_logger
|
||||||
from src.presentation.dependencies.queue_messanger import get_rabbit
|
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
|
from src.presentation.dependencies.unit_of_work import get_unit_of_work
|
||||||
|
|
||||||
|
|
||||||
def get_get_me_command(
|
def get_itpay_service() -> IItPayService:
|
||||||
logger: ILogger = Depends(get_logger),
|
return ItPayClient(
|
||||||
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
|
public_id=settings.ITPAY_PUBLIC_ID,
|
||||||
cache: ICache = Depends(get_cache),
|
api_secret=settings.ITPAY_API_SECRET,
|
||||||
) -> GetMeCommand:
|
api_base_url='https://api.gw.itpay.ru',
|
||||||
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_set_crypto_wallet_complete_command(
|
def get_create_order_command(
|
||||||
logger: ILogger = Depends(get_logger),
|
logger: ILogger = Depends(get_logger),
|
||||||
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
|
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
|
||||||
cache: ICache = Depends(get_cache),
|
remote_cache: ICache = Depends(get_remote_cache),
|
||||||
hash_service: IHashService = Depends(get_hash_service),
|
itpay_service: IItPayService = Depends(get_itpay_service),
|
||||||
) -> SetCryptoWalletCompleteCommand:
|
) -> CreateOrderCommand:
|
||||||
return SetCryptoWalletCompleteCommand(
|
payment_quote_service = PaymentQuoteService(remote_cache=remote_cache,logger=logger)
|
||||||
logger=logger,
|
return CreateOrderCommand(
|
||||||
unit_of_work=unit_of_work,
|
unit_of_work=unit_of_work,
|
||||||
cache=cache,
|
logger=logger,
|
||||||
hash_service=hash_service,
|
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),
|
logger: ILogger = Depends(get_logger),
|
||||||
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
|
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
|
||||||
cache: ICache = Depends(get_cache),
|
) -> ListOrdersCommand:
|
||||||
messanger: IQueueMessanger = Depends(get_rabbit),
|
return ListOrdersCommand(unit_of_work=unit_of_work,logger=logger)
|
||||||
hash_service: IHashService = Depends(get_hash_service),
|
|
||||||
) -> ChangePasswordStartCommand:
|
|
||||||
return ChangePasswordStartCommand(
|
def get_list_payments_command(
|
||||||
logger=logger,
|
logger: ILogger = Depends(get_logger),
|
||||||
unit_of_work=unit_of_work,
|
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
|
||||||
cache=cache,
|
) -> ListPaymentsCommand:
|
||||||
messanger=messanger,
|
return ListPaymentsCommand(unit_of_work=unit_of_work,logger=logger)
|
||||||
hash_service=hash_service,
|
|
||||||
|
|
||||||
|
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),
|
logger: ILogger = Depends(get_logger),
|
||||||
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
|
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
|
||||||
cache: ICache = Depends(get_cache),
|
receipt: IReceipt = Depends(get_cloud_kassir_receipt),
|
||||||
hash_service: IHashService = Depends(get_hash_service),
|
) -> CreateCryptoTransferCompletedCommand:
|
||||||
) -> ChangePasswordCompleteCommand:
|
return CreateCryptoTransferCompletedCommand(unit_of_work=unit_of_work,receipt=receipt,logger=logger)
|
||||||
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,
|
|
||||||
)
|
|
||||||
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 import Request
|
||||||
|
from fastapi.responses import ORJSONResponse
|
||||||
from starlette import status
|
from starlette import status
|
||||||
from src.infrastructure.logger import logger
|
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__}')
|
logger.exception(f'Unhandled exception: {type(exc).__name__}')
|
||||||
return ORJSONResponse(
|
return ORJSONResponse(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
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 decimal import Decimal
|
||||||
from urllib.parse import parse_qs
|
from urllib.parse import parse_qs
|
||||||
import aiohttp
|
import orjson
|
||||||
from fastapi import APIRouter, Depends, Request
|
from fastapi import APIRouter,Depends,Query,Request,WebSocket,WebSocketDisconnect
|
||||||
from fastapi.responses import ORJSONResponse
|
from fastapi.responses import ORJSONResponse
|
||||||
|
from fastapi.security.utils import get_authorization_scheme_param
|
||||||
from ulid import ULID
|
from ulid import ULID
|
||||||
from src.application.contracts import ILogger
|
from src.application.commands import CreateOrderCommand,CreatePaymentCommand,GetOrderCommand,GetOrderStatusCommand,GetPaymentCommand,GetPaymentConfigCommand,GetPaymentQuoteCommand,GetPaymentQuoteFromRubCommand,ListOrdersCommand,ListPaymentsCommand
|
||||||
from src.application.domain.dto import AuthContext
|
from src.application.contracts import IJwtService,ILogger
|
||||||
from src.application.domain.exceptions import ApplicationException
|
from src.application.domain.dto import AccessTokenPayload,AuthContext
|
||||||
from src.presentation.decorators import csrf_protect, require_access_token
|
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.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'])
|
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'
|
ERROR_RESPONSES = {
|
||||||
ITPAY_AUTHORIZATION = 'Token REPLACE_WITH_JWT_FROM_ITPAY_DASHBOARD'
|
400: {'model': ErrorResponse, 'description': 'Bad Request'},
|
||||||
HARDCODED_USDT_TO_RUB = Decimal('100')
|
401: {'model': ErrorResponse, 'description': 'Unauthorized'},
|
||||||
HARDCODED_GAS_RUB = Decimal('15')
|
403: {'model': ErrorResponse, 'description': 'Forbidden'},
|
||||||
HARDCODED_OUR_COMMISSION_RUB = Decimal('25')
|
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:
|
def _payment_config_response(quote: PaymentQuote) -> PaymentConfigResponse:
|
||||||
return (amount_usdt * HARDCODED_USDT_TO_RUB + HARDCODED_GAS_RUB + HARDCODED_OUR_COMMISSION_RUB).quantize(Decimal('0.01'))
|
return PaymentConfigResponse(
|
||||||
|
status_code=200,
|
||||||
|
usdt_exchange_rate=str(quote.usdt_exchange_rate),
|
||||||
|
gas_fee=str(quote.gas_fee),
|
||||||
@order_router.post('/create')
|
service_fee_rate=str(quote.service_fee_rate),
|
||||||
#@csrf_protect()
|
one_usdt_service_fee=str(quote.service_fee),
|
||||||
async def create_order(
|
one_usdt_total_price=str(quote.total_price),
|
||||||
request: Request,
|
created_at=quote.created_at.isoformat(),
|
||||||
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_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()
|
raw = await request.body()
|
||||||
ct = (request.headers.get('content-type') or '').lower()
|
ct = (request.headers.get('content-type') or '').lower()
|
||||||
if 'application/json' in ct:
|
if 'application/json' in ct:
|
||||||
try:
|
payload = orjson.loads(raw)
|
||||||
parsed = json.loads(raw.decode('utf-8'))
|
|
||||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
||||||
parsed = raw.decode('utf-8', errors='replace')
|
|
||||||
elif 'application/x-www-form-urlencoded' in ct:
|
elif 'application/x-www-form-urlencoded' in ct:
|
||||||
decoded = raw.decode('utf-8', errors='replace')
|
decoded = raw.decode('utf-8', errors='replace')
|
||||||
qs = parse_qs(decoded, keep_blank_values=True)
|
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:
|
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 = {
|
log_payload = {
|
||||||
'method': request.method,
|
'event': 'itpay_webhook_received',
|
||||||
'url': str(request.url),
|
'webhook_id': payload.get('id'),
|
||||||
'headers': {k: v for k, v in request.headers.items()},
|
'webhook_type': payload.get('type'),
|
||||||
'body': parsed,
|
'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})
|
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 decimal import Decimal
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel,Field
|
||||||
|
from src.application.domain.enums import OrderStatus,PaymentStatus
|
||||||
|
|
||||||
|
|
||||||
class CreateOrder(BaseModel):
|
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