47 Commits

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

View File

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

17
docker-compose.yml Normal file
View 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

View File

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

View 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: ...

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,3 +4,5 @@ from src.application.contracts.i_csrf_service import ICsrfService
from src.application.contracts.i_cache import ICache
from src.application.contracts.i_hash_service import IHashService
from src.application.contracts.i_queue_messanger import IQueueMessanger
from src.application.contracts.i_itpay_service import IItPayService
from src.application.contracts.i_receipt import IReceipt

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
from src.application.domain.entities.user import UserEntity
from src.application.domain.entities.session import SessionEntity
from src.application.domain.entities.order import OrderEntity
from src.application.domain.entities.payment import PaymentEntity
__all__ = ['UserEntity', 'SessionEntity']
__all__ = ['PaymentEntity', 'OrderEntity']

View File

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

View File

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

View File

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

View File

@@ -1,2 +1,5 @@
from src.application.domain.enums.log_level import LogLevel
from src.application.domain.enums.log_format import LogFormat
from src.application.domain.enums.itpay_payment_status import ItPayPaymentStatus
from src.application.domain.enums.order_status import OrderStatus
from src.application.domain.enums.payment_status import PaymentStatus

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,19 +4,34 @@ from functools import lru_cache
from typing import List, Literal
import os
from dotenv import load_dotenv, find_dotenv
from pydantic import Field, model_validator
from pydantic import AliasChoices, Field, field_validator, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from src.infrastructure.vault import create_hvac_client, read_kv2_secret
from src.infrastructure.vault import create_hvac_client_from_approle, read_kv2_secret
env_file = find_dotenv(".env")
if env_file:
load_dotenv(env_file)
def normalize_vault_base_url(raw: str) -> str:
u = raw.strip().rstrip('/')
if not u:
return raw.strip()
if '://' not in u:
return f'https://{u}'
return u
class Settings(BaseSettings):
VAULT_ADDR: str = Field(default="http://localhost:8200")
VAULT_TOKEN: str = Field(..., description="Vault token is required")
VAULT_MOUNT_POINT: str = Field(default="secrets")
VAULT_ADDR: str = Field(default='http://localhost:8200')
VAULT_ROLE_ID: str = Field(..., description='AppRole role_id')
VAULT_SECRET_ID: str = Field(
...,
description='AppRole secret_id',
validation_alias=AliasChoices('VAULT_SECRET_ID', 'VAULT_SECRET_TOKEN'),
)
VAULT_NAMESPACE: str | None = Field(default=None)
VAULT_MOUNT_POINT: str = Field(default='secrets')
VAULT_JWT_KID_PATH: str = "jwt/kid"
VAULT_JWT_KIDS_PREFIX: str = "jwt/kids"
@@ -59,6 +74,11 @@ class Settings(BaseSettings):
REDIS_PASSWORD: str | None = None
REDIS_DB: int = 0
KEYDB_REMOTE_HOST: str | None = None
KEYDB_REMOTE_PORT: int | None = None
KEYDB_REMOTE_PASSWORD: str | None = None
KEYDB_REMOTE_DB: int | None = None
RABBIT_HOST: str = "localhost"
RABBIT_PORT: int = 5672
RABBIT_USER: str = "guest"
@@ -68,6 +88,14 @@ class Settings(BaseSettings):
RABBIT_PUBLISH_PERSIST: bool = True
RABBIT_CONNECT_TIMEOUT: int = 5
RABBIT_EMAIL_CODE_QUEUE: str = "email.verification_code"
RABBIT_CRYPTO_TRANSFER_QUEUE: str = "crypto.transfer.requested"
RABBIT_CRYPTO_TRANSFER_COMPLETED_QUEUE: str = "crypto.transfer.completed"
ITPAY_PUBLIC_ID: str
ITPAY_API_SECRET: str
CLOUD_KASSIR_PUBLIC_ID: str = ''
CLOUD_KASSIR_API_SECRET: str = ''
LOG_LEVEL: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO"
LOG_FORMAT: Literal["JSON", "TEXT"] = "TEXT"
@@ -77,51 +105,184 @@ class Settings(BaseSettings):
env_file_encoding="utf-8",
case_sensitive=True,
extra="ignore",
populate_by_name=True,
)
@field_validator('VAULT_ADDR', mode='before')
@classmethod
def vault_addr_scheme(cls, v):
if v is None or not isinstance(v, str):
return v
return normalize_vault_base_url(v)
@model_validator(mode="before")
@classmethod
def load_from_vault(cls, data: dict):
addr = data.get("VAULT_ADDR") or os.getenv("VAULT_ADDR") or "http://localhost:8200"
token = data.get("VAULT_TOKEN") or os.getenv("VAULT_TOKEN")
mount = data.get("VAULT_MOUNT_POINT") or os.getenv("VAULT_MOUNT_POINT") or "secrets"
if not isinstance(data, dict):
return data
addr_raw = data.get('VAULT_ADDR') or os.getenv('VAULT_ADDR') or 'http://localhost:8200'
addr = normalize_vault_base_url(addr_raw)
data['VAULT_ADDR'] = addr
role_id = data.get('VAULT_ROLE_ID') or os.getenv('VAULT_ROLE_ID')
secret_id = (
data.get('VAULT_SECRET_ID')
or data.get('VAULT_SECRET_TOKEN')
or os.getenv('VAULT_SECRET_ID')
or os.getenv('VAULT_SECRET_TOKEN')
)
namespace = data.get('VAULT_NAMESPACE')
if namespace is None:
namespace = os.getenv('VAULT_NAMESPACE')
namespace = namespace if namespace else None
mount = data.get('VAULT_MOUNT_POINT') or os.getenv('VAULT_MOUNT_POINT') or 'secrets'
if not token:
raise RuntimeError("VAULT_TOKEN is required")
if not role_id or not secret_id:
raise RuntimeError('VAULT_ROLE_ID and VAULT_SECRET_ID (or VAULT_SECRET_TOKEN) are required for Vault AppRole')
client = create_hvac_client(url=addr, token=token, timeout=5)
data['VAULT_ROLE_ID'] = str(role_id).strip()
data['VAULT_SECRET_ID'] = str(secret_id).strip()
def safe_read(path: str) -> dict:
try:
client = create_hvac_client_from_approle(
url=addr,
role_id=role_id,
secret_id=secret_id,
namespace=namespace,
timeout=5,
)
def read_secret(path: str) -> dict:
return read_kv2_secret(client=client, mount_point=mount, path=path)
def read_secret_optional(path: str) -> dict:
try:
return read_secret(path)
except Exception:
return {}
database = safe_read("database")
rabbitmq = safe_read("rabbitmq")
csrf = safe_read("csrf")
database = read_secret('database')
csrf = read_secret_optional('csrf')
rabbitmq = read_secret_optional('rabbitmq')
if database:
required = ["HOST", "NAME", "USER", "PASSWORD", "PORT"]
missing = [k for k in required if k not in database]
if missing:
raise RuntimeError(f"Vault database secret missing keys {missing}")
db_ci = {str(k).lower(): v for k, v in database.items()}
data["DATABASE_HOST"] = database["HOST"]
data["DATABASE_PORT"] = database["PORT"]
data["DATABASE_NAME"] = database["NAME"]
data["DATABASE_USER"] = database["USER"]
data["DATABASE_PASSWORD"] = database["PASSWORD"]
def db_nonempty(key: str) -> bool:
v = db_ci.get(key)
if v is None:
return False
if isinstance(v, str) and not v.strip():
return False
return True
if rabbitmq:
data["RABBIT_HOST"] = rabbitmq.get("HOST", data.get("RABBIT_HOST"))
data["RABBIT_PORT"] = rabbitmq.get("PORT", data.get("RABBIT_PORT"))
data["RABBIT_USER"] = rabbitmq.get("USER", data.get("RABBIT_USER"))
data["RABBIT_PASSWORD"] = rabbitmq.get("PASSWORD", data.get("RABBIT_PASSWORD"))
data["RABBIT_VHOST"] = rabbitmq.get("VHOST", data.get("RABBIT_VHOST"))
required_db = ['host', 'name', 'user', 'password', 'port']
missing_db = [k for k in required_db if not db_nonempty(k)]
if missing_db:
raise RuntimeError(f'Vault secret database missing non-empty keys: {missing_db}')
data['DATABASE_HOST'] = str(db_ci['host']).strip()
data['DATABASE_PORT'] = int(db_ci['port'])
data['DATABASE_NAME'] = str(db_ci['name']).strip()
data['DATABASE_USER'] = str(db_ci['user']).strip()
data['DATABASE_PASSWORD'] = str(db_ci['password']).strip()
if csrf:
data["CSRF_SECRET_KEY"] = csrf.get("KEY", data.get("CSRF_SECRET_KEY"))
csrf_secret = None
for entry_key, entry_val in csrf.items():
if str(entry_key).lower() == 'key' and entry_val is not None and str(entry_val).strip():
csrf_secret = str(entry_val).strip()
break
if csrf_secret:
data['CSRF_SECRET_KEY'] = csrf_secret
if rabbitmq:
r_ci = {str(k).lower(): v for k, v in rabbitmq.items()}
def rb_set(field: str, env_key: str, *, as_int: bool = False) -> None:
v = r_ci.get(field)
if v is None:
return
if isinstance(v, str) and not v.strip():
return
data[env_key] = int(v) if as_int else str(v).strip()
rb_set('host', 'RABBIT_HOST')
rb_set('port', 'RABBIT_PORT', as_int=True)
rb_set('user', 'RABBIT_USER')
rb_set('password', 'RABBIT_PASSWORD')
rb_set('vhost', 'RABBIT_VHOST')
keydb = read_secret('keydb')
k_ci = {str(k).lower(): v for k, v in keydb.items()}
def keydb_nonempty(key: str) -> bool:
v = k_ci.get(key)
if v is None:
return False
if isinstance(v, str) and not v.strip():
return False
return True
missing_keydb = []
for req in ('host', 'port'):
if not keydb_nonempty(req):
missing_keydb.append(req)
db_raw = k_ci.get('database')
if db_raw is None:
db_raw = k_ci.get('db')
if db_raw is None or (isinstance(db_raw, str) and not str(db_raw).strip()):
missing_keydb.append('database')
if missing_keydb:
raise RuntimeError(
f'Vault secret keydb missing non-empty keys: {missing_keydb} (mount={mount},path=keydb)'
)
data['KEYDB_REMOTE_HOST'] = str(k_ci['host']).strip()
data['KEYDB_REMOTE_PORT'] = int(k_ci['port'])
data['KEYDB_REMOTE_DB'] = int(db_raw)
pw_raw = k_ci.get('password')
if pw_raw is not None and str(pw_raw).strip():
data['KEYDB_REMOTE_PASSWORD'] = str(pw_raw).strip()
else:
data['KEYDB_REMOTE_PASSWORD'] = None
itpay_public_id = data.get('ITPAY_PUBLIC_ID') or os.getenv('ITPAY_PUBLIC_ID')
itpay_api_secret = data.get('ITPAY_API_SECRET') or os.getenv('ITPAY_API_SECRET')
if itpay_public_id is not None and str(itpay_public_id).strip() and itpay_api_secret is not None and str(itpay_api_secret).strip():
data['ITPAY_PUBLIC_ID'] = str(itpay_public_id).strip()
data['ITPAY_API_SECRET'] = str(itpay_api_secret).strip()
else:
itpay = read_secret('itpay')
itpay_ci = {str(k).lower(): v for k, v in itpay.items()}
public_id = itpay_ci.get('public_id')
api_secret = itpay_ci.get('api_secret')
if api_secret is None:
api_secret = itpay_ci.get('secret')
missing = []
if public_id is None or not str(public_id).strip():
missing.append('public_id')
if api_secret is None or not str(api_secret).strip():
missing.append('api_secret')
if missing:
raise RuntimeError(f'Vault secret itpay missing non-empty keys: {missing} (mount={mount},path=itpay)')
data['ITPAY_PUBLIC_ID'] = str(public_id).strip()
data['ITPAY_API_SECRET'] = str(api_secret).strip()
ck_public = data.get('CLOUD_KASSIR_PUBLIC_ID') or os.getenv('CLOUD_KASSIR_PUBLIC_ID')
ck_secret = data.get('CLOUD_KASSIR_API_SECRET') or os.getenv('CLOUD_KASSIR_API_SECRET')
if ck_public is not None and str(ck_public).strip() and ck_secret is not None and str(ck_secret).strip():
data['CLOUD_KASSIR_PUBLIC_ID'] = str(ck_public).strip()
data['CLOUD_KASSIR_API_SECRET'] = str(ck_secret).strip()
else:
cloudkassir = read_secret_optional('cloudkassir')
if cloudkassir:
ck_ci = {str(k).lower(): v for k, v in cloudkassir.items()}
public_id_ck = ck_ci.get('public_id')
api_secret_ck = ck_ci.get('api_secret')
if api_secret_ck is None:
api_secret_ck = ck_ci.get('secret')
if public_id_ck is not None and str(public_id_ck).strip():
data['CLOUD_KASSIR_PUBLIC_ID'] = str(public_id_ck).strip()
if api_secret_ck is not None and str(api_secret_ck).strip():
data['CLOUD_KASSIR_API_SECRET'] = str(api_secret_ck).strip()
return data
@@ -134,8 +295,20 @@ class Settings(BaseSettings):
@property
def REDIS_URL(self) -> str:
auth = f":{self.REDIS_PASSWORD}@" if self.REDIS_PASSWORD else ""
return f"redis://{auth}{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"
return self.KEYDB_REMOTE_URL
@staticmethod
def _redis_url(*, host: str, port: int, password: str | None, db: int) -> str:
auth = f':{password}@' if password else ''
return f'redis://{auth}{host}:{port}/{db}'
@property
def KEYDB_REMOTE_URL(self) -> str:
host = self.KEYDB_REMOTE_HOST or self.REDIS_HOST
port = int(self.KEYDB_REMOTE_PORT) if self.KEYDB_REMOTE_PORT is not None else int(self.REDIS_PORT)
password = self.KEYDB_REMOTE_PASSWORD if self.KEYDB_REMOTE_PASSWORD is not None else self.REDIS_PASSWORD
db = int(self.KEYDB_REMOTE_DB) if self.KEYDB_REMOTE_DB is not None else int(self.REDIS_DB)
return self._redis_url(host=host, port=port, password=password, db=db)
@property
def RABBIT_URL(self) -> str:

View File

@@ -1,6 +1,7 @@
from src.infrastructure.database.models.base import Base
from src.infrastructure.database.models.order import Order
from src.infrastructure.database.models.payment import Payment
from src.infrastructure.database.models.user import UserModel
from src.infrastructure.database.models.sessions import Session
__all__ = ['Base', 'UserModel', 'Session']
__all__ = ['Base','Order','Payment','UserModel']

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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')

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,20 +2,22 @@ from __future__ import annotations
from contextlib import asynccontextmanager
import secrets
from typing import AsyncGenerator
from fastapi import Depends, FastAPI, status
from fastapi import Depends, FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
from fastapi.responses import HTMLResponse
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from src.application.domain.exceptions import ApplicationException
from src.application.domain.exceptions import ApplicationException,UnauthorizedException
from src.infrastructure.cache import create_redis_client
from src.infrastructure.config.settings import get_settings
from src.infrastructure.vault import JwtKeyStore, start_jwt_keys_scheduler
from src.infrastructure.utils import generate_instance_id
from src.infrastructure.logger import logger
from src.infrastructure.config import settings
from src.presentation.handlers import application_exception_handler, unhandled_exception_handler
from src.presentation.handler import application_exception_handler, unhandled_exception_handler
from src.presentation.messaging import crypto_transfer_router
from src.presentation.middleware import TraceIDMiddleware, SecurityHeadersMiddleware
from src.presentation.routing import order_router
from src.presentation.routing import order_router,orders_router,payment_router,payments_router
security = HTTPBasic()
@@ -24,8 +26,7 @@ async def verify_credentials(credentials: HTTPBasicCredentials = Depends(securit
user_ok = secrets.compare_digest(credentials.username, settings.DOCS_USERNAME)
pass_ok = secrets.compare_digest(credentials.password, settings.DOCS_PASSWORD)
if not (user_ok and pass_ok):
raise ApplicationException(
status_code=status.HTTP_401_UNAUTHORIZED,
raise UnauthorizedException(
message='Unauthorized',
headers={'WWW-Authenticate': 'Basic'},
)
@@ -39,11 +40,14 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
logger.set_instance_id(instance_id)
logger.info(f'Users service instance started with id {instance_id}')
app.state.redis = create_redis_client()
app.state.redis_remote = create_redis_client(settings.KEYDB_REMOTE_URL)
app.state.redis = app.state.redis_remote
jwt_store = JwtKeyStore(
vault_addr=settings.VAULT_ADDR,
vault_token=settings.VAULT_TOKEN,
vault_role_id=settings.VAULT_ROLE_ID,
vault_secret_id=settings.VAULT_SECRET_ID,
vault_namespace=settings.VAULT_NAMESPACE,
mount_point=settings.VAULT_MOUNT_POINT,
kid_path=settings.VAULT_JWT_KID_PATH,
kids_prefix=settings.VAULT_JWT_KIDS_PREFIX,
@@ -56,7 +60,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.jwt_key_store = jwt_store
app.state.jwt_keys_scheduler = jwt_scheduler
yield
await app.state.redis.aclose()
await app.state.redis_remote.aclose()
logger.info(f'Users service instance ended with id {instance_id}')
@@ -71,9 +75,21 @@ app.add_exception_handler(ApplicationException, application_exception_handler)
app.add_exception_handler(Exception, unhandled_exception_handler)
app.include_router(order_router)
app.include_router(orders_router)
app.include_router(payment_router)
app.include_router(payments_router)
app.include_router(crypto_transfer_router)
# Added middleware
app.add_middleware(
CORSMiddleware,
allow_origins=[],
allow_origin_regex='.*',
allow_credentials=True,
allow_methods=['*'],
allow_headers=['*'],
)
app.add_middleware(TraceIDMiddleware, logger=logger)
app.add_middleware(
SecurityHeadersMiddleware,
@@ -81,7 +97,7 @@ app.add_middleware(
hsts_preload=False,
frame_options='DENY',
referrer_policy='strict-origin-when-cross-origin',
content_security_policy="default-src 'self'; frame-ancestors 'none'; base-uri 'self'; object-src 'none'",
content_security_policy="default-src 'self'; script-src 'self' 'unsafe-inline' https://unpkg.com https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://unpkg.com; img-src 'self' data: https://fastapi.tiangolo.com; frame-ancestors 'none'; base-uri 'self'; object-src 'none'",
)

View File

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

View File

@@ -1,15 +1,19 @@
from __future__ import annotations
import functools
from typing import Any, Awaitable, Callable
from typing import Any,Awaitable,Callable
from fastapi import Request
from fastapi.responses import ORJSONResponse
from src.infrastructure.cache import KeydbCache
from src.infrastructure.logger import get_logger
from src.presentation.dependencies.cache import get_redis
from src.presentation.dependencies.cache import get_cache_remote
def cached(*, prefix: str) -> Callable:
def decorator(func: Callable[..., Awaitable[Any]]):
@functools.wraps(func)
async def wrapper(*args: Any, **kwargs: Any) -> Any:
@@ -31,8 +35,7 @@ def cached(*, prefix: str) -> Callable:
cache_key = f'{prefix}:{user_id}'
try:
redis = get_redis(request)
cache = KeydbCache(redis)
cache = get_cache_remote(request)
hit = await cache.get_user(user_id)
if hit is not None:
logger.debug(f'Cache hit key={cache_key}')

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,
)

View File

@@ -0,0 +1,5 @@
from src.presentation.messaging.crypto_transfer import crypto_transfer_router
__all__=['crypto_transfer_router']

View 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)

View File

@@ -1 +1 @@
from src.presentation.routing.order import order_router
from src.presentation.routing.order import order_router,orders_router,payment_router,payments_router

View File

@@ -1,114 +1,459 @@
import json
import asyncio
from decimal import Decimal
from urllib.parse import parse_qs
import aiohttp
from fastapi import APIRouter, Depends, Request
import orjson
from fastapi import APIRouter,Depends,Query,Request,WebSocket,WebSocketDisconnect
from fastapi.responses import ORJSONResponse
from fastapi.security.utils import get_authorization_scheme_param
from ulid import ULID
from src.application.contracts import ILogger
from src.application.domain.dto import AuthContext
from src.application.domain.exceptions import ApplicationException
from src.presentation.decorators import csrf_protect, require_access_token
from src.application.commands import CreateOrderCommand,CreatePaymentCommand,GetOrderCommand,GetOrderStatusCommand,GetPaymentCommand,GetPaymentConfigCommand,GetPaymentQuoteCommand,GetPaymentQuoteFromRubCommand,ListOrdersCommand,ListPaymentsCommand
from src.application.contracts import IJwtService,ILogger
from src.application.domain.dto import AccessTokenPayload,AuthContext
from src.application.domain.entities import OrderEntity,PaymentEntity
from src.application.domain.enums import OrderStatus
from src.application.domain.exceptions import ApplicationException,ConflictException
from src.application.services import PaymentQuote
from src.infrastructure.context_vars import trace_id_var
from src.presentation.decorators import require_access_token, csrf_protect
from src.presentation.dependencies.commands import get_create_order_command,get_create_payment_command,get_list_orders_command,get_list_payments_command,get_order_command,get_order_status_command,get_payment_command,get_payment_config_command,get_payment_quote_command,get_payment_quote_from_rub_command
from src.presentation.dependencies.logger import get_logger
from src.presentation.schemas.order import CreateOrder
from src.presentation.dependencies.security import get_jwt_service
from src.presentation.schemas.order import CreateOrder,CreateOrderResponse,ErrorResponse,OrderDetailResponse,OrderPaymentResponse,OrdersResponse,OrderStatusResponse,OrderWithPaymentResponse,PaymentConfigResponse,PaymentDetailResponse,PaymentQuoteResponse,PaymentResponse,PaymentsResponse
from src.presentation.schemas.itpay_payment_models import ItpayPaymentData
order_router = APIRouter(prefix='/order', tags=['orders'])
orders_router = APIRouter(tags=['orders'])
payment_router = APIRouter(prefix='/payment', tags=['payments'])
payments_router = APIRouter(tags=['payments'])
ITPAY_API_BASE = 'https://api.gw.itpay.ru'
ITPAY_AUTHORIZATION = 'Token REPLACE_WITH_JWT_FROM_ITPAY_DASHBOARD'
HARDCODED_USDT_TO_RUB = Decimal('100')
HARDCODED_GAS_RUB = Decimal('15')
HARDCODED_OUR_COMMISSION_RUB = Decimal('25')
ERROR_RESPONSES = {
400: {'model': ErrorResponse, 'description': 'Bad Request'},
401: {'model': ErrorResponse, 'description': 'Unauthorized'},
403: {'model': ErrorResponse, 'description': 'Forbidden'},
404: {'model': ErrorResponse, 'description': 'Not Found'},
409: {'model': ErrorResponse, 'description': 'Conflict'},
422: {'model': ErrorResponse, 'description': 'Validation Error'},
429: {'model': ErrorResponse, 'description': 'Too Many Requests'},
500: {'model': ErrorResponse, 'description': 'Internal Server Error'},
502: {'model': ErrorResponse, 'description': 'Bad Gateway'},
503: {'model': ErrorResponse, 'description': 'Service Unavailable'},
}
def _amount_rub_for_itpay(amount_usdt: Decimal) -> Decimal:
return (amount_usdt * HARDCODED_USDT_TO_RUB + HARDCODED_GAS_RUB + HARDCODED_OUR_COMMISSION_RUB).quantize(Decimal('0.01'))
@order_router.post('/create')
#@csrf_protect()
async def create_order(
request: Request,
body: CreateOrder,
#auth: AuthContext = Depends(require_access_token),
logger: ILogger = Depends(get_logger),
) -> ORJSONResponse:
amount_rub = _amount_rub_for_itpay(body.amount_usdt)
amount_str = str(amount_rub)
client_payment_id = str(ULID())
payload = {
'amount': amount_str,
'client_payment_id': client_payment_id,
'description': f'USDT {body.amount_usdt}',
'metadata': {
'user_id': '01KPSYW27JZ26HBDR3QS5J6VMS',
'amount_usdt': str(body.amount_usdt),
'rate': str(HARDCODED_USDT_TO_RUB),
'gas_rub': str(HARDCODED_GAS_RUB),
'commission_rub': str(HARDCODED_OUR_COMMISSION_RUB),
},
}
url = f'{ITPAY_API_BASE}/v1/payments'
headers = {
'Authorization': ITPAY_AUTHORIZATION,
'Content-Type': 'application/json',
'Accept': 'application/json',
}
try:
timeout = aiohttp.ClientTimeout(total=30)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.post(url, json=payload, headers=headers) as resp:
response_text = await resp.text()
try:
response_json = json.loads(response_text)
except json.JSONDecodeError:
response_json = {'raw': response_text}
if resp.status >= 400:
logger.warning(f'itpay payments POST {resp.status} {response_text}')
raise ApplicationException(status_code=502, message='Payment provider error')
except ApplicationException:
raise
except aiohttp.ClientError as e:
logger.error(str(e))
raise ApplicationException(status_code=502, message='Payment provider unreachable')
return ORJSONResponse(
content={
'itpay': response_json,
'client_payment_id': client_payment_id,
'amount_usdt': str(body.amount_usdt),
'amount_rub': amount_str,
'hardcoded': {
'usdt_to_rub': str(HARDCODED_USDT_TO_RUB),
'gas_rub': str(HARDCODED_GAS_RUB),
'commission_rub': str(HARDCODED_OUR_COMMISSION_RUB),
},
}
def _payment_config_response(quote: PaymentQuote) -> PaymentConfigResponse:
return PaymentConfigResponse(
status_code=200,
usdt_exchange_rate=str(quote.usdt_exchange_rate),
gas_fee=str(quote.gas_fee),
service_fee_rate=str(quote.service_fee_rate),
one_usdt_service_fee=str(quote.service_fee),
one_usdt_total_price=str(quote.total_price),
created_at=quote.created_at.isoformat(),
)
def _payment_quote_response(quote: PaymentQuote) -> PaymentQuoteResponse:
return PaymentQuoteResponse(
status_code=200,
usdt_amount=str(quote.usdt_amount),
usdt_exchange_rate=str(quote.usdt_exchange_rate),
gas_fee=str(quote.gas_fee),
service_fee=str(quote.service_fee),
total_price=str(quote.total_price),
service_fee_rate=str(quote.service_fee_rate),
created_at=quote.created_at.isoformat(),
)
@order_router.post('/webhook/itpay')
async def itpay_webhook(request: Request, logger: ILogger = Depends(get_logger)) -> ORJSONResponse:
def _order_response(o: OrderEntity) -> OrderPaymentResponse:
return OrderPaymentResponse(
id=o.id,
created_at=o.created_at.isoformat() if o.created_at is not None else None,
updated_at=o.updated_at.isoformat() if o.updated_at is not None else None,
user_id=o.user_id,
usdt_amount=str(o.usdt_amount) if o.usdt_amount is not None else None,
usdt_exchange_rate=str(o.usdt_exchange_rate) if o.usdt_exchange_rate is not None else None,
gas_fee=str(o.gas_fee) if o.gas_fee is not None else None,
total_price=str(o.total_price) if o.total_price is not None else None,
service_fee=str(o.service_fee) if o.service_fee is not None else None,
status=o.status,
client_payment_id=o.client_payment_id,
itpay_payment_qr_url_desktop=o.itpay_payment_qr_url_desktop,
itpay_payment_qr_url_android=o.itpay_payment_qr_url_android,
itpay_payment_qr_url_ios=o.itpay_payment_qr_url_ios,
itpay_payment_qr_image_desktop=o.itpay_payment_qr_image_desktop,
itpay_payment_qr_image_android=o.itpay_payment_qr_image_android,
itpay_payment_qr_image_ios=o.itpay_payment_qr_image_ios,
itpay_id=o.itpay_id,
itpay_qr_id=o.itpay_qr_id,
itpay_amount=str(o.itpay_amount) if o.itpay_amount is not None else None,
itpay_created_at=o.itpay_created_at.isoformat() if o.itpay_created_at is not None else None,
)
def _payment_response(payment: PaymentEntity) -> PaymentResponse:
return PaymentResponse(
id=payment.id,
created_at=payment.created_at.isoformat() if payment.created_at is not None else None,
updated_at=payment.updated_at.isoformat() if payment.updated_at is not None else None,
user_id=payment.user_id,
order_id=payment.order_id,
status=payment.status,
receipt_cloudekassir_id=payment.receipt_cloudekassir_id,
receipt_cloudekassir_link=payment.receipt_cloudekassir_link,
itpay_payment_id=payment.itpay_payment_id,
itpay_paid_amount=str(payment.itpay_paid_amount) if payment.itpay_paid_amount is not None else None,
transaction_id=payment.transaction_id,
web3_transaction_hash=payment.web3_transaction_hash,
paid_at=payment.paid_at.isoformat() if payment.paid_at is not None else None,
expired_date=payment.expired_date.isoformat() if payment.expired_date is not None else None,
)
def _order_detail_response(order: OrderEntity, payment: PaymentEntity | None) -> OrderDetailResponse:
return OrderDetailResponse(
status_code=200,
order=_order_response(order),
payment=_payment_response(payment) if payment is not None else None,
)
def _order_with_payment_response(order: OrderEntity, payment: PaymentEntity | None) -> OrderWithPaymentResponse:
return OrderWithPaymentResponse(
order=_order_response(order),
payment=_payment_response(payment) if payment is not None else None,
)
def _order_status_response(order: OrderEntity, payment: PaymentEntity | None) -> OrderStatusResponse:
return OrderStatusResponse(
status_code=200,
order_id=str(order.id),
order_status=order.status,
payment_status=payment.status if payment is not None else None,
receipt_cloudekassir_link=payment.receipt_cloudekassir_link if payment is not None else None,
web3_transaction_hash=payment.web3_transaction_hash if payment is not None else None,
updated_at=payment.updated_at.isoformat() if payment is not None and payment.updated_at is not None else order.updated_at.isoformat() if order.updated_at is not None else None,
)
def _extract_websocket_access_token(websocket: WebSocket) -> str | None:
token = websocket.cookies.get('access_token')
if token:
return token
auth = websocket.headers.get('Authorization')
if auth:
scheme,param = get_authorization_scheme_param(auth)
if scheme.lower() == 'bearer' and param:
return param
query_token = websocket.query_params.get('access_token') or websocket.query_params.get('token')
if query_token:
return str(query_token)
return None
async def _websocket_auth_context(websocket: WebSocket, jwt_service: IJwtService) -> AuthContext | None:
token = _extract_websocket_access_token(websocket)
if not token:
return None
try:
payload: AccessTokenPayload = await jwt_service.decode_access_token(token)
except ApplicationException:
return None
if payload.type != 'access':
return None
return AuthContext(user_id=payload.sub, sid=payload.sid, token=payload)
@order_router.post(
'/create',
response_model=CreateOrderResponse,
status_code=201,
responses=ERROR_RESPONSES,
)
@csrf_protect()
async def create_order(
request: Request,
payment_data: CreateOrder,
auth: AuthContext = Depends(require_access_token),
command: CreateOrderCommand = Depends(get_create_order_command),
logger: ILogger = Depends(get_logger),
) -> CreateOrderResponse:
o = await command(payment_data, auth.user_id)
itpay_error = o.status in (
OrderStatus.CANCELLED,
OrderStatus.REJECTED,
OrderStatus.ERROR,
)
log_ids = {
'event': 'order_create_itpay_failed' if itpay_error else 'order_created',
'order_id': o.id,
'user_id': o.user_id,
'client_payment_id': o.client_payment_id,
'itpay_id': o.itpay_id,
'order_status': o.status.value if o.status is not None else None,
}
logger.info(log_ids)
if itpay_error:
raise ConflictException(message='Payment provider rejected order')
content = CreateOrderResponse(
status_code=201,
order=_order_response(o),
)
return content
@payment_router.get(
'/config',
response_model=PaymentConfigResponse,
status_code=200,
responses=ERROR_RESPONSES,
)
async def payment_config(
command: GetPaymentConfigCommand = Depends(get_payment_config_command),
) -> PaymentConfigResponse:
quote = await command()
return _payment_config_response(quote)
@payment_router.get(
'/quote',
response_model=PaymentQuoteResponse,
status_code=200,
responses=ERROR_RESPONSES,
)
async def payment_quote(
usdt_amount: Decimal = Query(gt=0, decimal_places=2, max_digits=20),
command: GetPaymentQuoteCommand = Depends(get_payment_quote_command),
) -> PaymentQuoteResponse:
quote = await command(usdt_amount)
return _payment_quote_response(quote)
@payment_router.get(
'/quote/rub',
response_model=PaymentQuoteResponse,
status_code=200,
responses=ERROR_RESPONSES,
)
async def payment_quote_from_rub(
total_rub: Decimal = Query(gt=0, decimal_places=2, max_digits=20),
command: GetPaymentQuoteFromRubCommand = Depends(get_payment_quote_from_rub_command),
) -> PaymentQuoteResponse:
quote = await command(total_rub)
return _payment_quote_response(quote)
@payment_router.get(
'/orders',
response_model=OrdersResponse,
status_code=200,
responses=ERROR_RESPONSES,
)
@csrf_protect()
async def payment_list_orders(
request: Request,
limit: int = Query(default=20, ge=1, le=100),
offset: int = Query(default=0, ge=0),
auth: AuthContext = Depends(require_access_token),
command: ListOrdersCommand = Depends(get_list_orders_command),
) -> OrdersResponse:
orders = await command(user_id=auth.user_id,limit=limit,offset=offset)
items = [_order_with_payment_response(item.order,item.payment) for item in orders]
return OrdersResponse(status_code=200,orders=items,limit=limit,offset=offset)
@payment_router.get(
'/payments',
response_model=PaymentsResponse,
status_code=200,
responses=ERROR_RESPONSES,
)
@csrf_protect()
async def payment_list_payments(
request: Request,
limit: int = Query(default=20, ge=1, le=100),
offset: int = Query(default=0, ge=0),
auth: AuthContext = Depends(require_access_token),
command: ListPaymentsCommand = Depends(get_list_payments_command),
) -> PaymentsResponse:
payments = await command(user_id=auth.user_id,limit=limit,offset=offset)
return PaymentsResponse(
status_code=200,
payments=[_payment_response(payment) for payment in payments],
limit=limit,
offset=offset,
)
@payment_router.get(
'/payments/{payment_id}',
response_model=PaymentDetailResponse,
status_code=200,
responses=ERROR_RESPONSES,
)
@csrf_protect()
async def payment_detail(
request: Request,
payment_id: str,
auth: AuthContext = Depends(require_access_token),
command: GetPaymentCommand = Depends(get_payment_command),
) -> PaymentDetailResponse:
payment = await command(payment_id=payment_id,user_id=auth.user_id)
return PaymentDetailResponse(status_code=200,payment=_payment_response(payment))
@orders_router.get(
'/orders',
response_model=OrdersResponse,
status_code=200,
responses=ERROR_RESPONSES,
)
@csrf_protect()
async def list_orders(
request: Request,
limit: int = Query(default=20, ge=1, le=100),
offset: int = Query(default=0, ge=0),
auth: AuthContext = Depends(require_access_token),
command: ListOrdersCommand = Depends(get_list_orders_command),
) -> OrdersResponse:
orders = await command(user_id=auth.user_id,limit=limit,offset=offset)
items = [_order_with_payment_response(item.order,item.payment) for item in orders]
return OrdersResponse(status_code=200,orders=items,limit=limit,offset=offset)
@payments_router.get(
'/payments',
response_model=PaymentsResponse,
status_code=200,
responses=ERROR_RESPONSES,
)
@csrf_protect()
async def list_payments(
request: Request,
limit: int = Query(default=20, ge=1, le=100),
offset: int = Query(default=0, ge=0),
auth: AuthContext = Depends(require_access_token),
command: ListPaymentsCommand = Depends(get_list_payments_command),
) -> PaymentsResponse:
payments = await command(user_id=auth.user_id,limit=limit,offset=offset)
return PaymentsResponse(
status_code=200,
payments=[_payment_response(payment) for payment in payments],
limit=limit,
offset=offset,
)
@order_router.get(
'/{order_id}/status',
response_model=OrderStatusResponse,
status_code=200,
responses=ERROR_RESPONSES,
)
@csrf_protect()
async def order_status(
request: Request,
order_id: str,
auth: AuthContext = Depends(require_access_token),
command: GetOrderStatusCommand = Depends(get_order_status_command),
) -> OrderStatusResponse:
result = await command(order_id=order_id,user_id=auth.user_id)
return _order_status_response(result.order,result.payment)
@order_router.websocket('/{order_id}/events')
async def order_events(
websocket: WebSocket,
order_id: str,
command: GetOrderStatusCommand = Depends(get_order_status_command),
jwt_service: IJwtService = Depends(get_jwt_service),
logger: ILogger = Depends(get_logger),
) -> None:
trace_id = websocket.headers.get('X-Trace-ID') or websocket.headers.get('X-Request-ID') or str(ULID())
token = trace_id_var.set(trace_id)
try:
auth = await _websocket_auth_context(websocket, jwt_service)
if auth is None:
await websocket.close(code=1008)
return
await websocket.accept()
logger.info({'event':'order_events_connected','order_id':order_id,'user_id':auth.user_id})
last_payload: dict | None = None
while True:
try:
result = await command(order_id=order_id,user_id=auth.user_id)
except ApplicationException as exception:
await websocket.send_json({'event':'order_events_error','detail':exception.message,'status_code':exception.status_code})
await websocket.close(code=1008)
return
status_payload = _order_status_response(result.order,result.payment).model_dump(mode='json')
payload = {'event':'order_status','data':status_payload}
if payload != last_payload:
await websocket.send_text(orjson.dumps(payload).decode())
last_payload = payload
await asyncio.sleep(2)
except WebSocketDisconnect:
logger.info({'event':'order_events_disconnected','order_id':order_id})
finally:
trace_id_var.reset(token)
@order_router.get(
'/{order_id}',
response_model=OrderDetailResponse,
status_code=200,
responses=ERROR_RESPONSES,
)
@csrf_protect()
async def order_detail(
request: Request,
order_id: str,
auth: AuthContext = Depends(require_access_token),
command: GetOrderCommand = Depends(get_order_command),
) -> OrderDetailResponse:
result = await command(order_id=order_id,user_id=auth.user_id)
return _order_detail_response(result.order,result.payment)
@order_router.post(
'/webhook/itpay',
status_code=200,
responses=ERROR_RESPONSES,
)
async def itpay_webhook(
request: Request,
payment_command: CreatePaymentCommand = Depends(get_create_payment_command),
logger: ILogger = Depends(get_logger),
) -> ORJSONResponse:
raw = await request.body()
ct = (request.headers.get('content-type') or '').lower()
if 'application/json' in ct:
try:
parsed = json.loads(raw.decode('utf-8'))
except (json.JSONDecodeError, UnicodeDecodeError):
parsed = raw.decode('utf-8', errors='replace')
payload = orjson.loads(raw)
elif 'application/x-www-form-urlencoded' in ct:
decoded = raw.decode('utf-8', errors='replace')
qs = parse_qs(decoded, keep_blank_values=True)
parsed = {k: (vals[0] if len(vals) == 1 else vals) for k, vals in qs.items()}
payload = {k: (vals[0] if len(vals) == 1 else vals) for k, vals in qs.items()}
else:
parsed = raw.decode('utf-8', errors='replace')
payload = orjson.loads(raw)
data = payload.get('data') if isinstance(payload.get('data'), dict) else {}
metadata = data.get('metadata') if isinstance(data.get('metadata'), dict) else {}
trace_id = str(metadata.get('trace_id') or '').strip()
if trace_id:
logger.set_trace_id(trace_id)
status = str(data.get('status') or '').strip().lower()
log_payload = {
'method': request.method,
'url': str(request.url),
'headers': {k: v for k, v in request.headers.items()},
'body': parsed,
'event': 'itpay_webhook_received',
'webhook_id': payload.get('id'),
'webhook_type': payload.get('type'),
'payment_id': data.get('id'),
'client_payment_id': data.get('client_payment_id'),
'payment_status': status,
'itpay_metadata': metadata,
}
logger.info(json.dumps(log_payload, ensure_ascii=False, default=str))
logger.info(log_payload)
if status == 'completed':
payment = ItpayPaymentData.model_validate(data)
await payment_command(payment)
return ORJSONResponse(content={'status': 0})

View 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

View 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

View 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

View 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

View File

@@ -1,6 +1,121 @@
from decimal import Decimal
from pydantic import BaseModel, Field
from pydantic import BaseModel,Field
from src.application.domain.enums import OrderStatus,PaymentStatus
class CreateOrder(BaseModel):
amount_usdt: Decimal = Field(gt=0)
usdt_amount: Decimal = Field(gt=0, decimal_places=2, max_digits=20)
usdt_exchange_rate: Decimal = Field(gt=0, decimal_places=2, max_digits=20)
gas_fee: Decimal = Field(gt=0, decimal_places=2, max_digits=20)
total_price: Decimal = Field(gt=0, decimal_places=2, max_digits=20)
class OrderPaymentResponse(BaseModel):
id: str | None = None
created_at: str | None = None
updated_at: str | None = None
user_id: str | None = None
usdt_amount: str | None = None
usdt_exchange_rate: str | None = None
gas_fee: str | None = None
total_price: str | None = None
service_fee: str | None = None
status: OrderStatus | None = None
client_payment_id: str | None = None
itpay_payment_qr_url_desktop: str | None = None
itpay_payment_qr_url_android: str | None = None
itpay_payment_qr_url_ios: str | None = None
itpay_payment_qr_image_desktop: str | None = None
itpay_payment_qr_image_android: str | None = None
itpay_payment_qr_image_ios: str | None = None
itpay_id: str | None = None
itpay_qr_id: str | None = None
itpay_amount: str | None = None
itpay_created_at: str | None = None
class PaymentResponse(BaseModel):
id: str | None = None
created_at: str | None = None
updated_at: str | None = None
user_id: str | None = None
order_id: str | None = None
status: PaymentStatus | None = None
receipt_cloudekassir_id: str | None = None
receipt_cloudekassir_link: str | None = None
itpay_payment_id: str | None = None
itpay_paid_amount: str | None = None
transaction_id: str | None = None
web3_transaction_hash: str | None = None
paid_at: str | None = None
expired_date: str | None = None
class CreateOrderResponse(BaseModel):
status_code: int
order: OrderPaymentResponse
class PaymentConfigResponse(BaseModel):
status_code: int
usdt_exchange_rate: str
gas_fee: str
service_fee_rate: str
one_usdt_service_fee: str
one_usdt_total_price: str
created_at: str
class PaymentQuoteResponse(BaseModel):
status_code: int
usdt_amount: str
usdt_exchange_rate: str
gas_fee: str
service_fee: str
total_price: str
service_fee_rate: str
created_at: str
class OrderWithPaymentResponse(BaseModel):
order: OrderPaymentResponse
payment: PaymentResponse | None = None
class OrderDetailResponse(OrderWithPaymentResponse):
status_code: int
class OrdersResponse(BaseModel):
status_code: int
orders: list[OrderWithPaymentResponse]
limit: int
offset: int
class PaymentsResponse(BaseModel):
status_code: int
payments: list[PaymentResponse]
limit: int
offset: int
class PaymentDetailResponse(BaseModel):
status_code: int
payment: PaymentResponse
class OrderStatusResponse(BaseModel):
status_code: int
order_id: str
order_status: OrderStatus | None = None
payment_status: PaymentStatus | None = None
receipt_cloudekassir_link: str | None = None
web3_transaction_hash: str | None = None
updated_at: str | None = None
class ErrorResponse(BaseModel):
detail: str
status_code: int