Compare commits
63 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
688ab1d671 | ||
|
|
c1891e5bf3 | ||
|
|
70114b31ec | ||
|
|
bd3b4b19b3 | ||
|
|
910c046089 | ||
|
|
6c5493e3e4 | ||
|
|
1511831937 | ||
| b5df073c67 | |||
| 5ae1d8be34 | |||
| 33b8f1ed8a | |||
| 612963a606 | |||
| 0b7bd166c8 | |||
| 39979f9976 | |||
| 56841b83ce | |||
| 8f60637e8d | |||
| 20e0dc6db6 | |||
| d4c2e7d5be | |||
| 565816e710 | |||
| dc213cb9d9 | |||
| e41e89277f | |||
| 50bfaa9264 | |||
| 366bdc9515 | |||
| 6130188a4f | |||
| 687076e6dc | |||
| 631cd4861a | |||
| bb89aaeee5 | |||
| 4c702b6260 | |||
| 20840c95de | |||
| 07cd454248 | |||
| d4fe062f72 | |||
| e2a1d7e1b4 | |||
| 46b1e336d9 | |||
| 852ee9ec2e | |||
| 489c9cb2da | |||
| 42fcfbff34 | |||
| 1c32bdcb3f | |||
| 3e9625fb86 | |||
| 3f13032be1 | |||
| 10ff2a510d | |||
| c86c4b451b | |||
| 9c8a466789 | |||
| d7ccddc72c | |||
| ad51f1220f | |||
| 766280fd45 | |||
| 499947e44e | |||
| e929133db8 | |||
| 3dfde69a3e | |||
| ea0ca899ac | |||
| 195c0a8e53 | |||
| b6e4f8165f | |||
| be8aee7b73 | |||
| 22f27fa524 | |||
| 152a8ed6ac | |||
| 3e181dc904 | |||
| 45f2949fbc | |||
| bf68aca4fa | |||
| d1ac7e8e84 | |||
| ab772f1f02 | |||
| 64125149be | |||
| 814ba9f318 | |||
| a146a6a3e9 | |||
| 2627354673 | |||
| bea79634b5 |
@@ -21,8 +21,9 @@ COPY --from=builder /app/src /app/src
|
||||
ENV PATH="/app/.venv/bin:$PATH" \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONPATH=/app
|
||||
PYTHONPATH=/app \
|
||||
PROMETHEUS_MULTIPROC_DIR=/tmp/prometheus_multiproc
|
||||
|
||||
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 mkdir -p "$PROMETHEUS_MULTIPROC_DIR" && rm -f "$PROMETHEUS_MULTIPROC_DIR"/* && exec 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
|
||||
|
||||
63
docker-compose.yml
Normal file
63
docker-compose.yml
Normal file
@@ -0,0 +1,63 @@
|
||||
services:
|
||||
pay:
|
||||
container_name: pay-service
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
PYTHONUNBUFFERED: "1"
|
||||
APP_MODULE: "src.main:app"
|
||||
APP_HOST: "0.0.0.0"
|
||||
APP_PORT: "8000"
|
||||
APP_WORKERS: "2"
|
||||
KEYDB_HOST: "pay_keydb"
|
||||
KEYDB_PORT: "${KEYDB_PORT:-6380}"
|
||||
KEYDB_PASSWORD: "${REDIS_PASSWORD}"
|
||||
KEYDB_DB: "${REDIS_DB:-0}"
|
||||
env_file:
|
||||
- .env
|
||||
depends_on:
|
||||
pay_keydb:
|
||||
condition: service_healthy
|
||||
restart: no
|
||||
|
||||
pay_keydb:
|
||||
image: eqalpha/keydb
|
||||
container_name: pay_keydb
|
||||
restart: no
|
||||
expose:
|
||||
- "${KEYDB_PORT:-6380}"
|
||||
volumes:
|
||||
- pay_keydb_data:/data
|
||||
command:
|
||||
- keydb-server
|
||||
- --port
|
||||
- "${KEYDB_PORT:-6380}"
|
||||
- --requirepass
|
||||
- ${REDIS_PASSWORD}
|
||||
- --dir
|
||||
- /data
|
||||
- --appendonly
|
||||
- "yes"
|
||||
- --appendfsync
|
||||
- everysec
|
||||
- --save
|
||||
- "900"
|
||||
- "1"
|
||||
- --save
|
||||
- "300"
|
||||
- "10"
|
||||
- --save
|
||||
- "60"
|
||||
- "10000"
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "-p", "${KEYDB_PORT:-6380}", "-a", "${REDIS_PASSWORD}", "ping"]
|
||||
interval: 5s
|
||||
timeout: 2s
|
||||
retries: 20
|
||||
|
||||
|
||||
volumes:
|
||||
pay_keydb_data:
|
||||
@@ -16,6 +16,7 @@ dependencies = [
|
||||
"hvac==2.4.0",
|
||||
"itsdangerous==2.2.0",
|
||||
"orjson==3.11.7",
|
||||
"prometheus-client==0.23.1",
|
||||
"pydantic-settings==2.12.0",
|
||||
"python-jose==3.5.0",
|
||||
"python-ulid==3.1.0",
|
||||
|
||||
1
src/application/abstractions/__init__.py
Normal file
1
src/application/abstractions/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from src.application.abstractions.i_unit_of_work import IUnitOfWork
|
||||
28
src/application/abstractions/i_unit_of_work.py
Normal file
28
src/application/abstractions/i_unit_of_work.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from __future__ import annotations
|
||||
from typing import Protocol, runtime_checkable
|
||||
from src.application.abstractions.repositories import IOrderRepository,IPaymentRepository,ISbpWithdrawalRepository,IUserRepository,IRiskRepository
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class IUnitOfWork(Protocol):
|
||||
async def __aenter__(self) -> "IUnitOfWork": ...
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: ...
|
||||
|
||||
async def commit(self) -> None: ...
|
||||
async def rollback(self) -> None: ...
|
||||
|
||||
@property
|
||||
def order_repository(self) -> IOrderRepository: ...
|
||||
|
||||
@property
|
||||
def payment_repository(self) -> IPaymentRepository: ...
|
||||
|
||||
@property
|
||||
def sbp_withdrawal_repository(self) -> ISbpWithdrawalRepository: ...
|
||||
|
||||
@property
|
||||
def user_repository(self) -> IUserRepository: ...
|
||||
|
||||
@property
|
||||
def risk_repository(self) -> IRiskRepository: ...
|
||||
|
||||
5
src/application/abstractions/repositories/__init__.py
Normal file
5
src/application/abstractions/repositories/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from src.application.abstractions.repositories.i_order_repository import IOrderRepository
|
||||
from src.application.abstractions.repositories.i_payment_repository import IPaymentRepository
|
||||
from src.application.abstractions.repositories.i_sbp_withdrawal_repository import ISbpWithdrawalRepository
|
||||
from src.application.abstractions.repositories.i_user_repository import IUserRepository
|
||||
from src.application.abstractions.repositories.i_risk_repository import IRiskRepository
|
||||
@@ -0,0 +1,52 @@
|
||||
from abc import ABC,abstractmethod
|
||||
from datetime import datetime
|
||||
|
||||
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
|
||||
@abstractmethod
|
||||
async def count_recent_by_user(self,*,user_id: str,since: datetime) -> int:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@abstractmethod
|
||||
async def update_after_itpay_payment_created(self,order: OrderEntity) -> OrderEntity:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@abstractmethod
|
||||
async def update_after_itpay_failure(self,order: OrderEntity) -> OrderEntity:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@abstractmethod
|
||||
async def update_status(self,*,order_id:str,status:OrderStatus) -> None:
|
||||
raise NotImplementedError
|
||||
@@ -0,0 +1,40 @@
|
||||
from abc import ABC,abstractmethod
|
||||
from src.application.domain.entities import PaymentEntity
|
||||
from src.application.domain.enums import PaymentStatus
|
||||
|
||||
|
||||
class IPaymentRepository(ABC):
|
||||
@abstractmethod
|
||||
async def create_completed(self,*,user_id:str,order_id:str,itpay_payment_id:str,itpay_paid_amount:str|None,transaction_id:str|None,paid_at:str|None,expired_date:str|None) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@abstractmethod
|
||||
async def update_crypto_transfer_completed(self,*,order_id:str,web3_transaction_hash:str|None) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@abstractmethod
|
||||
async def update_status(self,*,order_id:str,status:PaymentStatus) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@abstractmethod
|
||||
async def update_receipt(self,*,order_id:str,receipt_cloudekassir_id:str|None,receipt_cloudekassir_link:str|None) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@abstractmethod
|
||||
async def get_by_order_id(self,order_id:str) -> PaymentEntity | None:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@abstractmethod
|
||||
async def get_by_id_for_user(self,*,payment_id:str,user_id:str) -> PaymentEntity | None:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@abstractmethod
|
||||
async def list_by_user_id(self,*,user_id:str,limit:int,offset:int) -> list[PaymentEntity]:
|
||||
raise NotImplementedError
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from src.application.domain.entities.risk import AuditEventEntity, RiskAssessmentEntity
|
||||
|
||||
|
||||
class IRiskRepository(ABC):
|
||||
@abstractmethod
|
||||
async def create_assessment(self, assessment: RiskAssessmentEntity) -> RiskAssessmentEntity:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def create_audit_event(self, event: AuditEventEntity) -> AuditEventEntity:
|
||||
raise NotImplementedError
|
||||
@@ -0,0 +1,47 @@
|
||||
from abc import ABC,abstractmethod
|
||||
from src.application.domain.entities import SbpWithdrawalEntity
|
||||
from src.application.domain.enums import SbpWithdrawalStatus
|
||||
|
||||
|
||||
class ISbpWithdrawalRepository(ABC):
|
||||
@abstractmethod
|
||||
async def create(self,withdrawal: SbpWithdrawalEntity) -> SbpWithdrawalEntity:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@abstractmethod
|
||||
async def get_by_id(self,withdraw_id: str) -> SbpWithdrawalEntity | None:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@abstractmethod
|
||||
async def get_by_id_for_user(self,*,withdraw_id: str,user_id: str) -> SbpWithdrawalEntity | None:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@abstractmethod
|
||||
async def list_by_user_id(self,*,user_id: str,limit: int,offset: int) -> list[SbpWithdrawalEntity]:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@abstractmethod
|
||||
async def update_status(
|
||||
self,
|
||||
*,
|
||||
withdraw_id: str,
|
||||
status: SbpWithdrawalStatus,
|
||||
provider_error: str | None = None,
|
||||
) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@abstractmethod
|
||||
async def update_payout_created(
|
||||
self,
|
||||
*,
|
||||
withdraw_id: str,
|
||||
mozen_order_id: str | None,
|
||||
status: SbpWithdrawalStatus,
|
||||
provider_error: str | None,
|
||||
) -> None:
|
||||
raise NotImplementedError
|
||||
@@ -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
|
||||
5
src/application/commands/__init__.py
Normal file
5
src/application/commands/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from src.application.commands.create_order_command import CreateOrderCommand
|
||||
from src.application.commands.create_payment_command import CreatePaymentCommand
|
||||
from src.application.commands.create_crypto_transfer_completed_command import CreateCryptoTransferCompletedCommand
|
||||
from src.application.commands.payment_read_commands import ClientOperationResult,GetOrderCommand,GetOrderStatusCommand,GetPaymentCommand,GetPaymentConfigCommand,GetPaymentQuoteCommand,GetPaymentQuoteFromRubCommand,ListClientOperationsCommand,ListOrdersCommand,ListPaymentsCommand,OrderPaymentResult
|
||||
from src.application.commands.sbp_withdrawal_commands import CreateSbpWithdrawalCommand,GetSbpBanksCommand,GetSbpWithdrawalCommand,HandleSbpWithdrawalWalletEventCommand
|
||||
@@ -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,
|
||||
})
|
||||
124
src/application/commands/create_order_command.py
Normal file
124
src/application/commands/create_order_command.py
Normal file
@@ -0,0 +1,124 @@
|
||||
from __future__ import annotations
|
||||
from datetime import datetime, timedelta, 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.entities.risk import AuditEventEntity, RiskAssessmentEntity
|
||||
from src.application.domain.enums import OrderStatus
|
||||
from src.application.domain.exceptions import ApplicationException, ForbiddenException, OrderTotalOutOfRangeException, PriceChangedException
|
||||
from src.application.services import PaymentQuoteService, RiskScoringService
|
||||
from src.application.services.payment_quote_service import MIN_TOTAL_RUB
|
||||
from src.infrastructure.database.decorators import transactional
|
||||
from src.presentation.schemas.order import CreateOrder
|
||||
|
||||
|
||||
class CreateOrderCommand:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
unit_of_work: IUnitOfWork,
|
||||
logger: ILogger,
|
||||
payment_quote_service: PaymentQuoteService,
|
||||
itpay_service: IItPayService,
|
||||
risk_scoring_service: RiskScoringService,
|
||||
) -> None:
|
||||
self._unit_of_work = unit_of_work
|
||||
self._logger = logger
|
||||
self._payment_quote_service = payment_quote_service
|
||||
self._itpay_service = itpay_service
|
||||
self._risk_scoring_service = risk_scoring_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')
|
||||
|
||||
recent_order_count = await self._unit_of_work.order_repository.count_recent_by_user(
|
||||
user_id=user_id,
|
||||
since=datetime.now(timezone.utc) - timedelta(minutes=10),
|
||||
)
|
||||
risk = self._risk_scoring_service.assess_order(
|
||||
user=user,
|
||||
total_price=payment_data.total_price,
|
||||
recent_order_count=recent_order_count,
|
||||
)
|
||||
if risk.decision == 'reject' and 'kyc_not_completed' in risk.reasons:
|
||||
await self._unit_of_work.risk_repository.create_audit_event(AuditEventEntity(
|
||||
actor_type='user',
|
||||
actor_id=user_id,
|
||||
action='payment_blocked_kyc_required',
|
||||
entity_type='user',
|
||||
entity_id=user_id,
|
||||
severity='warning',
|
||||
metadata={'score': risk.score, 'decision': risk.decision, 'reasons': risk.reasons},
|
||||
))
|
||||
raise ForbiddenException(message='KYC verification is required before creating payment orders')
|
||||
|
||||
client_payment_id = str(ULID())
|
||||
|
||||
if payment_data.total_price < MIN_TOTAL_RUB:
|
||||
raise OrderTotalOutOfRangeException(
|
||||
message='Order total is below minimum allowed amount',
|
||||
)
|
||||
|
||||
quote = await self._payment_quote_service.get_reference_quote(payment_data.usdt_amount)
|
||||
actual_gas_fee = quote.gas_fee
|
||||
actual_usdt_exchange_rate = quote.usdt_exchange_rate
|
||||
actual_service_fee = quote.service_fee
|
||||
actual_total_price = quote.total_price
|
||||
if actual_total_price > payment_data.total_price * Decimal('1.01'):
|
||||
self._logger.error('Price has changed, please refresh and try again')
|
||||
raise PriceChangedException()
|
||||
|
||||
order = OrderEntity(
|
||||
user_id=user_id,
|
||||
usdt_amount=payment_data.usdt_amount,
|
||||
usdt_exchange_rate=actual_usdt_exchange_rate,
|
||||
gas_fee=actual_gas_fee,
|
||||
service_fee=actual_service_fee,
|
||||
total_price=actual_total_price,
|
||||
status=OrderStatus.PENDING,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
client_payment_id=client_payment_id,
|
||||
)
|
||||
|
||||
saved = await self._unit_of_work.order_repository.create(order)
|
||||
await self._unit_of_work.risk_repository.create_assessment(RiskAssessmentEntity(
|
||||
user_id=user_id,
|
||||
order_id=saved.id,
|
||||
subject_type=user.account_type,
|
||||
score=risk.score,
|
||||
decision=risk.decision,
|
||||
reasons=risk.reasons,
|
||||
))
|
||||
await self._unit_of_work.risk_repository.create_audit_event(AuditEventEntity(
|
||||
actor_type='user',
|
||||
actor_id=user_id,
|
||||
action='order_risk_assessed',
|
||||
entity_type='order',
|
||||
entity_id=saved.id,
|
||||
severity='warning' if risk.decision != 'allow' else 'info',
|
||||
metadata={'score': risk.score, 'decision': risk.decision, 'reasons': risk.reasons},
|
||||
))
|
||||
if risk.decision in ('manual_review', 'reject'):
|
||||
saved.status = OrderStatus.REJECTED
|
||||
await self._unit_of_work.order_repository.update_status(order_id=saved.id, status=OrderStatus.REJECTED)
|
||||
return saved
|
||||
|
||||
with_itpay = await self._itpay_service.create_payment(saved, self._logger.get_trace_id())
|
||||
if with_itpay.status in (
|
||||
OrderStatus.CANCELLED,
|
||||
OrderStatus.REJECTED,
|
||||
OrderStatus.ERROR,
|
||||
):
|
||||
await self._unit_of_work.order_repository.update_after_itpay_failure(with_itpay)
|
||||
else:
|
||||
await self._unit_of_work.order_repository.update_after_itpay_payment_created(with_itpay)
|
||||
return with_itpay
|
||||
94
src/application/commands/create_payment_command.py
Normal file
94
src/application/commands/create_payment_command.py
Normal file
@@ -0,0 +1,94 @@
|
||||
from __future__ import annotations
|
||||
from ulid import ULID
|
||||
from src.application.abstractions import IUnitOfWork
|
||||
from src.application.contracts import ILogger,IQueueMessanger
|
||||
from src.application.domain.enums import OrderStatus
|
||||
from src.application.domain.exceptions import PaymentMetadataException
|
||||
from src.infrastructure.config import settings
|
||||
from src.infrastructure.database.decorators import transactional
|
||||
from src.presentation.schemas.itpay_payment_models import ItpayPaymentData
|
||||
|
||||
|
||||
class CreatePaymentCommand:
|
||||
def __init__(self, *, unit_of_work: IUnitOfWork, logger: ILogger, queue_messanger: IQueueMessanger):
|
||||
self._unit_of_work = unit_of_work
|
||||
self._logger = logger
|
||||
self._queue_messanger = queue_messanger
|
||||
|
||||
|
||||
async def _mark_order_completed(self, order_id: str, user_id: str) -> None:
|
||||
await self._unit_of_work.order_repository.update_status(
|
||||
order_id=order_id,
|
||||
status=OrderStatus.COMPLETED,
|
||||
)
|
||||
self._logger.info({
|
||||
'event': 'order_status_changed',
|
||||
'order_id': order_id,
|
||||
'user_id': user_id,
|
||||
'status': OrderStatus.COMPLETED.value,
|
||||
})
|
||||
|
||||
|
||||
@transactional
|
||||
async def __call__(self, payment: ItpayPaymentData) -> None:
|
||||
if str(payment.status).strip().lower() != 'completed':
|
||||
return
|
||||
metadata = payment.metadata or {}
|
||||
order_id = str(metadata.get('order_id') or '')
|
||||
user_id = str(metadata.get('user_id') or '')
|
||||
trace_id = str(metadata.get('trace_id') or self._logger.get_trace_id())
|
||||
self._logger.set_trace_id(trace_id)
|
||||
if not order_id:
|
||||
raise PaymentMetadataException(message='Itpay webhook metadata missing order_id')
|
||||
if not user_id:
|
||||
raise PaymentMetadataException(message='Itpay webhook metadata missing user_id')
|
||||
payment_created = await self._unit_of_work.payment_repository.create_completed(
|
||||
user_id=user_id,
|
||||
order_id=order_id,
|
||||
itpay_payment_id=str(payment.id),
|
||||
itpay_paid_amount=str(payment.amount) if payment.amount is not None else None,
|
||||
transaction_id=str(payment.transaction_id) if payment.transaction_id is not None else None,
|
||||
paid_at=str(payment.paid) if payment.paid is not None else None,
|
||||
expired_date=str(payment.expired_date) if payment.expired_date is not None else None,
|
||||
)
|
||||
if not payment_created:
|
||||
await self._mark_order_completed(order_id, user_id)
|
||||
return
|
||||
await self._mark_order_completed(order_id, user_id)
|
||||
self._logger.info({
|
||||
'event': 'payment_money_accepted',
|
||||
'order_id': order_id,
|
||||
'user_id': user_id,
|
||||
})
|
||||
message_id = str(ULID())
|
||||
message: dict[str,str] = {
|
||||
'order_id': order_id,
|
||||
'user_id': user_id,
|
||||
'trace_id': trace_id,
|
||||
'message_id': message_id,
|
||||
}
|
||||
try:
|
||||
await self._queue_messanger.publish_to_queue(
|
||||
queue=settings.RABBIT_CRYPTO_TRANSFER_QUEUE,
|
||||
message=message,
|
||||
message_id=message_id,
|
||||
correlation_id=message['trace_id'],
|
||||
headers={'trace_id': trace_id},
|
||||
)
|
||||
except Exception as exception:
|
||||
self._logger.error({
|
||||
'event': 'crypto_transfer_message_publish_failed',
|
||||
'queue': settings.RABBIT_CRYPTO_TRANSFER_QUEUE,
|
||||
'order_id': order_id,
|
||||
'user_id': user_id,
|
||||
'message_id': message_id,
|
||||
'error': str(exception),
|
||||
})
|
||||
raise
|
||||
self._logger.info({
|
||||
'event': 'crypto_transfer_message_published',
|
||||
'queue': settings.RABBIT_CRYPTO_TRANSFER_QUEUE,
|
||||
'order_id': order_id,
|
||||
'user_id': user_id,
|
||||
'message_id': message_id,
|
||||
})
|
||||
197
src/application/commands/payment_read_commands.py
Normal file
197
src/application/commands/payment_read_commands.py
Normal file
@@ -0,0 +1,197 @@
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime,timezone
|
||||
from decimal import Decimal
|
||||
from typing import Literal
|
||||
from src.application.abstractions import IUnitOfWork
|
||||
from src.application.contracts import ILogger
|
||||
from src.application.domain.entities import OrderEntity,PaymentEntity,SbpWithdrawalEntity
|
||||
from src.application.domain.exceptions import NotFoundException
|
||||
from src.application.services import PaymentQuote,PaymentQuoteService
|
||||
from src.infrastructure.database.decorators import transactional
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class OrderPaymentResult:
|
||||
order: OrderEntity
|
||||
payment: PaymentEntity | None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ClientOperationResult:
|
||||
type: Literal['payment','withdrawal']
|
||||
created_at: datetime
|
||||
payment: PaymentEntity | None = None
|
||||
withdrawal: SbpWithdrawalEntity | None = None
|
||||
|
||||
|
||||
def _parse_cursor(cursor: str | None) -> datetime | None:
|
||||
if not cursor:
|
||||
return None
|
||||
parsed = datetime.fromisoformat(cursor.replace('Z','+00:00'))
|
||||
if parsed.tzinfo is None:
|
||||
return parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed
|
||||
|
||||
|
||||
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, cursor: str | None = None) -> list[OrderPaymentResult]:
|
||||
orders = await self._unit_of_work.order_repository.list_by_user_id(
|
||||
user_id=user_id,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
cursor_created_at=_parse_cursor(cursor),
|
||||
)
|
||||
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,'cursor':cursor})
|
||||
return items
|
||||
|
||||
|
||||
class ListPaymentsCommand:
|
||||
def __init__(self, *, unit_of_work: IUnitOfWork, logger: ILogger):
|
||||
self._unit_of_work = unit_of_work
|
||||
self._logger = logger
|
||||
|
||||
|
||||
@transactional
|
||||
async def __call__(self, *, user_id: str, limit: int, offset: int) -> list[PaymentEntity]:
|
||||
payments = await self._unit_of_work.payment_repository.list_by_user_id(
|
||||
user_id=user_id,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
self._logger.info({'event':'payments_list_requested','user_id':user_id,'limit':limit,'offset':offset})
|
||||
return payments
|
||||
|
||||
|
||||
class ListClientOperationsCommand:
|
||||
def __init__(self, *, unit_of_work: IUnitOfWork, logger: ILogger):
|
||||
self._unit_of_work = unit_of_work
|
||||
self._logger = logger
|
||||
|
||||
|
||||
@transactional
|
||||
async def __call__(self, *, user_id: str, limit: int, offset: int, cursor: str | None = None) -> list[ClientOperationResult]:
|
||||
cursor_created_at=_parse_cursor(cursor)
|
||||
fetch_limit=limit if cursor_created_at is not None else limit+offset
|
||||
payments=await self._unit_of_work.payment_repository.list_by_user_id(
|
||||
user_id=user_id,
|
||||
limit=fetch_limit,
|
||||
offset=0,
|
||||
cursor_created_at=cursor_created_at,
|
||||
)
|
||||
withdrawals=await self._unit_of_work.sbp_withdrawal_repository.list_by_user_id(
|
||||
user_id=user_id,
|
||||
limit=fetch_limit,
|
||||
offset=0,
|
||||
cursor_created_at=cursor_created_at,
|
||||
)
|
||||
min_dt=datetime.min.replace(tzinfo=timezone.utc)
|
||||
items=[
|
||||
ClientOperationResult(type='payment',created_at=payment.created_at or min_dt,payment=payment)
|
||||
for payment in payments
|
||||
]
|
||||
items.extend(
|
||||
ClientOperationResult(type='withdrawal',created_at=withdrawal.created_at or min_dt,withdrawal=withdrawal)
|
||||
for withdrawal in withdrawals
|
||||
)
|
||||
items.sort(key=lambda item:item.created_at,reverse=True)
|
||||
self._logger.info({'event':'client_operations_list_requested','user_id':user_id,'limit':limit,'offset':offset,'cursor':cursor})
|
||||
return items[:limit] if cursor_created_at is not None else items[offset:offset+limit]
|
||||
|
||||
|
||||
class GetPaymentCommand:
|
||||
def __init__(self, *, unit_of_work: IUnitOfWork, logger: ILogger):
|
||||
self._unit_of_work = unit_of_work
|
||||
self._logger = logger
|
||||
|
||||
|
||||
@transactional
|
||||
async def __call__(self, *, payment_id: str, user_id: str) -> PaymentEntity:
|
||||
payment = await self._unit_of_work.payment_repository.get_by_id_for_user(
|
||||
payment_id=payment_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
if payment is None:
|
||||
raise NotFoundException(message='Payment not found')
|
||||
self._logger.info({'event':'payment_detail_requested','payment_id':payment_id,'user_id':user_id})
|
||||
return payment
|
||||
|
||||
|
||||
class GetOrderCommand:
|
||||
def __init__(self, *, unit_of_work: IUnitOfWork, logger: ILogger):
|
||||
self._unit_of_work = unit_of_work
|
||||
self._logger = logger
|
||||
|
||||
|
||||
@transactional
|
||||
async def __call__(self, *, order_id: str, user_id: str) -> OrderPaymentResult:
|
||||
order = await self._unit_of_work.order_repository.get_by_id_for_user(order_id=order_id,user_id=user_id)
|
||||
if order is None:
|
||||
raise NotFoundException(message='Order not found')
|
||||
payment = await self._unit_of_work.payment_repository.get_by_order_id(order_id)
|
||||
self._logger.info({'event':'order_detail_requested','order_id':order_id,'user_id':user_id})
|
||||
return OrderPaymentResult(order=order,payment=payment)
|
||||
|
||||
|
||||
class GetOrderStatusCommand:
|
||||
def __init__(self, *, unit_of_work: IUnitOfWork, logger: ILogger):
|
||||
self._unit_of_work = unit_of_work
|
||||
self._logger = logger
|
||||
|
||||
|
||||
@transactional
|
||||
async def __call__(self, *, order_id: str, user_id: str) -> OrderPaymentResult:
|
||||
order = await self._unit_of_work.order_repository.get_by_id_for_user(order_id=order_id,user_id=user_id)
|
||||
if order is None:
|
||||
raise NotFoundException(message='Order not found')
|
||||
payment = await self._unit_of_work.payment_repository.get_by_order_id(order_id)
|
||||
self._logger.info({'event':'order_status_requested','order_id':order_id,'user_id':user_id})
|
||||
return OrderPaymentResult(order=order,payment=payment)
|
||||
271
src/application/commands/sbp_withdrawal_commands.py
Normal file
271
src/application/commands/sbp_withdrawal_commands.py
Normal file
@@ -0,0 +1,271 @@
|
||||
from __future__ import annotations
|
||||
from datetime import datetime,timezone
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
import re
|
||||
from ulid import ULID
|
||||
from src.application.abstractions import IUnitOfWork
|
||||
from src.application.contracts import IQueueMessanger,ILogger,IMozenSbpService,SbpBank
|
||||
from src.application.domain.entities import SbpWithdrawalEntity
|
||||
from src.application.domain.enums import SbpWithdrawalStatus
|
||||
from src.application.domain.exceptions import BadRequestException,NotFoundException,PaymentProviderException,ServiceUnavailableException
|
||||
from src.application.services import SbpWithdrawalQuoteService
|
||||
from src.infrastructure.config import settings
|
||||
from src.infrastructure.database.decorators import transactional
|
||||
|
||||
|
||||
class GetSbpBanksCommand:
|
||||
def __init__(self,*,mozen_sbp_service: IMozenSbpService,logger: ILogger):
|
||||
self._mozen_sbp_service=mozen_sbp_service
|
||||
self._logger=logger
|
||||
|
||||
|
||||
async def __call__(self) -> list[SbpBank]:
|
||||
return await self._mozen_sbp_service.get_banks(self._logger.get_trace_id())
|
||||
|
||||
|
||||
class CreateSbpWithdrawalCommand:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
unit_of_work: IUnitOfWork,
|
||||
logger: ILogger,
|
||||
queue_messanger: IQueueMessanger,
|
||||
mozen_sbp_service: IMozenSbpService,
|
||||
quote_service: SbpWithdrawalQuoteService,
|
||||
):
|
||||
self._unit_of_work=unit_of_work
|
||||
self._logger=logger
|
||||
self._queue_messanger=queue_messanger
|
||||
self._mozen_sbp_service=mozen_sbp_service
|
||||
self._quote_service=quote_service
|
||||
|
||||
|
||||
async def _bank_name(self,bank_id: str) -> str:
|
||||
banks=await self._mozen_sbp_service.get_banks(self._logger.get_trace_id())
|
||||
for bank in banks:
|
||||
if bank.id==bank_id:
|
||||
return bank.name
|
||||
raise BadRequestException(message='SBP bank not found')
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _normalize_russian_phone(phone: str | None) -> str:
|
||||
digits=re.sub(r'\D','',phone or '')
|
||||
if len(digits)!=11:
|
||||
raise BadRequestException(message='Russian phone number is required for SBP withdrawal')
|
||||
if digits.startswith('8'):
|
||||
digits=f'7{digits[1:]}'
|
||||
if not digits.startswith('7'):
|
||||
raise BadRequestException(message='Russian phone number is required for SBP withdrawal')
|
||||
return f'+{digits}'
|
||||
|
||||
|
||||
@transactional
|
||||
async def __call__(self,*,user_id: str,bank_id: str,usdt_amount: Decimal) -> SbpWithdrawalEntity:
|
||||
user=await self._unit_of_work.user_repository.get(user_id)
|
||||
if user is None:
|
||||
raise NotFoundException(message='User not found')
|
||||
phone=self._normalize_russian_phone(user.phone)
|
||||
wallet_address=str(settings.CRYPTO_USDT_CONTRACT_ADDRESS or '').strip()
|
||||
if not wallet_address:
|
||||
raise ServiceUnavailableException(message='USDT contract address unavailable')
|
||||
sender_wallet_address=str(user.erc20 or '').strip()
|
||||
if not sender_wallet_address:
|
||||
raise BadRequestException(message='User ERC20 wallet address is required for SBP withdrawal')
|
||||
trace_id=self._logger.get_trace_id()
|
||||
withdraw_id=str(ULID())
|
||||
bank_name=await self._bank_name(bank_id)
|
||||
quote=await self._quote_service.get_quote(usdt_amount)
|
||||
withdrawal=SbpWithdrawalEntity(
|
||||
id=withdraw_id,
|
||||
user_id=user_id,
|
||||
bank_id=bank_id,
|
||||
bank_name=bank_name,
|
||||
phone=phone,
|
||||
usdt_amount=quote.usdt_amount,
|
||||
rub_amount=quote.rub_amount,
|
||||
usdt_exchange_rate=quote.usdt_exchange_rate,
|
||||
service_fee_rate=quote.service_fee_rate,
|
||||
service_fee_usdt=quote.service_fee_usdt,
|
||||
trace_id=trace_id,
|
||||
status=SbpWithdrawalStatus.WAITING_USDT,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
saved=await self._unit_of_work.sbp_withdrawal_repository.create(withdrawal)
|
||||
message_id=str(ULID())
|
||||
message: dict[str,Any]={
|
||||
'user_id':user_id,
|
||||
'usdt_amount':str(quote.usdt_amount),
|
||||
'trace_id':trace_id,
|
||||
'wallet_address':wallet_address,
|
||||
'sender_wallet_address':sender_wallet_address,
|
||||
'receiver_wallet_address':wallet_address,
|
||||
'withdraw_id':withdraw_id,
|
||||
'message_id':message_id,
|
||||
}
|
||||
await self._queue_messanger.publish_to_queue(
|
||||
queue=settings.RABBIT_SBP_WITHDRAWAL_USDT_REQUESTED_QUEUE,
|
||||
message=message,
|
||||
message_id=message_id,
|
||||
correlation_id=trace_id,
|
||||
headers={'trace_id':trace_id},
|
||||
)
|
||||
self._logger.info({
|
||||
'event':'sbp_withdrawal_usdt_request_published',
|
||||
'withdraw_id':withdraw_id,
|
||||
'user_id':user_id,
|
||||
'message_id':message_id,
|
||||
})
|
||||
return saved
|
||||
|
||||
|
||||
class GetSbpWithdrawalCommand:
|
||||
def __init__(self,*,unit_of_work: IUnitOfWork,logger: ILogger):
|
||||
self._unit_of_work=unit_of_work
|
||||
self._logger=logger
|
||||
|
||||
|
||||
@transactional
|
||||
async def __call__(self,*,withdraw_id: str,user_id: str) -> SbpWithdrawalEntity:
|
||||
withdrawal=await self._unit_of_work.sbp_withdrawal_repository.get_by_id_for_user(
|
||||
withdraw_id=withdraw_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
if withdrawal is None:
|
||||
raise NotFoundException(message='SBP withdrawal not found')
|
||||
return withdrawal
|
||||
|
||||
|
||||
class HandleSbpWithdrawalWalletEventCommand:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
unit_of_work: IUnitOfWork,
|
||||
logger: ILogger,
|
||||
mozen_sbp_service: IMozenSbpService,
|
||||
):
|
||||
self._unit_of_work=unit_of_work
|
||||
self._logger=logger
|
||||
self._mozen_sbp_service=mozen_sbp_service
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _event_error(
|
||||
*,
|
||||
event_type: str,
|
||||
received_usdt_amount: Decimal | None,
|
||||
tx_hash: str | None,
|
||||
error: str | None,
|
||||
) -> str | None:
|
||||
parts=(
|
||||
f'event_type={event_type}',
|
||||
f'received_usdt_amount={received_usdt_amount}' if received_usdt_amount is not None else '',
|
||||
f'tx_hash={tx_hash}' if tx_hash else '',
|
||||
f'error={error}' if error else '',
|
||||
)
|
||||
value=';'.join(part for part in parts if part)
|
||||
return value or None
|
||||
|
||||
|
||||
@transactional
|
||||
async def __call__(
|
||||
self,
|
||||
*,
|
||||
withdraw_id: str,
|
||||
user_id: str,
|
||||
event_type: str,
|
||||
received_usdt_amount: Decimal | None = None,
|
||||
tx_hash: str | None = None,
|
||||
error: str | None = None,
|
||||
) -> None:
|
||||
withdrawal=await self._unit_of_work.sbp_withdrawal_repository.get_by_id(withdraw_id)
|
||||
if withdrawal is None:
|
||||
raise NotFoundException(message='SBP withdrawal not found')
|
||||
if withdrawal.user_id!=user_id:
|
||||
raise BadRequestException(message='SBP withdrawal user mismatch')
|
||||
normalized_event_type=event_type.strip().lower()
|
||||
if withdrawal.status in (
|
||||
SbpWithdrawalStatus.PAYOUT_PROCESSING,
|
||||
SbpWithdrawalStatus.PAYOUT_COMPLETED,
|
||||
SbpWithdrawalStatus.PAYOUT_FAILED,
|
||||
SbpWithdrawalStatus.CANCELLED,
|
||||
SbpWithdrawalStatus.EXPIRED,
|
||||
):
|
||||
self._logger.info({
|
||||
'event':'sbp_withdrawal_wallet_event_ignored',
|
||||
'withdraw_id':withdraw_id,
|
||||
'user_id':user_id,
|
||||
'wallet_event_type':normalized_event_type,
|
||||
'status':withdrawal.status.value if withdrawal.status is not None else None,
|
||||
})
|
||||
return
|
||||
provider_error=self._event_error(
|
||||
event_type=normalized_event_type,
|
||||
received_usdt_amount=received_usdt_amount,
|
||||
tx_hash=tx_hash,
|
||||
error=error,
|
||||
)
|
||||
if normalized_event_type in ('usdt_underpaid','underpaid'):
|
||||
await self._unit_of_work.sbp_withdrawal_repository.update_status(
|
||||
withdraw_id=withdraw_id,
|
||||
status=SbpWithdrawalStatus.USDT_UNDERPAID,
|
||||
provider_error=provider_error,
|
||||
)
|
||||
return
|
||||
if normalized_event_type in ('expired','usdt_expired'):
|
||||
await self._unit_of_work.sbp_withdrawal_repository.update_status(
|
||||
withdraw_id=withdraw_id,
|
||||
status=SbpWithdrawalStatus.EXPIRED,
|
||||
provider_error=provider_error,
|
||||
)
|
||||
return
|
||||
if normalized_event_type in ('failed','wallet_error','watch_failed','error'):
|
||||
await self._unit_of_work.sbp_withdrawal_repository.update_status(
|
||||
withdraw_id=withdraw_id,
|
||||
status=SbpWithdrawalStatus.WALLET_ERROR,
|
||||
provider_error=provider_error,
|
||||
)
|
||||
return
|
||||
if normalized_event_type not in ('usdt_received','received','confirmed'):
|
||||
raise BadRequestException(message='Unknown SBP withdrawal wallet event type')
|
||||
await self._unit_of_work.sbp_withdrawal_repository.update_status(
|
||||
withdraw_id=withdraw_id,
|
||||
status=SbpWithdrawalStatus.USDT_RECEIVED,
|
||||
provider_error=provider_error,
|
||||
)
|
||||
try:
|
||||
result=await self._mozen_sbp_service.make_withdrawal(
|
||||
user_id=user_id,
|
||||
withdraw_id=withdraw_id,
|
||||
phone=str(withdrawal.phone or ''),
|
||||
bank_id=str(withdrawal.bank_id or ''),
|
||||
amount=Decimal('1'),
|
||||
trace_id=str(withdrawal.trace_id or self._logger.get_trace_id()),
|
||||
)
|
||||
except PaymentProviderException as exception:
|
||||
await self._unit_of_work.sbp_withdrawal_repository.update_status(
|
||||
withdraw_id=withdraw_id,
|
||||
status=SbpWithdrawalStatus.PAYOUT_FAILED,
|
||||
provider_error=exception.message,
|
||||
)
|
||||
return
|
||||
status=SbpWithdrawalStatus.PAYOUT_PROCESSING
|
||||
provider_error=result.error
|
||||
if result.error:
|
||||
status=SbpWithdrawalStatus.PAYOUT_FAILED
|
||||
elif result.status.strip().lower() in ('completed','success','succeeded','paid'):
|
||||
status=SbpWithdrawalStatus.PAYOUT_COMPLETED
|
||||
await self._unit_of_work.sbp_withdrawal_repository.update_payout_created(
|
||||
withdraw_id=withdraw_id,
|
||||
mozen_order_id=result.mozen_order_id,
|
||||
status=status,
|
||||
provider_error=provider_error,
|
||||
)
|
||||
self._logger.info({
|
||||
'event':'sbp_withdrawal_payout_created',
|
||||
'withdraw_id':withdraw_id,
|
||||
'user_id':user_id,
|
||||
'mozen_order_id':result.mozen_order_id,
|
||||
'status':status.value,
|
||||
})
|
||||
@@ -4,3 +4,6 @@ from src.application.contracts.i_csrf_service import ICsrfService
|
||||
from src.application.contracts.i_cache import ICache
|
||||
from src.application.contracts.i_hash_service import IHashService
|
||||
from src.application.contracts.i_queue_messanger import IQueueMessanger
|
||||
from src.application.contracts.i_itpay_service import IItPayService
|
||||
from src.application.contracts.i_receipt import IReceipt
|
||||
from src.application.contracts.i_mozen_sbp_service import IMozenSbpService,SbpBank,SbpPayoutResult
|
||||
@@ -17,6 +17,10 @@ class ICache(ABC):
|
||||
async def get(self, key: str) -> str | None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def hget(self, key: str, field: str) -> str | None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def delete(self, key: str) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
11
src/application/contracts/i_itpay_service.py
Normal file
11
src/application/contracts/i_itpay_service.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from abc import ABC,abstractmethod
|
||||
|
||||
from src.application.domain.entities.order import OrderEntity
|
||||
|
||||
|
||||
class IItPayService(ABC):
|
||||
|
||||
|
||||
@abstractmethod
|
||||
async def create_payment(self,order: OrderEntity,trace_id: str) -> OrderEntity:
|
||||
pass
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import Protocol, Optional, Callable
|
||||
from typing import Any,Callable,Optional,Protocol
|
||||
from src.application.domain.enums.log_format import LogFormat
|
||||
from src.application.domain.enums.log_level import LogLevel
|
||||
|
||||
@@ -43,26 +43,26 @@ class ILogger(Protocol):
|
||||
"""Get current service instance id"""
|
||||
...
|
||||
|
||||
def debug(self, message: str) -> None:
|
||||
def debug(self, message: Any) -> None:
|
||||
"""Log debug message"""
|
||||
...
|
||||
|
||||
def info(self, message: str) -> None:
|
||||
def info(self, message: Any) -> None:
|
||||
"""Log info message"""
|
||||
...
|
||||
|
||||
def warning(self, message: str) -> None:
|
||||
def warning(self, message: Any) -> None:
|
||||
"""Log warning message"""
|
||||
...
|
||||
|
||||
def error(self, message: str) -> None:
|
||||
def error(self, message: Any) -> None:
|
||||
"""Log error message"""
|
||||
...
|
||||
|
||||
def critical(self, message: str) -> None:
|
||||
def critical(self, message: Any) -> None:
|
||||
"""Log critical message"""
|
||||
...
|
||||
|
||||
def exception(self, message: str) -> None:
|
||||
def exception(self, message: Any) -> None:
|
||||
"""Log exception with traceback"""
|
||||
...
|
||||
36
src/application/contracts/i_mozen_sbp_service.py
Normal file
36
src/application/contracts/i_mozen_sbp_service.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from abc import ABC,abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SbpBank:
|
||||
id: str
|
||||
name: str
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SbpPayoutResult:
|
||||
status: str
|
||||
mozen_order_id: str | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class IMozenSbpService(ABC):
|
||||
@abstractmethod
|
||||
async def get_banks(self,trace_id: str) -> list[SbpBank]:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@abstractmethod
|
||||
async def make_withdrawal(
|
||||
self,
|
||||
*,
|
||||
user_id: str,
|
||||
withdraw_id: str,
|
||||
phone: str,
|
||||
bank_id: str,
|
||||
amount: Decimal,
|
||||
trace_id: str,
|
||||
) -> SbpPayoutResult:
|
||||
raise NotImplementedError
|
||||
25
src/application/contracts/i_receipt.py
Normal file
25
src/application/contracts/i_receipt.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from abc import ABC,abstractmethod
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
|
||||
class IReceipt(ABC):
|
||||
@abstractmethod
|
||||
async def create_receipt(
|
||||
self,
|
||||
*,
|
||||
order_id: str,
|
||||
user_id: str,
|
||||
email: str,
|
||||
total_amount: Decimal,
|
||||
principal_amount: Decimal,
|
||||
service_fee: Decimal,
|
||||
customer_info: str = '',
|
||||
customer_inn: str = '',
|
||||
customer_birthday: str = '',
|
||||
success_url: str | None = None,
|
||||
fail_url: str | None = None,
|
||||
request_id: str | None = None,
|
||||
) -> dict[str,Any]:
|
||||
raise NotImplementedError
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from src.application.domain.entities.user import UserEntity
|
||||
from src.application.domain.entities.session import SessionEntity
|
||||
from src.application.domain.entities.order import OrderEntity
|
||||
from src.application.domain.entities.payment import PaymentEntity
|
||||
from src.application.domain.entities.sbp_withdrawal import SbpWithdrawalEntity
|
||||
|
||||
|
||||
__all__ = ['UserEntity', 'SessionEntity']
|
||||
__all__ = ['PaymentEntity','OrderEntity','SbpWithdrawalEntity']
|
||||
36
src/application/domain/entities/order.py
Normal file
36
src/application/domain/entities/order.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from src.application.domain.enums import OrderStatus
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class OrderEntity:
|
||||
id: str | None = None
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
user_id: str | None = None
|
||||
usdt_amount: Decimal | None = None
|
||||
usdt_exchange_rate: Decimal | None = None
|
||||
gas_fee: Decimal | None = None
|
||||
total_price: Decimal | None = None
|
||||
service_fee: Decimal | None = None
|
||||
status: OrderStatus | None = None
|
||||
|
||||
client_payment_id: str | None = None
|
||||
|
||||
itpay_payment_qr_url_desktop: str | None = None
|
||||
itpay_payment_qr_url_android: str | None = None
|
||||
itpay_payment_qr_url_ios: str | None = None
|
||||
|
||||
itpay_payment_qr_image_desktop: str | None = None
|
||||
itpay_payment_qr_image_android: str | None = None
|
||||
itpay_payment_qr_image_ios: str | None = None
|
||||
|
||||
itpay_id: str | None = None
|
||||
itpay_qr_id: str | None = None
|
||||
itpay_amount: Decimal | None = None
|
||||
itpay_created_at: datetime | None = None
|
||||
|
||||
27
src/application/domain/entities/payment.py
Normal file
27
src/application/domain/entities/payment.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from src.application.domain.enums import PaymentStatus
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class PaymentEntity:
|
||||
id: str | None = None
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
user_id: str | None = None
|
||||
|
||||
order_id: str | None = None
|
||||
status: PaymentStatus | None = None
|
||||
|
||||
receipt_cloudekassir_id: str | None = None
|
||||
receipt_cloudekassir_link: str | None = None
|
||||
|
||||
itpay_payment_id: str | None = None
|
||||
itpay_paid_amount: Decimal | None = None
|
||||
transaction_id: str | None = None
|
||||
web3_transaction_hash: str | None = None
|
||||
paid_at: datetime | None = None
|
||||
expired_date: datetime | None = None
|
||||
30
src/application/domain/entities/risk.py
Normal file
30
src/application/domain/entities/risk.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RiskAssessmentEntity:
|
||||
id: str | None = None
|
||||
user_id: str | None = None
|
||||
order_id: str | None = None
|
||||
subject_type: str = "individual"
|
||||
score: int = 0
|
||||
decision: str = "allow"
|
||||
reasons: list[str] = field(default_factory=list)
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AuditEventEntity:
|
||||
id: str | None = None
|
||||
actor_type: str = "system"
|
||||
actor_id: str | None = None
|
||||
action: str = ""
|
||||
entity_type: str = ""
|
||||
entity_id: str | None = None
|
||||
severity: str = "info"
|
||||
metadata: dict | None = None
|
||||
created_at: datetime | None = None
|
||||
29
src/application/domain/entities/sbp_withdrawal.py
Normal file
29
src/application/domain/entities/sbp_withdrawal.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from src.application.domain.enums import SbpWithdrawalStatus
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SbpWithdrawalEntity:
|
||||
id: str | None = None
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
user_id: str | None = None
|
||||
bank_id: str | None = None
|
||||
bank_name: str | None = None
|
||||
phone: str | None = None
|
||||
usdt_amount: Decimal | None = None
|
||||
rub_amount: Decimal | None = None
|
||||
usdt_exchange_rate: Decimal | None = None
|
||||
service_fee_rate: Decimal | None = None
|
||||
service_fee_usdt: Decimal | None = None
|
||||
mozen_order_id: str | None = None
|
||||
trace_id: str | None = None
|
||||
status: SbpWithdrawalStatus | None = None
|
||||
provider_error: str | None = None
|
||||
usdt_received_at: datetime | None = None
|
||||
payout_created_at: datetime | None = None
|
||||
payout_completed_at: datetime | None = None
|
||||
@@ -14,13 +14,14 @@ class UserEntity:
|
||||
last_name: str | None = None
|
||||
birth_date: date | None = None
|
||||
|
||||
crypto_wallet: str | None = None
|
||||
encrypted_mnemonic: str | None = None
|
||||
phone: str | None = None
|
||||
|
||||
bik: str | None = None
|
||||
account_number: str | None = None
|
||||
card_number: str | None = None
|
||||
passport_data: str | None = None
|
||||
inn: str | None = None
|
||||
erc20: str | None = None
|
||||
|
||||
avatar_link: str | None = None
|
||||
|
||||
kyc_verified: bool | None = None
|
||||
is_deleted: bool | None = None
|
||||
@@ -28,3 +29,5 @@ class UserEntity:
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
kyc_verified_at: datetime | None = None
|
||||
|
||||
account_type: str = 'individual'
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
from src.application.domain.enums.log_level import LogLevel
|
||||
from src.application.domain.enums.log_format import LogFormat
|
||||
from src.application.domain.enums.itpay_payment_status import ItPayPaymentStatus
|
||||
from src.application.domain.enums.order_status import OrderStatus
|
||||
from src.application.domain.enums.payment_status import PaymentStatus
|
||||
from src.application.domain.enums.sbp_withdrawal_status import SbpWithdrawalStatus
|
||||
7
src/application/domain/enums/itpay_payment_status.py
Normal file
7
src/application/domain/enums/itpay_payment_status.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class ItPayPaymentStatus(Enum):
|
||||
COMPLETED = "payment.completed"
|
||||
REJECTED = "payment.rejected"
|
||||
CANCELLED = "payment.canceled"
|
||||
9
src/application/domain/enums/order_status.py
Normal file
9
src/application/domain/enums/order_status.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class OrderStatus(str, Enum):
|
||||
PENDING = 'pending'
|
||||
REJECTED = 'rejected'
|
||||
COMPLETED = 'completed'
|
||||
CANCELLED = 'cancelled'
|
||||
ERROR = 'error'
|
||||
13
src/application/domain/enums/payment_status.py
Normal file
13
src/application/domain/enums/payment_status.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class PaymentStatus(str,Enum):
|
||||
PENDING='pending'
|
||||
MONEY_ACCEPTED='money_accepted'
|
||||
WEB3_PROCESSING='web3_processing'
|
||||
WEB3_HASH_ERROR='web3_hash_error'
|
||||
WEB3_BALANCE_PROBLEM='web3_balance_problem'
|
||||
USDT_DELIVERED='usdt_delivered'
|
||||
RECEIPT_ERROR='receipt_error'
|
||||
COMPLETED='completed'
|
||||
|
||||
14
src/application/domain/enums/sbp_withdrawal_status.py
Normal file
14
src/application/domain/enums/sbp_withdrawal_status.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class SbpWithdrawalStatus(str,Enum):
|
||||
CREATED='created'
|
||||
WAITING_USDT='waiting_usdt'
|
||||
USDT_UNDERPAID='usdt_underpaid'
|
||||
USDT_RECEIVED='usdt_received'
|
||||
WALLET_ERROR='wallet_error'
|
||||
PAYOUT_PROCESSING='payout_processing'
|
||||
PAYOUT_COMPLETED='payout_completed'
|
||||
PAYOUT_FAILED='payout_failed'
|
||||
CANCELLED='cancelled'
|
||||
EXPIRED='expired'
|
||||
@@ -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',
|
||||
]
|
||||
@@ -14,5 +14,6 @@ class ApplicationException(Exception):
|
||||
self.message = message
|
||||
self.headers = headers
|
||||
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.status_code}: {self.message}"
|
||||
return f'{self.status_code}: {self.message}'
|
||||
12
src/application/domain/exceptions/bad_gateway_exception.py
Normal file
12
src/application/domain/exceptions/bad_gateway_exception.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from __future__ import annotations
|
||||
from typing import Mapping
|
||||
from src.application.domain.exceptions.application_exception import ApplicationException
|
||||
|
||||
|
||||
class BadGatewayException(ApplicationException):
|
||||
def __init__(
|
||||
self,
|
||||
message: str = 'Bad Gateway',
|
||||
headers: Mapping[str,str] | None = None,
|
||||
):
|
||||
super().__init__(status_code=502,message=message,headers=headers)
|
||||
12
src/application/domain/exceptions/bad_request_exception.py
Normal file
12
src/application/domain/exceptions/bad_request_exception.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from __future__ import annotations
|
||||
from typing import Mapping
|
||||
from src.application.domain.exceptions.application_exception import ApplicationException
|
||||
|
||||
|
||||
class BadRequestException(ApplicationException):
|
||||
def __init__(
|
||||
self,
|
||||
message: str = 'Bad Request',
|
||||
headers: Mapping[str,str] | None = None,
|
||||
):
|
||||
super().__init__(status_code=400,message=message,headers=headers)
|
||||
12
src/application/domain/exceptions/conflict_exception.py
Normal file
12
src/application/domain/exceptions/conflict_exception.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from __future__ import annotations
|
||||
from typing import Mapping
|
||||
from src.application.domain.exceptions.application_exception import ApplicationException
|
||||
|
||||
|
||||
class ConflictException(ApplicationException):
|
||||
def __init__(
|
||||
self,
|
||||
message: str = 'Conflict',
|
||||
headers: Mapping[str,str] | None = None,
|
||||
):
|
||||
super().__init__(status_code=409,message=message,headers=headers)
|
||||
12
src/application/domain/exceptions/csrf_exception.py
Normal file
12
src/application/domain/exceptions/csrf_exception.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from __future__ import annotations
|
||||
from typing import Mapping
|
||||
from src.application.domain.exceptions.application_exception import ApplicationException
|
||||
|
||||
|
||||
class CsrfException(ApplicationException):
|
||||
def __init__(
|
||||
self,
|
||||
message: str = 'CSRF token invalid',
|
||||
headers: Mapping[str,str] | None = None,
|
||||
):
|
||||
super().__init__(status_code=403,message=message,headers=headers)
|
||||
12
src/application/domain/exceptions/forbidden_exception.py
Normal file
12
src/application/domain/exceptions/forbidden_exception.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from __future__ import annotations
|
||||
from typing import Mapping
|
||||
from src.application.domain.exceptions.application_exception import ApplicationException
|
||||
|
||||
|
||||
class ForbiddenException(ApplicationException):
|
||||
def __init__(
|
||||
self,
|
||||
message: str = 'Forbidden',
|
||||
headers: Mapping[str,str] | None = None,
|
||||
):
|
||||
super().__init__(status_code=403,message=message,headers=headers)
|
||||
@@ -0,0 +1,12 @@
|
||||
from __future__ import annotations
|
||||
from typing import Mapping
|
||||
from src.application.domain.exceptions.application_exception import ApplicationException
|
||||
|
||||
|
||||
class InternalServerException(ApplicationException):
|
||||
def __init__(
|
||||
self,
|
||||
message: str = 'Internal Server Error',
|
||||
headers: Mapping[str,str] | None = None,
|
||||
):
|
||||
super().__init__(status_code=500,message=message,headers=headers)
|
||||
13
src/application/domain/exceptions/jwt_exception.py
Normal file
13
src/application/domain/exceptions/jwt_exception.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from __future__ import annotations
|
||||
from typing import Mapping
|
||||
from src.application.domain.exceptions.application_exception import ApplicationException
|
||||
|
||||
|
||||
class JwtException(ApplicationException):
|
||||
def __init__(
|
||||
self,
|
||||
message: str = 'Invalid token',
|
||||
status_code: int = 401,
|
||||
headers: Mapping[str,str] | None = None,
|
||||
):
|
||||
super().__init__(status_code=status_code,message=message,headers=headers)
|
||||
12
src/application/domain/exceptions/not_found_exception.py
Normal file
12
src/application/domain/exceptions/not_found_exception.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from __future__ import annotations
|
||||
from typing import Mapping
|
||||
from src.application.domain.exceptions.application_exception import ApplicationException
|
||||
|
||||
|
||||
class NotFoundException(ApplicationException):
|
||||
def __init__(
|
||||
self,
|
||||
message: str = 'Not Found',
|
||||
headers: Mapping[str,str] | None = None,
|
||||
):
|
||||
super().__init__(status_code=404,message=message,headers=headers)
|
||||
@@ -0,0 +1,12 @@
|
||||
from __future__ import annotations
|
||||
from typing import Mapping
|
||||
from src.application.domain.exceptions.bad_request_exception import BadRequestException
|
||||
|
||||
|
||||
class OrderTotalOutOfRangeException(BadRequestException):
|
||||
def __init__(
|
||||
self,
|
||||
message: str = 'Order total is outside allowed range',
|
||||
headers: Mapping[str,str] | None = None,
|
||||
) -> None:
|
||||
super().__init__(message=message, headers=headers)
|
||||
@@ -0,0 +1,12 @@
|
||||
from __future__ import annotations
|
||||
from typing import Mapping
|
||||
from src.application.domain.exceptions.application_exception import ApplicationException
|
||||
|
||||
|
||||
class PaymentMetadataException(ApplicationException):
|
||||
def __init__(
|
||||
self,
|
||||
message: str = 'Payment metadata invalid',
|
||||
headers: Mapping[str,str] | None = None,
|
||||
):
|
||||
super().__init__(status_code=400,message=message,headers=headers)
|
||||
@@ -0,0 +1,12 @@
|
||||
from __future__ import annotations
|
||||
from typing import Mapping
|
||||
from src.application.domain.exceptions.application_exception import ApplicationException
|
||||
|
||||
|
||||
class PaymentProviderException(ApplicationException):
|
||||
def __init__(
|
||||
self,
|
||||
message: str = 'Payment provider error',
|
||||
headers: Mapping[str,str] | None = None,
|
||||
):
|
||||
super().__init__(status_code=502,message=message,headers=headers)
|
||||
12
src/application/domain/exceptions/price_changed_exception.py
Normal file
12
src/application/domain/exceptions/price_changed_exception.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from __future__ import annotations
|
||||
from typing import Mapping
|
||||
from src.application.domain.exceptions.application_exception import ApplicationException
|
||||
|
||||
|
||||
class PriceChangedException(ApplicationException):
|
||||
def __init__(
|
||||
self,
|
||||
message: str = 'Price has changed, please refresh and try again',
|
||||
headers: Mapping[str,str] | None = None,
|
||||
):
|
||||
super().__init__(status_code=409,message=message,headers=headers)
|
||||
13
src/application/domain/exceptions/rate_limit_exception.py
Normal file
13
src/application/domain/exceptions/rate_limit_exception.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from __future__ import annotations
|
||||
from typing import Mapping
|
||||
from src.application.domain.exceptions.application_exception import ApplicationException
|
||||
|
||||
|
||||
class RateLimitException(ApplicationException):
|
||||
def __init__(
|
||||
self,
|
||||
message: str = 'Too Many Requests',
|
||||
status_code: int = 429,
|
||||
headers: Mapping[str,str] | None = None,
|
||||
):
|
||||
super().__init__(status_code=status_code,message=message,headers=headers)
|
||||
12
src/application/domain/exceptions/receipt_data_exception.py
Normal file
12
src/application/domain/exceptions/receipt_data_exception.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from __future__ import annotations
|
||||
from typing import Mapping
|
||||
from src.application.domain.exceptions.application_exception import ApplicationException
|
||||
|
||||
|
||||
class ReceiptDataException(ApplicationException):
|
||||
def __init__(
|
||||
self,
|
||||
message: str = 'Receipt data invalid',
|
||||
headers: Mapping[str,str] | None = None,
|
||||
):
|
||||
super().__init__(status_code=400,message=message,headers=headers)
|
||||
@@ -0,0 +1,13 @@
|
||||
from __future__ import annotations
|
||||
from typing import Mapping
|
||||
from src.application.domain.exceptions.application_exception import ApplicationException
|
||||
|
||||
|
||||
class ReceiptProviderException(ApplicationException):
|
||||
def __init__(
|
||||
self,
|
||||
message: str = 'Receipt provider error',
|
||||
status_code: int = 502,
|
||||
headers: Mapping[str,str] | None = None,
|
||||
):
|
||||
super().__init__(status_code=status_code,message=message,headers=headers)
|
||||
@@ -0,0 +1,12 @@
|
||||
from __future__ import annotations
|
||||
from typing import Mapping
|
||||
from src.application.domain.exceptions.application_exception import ApplicationException
|
||||
|
||||
|
||||
class ServiceUnavailableException(ApplicationException):
|
||||
def __init__(
|
||||
self,
|
||||
message: str = 'Service Unavailable',
|
||||
headers: Mapping[str,str] | None = None,
|
||||
):
|
||||
super().__init__(status_code=503,message=message,headers=headers)
|
||||
12
src/application/domain/exceptions/unauthorized_exception.py
Normal file
12
src/application/domain/exceptions/unauthorized_exception.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from __future__ import annotations
|
||||
from typing import Mapping
|
||||
from src.application.domain.exceptions.application_exception import ApplicationException
|
||||
|
||||
|
||||
class UnauthorizedException(ApplicationException):
|
||||
def __init__(
|
||||
self,
|
||||
message: str = 'Unauthorized',
|
||||
headers: Mapping[str,str] | None = None,
|
||||
):
|
||||
super().__init__(status_code=401,message=message,headers=headers)
|
||||
6
src/application/services/__init__.py
Normal file
6
src/application/services/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from src.application.services.payment_quote_service import PaymentQuote,PaymentQuoteService
|
||||
from src.application.services.risk_scoring import RiskAssessmentResult,RiskScoringService
|
||||
from src.application.services.sbp_withdrawal_quote_service import SbpWithdrawalQuote,SbpWithdrawalQuoteService
|
||||
|
||||
|
||||
__all__ = ['PaymentQuote', 'PaymentQuoteService']
|
||||
184
src/application/services/payment_quote_service.py
Normal file
184
src/application/services/payment_quote_service.py
Normal file
@@ -0,0 +1,184 @@
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal, ROUND_DOWN, ROUND_UP
|
||||
from src.application.contracts import ICache, ILogger
|
||||
from src.application.domain.exceptions import OrderTotalOutOfRangeException, ServiceUnavailableException
|
||||
|
||||
|
||||
MIN_TOTAL_RUB: Decimal = Decimal('5000')
|
||||
|
||||
_MAX_TOTAL_RUB: Decimal = Decimal('600000')
|
||||
|
||||
_FEE_TIERS: tuple[tuple[Decimal, Decimal, Decimal, bool, bool], ...] = (
|
||||
(Decimal('0.08'), Decimal('0'), Decimal('30000'), True, True),
|
||||
(Decimal('0.06'), Decimal('30000'), Decimal('100000'), False, True),
|
||||
(Decimal('0.04'), Decimal('30000'), Decimal('600000'), False, True),
|
||||
)
|
||||
|
||||
|
||||
def _total_in_bracket(
|
||||
total: Decimal,
|
||||
lo: Decimal,
|
||||
hi: Decimal,
|
||||
*,
|
||||
lo_inclusive: bool,
|
||||
hi_inclusive: bool,
|
||||
) -> bool:
|
||||
if lo_inclusive:
|
||||
ok_lo = total >= lo
|
||||
else:
|
||||
ok_lo = total > lo
|
||||
if hi_inclusive:
|
||||
ok_hi = total <= hi
|
||||
else:
|
||||
ok_hi = total < hi
|
||||
return ok_lo and ok_hi
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class PaymentQuote:
|
||||
usdt_amount: Decimal
|
||||
usdt_exchange_rate: Decimal
|
||||
gas_fee: Decimal
|
||||
service_fee: Decimal
|
||||
total_price: Decimal
|
||||
service_fee_rate: Decimal
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class PaymentQuoteService:
|
||||
def __init__(self, *, remote_cache: ICache, logger: ILogger):
|
||||
self._remote_cache = remote_cache
|
||||
self._logger = logger
|
||||
|
||||
|
||||
async def _load_prices(self) -> tuple[Decimal, Decimal]:
|
||||
rate_raw = await self._remote_cache.hget('tradex:rub:rate', 'value')
|
||||
gas_raw = await self._remote_cache.hget('gwei:eth:last', 'normal_rub')
|
||||
self._logger.info(f'Actual market values: rate={rate_raw}, gas={gas_raw}')
|
||||
|
||||
if rate_raw is None:
|
||||
self._logger.error('Exchange rate unavailable')
|
||||
raise ServiceUnavailableException(message='Exchange rate unavailable')
|
||||
|
||||
if gas_raw is None:
|
||||
self._logger.error('Exchange gas unavailable')
|
||||
raise ServiceUnavailableException(message='Exchange gas unavailable')
|
||||
|
||||
gas_fee = Decimal(gas_raw).quantize(Decimal('0.00'), rounding=ROUND_UP)
|
||||
usdt_exchange_rate = Decimal(rate_raw).quantize(Decimal('0.00'), rounding=ROUND_UP)
|
||||
return usdt_exchange_rate, gas_fee
|
||||
|
||||
|
||||
def _compose_quote(
|
||||
self,
|
||||
usdt_amount: Decimal,
|
||||
usdt_exchange_rate: Decimal,
|
||||
gas_fee: Decimal,
|
||||
) -> PaymentQuote | None:
|
||||
base_rub = usdt_amount * usdt_exchange_rate
|
||||
chosen_rate: Decimal | None = None
|
||||
service_fee: Decimal | None = None
|
||||
total_price: Decimal | None = None
|
||||
|
||||
for fee_rate, lo, hi, li, ri in _FEE_TIERS:
|
||||
sf = (base_rub * fee_rate).quantize(Decimal('0.01'))
|
||||
tp = (base_rub + sf + gas_fee).quantize(Decimal('0.01'))
|
||||
if _total_in_bracket(tp, lo, hi, lo_inclusive=li, hi_inclusive=ri):
|
||||
chosen_rate = fee_rate
|
||||
service_fee = sf
|
||||
total_price = tp
|
||||
break
|
||||
|
||||
if chosen_rate is None or service_fee is None or total_price is None:
|
||||
return None
|
||||
|
||||
return PaymentQuote(
|
||||
usdt_amount=usdt_amount,
|
||||
usdt_exchange_rate=usdt_exchange_rate,
|
||||
gas_fee=gas_fee,
|
||||
service_fee=service_fee,
|
||||
total_price=total_price,
|
||||
service_fee_rate=chosen_rate,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
async def get_reference_quote(self, usdt_amount: Decimal) -> PaymentQuote:
|
||||
if usdt_amount <= Decimal('0'):
|
||||
raise OrderTotalOutOfRangeException(
|
||||
message='Order total is below minimum allowed amount',
|
||||
)
|
||||
|
||||
usdt_exchange_rate, gas_fee = await self._load_prices()
|
||||
quote = self._compose_quote(usdt_amount, usdt_exchange_rate, gas_fee)
|
||||
if quote is None:
|
||||
raise OrderTotalOutOfRangeException(
|
||||
message='Order total exceeds maximum allowed amount',
|
||||
)
|
||||
return quote
|
||||
|
||||
|
||||
async def get_quote(self, usdt_amount: Decimal) -> PaymentQuote:
|
||||
quote = await self.get_reference_quote(usdt_amount)
|
||||
if quote.total_price < MIN_TOTAL_RUB:
|
||||
raise OrderTotalOutOfRangeException(
|
||||
message='Order total is below minimum allowed amount',
|
||||
)
|
||||
return quote
|
||||
|
||||
|
||||
async def get_quote_from_total_rub(self, total_rub: Decimal) -> PaymentQuote:
|
||||
if total_rub < MIN_TOTAL_RUB:
|
||||
raise OrderTotalOutOfRangeException(
|
||||
message='Order total is below minimum allowed amount',
|
||||
)
|
||||
|
||||
if total_rub > _MAX_TOTAL_RUB:
|
||||
raise OrderTotalOutOfRangeException(
|
||||
message='Order total exceeds maximum allowed amount',
|
||||
)
|
||||
|
||||
usdt_exchange_rate, gas_fee = await self._load_prices()
|
||||
|
||||
u_cap = ((_MAX_TOTAL_RUB - gas_fee) / (usdt_exchange_rate * Decimal('1.04'))).quantize(
|
||||
Decimal('0.01'),
|
||||
rounding=ROUND_DOWN,
|
||||
)
|
||||
n_hi = int((u_cap * 100).to_integral_value(rounding=ROUND_DOWN))
|
||||
if n_hi < 1:
|
||||
raise OrderTotalOutOfRangeException(
|
||||
message='Order total is below minimum allowed amount',
|
||||
)
|
||||
|
||||
n_lo = 1
|
||||
best_cent: int | None = None
|
||||
while n_lo <= n_hi:
|
||||
mid = (n_lo + n_hi) // 2
|
||||
u = Decimal(mid) / Decimal(100)
|
||||
quote = self._compose_quote(u, usdt_exchange_rate, gas_fee)
|
||||
if quote is None:
|
||||
n_hi = mid - 1
|
||||
continue
|
||||
if quote.total_price >= total_rub:
|
||||
best_cent = mid
|
||||
n_hi = mid - 1
|
||||
else:
|
||||
n_lo = mid + 1
|
||||
|
||||
if best_cent is None:
|
||||
raise OrderTotalOutOfRangeException(
|
||||
message='Order total exceeds maximum allowed amount',
|
||||
)
|
||||
|
||||
final = self._compose_quote(
|
||||
Decimal(best_cent) / Decimal(100),
|
||||
usdt_exchange_rate,
|
||||
gas_fee,
|
||||
)
|
||||
if final is None:
|
||||
raise OrderTotalOutOfRangeException(
|
||||
message='Order total exceeds maximum allowed amount',
|
||||
)
|
||||
return final
|
||||
67
src/application/services/risk_scoring.py
Normal file
67
src/application/services/risk_scoring.py
Normal file
@@ -0,0 +1,67 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
|
||||
from src.application.domain.entities.user import UserEntity
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RiskAssessmentResult:
|
||||
score: int
|
||||
decision: str
|
||||
reasons: list[str]
|
||||
|
||||
|
||||
class RiskScoringService:
|
||||
MANUAL_REVIEW_THRESHOLD = 60
|
||||
REJECT_THRESHOLD = 80
|
||||
|
||||
def assess_order(
|
||||
self,
|
||||
*,
|
||||
user: UserEntity,
|
||||
total_price: Decimal,
|
||||
recent_order_count: int,
|
||||
) -> RiskAssessmentResult:
|
||||
if user.account_type == "legal_entity":
|
||||
return RiskAssessmentResult(score=0, decision="skip", reasons=[])
|
||||
|
||||
score = 0
|
||||
reasons: list[str] = []
|
||||
|
||||
if not user.kyc_verified:
|
||||
score += 100
|
||||
reasons.append("kyc_not_completed")
|
||||
|
||||
is_new_account = False
|
||||
created_at = user.created_at
|
||||
if created_at is not None:
|
||||
if created_at.tzinfo is None:
|
||||
created_at = created_at.replace(tzinfo=timezone.utc)
|
||||
age = datetime.now(timezone.utc) - created_at
|
||||
if age.total_seconds() < 24 * 60 * 60:
|
||||
is_new_account = True
|
||||
score += 20
|
||||
reasons.append("new_account")
|
||||
|
||||
if is_new_account and total_price >= Decimal("200000.00"):
|
||||
score += 30
|
||||
reasons.append("new_account_large_amount")
|
||||
elif is_new_account and total_price >= Decimal("50000.00"):
|
||||
score += 15
|
||||
reasons.append("new_account_elevated_amount")
|
||||
|
||||
if recent_order_count >= 5:
|
||||
score += 20
|
||||
reasons.append("velocity_spike")
|
||||
|
||||
score = min(score, 100)
|
||||
if score >= self.REJECT_THRESHOLD:
|
||||
decision = "reject"
|
||||
elif score >= self.MANUAL_REVIEW_THRESHOLD:
|
||||
decision = "manual_review"
|
||||
else:
|
||||
decision = "allow"
|
||||
return RiskAssessmentResult(score=score, decision=decision, reasons=reasons)
|
||||
64
src/application/services/sbp_withdrawal_quote_service.py
Normal file
64
src/application/services/sbp_withdrawal_quote_service.py
Normal file
@@ -0,0 +1,64 @@
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal,ROUND_DOWN,ROUND_UP
|
||||
from src.application.contracts import ICache,ILogger
|
||||
from src.application.domain.exceptions import OrderTotalOutOfRangeException,ServiceUnavailableException
|
||||
|
||||
MIN_SBP_WITHDRAWAL_USDT: Decimal=Decimal('100')
|
||||
MAX_SBP_WITHDRAWAL_USDT: Decimal=Decimal('5000')
|
||||
|
||||
_FEE_TIERS: tuple[tuple[Decimal,Decimal,Decimal],...]=(
|
||||
(Decimal('100'),Decimal('2000'),Decimal('0.08')),
|
||||
(Decimal('2000.01'),Decimal('4000'),Decimal('0.06')),
|
||||
(Decimal('4000.01'),Decimal('5000'),Decimal('0.04')),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SbpWithdrawalQuote:
|
||||
usdt_amount: Decimal
|
||||
rub_amount: Decimal
|
||||
usdt_exchange_rate: Decimal
|
||||
service_fee_rate: Decimal
|
||||
service_fee_usdt: Decimal
|
||||
|
||||
|
||||
class SbpWithdrawalQuoteService:
|
||||
def __init__(self,*,remote_cache: ICache,logger: ILogger):
|
||||
self._remote_cache=remote_cache
|
||||
self._logger=logger
|
||||
|
||||
|
||||
async def _load_rate(self) -> Decimal:
|
||||
rate_raw=await self._remote_cache.hget('tradex:rub:rate','value')
|
||||
self._logger.info(f'Actual market value for SBP withdrawal: rate={rate_raw}')
|
||||
if rate_raw is None:
|
||||
raise ServiceUnavailableException(message='Exchange rate unavailable')
|
||||
return Decimal(rate_raw).quantize(Decimal('0.00'),rounding=ROUND_UP)
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _fee_rate(usdt_amount: Decimal) -> Decimal:
|
||||
for lo,hi,rate in _FEE_TIERS:
|
||||
if lo<=usdt_amount<=hi:
|
||||
return rate
|
||||
raise OrderTotalOutOfRangeException(message='Withdrawal amount is out of range')
|
||||
|
||||
|
||||
async def get_quote(self,usdt_amount: Decimal) -> SbpWithdrawalQuote:
|
||||
if usdt_amount<MIN_SBP_WITHDRAWAL_USDT:
|
||||
raise OrderTotalOutOfRangeException(message='Withdrawal amount is below minimum allowed amount')
|
||||
if usdt_amount>MAX_SBP_WITHDRAWAL_USDT:
|
||||
raise OrderTotalOutOfRangeException(message='Withdrawal amount exceeds maximum allowed amount')
|
||||
rate=await self._load_rate()
|
||||
fee_rate=self._fee_rate(usdt_amount)
|
||||
service_fee=(usdt_amount*fee_rate).quantize(Decimal('0.01'))
|
||||
payout_usdt=(usdt_amount-service_fee).quantize(Decimal('0.01'))
|
||||
rub_amount=(payout_usdt*rate).quantize(Decimal('1'),rounding=ROUND_DOWN)
|
||||
return SbpWithdrawalQuote(
|
||||
usdt_amount=usdt_amount,
|
||||
rub_amount=rub_amount,
|
||||
usdt_exchange_rate=rate,
|
||||
service_fee_rate=fee_rate,
|
||||
service_fee_usdt=service_fee,
|
||||
)
|
||||
@@ -1,29 +0,0 @@
|
||||
from src.infrastructure.database.decorators import transactional
|
||||
from src.presentation.schemas.order import CreateOrder
|
||||
|
||||
|
||||
class UserLoginStartCommand:
|
||||
def __init__(
|
||||
self,
|
||||
hash_service: IHashService,
|
||||
cache: ICache,
|
||||
unit_of_work: IUnitOfWork,
|
||||
logger: ILogger,
|
||||
messanger: IQueueMessanger,
|
||||
):
|
||||
self._hash_service = hash_service
|
||||
self._unit_of_work = unit_of_work
|
||||
self._cache = cache
|
||||
self._logger = logger
|
||||
self._messanger = messanger
|
||||
|
||||
|
||||
@transactional
|
||||
async def __call__(self, payment_data: CreateOrder) -> bool:
|
||||
|
||||
|
||||
metadata: dict = {
|
||||
'user_id': str(payment_data.user_id),
|
||||
}
|
||||
|
||||
|
||||
3
src/infrastructure/cache/__init__.py
vendored
3
src/infrastructure/cache/__init__.py
vendored
@@ -1,2 +1,5 @@
|
||||
from src.infrastructure.cache.client import create_redis_client
|
||||
from src.infrastructure.cache.keydb_client import KeydbCache
|
||||
from src.infrastructure.cache.remote_cache import RemoteCache
|
||||
|
||||
__all__ = ['create_redis_client', 'KeydbCache', 'RemoteCache']
|
||||
|
||||
5
src/infrastructure/cache/client.py
vendored
5
src/infrastructure/cache/client.py
vendored
@@ -3,9 +3,10 @@ from redis.asyncio.client import Redis
|
||||
from src.infrastructure.config import settings
|
||||
|
||||
|
||||
def create_redis_client() -> Redis:
|
||||
def create_redis_client(url:str|None=None) -> Redis:
|
||||
redis_url = url or settings.KEYDB_MARKET_URL
|
||||
return redis.from_url(
|
||||
settings.REDIS_URL,
|
||||
redis_url,
|
||||
max_connections=50,
|
||||
decode_responses=True,
|
||||
socket_timeout=5,
|
||||
|
||||
11
src/infrastructure/cache/keydb_client.py
vendored
11
src/infrastructure/cache/keydb_client.py
vendored
@@ -20,6 +20,9 @@ class KeydbCache(ICache):
|
||||
async def get(self, key: str) -> str | None:
|
||||
return await self._r.get(key)
|
||||
|
||||
async def hget(self, key: str, field: str) -> str | None:
|
||||
return await self._r.hget(key, field)
|
||||
|
||||
async def delete(self, key: str) -> bool:
|
||||
return (await self._r.delete(key)) > 0
|
||||
|
||||
@@ -37,12 +40,12 @@ class KeydbCache(ICache):
|
||||
'middle_name': user.middle_name,
|
||||
'last_name': user.last_name,
|
||||
'birth_date': str(user.birth_date) if user.birth_date else None,
|
||||
'crypto_wallet': user.crypto_wallet,
|
||||
'encrypted_mnemonic': user.encrypted_mnemonic,
|
||||
'phone': user.phone,
|
||||
'bik': user.bik,
|
||||
'account_number': user.account_number,
|
||||
'card_number': user.card_number,
|
||||
'passport_data': user.passport_data,
|
||||
'inn': user.inn,
|
||||
'erc20': user.erc20,
|
||||
'avatar_link': user.avatar_link,
|
||||
'kyc_verified': user.kyc_verified,
|
||||
'is_deleted': user.is_deleted,
|
||||
'created_at': user.created_at.isoformat() if user.created_at else None,
|
||||
|
||||
68
src/infrastructure/cache/remote_cache.py
vendored
Normal file
68
src/infrastructure/cache/remote_cache.py
vendored
Normal file
@@ -0,0 +1,68 @@
|
||||
from __future__ import annotations
|
||||
import orjson
|
||||
from redis.asyncio.client import Redis
|
||||
from src.application.contracts import ICache
|
||||
from src.application.domain.entities.user import UserEntity
|
||||
|
||||
|
||||
class RemoteCache(ICache):
|
||||
|
||||
|
||||
USER_PREFIX = 'user:me'
|
||||
|
||||
|
||||
def __init__(self,redis_client: Redis) -> None:
|
||||
self._r = redis_client
|
||||
|
||||
|
||||
async def set(self,key: str,value: str,ttl: int) -> bool:
|
||||
return bool(await self._r.set(key,value,ex=ttl))
|
||||
|
||||
|
||||
async def set_nx(self,key: str,value: str,ttl: int) -> bool:
|
||||
return bool(await self._r.set(key,value,ex=ttl,nx=True))
|
||||
|
||||
|
||||
async def get(self,key: str) -> str | None:
|
||||
mapping = await self._r.hgetall(key)
|
||||
if not mapping:
|
||||
return None
|
||||
return mapping.get('usdt_rub')
|
||||
|
||||
|
||||
async def hget(self,key: str,field: str) -> str | None:
|
||||
return await self._r.hget(key,field)
|
||||
|
||||
|
||||
async def delete(self,key: str) -> bool:
|
||||
return (await self._r.delete(key)) > 0
|
||||
|
||||
|
||||
async def get_user(self,user_id: str) -> dict | None:
|
||||
raw = await self._r.get(f'{self.USER_PREFIX}:{user_id}')
|
||||
if raw is None:
|
||||
return None
|
||||
return orjson.loads(raw)
|
||||
|
||||
|
||||
async def set_user(self,user_id: str,user: UserEntity,ttl: int = 300) -> None:
|
||||
data = orjson.dumps({
|
||||
'id': user.id,
|
||||
'email': user.email,
|
||||
'first_name': user.first_name,
|
||||
'middle_name': user.middle_name,
|
||||
'last_name': user.last_name,
|
||||
'birth_date': str(user.birth_date) if user.birth_date else None,
|
||||
'encrypted_mnemonic': user.encrypted_mnemonic,
|
||||
'phone': user.phone,
|
||||
'passport_data': user.passport_data,
|
||||
'inn': user.inn,
|
||||
'erc20': user.erc20,
|
||||
'avatar_link': user.avatar_link,
|
||||
'kyc_verified': user.kyc_verified,
|
||||
'is_deleted': user.is_deleted,
|
||||
'created_at': user.created_at.isoformat() if user.created_at else None,
|
||||
'updated_at': user.updated_at.isoformat() if user.updated_at else None,
|
||||
'kyc_verified_at': user.kyc_verified_at.isoformat() if user.kyc_verified_at else None,
|
||||
})
|
||||
await self._r.set(f'{self.USER_PREFIX}:{user_id}',data,ex=ttl)
|
||||
5
src/infrastructure/cloud_kassir/__init__.py
Normal file
5
src/infrastructure/cloud_kassir/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from src.infrastructure.cloud_kassir.client import ClaudeKassirClient
|
||||
|
||||
|
||||
__all__=['ClaudeKassirClient']
|
||||
|
||||
151
src/infrastructure/cloud_kassir/client.py
Normal file
151
src/infrastructure/cloud_kassir/client.py
Normal file
@@ -0,0 +1,151 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
from ulid import ULID
|
||||
import aiohttp
|
||||
import orjson
|
||||
from aiohttp import BasicAuth, ClientTimeout
|
||||
from src.application.contracts import IReceipt
|
||||
from src.application.domain.exceptions import ApplicationException,ReceiptProviderException
|
||||
from src.infrastructure.cloud_kassir.constants import CLOUD_KASSIR_API_BASE_URL
|
||||
|
||||
|
||||
class ClaudeKassirClient(IReceipt):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
public_id: str,
|
||||
api_secret: str,
|
||||
inn: str,
|
||||
api_base_url: str = CLOUD_KASSIR_API_BASE_URL,
|
||||
success_url: str | None = None,
|
||||
fail_url: str | None = None,
|
||||
timeout_seconds: float = 30,
|
||||
) -> None:
|
||||
self._public_id = public_id
|
||||
self._api_secret = api_secret
|
||||
self._inn = inn
|
||||
self._api_base_url = api_base_url.rstrip('/')
|
||||
self._success_url = success_url
|
||||
self._fail_url = fail_url
|
||||
self._timeout = ClientTimeout(total=timeout_seconds)
|
||||
|
||||
async def create_receipt(
|
||||
self,
|
||||
*,
|
||||
order_id: str,
|
||||
user_id: str,
|
||||
email: str,
|
||||
total_amount: Decimal,
|
||||
principal_amount: Decimal,
|
||||
service_fee: Decimal,
|
||||
customer_info: str = '',
|
||||
customer_inn: str = '',
|
||||
customer_birthday: str = '',
|
||||
success_url: str | None = None,
|
||||
fail_url: str | None = None,
|
||||
request_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
total = total_amount.quantize(Decimal('0.01'))
|
||||
principal = principal_amount.quantize(Decimal('0.01'))
|
||||
fee = service_fee.quantize(Decimal('0.01'))
|
||||
description = f'Оплата по заявке №{order_id}'
|
||||
principal_description = f'Исполнение поручения принципала по заявке №{order_id}'
|
||||
fee_description = f'Агентское вознаграждение по заявке №{order_id}'
|
||||
payload: dict[str, Any] = {
|
||||
'Inn': self._inn,
|
||||
'Type': 'Income',
|
||||
'InvoiceId': order_id,
|
||||
'AccountId': user_id,
|
||||
'Description': description,
|
||||
'CustomerReceipt': {
|
||||
'TaxationSystem': 2,
|
||||
'Items': [
|
||||
{
|
||||
'label': principal_description,
|
||||
'price': float(principal),
|
||||
'quantity': 1,
|
||||
'amount': float(principal),
|
||||
'vat': 0,
|
||||
'method': 4,
|
||||
'object': 4,
|
||||
'measurement_unit': 'шт',
|
||||
'agentSign': 6,
|
||||
'agentData': {
|
||||
'agentOperationName': 'Исполнение поручения принципала',
|
||||
},
|
||||
'purveyorData': {
|
||||
'name': 'Elcsa',
|
||||
'inn': self._inn,
|
||||
},
|
||||
},
|
||||
{
|
||||
'label': fee_description,
|
||||
'price': float(fee),
|
||||
'quantity': 1,
|
||||
'amount': float(fee),
|
||||
'vat': 0,
|
||||
'method': 4,
|
||||
'object': 11,
|
||||
'measurement_unit': 'шт',
|
||||
},
|
||||
],
|
||||
'amounts': {
|
||||
'electronic': float(total),
|
||||
'advancePayment': 0,
|
||||
'credit': 0,
|
||||
'provision': 0,
|
||||
},
|
||||
'email': email,
|
||||
'customerInfo': customer_info,
|
||||
'customerInn': customer_inn,
|
||||
'customerBirthday': customer_birthday,
|
||||
},
|
||||
'Email': email,
|
||||
'SuccessUrl': success_url or self._success_url,
|
||||
'FailUrl': fail_url or self._fail_url,
|
||||
}
|
||||
if payload['SuccessUrl'] is None:
|
||||
payload.pop('SuccessUrl')
|
||||
if payload['FailUrl'] is None:
|
||||
payload.pop('FailUrl')
|
||||
url = f'{self._api_base_url}/kkt/receipt'
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-Request-ID': request_id or str(ULID()),
|
||||
}
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=self._timeout) as session:
|
||||
auth = BasicAuth(self._public_id, self._api_secret)
|
||||
async with session.post(url, json=payload, headers=headers, auth=auth) as resp:
|
||||
raw = (await resp.text()).strip()
|
||||
if not raw:
|
||||
raise ReceiptProviderException(
|
||||
message=f'Receipt provider empty response (HTTP {resp.status})',
|
||||
)
|
||||
try:
|
||||
parsed: Any = orjson.loads(raw)
|
||||
except orjson.JSONDecodeError:
|
||||
preview = raw[:240].replace('\n', ' ')
|
||||
raise ReceiptProviderException(
|
||||
message=f'Receipt provider non-JSON response (HTTP {resp.status}): {preview}',
|
||||
)
|
||||
if not isinstance(parsed, dict):
|
||||
raise ReceiptProviderException(message='Receipt provider invalid response')
|
||||
body = parsed
|
||||
if resp.status >= 400:
|
||||
raise ReceiptProviderException(
|
||||
message=str(body.get('Message') or 'Receipt provider error'),
|
||||
)
|
||||
if body.get('Success') is False:
|
||||
raise ReceiptProviderException(
|
||||
status_code=409,
|
||||
message=str(body.get('Message') or 'Receipt provider rejected receipt'),
|
||||
)
|
||||
return body
|
||||
except ApplicationException:
|
||||
raise
|
||||
except (TimeoutError,aiohttp.ClientError):
|
||||
raise ReceiptProviderException(message='Receipt provider unreachable')
|
||||
4
src/infrastructure/cloud_kassir/constants.py
Normal file
4
src/infrastructure/cloud_kassir/constants.py
Normal file
@@ -0,0 +1,4 @@
|
||||
CLOUD_KASSIR_API_BASE_URL = 'https://api.cloudpayments.ru'
|
||||
CLOUD_KASSIR_INN = '9810001062'
|
||||
CLOUD_KASSIR_SUCCESS_URL = 'https://yourdomain.com/success'
|
||||
CLOUD_KASSIR_FAIL_URL = 'https://yourdomain.com/fail'
|
||||
@@ -1,22 +1,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
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 dotenv import find_dotenv,load_dotenv
|
||||
from pydantic import AliasChoices,Field,field_validator,model_validator
|
||||
from pydantic_settings import BaseSettings,SettingsConfigDict
|
||||
from src.infrastructure.vault import create_hvac_client, read_kv2_secret
|
||||
from src.infrastructure.vault import create_hvac_client_from_approle, read_kv2_secret
|
||||
|
||||
env_file = find_dotenv(".env")
|
||||
env_file = find_dotenv('.env')
|
||||
if env_file:
|
||||
load_dotenv(env_file)
|
||||
|
||||
|
||||
def normalize_vault_base_url(raw: str) -> str:
|
||||
u = raw.strip().rstrip('/')
|
||||
if not u:
|
||||
return raw.strip()
|
||||
if '://' not in u:
|
||||
return f'https://{u}'
|
||||
return u
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
VAULT_ADDR: str = Field(default="http://localhost:8200")
|
||||
VAULT_TOKEN: str = Field(..., description="Vault token is required")
|
||||
VAULT_MOUNT_POINT: str = Field(default="secrets")
|
||||
VAULT_ADDR: str = Field(default='http://localhost:8200')
|
||||
VAULT_ROLE_ID: str = Field(..., description='AppRole role_id')
|
||||
VAULT_SECRET_ID: str = Field(
|
||||
...,
|
||||
description='AppRole secret_id',
|
||||
validation_alias=AliasChoices('VAULT_SECRET_ID', 'VAULT_SECRET_TOKEN'),
|
||||
)
|
||||
VAULT_NAMESPACE: str | None = Field(default=None)
|
||||
VAULT_MOUNT_POINT: str = Field(default='secrets')
|
||||
|
||||
VAULT_JWT_KID_PATH: str = "jwt/kid"
|
||||
VAULT_JWT_KIDS_PREFIX: str = "jwt/kids"
|
||||
@@ -45,6 +60,8 @@ class Settings(BaseSettings):
|
||||
CSRF_COOKIE_PATH: str = "/"
|
||||
CSRF_COOKIE_DOMAIN: str | None = None
|
||||
|
||||
CORS_ALLOW_ORIGIN_REGEX: str = r'https?://([a-z0-9-]+\.)*elcsa\.ru(:\d+)?$'
|
||||
|
||||
DOCS_USERNAME: str = "admin"
|
||||
DOCS_PASSWORD: str = "admin"
|
||||
|
||||
@@ -59,6 +76,15 @@ class Settings(BaseSettings):
|
||||
REDIS_PASSWORD: str | None = None
|
||||
REDIS_DB: int = 0
|
||||
|
||||
KEYDB_REMOTE_HOST: str | None = None
|
||||
KEYDB_REMOTE_PORT: int | None = None
|
||||
KEYDB_REMOTE_PASSWORD: str | None = None
|
||||
KEYDB_REMOTE_DB: int | None = None
|
||||
KEYDB_HOST: str | None = Field(default=None,validation_alias=AliasChoices('KEYDB_HOST','KEYDB_CACHE_HOST'))
|
||||
KEYDB_PORT: int | None = Field(default=None,validation_alias=AliasChoices('KEYDB_PORT','KEYDB_CACHE_PORT'))
|
||||
KEYDB_PASSWORD: str | None = Field(default=None,validation_alias=AliasChoices('KEYDB_PASSWORD','KEYDB_CACHE_PASSWORD'))
|
||||
KEYDB_DB: int | None = Field(default=None,validation_alias=AliasChoices('KEYDB_DB','KEYDB_CACHE_DB'))
|
||||
|
||||
RABBIT_HOST: str = "localhost"
|
||||
RABBIT_PORT: int = 5672
|
||||
RABBIT_USER: str = "guest"
|
||||
@@ -68,6 +94,25 @@ class Settings(BaseSettings):
|
||||
RABBIT_PUBLISH_PERSIST: bool = True
|
||||
RABBIT_CONNECT_TIMEOUT: int = 5
|
||||
RABBIT_EMAIL_CODE_QUEUE: str = "email.verification_code"
|
||||
RABBIT_CRYPTO_TRANSFER_QUEUE: str = "crypto.transfer.requested"
|
||||
RABBIT_CRYPTO_TRANSFER_COMPLETED_QUEUE: str = "crypto.transfer.completed"
|
||||
RABBIT_SBP_WITHDRAWAL_USDT_REQUESTED_QUEUE: str = 'sbp.withdrawal.usdt.requested'
|
||||
RABBIT_SBP_WITHDRAWAL_WALLET_EVENTS_QUEUE: str = 'sbp.withdrawal.wallet.events'
|
||||
|
||||
ITPAY_PUBLIC_ID: str
|
||||
ITPAY_API_SECRET: str
|
||||
|
||||
MOZEN_API_BASE_URL: str = 'https://extgw.mozen.io'
|
||||
MOZEN_API_SECRET: str = ''
|
||||
MOZEN_CLIENT_ID: str = ''
|
||||
MOZEN_ENDPOINT_ID: str = ''
|
||||
MOZEN_MERCHANT_ID: str = ''
|
||||
MOZEN_PARK_ID: str = ''
|
||||
MOZEN_PARK_TOKEN: str = ''
|
||||
CRYPTO_USDT_CONTRACT_ADDRESS: str = ''
|
||||
|
||||
CLOUD_KASSIR_PUBLIC_ID: str = ''
|
||||
CLOUD_KASSIR_API_SECRET: str = ''
|
||||
|
||||
LOG_LEVEL: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO"
|
||||
LOG_FORMAT: Literal["JSON", "TEXT"] = "TEXT"
|
||||
@@ -77,51 +122,211 @@ class Settings(BaseSettings):
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=True,
|
||||
extra="ignore",
|
||||
populate_by_name=True,
|
||||
)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@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')
|
||||
|
||||
extgw = read_secret_optional('extgw')
|
||||
if extgw:
|
||||
extgw_ci = {str(k).lower(): v for k, v in extgw.items()}
|
||||
|
||||
def extgw_set(field: str, env_key: str) -> None:
|
||||
v = extgw_ci.get(field)
|
||||
if v is None:
|
||||
return
|
||||
if isinstance(v, str) and not v.strip():
|
||||
return
|
||||
data[env_key] = str(v).strip()
|
||||
|
||||
extgw_set('api_base_url', 'MOZEN_API_BASE_URL')
|
||||
extgw_set('api_secret', 'MOZEN_API_SECRET')
|
||||
extgw_set('client_id', 'MOZEN_CLIENT_ID')
|
||||
extgw_set('endpoint_id', 'MOZEN_ENDPOINT_ID')
|
||||
extgw_set('merchant_id', 'MOZEN_MERCHANT_ID')
|
||||
extgw_set('park_id', 'MOZEN_PARK_ID')
|
||||
extgw_set('park_token', 'MOZEN_PARK_TOKEN')
|
||||
|
||||
crypto = read_secret_optional('crypto')
|
||||
if crypto:
|
||||
crypto_ci = {str(k).lower(): v for k, v in crypto.items()}
|
||||
usdt_contract_address = crypto_ci.get('usdt_contract_address')
|
||||
if usdt_contract_address is not None and str(usdt_contract_address).strip():
|
||||
data['CRYPTO_USDT_CONTRACT_ADDRESS'] = str(usdt_contract_address).strip()
|
||||
|
||||
keydb = read_secret('keydb')
|
||||
k_ci = {str(k).lower(): v for k, v in keydb.items()}
|
||||
|
||||
def keydb_nonempty(key: str) -> bool:
|
||||
v = k_ci.get(key)
|
||||
if v is None:
|
||||
return False
|
||||
if isinstance(v, str) and not v.strip():
|
||||
return False
|
||||
return True
|
||||
|
||||
missing_keydb = []
|
||||
for req in ('host', 'port'):
|
||||
if not keydb_nonempty(req):
|
||||
missing_keydb.append(req)
|
||||
db_raw = k_ci.get('database')
|
||||
if db_raw is None:
|
||||
db_raw = k_ci.get('db')
|
||||
if db_raw is None or (isinstance(db_raw, str) and not str(db_raw).strip()):
|
||||
missing_keydb.append('database')
|
||||
if missing_keydb:
|
||||
raise RuntimeError(
|
||||
f'Vault secret keydb missing non-empty keys: {missing_keydb} (mount={mount},path=keydb)'
|
||||
)
|
||||
|
||||
data['KEYDB_REMOTE_HOST'] = str(k_ci['host']).strip()
|
||||
data['KEYDB_REMOTE_PORT'] = int(k_ci['port'])
|
||||
data['KEYDB_REMOTE_DB'] = int(db_raw)
|
||||
pw_raw = k_ci.get('password')
|
||||
if pw_raw is not None and str(pw_raw).strip():
|
||||
data['KEYDB_REMOTE_PASSWORD'] = str(pw_raw).strip()
|
||||
else:
|
||||
data['KEYDB_REMOTE_PASSWORD'] = None
|
||||
|
||||
itpay_public_id = data.get('ITPAY_PUBLIC_ID') or os.getenv('ITPAY_PUBLIC_ID')
|
||||
itpay_api_secret = data.get('ITPAY_API_SECRET') or os.getenv('ITPAY_API_SECRET')
|
||||
if itpay_public_id is not None and str(itpay_public_id).strip() and itpay_api_secret is not None and str(itpay_api_secret).strip():
|
||||
data['ITPAY_PUBLIC_ID'] = str(itpay_public_id).strip()
|
||||
data['ITPAY_API_SECRET'] = str(itpay_api_secret).strip()
|
||||
else:
|
||||
itpay = read_secret('itpay')
|
||||
itpay_ci = {str(k).lower(): v for k, v in itpay.items()}
|
||||
public_id = itpay_ci.get('public_id')
|
||||
api_secret = itpay_ci.get('api_secret')
|
||||
if api_secret is None:
|
||||
api_secret = itpay_ci.get('secret')
|
||||
missing = []
|
||||
if public_id is None or not str(public_id).strip():
|
||||
missing.append('public_id')
|
||||
if api_secret is None or not str(api_secret).strip():
|
||||
missing.append('api_secret')
|
||||
if missing:
|
||||
raise RuntimeError(f'Vault secret itpay missing non-empty keys: {missing} (mount={mount},path=itpay)')
|
||||
data['ITPAY_PUBLIC_ID'] = str(public_id).strip()
|
||||
data['ITPAY_API_SECRET'] = str(api_secret).strip()
|
||||
|
||||
ck_public = data.get('CLOUD_KASSIR_PUBLIC_ID') or os.getenv('CLOUD_KASSIR_PUBLIC_ID')
|
||||
ck_secret = data.get('CLOUD_KASSIR_API_SECRET') or os.getenv('CLOUD_KASSIR_API_SECRET')
|
||||
if ck_public is not None and str(ck_public).strip() and ck_secret is not None and str(ck_secret).strip():
|
||||
data['CLOUD_KASSIR_PUBLIC_ID'] = str(ck_public).strip()
|
||||
data['CLOUD_KASSIR_API_SECRET'] = str(ck_secret).strip()
|
||||
else:
|
||||
cloudkassir = read_secret_optional('cloudkassir')
|
||||
if cloudkassir:
|
||||
ck_ci = {str(k).lower(): v for k, v in cloudkassir.items()}
|
||||
public_id_ck = ck_ci.get('public_id')
|
||||
api_secret_ck = ck_ci.get('api_secret')
|
||||
if api_secret_ck is None:
|
||||
api_secret_ck = ck_ci.get('secret')
|
||||
if public_id_ck is not None and str(public_id_ck).strip():
|
||||
data['CLOUD_KASSIR_PUBLIC_ID'] = str(public_id_ck).strip()
|
||||
if api_secret_ck is not None and str(api_secret_ck).strip():
|
||||
data['CLOUD_KASSIR_API_SECRET'] = str(api_secret_ck).strip()
|
||||
|
||||
return data
|
||||
|
||||
@@ -134,8 +339,41 @@ 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_MARKET_URL
|
||||
|
||||
@staticmethod
|
||||
def _redis_url(*, host: str, port: int, password: str | None, db: int) -> str:
|
||||
auth = f':{password}@' if password else ''
|
||||
return f'redis://{auth}{host}:{port}/{db}'
|
||||
|
||||
@property
|
||||
def KEYDB_MARKET_URL(self) -> str:
|
||||
if self.KEYDB_REMOTE_HOST is None or self.KEYDB_REMOTE_PORT is None or self.KEYDB_REMOTE_DB is None:
|
||||
raise RuntimeError('Vault KeyDB settings are required for market data')
|
||||
return self._redis_url(
|
||||
host=self.KEYDB_REMOTE_HOST,
|
||||
port=int(self.KEYDB_REMOTE_PORT),
|
||||
password=self.KEYDB_REMOTE_PASSWORD,
|
||||
db=int(self.KEYDB_REMOTE_DB),
|
||||
)
|
||||
|
||||
@property
|
||||
def KEYDB_REMOTE_URL(self) -> str:
|
||||
return self.KEYDB_MARKET_URL
|
||||
|
||||
@property
|
||||
def KEYDB_CACHE_URL(self) -> str | None:
|
||||
if self.KEYDB_HOST is None or not self.KEYDB_HOST.strip():
|
||||
return None
|
||||
if self.KEYDB_PORT is None:
|
||||
return None
|
||||
db = int(self.KEYDB_DB) if self.KEYDB_DB is not None else 0
|
||||
return self._redis_url(
|
||||
host=self.KEYDB_HOST.strip(),
|
||||
port=int(self.KEYDB_PORT),
|
||||
password=self.KEYDB_PASSWORD,
|
||||
db=db,
|
||||
)
|
||||
|
||||
@property
|
||||
def RABBIT_URL(self) -> str:
|
||||
@@ -144,7 +382,7 @@ class Settings(BaseSettings):
|
||||
|
||||
@property
|
||||
def EXCLUDED_PATHS(self) -> List[str]:
|
||||
return ["/docs", "/redoc", "/openapi.json", "/ping", "/health"]
|
||||
return ["/docs", "/redoc", "/openapi.json", "/ping", "/health", '/healthcheck']
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
from src.infrastructure.database.models.base import Base
|
||||
from src.infrastructure.database.models.order import Order
|
||||
from src.infrastructure.database.models.payment import Payment
|
||||
from src.infrastructure.database.models.sbp_withdrawal import SbpWithdrawal
|
||||
from src.infrastructure.database.models.user import UserModel
|
||||
from src.infrastructure.database.models.sessions import Session
|
||||
from src.infrastructure.database.models.risk import AuditEventModel,RiskAssessmentModel
|
||||
|
||||
__all__ = ['Base', 'UserModel', 'Session']
|
||||
|
||||
__all__ = ['Base','Order','Payment','SbpWithdrawal','UserModel','RiskAssessmentModel','AuditEventModel']
|
||||
|
||||
54
src/infrastructure/database/models/order.py
Normal file
54
src/infrastructure/database/models/order.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from sqlalchemy import DateTime, Enum as SAEnum, ForeignKey, Numeric, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from src.application.domain.enums import OrderStatus
|
||||
from src.infrastructure.database.models.base import Base
|
||||
from src.infrastructure.database.models.mixins import AuditTimestampsMixin, UlidPrimaryKeyMixin
|
||||
|
||||
|
||||
class Order(Base, UlidPrimaryKeyMixin, AuditTimestampsMixin):
|
||||
__tablename__ = 'orders'
|
||||
|
||||
user_id: Mapped[str] = mapped_column(
|
||||
String(26),
|
||||
ForeignKey('users.id', ondelete='RESTRICT'),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
usdt_amount: Mapped[Decimal] = mapped_column(Numeric(38, 2), nullable=False)
|
||||
usdt_exchange_rate: Mapped[Decimal] = mapped_column(Numeric(38, 2), nullable=False)
|
||||
gas_fee: Mapped[Decimal] = mapped_column(Numeric(38, 2), nullable=False)
|
||||
total_price: Mapped[Decimal] = mapped_column(Numeric(38, 2), nullable=False)
|
||||
service_fee: Mapped[Decimal] = mapped_column(Numeric(38, 2), nullable=False)
|
||||
status: Mapped[OrderStatus] = mapped_column(
|
||||
SAEnum(OrderStatus,name='order_status_enum',values_callable=lambda x:[e.value for e in x]),
|
||||
nullable=False,
|
||||
index=True,
|
||||
default=OrderStatus.PENDING,
|
||||
)
|
||||
|
||||
client_payment_id: Mapped[str] = mapped_column(
|
||||
String(26),
|
||||
nullable=False,
|
||||
unique=True,
|
||||
index=True
|
||||
)
|
||||
|
||||
itpay_payment_qr_url_desktop: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
itpay_payment_qr_url_android: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
itpay_payment_qr_url_ios: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
itpay_payment_qr_image_desktop: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
itpay_payment_qr_image_android: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
itpay_payment_qr_image_ios: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
itpay_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
itpay_qr_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
itpay_amount: Mapped[Decimal] = mapped_column(Numeric(38, 2), nullable=False)
|
||||
itpay_created_at: Mapped[DateTime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False
|
||||
)
|
||||
|
||||
47
src/infrastructure/database/models/payment.py
Normal file
47
src/infrastructure/database/models/payment.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from sqlalchemy import DateTime,Enum as SAEnum,ForeignKey,Numeric,String,UniqueConstraint,Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from src.application.domain.enums import PaymentStatus
|
||||
from src.infrastructure.database.models.base import Base
|
||||
from src.infrastructure.database.models.mixins import AuditTimestampsMixin, UlidPrimaryKeyMixin
|
||||
|
||||
|
||||
class Payment(Base, UlidPrimaryKeyMixin, AuditTimestampsMixin):
|
||||
__tablename__ = 'payments'
|
||||
__table_args__ = (
|
||||
UniqueConstraint('order_id', name='uq_payments_order_id'),
|
||||
)
|
||||
|
||||
user_id: Mapped[str] = mapped_column(
|
||||
String(26),
|
||||
ForeignKey('users.id', ondelete='RESTRICT'),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
order_id: Mapped[str] = mapped_column(
|
||||
String(26),
|
||||
ForeignKey('orders.id', ondelete='RESTRICT'),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
status: Mapped[PaymentStatus] = mapped_column(
|
||||
SAEnum(PaymentStatus,name='payment_status_enum',values_callable=lambda x:[e.value for e in x]),
|
||||
nullable=False,
|
||||
index=True,
|
||||
default=PaymentStatus.PENDING,
|
||||
)
|
||||
|
||||
receipt_cloudekassir_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
receipt_cloudekassir_link: Mapped[str | None] = mapped_column(nullable=True)
|
||||
|
||||
itpay_payment_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
itpay_paid_amount: Mapped[Decimal | None] = mapped_column(Numeric(38, 2), nullable=True)
|
||||
transaction_id: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
web3_transaction_hash: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
paid_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
expired_date: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
32
src/infrastructure/database/models/risk.py
Normal file
32
src/infrastructure/database/models/risk.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, func, text
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from src.infrastructure.database.models.base import Base
|
||||
from src.infrastructure.database.models.mixins import AuditTimestampsMixin, UlidPrimaryKeyMixin
|
||||
|
||||
|
||||
class RiskAssessmentModel(Base, UlidPrimaryKeyMixin, AuditTimestampsMixin):
|
||||
__tablename__ = "risk_assessments"
|
||||
|
||||
user_id: Mapped[str] = mapped_column(String(26), ForeignKey("users.id", ondelete="RESTRICT"), nullable=False, index=True)
|
||||
order_id: Mapped[str | None] = mapped_column(String(26), ForeignKey("orders.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
subject_type: Mapped[str] = mapped_column(String(32), nullable=False, default="individual", server_default="individual", index=True)
|
||||
score: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
decision: Mapped[str] = mapped_column(String(32), nullable=False, default="allow", index=True)
|
||||
reasons: Mapped[list[str]] = mapped_column(JSONB, nullable=False, default=list, server_default=text("'[]'::jsonb"))
|
||||
|
||||
|
||||
class AuditEventModel(Base, UlidPrimaryKeyMixin):
|
||||
__tablename__ = "audit_events"
|
||||
|
||||
actor_type: Mapped[str] = mapped_column(String(32), nullable=False, default="system", server_default="system", index=True)
|
||||
actor_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
action: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
|
||||
entity_type: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
entity_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
severity: Mapped[str] = mapped_column(String(32), nullable=False, default="info", server_default="info", index=True)
|
||||
metadata_json: Mapped[dict] = mapped_column("metadata", JSONB, nullable=False, default=dict, server_default=text("'{}'::jsonb"))
|
||||
created_at: Mapped[DateTime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
39
src/infrastructure/database/models/sbp_withdrawal.py
Normal file
39
src/infrastructure/database/models/sbp_withdrawal.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from sqlalchemy import DateTime,Enum as SAEnum,ForeignKey,Numeric,String,Text
|
||||
from sqlalchemy.orm import Mapped,mapped_column
|
||||
from src.application.domain.enums import SbpWithdrawalStatus
|
||||
from src.infrastructure.database.models.base import Base
|
||||
from src.infrastructure.database.models.mixins import AuditTimestampsMixin,UlidPrimaryKeyMixin
|
||||
|
||||
|
||||
class SbpWithdrawal(Base,UlidPrimaryKeyMixin,AuditTimestampsMixin):
|
||||
__tablename__='sbp_withdrawals'
|
||||
|
||||
user_id: Mapped[str]=mapped_column(
|
||||
String(26),
|
||||
ForeignKey('users.id',ondelete='RESTRICT'),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
bank_id: Mapped[str]=mapped_column(String(32),nullable=False,index=True)
|
||||
bank_name: Mapped[str]=mapped_column(String(255),nullable=False)
|
||||
phone: Mapped[str]=mapped_column(String(32),nullable=False)
|
||||
usdt_amount: Mapped[Decimal]=mapped_column(Numeric(38,2),nullable=False)
|
||||
rub_amount: Mapped[Decimal]=mapped_column(Numeric(38,2),nullable=False)
|
||||
usdt_exchange_rate: Mapped[Decimal]=mapped_column(Numeric(38,2),nullable=False)
|
||||
service_fee_rate: Mapped[Decimal]=mapped_column(Numeric(8,4),nullable=False)
|
||||
service_fee_usdt: Mapped[Decimal]=mapped_column(Numeric(38,2),nullable=False)
|
||||
mozen_order_id: Mapped[str | None]=mapped_column(String(128),nullable=True,index=True)
|
||||
trace_id: Mapped[str]=mapped_column(String(64),nullable=False,index=True)
|
||||
status: Mapped[SbpWithdrawalStatus]=mapped_column(
|
||||
SAEnum(SbpWithdrawalStatus,name='sbp_withdrawal_status_enum',values_callable=lambda x:[e.value for e in x]),
|
||||
nullable=False,
|
||||
index=True,
|
||||
default=SbpWithdrawalStatus.CREATED,
|
||||
)
|
||||
provider_error: Mapped[str | None]=mapped_column(Text,nullable=True)
|
||||
usdt_received_at: Mapped[datetime | None]=mapped_column(DateTime(timezone=True),nullable=True)
|
||||
payout_created_at: Mapped[datetime | None]=mapped_column(DateTime(timezone=True),nullable=True)
|
||||
payout_completed_at: Mapped[datetime | None]=mapped_column(DateTime(timezone=True),nullable=True)
|
||||
@@ -1,50 +0,0 @@
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import String, DateTime, ForeignKey, Index
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from ulid import ULID
|
||||
from src.infrastructure.database.models import Base
|
||||
from src.infrastructure.database.models.mixins import UlidPrimaryKeyMixin, AuditTimestampsMixin
|
||||
|
||||
|
||||
class Session(Base, UlidPrimaryKeyMixin, AuditTimestampsMixin):
|
||||
__tablename__ = "sessions"
|
||||
|
||||
sid: Mapped[str] = mapped_column(
|
||||
String(26),
|
||||
unique=True,
|
||||
index=True,
|
||||
nullable=False,
|
||||
default=lambda: str(ULID()),
|
||||
)
|
||||
|
||||
user_id: Mapped[str] = mapped_column(
|
||||
String(26),
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
index=True,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
device_id: Mapped[str] = mapped_column(
|
||||
String(26),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
user_agent: Mapped[str | None] = mapped_column(String(500))
|
||||
first_ip: Mapped[str | None] = mapped_column(String(64))
|
||||
last_ip: Mapped[str | None] = mapped_column(String(64))
|
||||
|
||||
last_seen_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
refresh_jti_hash: Mapped[str | None] = mapped_column(String(255))
|
||||
refresh_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
|
||||
Index("ux_sessions_user_device", Session.user_id, Session.device_id, unique=True)
|
||||
Index("ix_sessions_user_active", Session.user_id, Session.revoked_at)
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
from sqlalchemy import Boolean, Date, String, DateTime
|
||||
|
||||
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
|
||||
@@ -16,13 +17,17 @@ class UserModel(Base, UlidPrimaryKeyMixin, AuditTimestampsMixin, SoftDeleteMixin
|
||||
middle_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
birth_date: Mapped[Date | None] = mapped_column(Date, nullable=True)
|
||||
|
||||
crypto_wallet: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
encrypted_mnemonic: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
phone: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||
|
||||
bik: Mapped[str | None] = mapped_column(String(9), nullable=True)
|
||||
account_number: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
card_number: Mapped[str | None] = mapped_column(String(19), nullable=True)
|
||||
passport_data: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
inn: Mapped[str | None] = mapped_column(String(12), nullable=True)
|
||||
erc20: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
|
||||
avatar_link: Mapped[str | None] = mapped_column(String(2048), nullable=True)
|
||||
|
||||
kyc_verified: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default='false', default=False)
|
||||
kyc_verified_at: Mapped[DateTime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
account_type: Mapped[str] = mapped_column(String(20), nullable=False, server_default='individual', default='individual')
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
from src.infrastructure.database.repositories.user_repository import UserRepository
|
||||
182
src/infrastructure/database/repositories/order_repository.py
Normal file
182
src/infrastructure/database/repositories/order_repository.py
Normal file
@@ -0,0 +1,182 @@
|
||||
from __future__ import annotations
|
||||
from dataclasses import replace
|
||||
from datetime import datetime,timezone
|
||||
from decimal import Decimal
|
||||
from sqlalchemy import desc,func,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,cursor_created_at=None) -> list[OrderEntity]:
|
||||
stmt=(
|
||||
select(Order)
|
||||
.where(Order.user_id==user_id)
|
||||
.order_by(desc(Order.created_at))
|
||||
.limit(limit)
|
||||
)
|
||||
if cursor_created_at is not None:
|
||||
stmt=stmt.where(Order.created_at < cursor_created_at)
|
||||
else:
|
||||
stmt=stmt.offset(offset)
|
||||
result=await self._session.scalars(stmt)
|
||||
return [self._to_entity(model) for model in result.all()]
|
||||
|
||||
|
||||
async def count_recent_by_user(self,*,user_id: str,since: datetime) -> int:
|
||||
stmt = select(func.count()).select_from(Order).where(Order.user_id == user_id, Order.created_at >= since)
|
||||
result = await self._session.execute(stmt)
|
||||
return int(result.scalar_one())
|
||||
|
||||
|
||||
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()
|
||||
127
src/infrastructure/database/repositories/payment_repository.py
Normal file
127
src/infrastructure/database/repositories/payment_repository.py
Normal file
@@ -0,0 +1,127 @@
|
||||
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:
|
||||
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
|
||||
stmt=(
|
||||
insert(Payment)
|
||||
.values(
|
||||
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,
|
||||
)
|
||||
.on_conflict_do_nothing(index_elements=[Payment.order_id])
|
||||
)
|
||||
result=await self._session.execute(stmt)
|
||||
await self._session.flush()
|
||||
return bool(result.rowcount)
|
||||
|
||||
|
||||
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,cursor_created_at=None) -> list[PaymentEntity]:
|
||||
stmt=(
|
||||
select(Payment)
|
||||
.where(Payment.user_id==user_id)
|
||||
.order_by(desc(Payment.created_at))
|
||||
.limit(limit)
|
||||
)
|
||||
if cursor_created_at is not None:
|
||||
stmt=stmt.where(Payment.created_at < cursor_created_at)
|
||||
else:
|
||||
stmt=stmt.offset(offset)
|
||||
result=await self._session.scalars(stmt)
|
||||
return [self._to_entity(model) for model in result.all()]
|
||||
|
||||
43
src/infrastructure/database/repositories/risk_repository.py
Normal file
43
src/infrastructure/database/repositories/risk_repository.py
Normal file
@@ -0,0 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.application.abstractions.repositories.i_risk_repository import IRiskRepository
|
||||
from src.application.contracts import ILogger
|
||||
from src.application.domain.entities.risk import AuditEventEntity, RiskAssessmentEntity
|
||||
from src.infrastructure.database.models.risk import AuditEventModel, RiskAssessmentModel
|
||||
|
||||
|
||||
class RiskRepository(IRiskRepository):
|
||||
def __init__(self, session: AsyncSession, logger: ILogger):
|
||||
self._session = session
|
||||
self._logger = logger
|
||||
|
||||
async def create_assessment(self, assessment: RiskAssessmentEntity) -> RiskAssessmentEntity:
|
||||
model = RiskAssessmentModel(
|
||||
user_id=assessment.user_id,
|
||||
order_id=assessment.order_id,
|
||||
subject_type=assessment.subject_type,
|
||||
score=assessment.score,
|
||||
decision=assessment.decision,
|
||||
reasons=assessment.reasons,
|
||||
)
|
||||
self._session.add(model)
|
||||
await self._session.flush()
|
||||
return replace(assessment, id=model.id, created_at=model.created_at, updated_at=model.updated_at)
|
||||
|
||||
async def create_audit_event(self, event: AuditEventEntity) -> AuditEventEntity:
|
||||
model = AuditEventModel(
|
||||
actor_type=event.actor_type,
|
||||
actor_id=event.actor_id,
|
||||
action=event.action,
|
||||
entity_type=event.entity_type,
|
||||
entity_id=event.entity_id,
|
||||
severity=event.severity,
|
||||
metadata_json=event.metadata or {},
|
||||
)
|
||||
self._session.add(model)
|
||||
await self._session.flush()
|
||||
return replace(event, id=model.id, created_at=model.created_at)
|
||||
@@ -0,0 +1,140 @@
|
||||
from __future__ import annotations
|
||||
from dataclasses import replace
|
||||
from datetime import datetime,timezone
|
||||
from sqlalchemy import desc,select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from src.application.abstractions.repositories import ISbpWithdrawalRepository
|
||||
from src.application.contracts import ILogger
|
||||
from src.application.domain.entities import SbpWithdrawalEntity
|
||||
from src.application.domain.enums import SbpWithdrawalStatus
|
||||
from src.infrastructure.database.models.sbp_withdrawal import SbpWithdrawal
|
||||
|
||||
|
||||
class SbpWithdrawalRepository(ISbpWithdrawalRepository):
|
||||
def __init__(self,session: AsyncSession,logger: ILogger):
|
||||
self._session=session
|
||||
self._logger=logger
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _to_entity(model: SbpWithdrawal) -> SbpWithdrawalEntity:
|
||||
return SbpWithdrawalEntity(
|
||||
id=model.id,
|
||||
created_at=model.created_at,
|
||||
updated_at=model.updated_at,
|
||||
user_id=model.user_id,
|
||||
bank_id=model.bank_id,
|
||||
bank_name=model.bank_name,
|
||||
phone=model.phone,
|
||||
usdt_amount=model.usdt_amount,
|
||||
rub_amount=model.rub_amount,
|
||||
usdt_exchange_rate=model.usdt_exchange_rate,
|
||||
service_fee_rate=model.service_fee_rate,
|
||||
service_fee_usdt=model.service_fee_usdt,
|
||||
mozen_order_id=model.mozen_order_id,
|
||||
trace_id=model.trace_id,
|
||||
status=model.status,
|
||||
provider_error=model.provider_error,
|
||||
usdt_received_at=model.usdt_received_at,
|
||||
payout_created_at=model.payout_created_at,
|
||||
payout_completed_at=model.payout_completed_at,
|
||||
)
|
||||
|
||||
|
||||
async def create(self,withdrawal: SbpWithdrawalEntity) -> SbpWithdrawalEntity:
|
||||
values={
|
||||
'user_id':withdrawal.user_id,
|
||||
'bank_id':withdrawal.bank_id,
|
||||
'bank_name':withdrawal.bank_name,
|
||||
'phone':withdrawal.phone,
|
||||
'usdt_amount':withdrawal.usdt_amount,
|
||||
'rub_amount':withdrawal.rub_amount,
|
||||
'usdt_exchange_rate':withdrawal.usdt_exchange_rate,
|
||||
'service_fee_rate':withdrawal.service_fee_rate,
|
||||
'service_fee_usdt':withdrawal.service_fee_usdt,
|
||||
'mozen_order_id':withdrawal.mozen_order_id,
|
||||
'trace_id':withdrawal.trace_id,
|
||||
'status':withdrawal.status or SbpWithdrawalStatus.CREATED,
|
||||
'provider_error':withdrawal.provider_error,
|
||||
'usdt_received_at':withdrawal.usdt_received_at,
|
||||
'payout_created_at':withdrawal.payout_created_at,
|
||||
'payout_completed_at':withdrawal.payout_completed_at,
|
||||
}
|
||||
if withdrawal.id is not None:
|
||||
values['id']=withdrawal.id
|
||||
model=SbpWithdrawal(**values)
|
||||
self._session.add(model)
|
||||
await self._session.flush()
|
||||
return replace(withdrawal,id=model.id,created_at=model.created_at,updated_at=model.updated_at)
|
||||
|
||||
|
||||
async def get_by_id(self,withdraw_id: str) -> SbpWithdrawalEntity | None:
|
||||
stmt=select(SbpWithdrawal).where(SbpWithdrawal.id==withdraw_id)
|
||||
model=await self._session.scalar(stmt)
|
||||
if model is None:
|
||||
return None
|
||||
return self._to_entity(model)
|
||||
|
||||
|
||||
async def get_by_id_for_user(self,*,withdraw_id: str,user_id: str) -> SbpWithdrawalEntity | None:
|
||||
stmt=select(SbpWithdrawal).where(SbpWithdrawal.id==withdraw_id,SbpWithdrawal.user_id==user_id)
|
||||
model=await self._session.scalar(stmt)
|
||||
if model is None:
|
||||
return None
|
||||
return self._to_entity(model)
|
||||
|
||||
|
||||
async def list_by_user_id(self,*,user_id: str,limit: int,offset: int,cursor_created_at=None) -> list[SbpWithdrawalEntity]:
|
||||
stmt=(
|
||||
select(SbpWithdrawal)
|
||||
.where(SbpWithdrawal.user_id==user_id)
|
||||
.order_by(desc(SbpWithdrawal.created_at))
|
||||
.limit(limit)
|
||||
)
|
||||
if cursor_created_at is not None:
|
||||
stmt=stmt.where(SbpWithdrawal.created_at < cursor_created_at)
|
||||
else:
|
||||
stmt=stmt.offset(offset)
|
||||
result=await self._session.scalars(stmt)
|
||||
return [self._to_entity(model) for model in result.all()]
|
||||
|
||||
|
||||
async def update_status(
|
||||
self,
|
||||
*,
|
||||
withdraw_id: str,
|
||||
status: SbpWithdrawalStatus,
|
||||
provider_error: str | None = None,
|
||||
) -> None:
|
||||
model=await self._session.get(SbpWithdrawal,withdraw_id)
|
||||
if model is None:
|
||||
return
|
||||
model.status=status
|
||||
model.provider_error=provider_error
|
||||
if status==SbpWithdrawalStatus.USDT_RECEIVED:
|
||||
model.usdt_received_at=datetime.now(timezone.utc)
|
||||
if status==SbpWithdrawalStatus.PAYOUT_PROCESSING:
|
||||
model.payout_created_at=datetime.now(timezone.utc)
|
||||
if status==SbpWithdrawalStatus.PAYOUT_COMPLETED:
|
||||
model.payout_completed_at=datetime.now(timezone.utc)
|
||||
await self._session.flush()
|
||||
|
||||
|
||||
async def update_payout_created(
|
||||
self,
|
||||
*,
|
||||
withdraw_id: str,
|
||||
mozen_order_id: str | None,
|
||||
status: SbpWithdrawalStatus,
|
||||
provider_error: str | None,
|
||||
) -> None:
|
||||
model=await self._session.get(SbpWithdrawal,withdraw_id)
|
||||
if model is None:
|
||||
return
|
||||
model.mozen_order_id=mozen_order_id
|
||||
model.status=status
|
||||
model.provider_error=provider_error
|
||||
model.payout_created_at=datetime.now(timezone.utc)
|
||||
if status==SbpWithdrawalStatus.PAYOUT_COMPLETED:
|
||||
model.payout_completed_at=datetime.now(timezone.utc)
|
||||
await self._session.flush()
|
||||
@@ -1,13 +1,11 @@
|
||||
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):
|
||||
@@ -15,100 +13,39 @@ class UserRepository(IUserRepository):
|
||||
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:
|
||||
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)
|
||||
|
||||
@@ -1,21 +1,32 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
from src.application.abstractions import IUnitOfWork
|
||||
from src.application.abstractions.repositories import IUserRepository, ISessionRepository
|
||||
from src.application.abstractions.repositories import IOrderRepository, IPaymentRepository, ISbpWithdrawalRepository, IUserRepository, IRiskRepository
|
||||
from src.application.contracts import ILogger
|
||||
from src.infrastructure.database.repositories import UserRepository, SessionRepository
|
||||
|
||||
from src.infrastructure.database.repositories.order_repository import OrderRepository
|
||||
from src.infrastructure.database.repositories.payment_repository import PaymentRepository
|
||||
from src.infrastructure.database.repositories.sbp_withdrawal_repository import SbpWithdrawalRepository
|
||||
from src.infrastructure.database.repositories.user_repository import UserRepository
|
||||
from src.infrastructure.database.repositories.risk_repository import RiskRepository
|
||||
|
||||
|
||||
class UnitOfWork(IUnitOfWork):
|
||||
def __init__(self, session_factory: async_sessionmaker[AsyncSession], logger: ILogger):
|
||||
self.session_factory = session_factory
|
||||
self._session: AsyncSession = None
|
||||
self._user_repository: IUserRepository = None
|
||||
self._session_repository: ISessionRepository = None
|
||||
self._order_repository: IOrderRepository | None = None
|
||||
self._payment_repository: IPaymentRepository | None = None
|
||||
self._sbp_withdrawal_repository: ISbpWithdrawalRepository | None = None
|
||||
self._user_repository: IUserRepository | None = None
|
||||
self._risk_repository: IRiskRepository | None = None
|
||||
self._logger: ILogger = logger
|
||||
|
||||
async def __aenter__(self):
|
||||
self._session = self.session_factory()
|
||||
self._order_repository = None
|
||||
self._payment_repository = None
|
||||
self._sbp_withdrawal_repository = None
|
||||
self._user_repository = None
|
||||
self._risk_repository = None
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
@@ -29,6 +40,24 @@ class UnitOfWork(IUnitOfWork):
|
||||
self._logger.debug('Commit')
|
||||
await self._session.close()
|
||||
|
||||
@property
|
||||
def order_repository(self) -> IOrderRepository:
|
||||
if self._order_repository is None:
|
||||
self._order_repository = OrderRepository(session=self._session, logger=self._logger)
|
||||
return self._order_repository
|
||||
|
||||
@property
|
||||
def payment_repository(self) -> IPaymentRepository:
|
||||
if self._payment_repository is None:
|
||||
self._payment_repository = PaymentRepository(session=self._session, logger=self._logger)
|
||||
return self._payment_repository
|
||||
|
||||
@property
|
||||
def sbp_withdrawal_repository(self) -> ISbpWithdrawalRepository:
|
||||
if self._sbp_withdrawal_repository is None:
|
||||
self._sbp_withdrawal_repository = SbpWithdrawalRepository(session=self._session, logger=self._logger)
|
||||
return self._sbp_withdrawal_repository
|
||||
|
||||
@property
|
||||
def user_repository(self) -> IUserRepository:
|
||||
if self._user_repository is None:
|
||||
@@ -36,7 +65,7 @@ class UnitOfWork(IUnitOfWork):
|
||||
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
|
||||
def risk_repository(self) -> IRiskRepository:
|
||||
if self._risk_repository is None:
|
||||
self._risk_repository = RiskRepository(session=self._session, logger=self._logger)
|
||||
return self._risk_repository
|
||||
|
||||
123
src/infrastructure/itpay/client.py
Normal file
123
src/infrastructure/itpay/client.py
Normal file
@@ -0,0 +1,123 @@
|
||||
import orjson
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal,InvalidOperation
|
||||
from typing import Any
|
||||
import aiohttp
|
||||
from aiohttp import BasicAuth, ClientTimeout
|
||||
from src.application.contracts.i_itpay_service import IItPayService
|
||||
from src.application.domain.entities.order import OrderEntity
|
||||
from src.application.domain.enums import OrderStatus
|
||||
from src.application.domain.exceptions import ApplicationException,PaymentProviderException
|
||||
|
||||
|
||||
class ItPayClient(IItPayService):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
public_id: str,
|
||||
api_secret: str,
|
||||
api_base_url: str = 'https://api.gw.itpay.ru',
|
||||
timeout_seconds: float = 30,
|
||||
) -> None:
|
||||
self._api_base_url = api_base_url.rstrip('/')
|
||||
self._public_id = public_id
|
||||
self._api_secret = api_secret
|
||||
self._timeout = ClientTimeout(total=timeout_seconds)
|
||||
|
||||
async def create_payment(self, order: OrderEntity, trace_id: str) -> OrderEntity:
|
||||
total = order.total_price if order.total_price is not None else Decimal('0')
|
||||
amount = total if isinstance(total, Decimal) else Decimal(str(total))
|
||||
amount_str = str(amount.quantize(Decimal('0.01')))
|
||||
metadata: dict[str,Any] = {
|
||||
'trace_id': trace_id,
|
||||
'order_id': order.id,
|
||||
'user_id': order.user_id,
|
||||
'usdt_amount': str(order.usdt_amount) if order.usdt_amount is not None else None,
|
||||
'usdt_exchange_rate': str(order.usdt_exchange_rate) if order.usdt_exchange_rate is not None else None,
|
||||
'gas_fee': str(order.gas_fee) if order.gas_fee is not None else None,
|
||||
'service_fee': str(order.service_fee) if order.service_fee is not None else None,
|
||||
'agent_fee': str(order.service_fee) if order.service_fee is not None else None,
|
||||
'amount': amount_str,
|
||||
'total_amount': amount_str,
|
||||
}
|
||||
if order.total_price is not None and order.service_fee is not None:
|
||||
principal = (Decimal(str(order.total_price)) - Decimal(str(order.service_fee))).quantize(Decimal('0.01'))
|
||||
metadata['principal_amount'] = str(principal)
|
||||
metadata = {k:v for k,v in metadata.items() if v is not None and v != ''}
|
||||
payload: dict[str, Any] = {
|
||||
'amount': amount_str,
|
||||
'client_payment_id': order.client_payment_id or '',
|
||||
'method': 'sbp',
|
||||
'description': 'CFU',
|
||||
'metadata': metadata,
|
||||
}
|
||||
url = f'{self._api_base_url}/v1/payments'
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
}
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=self._timeout) as session:
|
||||
auth = BasicAuth(self._public_id, self._api_secret)
|
||||
async with session.post(url, json=payload, headers=headers, auth=auth) as resp:
|
||||
response_text = await resp.text()
|
||||
try:
|
||||
response_json: dict[str, Any] = orjson.loads(response_text)
|
||||
except orjson.JSONDecodeError:
|
||||
response_json = {'raw': response_text}
|
||||
if resp.status >= 400:
|
||||
raise PaymentProviderException(message='Payment provider error')
|
||||
body_raw = response_json.get('data')
|
||||
body = body_raw if isinstance(body_raw, dict) else response_json
|
||||
|
||||
status = str(body.get('status') or '').strip().lower()
|
||||
itpay_id = str(body.get('id') or '').strip()
|
||||
if not status or not itpay_id:
|
||||
raise PaymentProviderException(message='Payment provider response invalid')
|
||||
|
||||
if status == 'cancelled':
|
||||
return replace(order, status=OrderStatus.CANCELLED, itpay_id=itpay_id)
|
||||
if status == 'rejected':
|
||||
return replace(order, status=OrderStatus.REJECTED, itpay_id=itpay_id)
|
||||
if status == 'error':
|
||||
return replace(order, status=OrderStatus.ERROR, itpay_id=itpay_id)
|
||||
|
||||
qrc_id = str(body.get('qrc_id') or '').strip()
|
||||
if not qrc_id:
|
||||
raise PaymentProviderException(message='Payment provider response invalid')
|
||||
itpay_amount = Decimal(str(body.get('amount')))
|
||||
|
||||
created_norm = str(body.get('created') or '').replace('Z', '+00:00')
|
||||
itpay_created_at = datetime.fromisoformat(created_norm)
|
||||
|
||||
payment_qr_urls = body.get('payment_qr_urls')
|
||||
if isinstance(payment_qr_urls, str):
|
||||
payment_qr_urls = orjson.loads(payment_qr_urls)
|
||||
|
||||
payment_qr_images = body.get('payment_qr_images')
|
||||
if isinstance(payment_qr_images, str):
|
||||
payment_qr_images = orjson.loads(payment_qr_images)
|
||||
if not isinstance(payment_qr_urls,dict) or not isinstance(payment_qr_images,dict):
|
||||
raise PaymentProviderException(message='Payment provider response invalid')
|
||||
|
||||
return replace(
|
||||
order,
|
||||
itpay_id=itpay_id,
|
||||
itpay_qr_id=qrc_id,
|
||||
itpay_created_at=itpay_created_at,
|
||||
itpay_amount=itpay_amount,
|
||||
itpay_payment_qr_url_android=str(payment_qr_urls['android']),
|
||||
itpay_payment_qr_url_ios=str(payment_qr_urls['ios']),
|
||||
itpay_payment_qr_url_desktop=str(payment_qr_urls['desktop']),
|
||||
itpay_payment_qr_image_android=str(payment_qr_images['android']),
|
||||
itpay_payment_qr_image_ios=str(payment_qr_images['ios']),
|
||||
itpay_payment_qr_image_desktop=str(payment_qr_images['desktop']),
|
||||
)
|
||||
except ApplicationException:
|
||||
raise
|
||||
except (TimeoutError,aiohttp.ClientError):
|
||||
raise PaymentProviderException(message='Payment provider unreachable')
|
||||
except (KeyError,ValueError,TypeError,InvalidOperation,orjson.JSONDecodeError) as exception:
|
||||
raise PaymentProviderException(message='Payment provider response invalid') from exception
|
||||
@@ -1,7 +1,7 @@
|
||||
import traceback
|
||||
import inspect
|
||||
import sys
|
||||
import json
|
||||
import orjson
|
||||
from datetime import datetime
|
||||
from typing import Callable, Optional, Any
|
||||
from ulid import ULID
|
||||
@@ -59,7 +59,7 @@ class Logger(ILogger):
|
||||
def clear_trace_id(self) -> None:
|
||||
trace_id_var.set("N/A")
|
||||
|
||||
def _prepare_log_data(self, level: LogLevel, message: str) -> dict[str, Any]:
|
||||
def _prepare_log_data(self, level: LogLevel, message: Any) -> dict[str, Any]:
|
||||
current_frame = inspect.currentframe()
|
||||
if (
|
||||
current_frame
|
||||
@@ -75,34 +75,37 @@ class Logger(ILogger):
|
||||
line_number = 0
|
||||
|
||||
log_data = {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"level": level.name,
|
||||
"instance_id": self.instance_id,
|
||||
"file": filename,
|
||||
"line": line_number,
|
||||
"trace_id": trace_id_var.get(),
|
||||
"message": message,
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'level': level.name,
|
||||
'instance_id': self.instance_id,
|
||||
'file': filename,
|
||||
'line': line_number,
|
||||
'trace_id': trace_id_var.get(),
|
||||
}
|
||||
if isinstance(message, dict):
|
||||
log_data.update(message)
|
||||
else:
|
||||
log_data['message'] = message
|
||||
|
||||
if level == LogLevel.EXCEPTION:
|
||||
log_data["exception"] = traceback.format_exc()
|
||||
log_data['exception'] = traceback.format_exc()
|
||||
|
||||
return log_data
|
||||
|
||||
def _log(self, level: LogLevel, message: str) -> None:
|
||||
def _log(self, level: LogLevel, message: Any) -> None:
|
||||
if level >= self.min_level:
|
||||
log_data = self._prepare_log_data(level, message)
|
||||
|
||||
if self.log_format == LogFormat.JSON:
|
||||
log_message = json.dumps(log_data, ensure_ascii=False)
|
||||
log_message = orjson.dumps(log_data).decode()
|
||||
else:
|
||||
log_message = (
|
||||
f"{log_data['timestamp']} - {log_data['level']} - "
|
||||
f"{log_data['instance_id']} - {log_data['trace_id']} - "
|
||||
f"{log_data['file']}:{log_data['line']} - "
|
||||
f"{log_data['message']}"
|
||||
f"{log_data.get('message', log_data.get('event', ''))}"
|
||||
)
|
||||
if "exception" in log_data:
|
||||
if 'exception' in log_data:
|
||||
log_message += f"\nTraceback:\n{log_data['exception']}"
|
||||
|
||||
self._write(log_message)
|
||||
@@ -110,20 +113,20 @@ class Logger(ILogger):
|
||||
def _write(self, message: str) -> None:
|
||||
sys.stdout.write(message + "\n")
|
||||
|
||||
def debug(self, message: str) -> None:
|
||||
def debug(self, message: Any) -> None:
|
||||
self._log(LogLevel.DEBUG, message)
|
||||
|
||||
def info(self, message: str) -> None:
|
||||
def info(self, message: Any) -> None:
|
||||
self._log(LogLevel.INFO, message)
|
||||
|
||||
def warning(self, message: str) -> None:
|
||||
def warning(self, message: Any) -> None:
|
||||
self._log(LogLevel.WARNING, message)
|
||||
|
||||
def error(self, message: str) -> None:
|
||||
def error(self, message: Any) -> None:
|
||||
self._log(LogLevel.ERROR, message)
|
||||
|
||||
def critical(self, message: str) -> None:
|
||||
def critical(self, message: Any) -> None:
|
||||
self._log(LogLevel.CRITICAL, message)
|
||||
|
||||
def exception(self, message: str) -> None:
|
||||
def exception(self, message: Any) -> None:
|
||||
self._log(LogLevel.EXCEPTION, message)
|
||||
1
src/infrastructure/messanger/__init__.py
Normal file
1
src/infrastructure/messanger/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from src.infrastructure.messanger.rabbit_client import RabbitClient
|
||||
72
src/infrastructure/messanger/rabbit_client.py
Normal file
72
src/infrastructure/messanger/rabbit_client.py
Normal file
@@ -0,0 +1,72 @@
|
||||
from typing import Any, Mapping
|
||||
from faststream.rabbit import RabbitBroker
|
||||
from src.application.contracts import IQueueMessanger
|
||||
from src.infrastructure.config import settings
|
||||
|
||||
|
||||
class RabbitClient(IQueueMessanger):
|
||||
def __init__(self) -> None:
|
||||
self._broker = RabbitBroker(
|
||||
settings.RABBIT_URL,
|
||||
)
|
||||
self._connected = False
|
||||
|
||||
async def connect(self) -> None:
|
||||
if self._connected:
|
||||
return
|
||||
await self._broker.connect()
|
||||
self._connected = True
|
||||
|
||||
async def close(self) -> None:
|
||||
if not self._connected:
|
||||
return
|
||||
await self._broker.close()
|
||||
self._connected = False
|
||||
|
||||
async def _ensure_connected(self) -> None:
|
||||
if not self._connected:
|
||||
await self.connect()
|
||||
|
||||
async def publish_to_queue(
|
||||
self,
|
||||
queue: str,
|
||||
message: Any,
|
||||
*,
|
||||
persist: bool | None = None,
|
||||
headers: Mapping[str, Any] | None = None,
|
||||
correlation_id: str | None = None,
|
||||
message_id: str | None = None,
|
||||
) -> None:
|
||||
await self._ensure_connected()
|
||||
|
||||
await self._broker.publish(
|
||||
message,
|
||||
queue=queue,
|
||||
persist=settings.RABBIT_PUBLISH_PERSIST if persist is None else persist,
|
||||
headers=headers,
|
||||
correlation_id=correlation_id,
|
||||
message_id=message_id,
|
||||
)
|
||||
|
||||
async def publish(
|
||||
self,
|
||||
message: Any,
|
||||
*,
|
||||
exchange: str,
|
||||
routing_key: str,
|
||||
persist: bool | None = None,
|
||||
headers: Mapping[str, Any] | None = None,
|
||||
correlation_id: str | None = None,
|
||||
message_id: str | None = None,
|
||||
) -> None:
|
||||
await self._ensure_connected()
|
||||
|
||||
await self._broker.publish(
|
||||
message,
|
||||
exchange=exchange,
|
||||
routing_key=routing_key,
|
||||
persist=settings.RABBIT_PUBLISH_PERSIST if persist is None else persist,
|
||||
headers=headers,
|
||||
correlation_id=correlation_id,
|
||||
message_id=message_id,
|
||||
)
|
||||
174
src/infrastructure/metrics.py
Normal file
174
src/infrastructure/metrics.py
Normal file
@@ -0,0 +1,174 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Request, Response
|
||||
from prometheus_client import CollectorRegistry, Counter, Histogram, CONTENT_TYPE_LATEST, generate_latest, multiprocess
|
||||
from sqlalchemy import func, select, text
|
||||
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
||||
from starlette.responses import Response as StarletteResponse
|
||||
|
||||
from src.infrastructure.database.context import async_session_maker
|
||||
from src.infrastructure.database.models import Order, Payment, RiskAssessmentModel, UserModel
|
||||
|
||||
SERVICE_NAME = "payment"
|
||||
BUSINESS_CACHE_TTL_SECONDS = 30
|
||||
LATENCY_BUCKETS = (0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0)
|
||||
WINDOWS = (("all", None), ("30d", timedelta(days=30)), ("7d", timedelta(days=7)), ("24h", timedelta(days=1)), ("1h", timedelta(hours=1)))
|
||||
|
||||
HTTP_REQUESTS = Counter("payment_http_requests_total", "Total HTTP requests handled by payment service.", ("service", "method", "route", "status_code"))
|
||||
HTTP_LATENCY = Histogram("payment_http_request_duration_seconds", "HTTP request duration in seconds for payment service.", ("service", "method", "route"), buckets=LATENCY_BUCKETS)
|
||||
|
||||
|
||||
def _route_template(request: Request) -> str:
|
||||
route = request.scope.get("route")
|
||||
path = getattr(route, "path", None)
|
||||
return str(path) if path else "__unmatched__"
|
||||
|
||||
|
||||
class PrometheusMetricsMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> StarletteResponse:
|
||||
if request.url.path == "/metrics":
|
||||
return await call_next(request)
|
||||
started = time.perf_counter()
|
||||
status_code = "500"
|
||||
try:
|
||||
response = await call_next(request)
|
||||
status_code = str(response.status_code)
|
||||
return response
|
||||
finally:
|
||||
elapsed = time.perf_counter() - started
|
||||
route = _route_template(request)
|
||||
HTTP_REQUESTS.labels(SERVICE_NAME, request.method, route, status_code).inc()
|
||||
HTTP_LATENCY.labels(SERVICE_NAME, request.method, route).observe(elapsed)
|
||||
|
||||
|
||||
class CachedBusinessMetrics:
|
||||
def __init__(self, ttl_seconds: int, collector: Callable[[], Awaitable[list[str]]]) -> None:
|
||||
self._ttl_seconds = ttl_seconds
|
||||
self._collector = collector
|
||||
self._expires_at = 0.0
|
||||
self._lines: list[str] = []
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def get(self) -> list[str]:
|
||||
now = time.monotonic()
|
||||
if now < self._expires_at:
|
||||
return self._lines
|
||||
async with self._lock:
|
||||
now = time.monotonic()
|
||||
if now < self._expires_at:
|
||||
return self._lines
|
||||
self._lines = await self._collector()
|
||||
self._expires_at = now + self._ttl_seconds
|
||||
return self._lines
|
||||
|
||||
|
||||
def _labels(labels: dict[str, str]) -> str:
|
||||
escaped = {key: value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n") for key, value in labels.items()}
|
||||
return ",".join(f'{key}="{value}"' for key, value in escaped.items())
|
||||
|
||||
|
||||
def _sample(name: str, value: int | float, labels: dict[str, str] | None = None) -> str:
|
||||
return f"{name}{{{_labels(labels)}}} {value}" if labels else f"{name} {value}"
|
||||
|
||||
|
||||
async def _scalar(session: Any, statement: Any) -> int:
|
||||
result = await session.execute(statement)
|
||||
return int(result.scalar_one() or 0)
|
||||
|
||||
|
||||
async def _rows(session: Any, statement: Any) -> list[Any]:
|
||||
result = await session.execute(statement)
|
||||
return list(result.all())
|
||||
|
||||
|
||||
def _status_value(status: Any) -> str:
|
||||
return getattr(status, "value", str(status))
|
||||
|
||||
|
||||
async def _collect_business_metrics() -> list[str]:
|
||||
lines: list[str] = [
|
||||
"# TYPE payment_orders_total gauge",
|
||||
"# TYPE payment_orders_by_status_total gauge",
|
||||
"# TYPE payment_orders_created_total gauge",
|
||||
"# TYPE payments_total gauge",
|
||||
"# TYPE payments_by_status_total gauge",
|
||||
"# TYPE payments_created_total gauge",
|
||||
"# TYPE payments_succeeded_total gauge",
|
||||
"# TYPE payments_failed_total gauge",
|
||||
"# TYPE risk_assessments_total gauge",
|
||||
"# TYPE risk_assessments_by_decision_total gauge",
|
||||
"# TYPE risk_score_average gauge",
|
||||
"# TYPE risk_high_risk_total gauge",
|
||||
"# TYPE product_funnel_registrations_total gauge",
|
||||
"# TYPE product_funnel_kyc_started_total gauge",
|
||||
"# TYPE product_funnel_kyc_completed_total gauge",
|
||||
"# TYPE product_funnel_first_payment_total gauge",
|
||||
"# TYPE product_funnel_successful_operations_total gauge",
|
||||
]
|
||||
async with async_session_maker() as session:
|
||||
for model, metric_prefix in ((Order, "payment_orders"), (Payment, "payments")):
|
||||
total = await _scalar(session, select(func.count()).select_from(model))
|
||||
lines.append(_sample(f"{metric_prefix}_total", total, {"service": SERVICE_NAME}))
|
||||
for status, count in await _rows(session, select(model.status, func.count()).group_by(model.status)):
|
||||
lines.append(_sample(f"{metric_prefix}_by_status_total", int(count), {"service": SERVICE_NAME, "status": _status_value(status)}))
|
||||
for window_name, delta in WINDOWS:
|
||||
conditions = []
|
||||
if delta is not None:
|
||||
conditions.append(model.created_at >= func.now() - delta)
|
||||
created = await _scalar(session, select(func.count()).select_from(model).where(*conditions))
|
||||
lines.append(_sample(f"{metric_prefix}_created_total", created, {"service": SERVICE_NAME, "window": window_name}))
|
||||
paid_total = await _scalar(session, select(func.count()).select_from(Payment).where(Payment.paid_at.is_not(None)))
|
||||
failed_total = await _scalar(session, select(func.count()).select_from(Payment).where(Payment.status.in_(["web3_hash_error", "web3_balance_problem", "receipt_error"])))
|
||||
lines.append(_sample("payments_succeeded_total", paid_total, {"service": SERVICE_NAME}))
|
||||
lines.append(_sample("payments_failed_total", failed_total, {"service": SERVICE_NAME}))
|
||||
|
||||
risk_total = await _scalar(session, select(func.count()).select_from(RiskAssessmentModel))
|
||||
avg_score_result = await session.execute(select(func.coalesce(func.avg(RiskAssessmentModel.score), 0)))
|
||||
avg_score = float(avg_score_result.scalar_one() or 0)
|
||||
high_risk = await _scalar(session, select(func.count()).select_from(RiskAssessmentModel).where(RiskAssessmentModel.score >= 60))
|
||||
lines.append(_sample("risk_assessments_total", risk_total, {"service": SERVICE_NAME}))
|
||||
lines.append(_sample("risk_score_average", avg_score, {"service": SERVICE_NAME}))
|
||||
lines.append(_sample("risk_high_risk_total", high_risk, {"service": SERVICE_NAME}))
|
||||
for decision, count in await _rows(session, select(RiskAssessmentModel.decision, func.count()).group_by(RiskAssessmentModel.decision)):
|
||||
lines.append(_sample("risk_assessments_by_decision_total", int(count), {"service": SERVICE_NAME, "decision": str(decision)}))
|
||||
|
||||
registrations = await _scalar(session, select(func.count()).select_from(UserModel))
|
||||
kyc_started = await _scalar(session, text("select count(distinct user_id) from kyc"))
|
||||
kyc_completed = await _scalar(session, select(func.count()).select_from(UserModel).where(UserModel.kyc_verified.is_(True)))
|
||||
first_payment = await _scalar(session, select(func.count(func.distinct(Order.user_id))))
|
||||
successful_operations = await _scalar(session, select(func.count()).select_from(Payment).where(Payment.status == "completed"))
|
||||
lines.append(_sample("product_funnel_registrations_total", registrations, {"service": SERVICE_NAME}))
|
||||
lines.append(_sample("product_funnel_kyc_started_total", kyc_started, {"service": SERVICE_NAME}))
|
||||
lines.append(_sample("product_funnel_kyc_completed_total", kyc_completed, {"service": SERVICE_NAME}))
|
||||
lines.append(_sample("product_funnel_first_payment_total", first_payment, {"service": SERVICE_NAME}))
|
||||
lines.append(_sample("product_funnel_successful_operations_total", successful_operations, {"service": SERVICE_NAME}))
|
||||
lines.append(_sample("payment_metrics_scrape_success", 1, {"service": SERVICE_NAME}))
|
||||
return lines
|
||||
|
||||
|
||||
_business_metrics_cache = CachedBusinessMetrics(BUSINESS_CACHE_TTL_SECONDS, _collect_business_metrics)
|
||||
|
||||
|
||||
def _client_metrics() -> bytes:
|
||||
if os.environ.get("PROMETHEUS_MULTIPROC_DIR"):
|
||||
registry = CollectorRegistry()
|
||||
multiprocess.MultiProcessCollector(registry)
|
||||
return generate_latest(registry)
|
||||
return generate_latest()
|
||||
|
||||
|
||||
async def metrics_response() -> Response:
|
||||
lines: list[str] = []
|
||||
try:
|
||||
lines.extend(await _business_metrics_cache.get())
|
||||
except Exception:
|
||||
lines.append(_sample("payment_metrics_scrape_success", 0, {"service": SERVICE_NAME}))
|
||||
body = _client_metrics().decode("utf-8") + "\n".join(lines) + "\n"
|
||||
return Response(body, media_type=CONTENT_TYPE_LATEST)
|
||||
4
src/infrastructure/mozen/__init__.py
Normal file
4
src/infrastructure/mozen/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from src.infrastructure.mozen.sbp_client import MozenSbpClient
|
||||
|
||||
|
||||
__all__=['MozenSbpClient']
|
||||
115
src/infrastructure/mozen/sbp_client.py
Normal file
115
src/infrastructure/mozen/sbp_client.py
Normal file
@@ -0,0 +1,115 @@
|
||||
from __future__ import annotations
|
||||
from decimal import Decimal,ROUND_DOWN
|
||||
from typing import Any
|
||||
import aiohttp
|
||||
import orjson
|
||||
from aiohttp import ClientTimeout
|
||||
from src.application.contracts import IMozenSbpService,SbpBank,SbpPayoutResult
|
||||
from src.application.domain.exceptions import PaymentProviderException,ServiceUnavailableException
|
||||
|
||||
|
||||
class MozenSbpClient(IMozenSbpService):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
api_base_url: str,
|
||||
client_id: str,
|
||||
park_token: str,
|
||||
timeout_seconds: float = 30,
|
||||
) -> None:
|
||||
self._api_base_url=api_base_url.rstrip('/')
|
||||
self._client_id=client_id
|
||||
self._park_token=park_token
|
||||
self._timeout=ClientTimeout(total=timeout_seconds)
|
||||
|
||||
|
||||
def _auth_headers(self,trace_id: str) -> dict[str,str]:
|
||||
if not self._client_id or not self._park_token:
|
||||
raise ServiceUnavailableException(message='Mozen SBP credentials unavailable')
|
||||
return {
|
||||
'Accept':'application/json',
|
||||
'Content-Type':'application/json',
|
||||
'x-trace-id':trace_id,
|
||||
'x-client-id':self._client_id,
|
||||
'park-token':self._park_token,
|
||||
}
|
||||
|
||||
|
||||
async def get_banks(self,trace_id: str) -> list[SbpBank]:
|
||||
url=f'{self._api_base_url}/api/v2/payout/sbp/banks'
|
||||
headers={'Accept':'application/json','x-trace-id':trace_id}
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=self._timeout) as session:
|
||||
async with session.get(url,headers=headers) as resp:
|
||||
response_text=await resp.text()
|
||||
try:
|
||||
response_json=orjson.loads(response_text)
|
||||
except orjson.JSONDecodeError:
|
||||
response_json=[]
|
||||
if resp.status>=400:
|
||||
raise PaymentProviderException(message='Mozen SBP banks provider error')
|
||||
if not isinstance(response_json,list):
|
||||
raise PaymentProviderException(message='Mozen SBP banks response invalid')
|
||||
banks: list[SbpBank]=[]
|
||||
for item in response_json:
|
||||
if not isinstance(item,dict):
|
||||
continue
|
||||
bank_id=str(item.get('id') or '').strip()
|
||||
name=str(item.get('name') or '').strip()
|
||||
if bank_id and name:
|
||||
banks.append(SbpBank(id=bank_id,name=name))
|
||||
return banks
|
||||
except PaymentProviderException:
|
||||
raise
|
||||
except (TimeoutError,aiohttp.ClientError):
|
||||
raise PaymentProviderException(message='Mozen SBP banks provider unreachable')
|
||||
|
||||
|
||||
async def make_withdrawal(
|
||||
self,
|
||||
*,
|
||||
user_id: str,
|
||||
withdraw_id: str,
|
||||
phone: str,
|
||||
bank_id: str,
|
||||
amount: Decimal,
|
||||
trace_id: str,
|
||||
) -> SbpPayoutResult:
|
||||
amount_int=int(amount.quantize(Decimal('1'),rounding=ROUND_DOWN))
|
||||
payload: dict[str,Any]={
|
||||
'amount':amount_int,
|
||||
'order_id':withdraw_id,
|
||||
'phone':phone,
|
||||
'bank_id':bank_id,
|
||||
'description':'SBP withdrawal',
|
||||
'user_id':user_id,
|
||||
}
|
||||
url=f'{self._api_base_url}/api/v2/payout/sbp/withdrawal'
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=self._timeout) as session:
|
||||
async with session.post(url,json=payload,headers=self._auth_headers(trace_id)) as resp:
|
||||
response_text=await resp.text()
|
||||
try:
|
||||
response_json=orjson.loads(response_text)
|
||||
except orjson.JSONDecodeError:
|
||||
response_json={'raw':response_text}
|
||||
if resp.status>=400:
|
||||
raise PaymentProviderException(message='Mozen SBP withdrawal provider error')
|
||||
if not isinstance(response_json,dict):
|
||||
raise PaymentProviderException(message='Mozen SBP withdrawal response invalid')
|
||||
error=response_json.get('error')
|
||||
if isinstance(error,dict):
|
||||
error_message=str(error.get('message') or error.get('code') or '').strip() or 'Mozen SBP withdrawal rejected'
|
||||
return SbpPayoutResult(status=str(response_json.get('status') or 'error'),error=error_message)
|
||||
request=response_json.get('withdrawal_request')
|
||||
mozen_order_id=None
|
||||
if isinstance(request,dict):
|
||||
mozen_order_id=str(request.get('id') or '').strip() or None
|
||||
return SbpPayoutResult(
|
||||
status=str(response_json.get('status') or '').strip(),
|
||||
mozen_order_id=mozen_order_id,
|
||||
)
|
||||
except PaymentProviderException:
|
||||
raise
|
||||
except (TimeoutError,aiohttp.ClientError):
|
||||
raise PaymentProviderException(message='Mozen SBP withdrawal provider unreachable')
|
||||
@@ -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)
|
||||
|
||||
@@ -9,9 +9,7 @@ class HashService(IHashService):
|
||||
|
||||
async def hash(self, value: str) -> str:
|
||||
hashed_value = bcrypt.hashpw(value.encode(), bcrypt.gensalt())
|
||||
self._logger.info(f'Hash value {hashed_value.decode()}')
|
||||
return hashed_value.decode()
|
||||
|
||||
async def verify(self, hashed_value: str, plain_value: str) -> bool:
|
||||
self._logger.info(f'Hash value {hashed_value[:10]}')
|
||||
return bcrypt.checkpw(plain_value.encode(), hashed_value.encode())
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
from jose import jwt, ExpiredSignatureError, JWTError
|
||||
from src.application.contracts import ILogger, IJwtService
|
||||
from src.application.domain.dto import AccessTokenPayload
|
||||
from src.application.domain.exceptions import ApplicationException
|
||||
from src.application.domain.exceptions import ApplicationException,InternalServerException,JwtException
|
||||
from src.infrastructure.config.settings import settings
|
||||
from src.infrastructure.vault import JwtKeyStore
|
||||
|
||||
@@ -17,7 +17,7 @@ class JwtService(IJwtService):
|
||||
|
||||
if payload.get('type') != 'access':
|
||||
self._logger.warning(f'Access token invalid type received_type={payload.get('type')}')
|
||||
raise ApplicationException(status_code=401, message='Invalid token type')
|
||||
raise JwtException(message='Invalid token type')
|
||||
|
||||
try:
|
||||
return AccessTokenPayload(
|
||||
@@ -32,7 +32,7 @@ class JwtService(IJwtService):
|
||||
)
|
||||
except KeyError as exception:
|
||||
self._logger.warning(f'Access token missing claim error={str(exception)}')
|
||||
raise ApplicationException(status_code=401, message=f'Missing token claim: {exception}')
|
||||
raise JwtException(message=f'Missing token claim: {exception}')
|
||||
|
||||
async def _decode_and_verify(self, token: str) -> dict:
|
||||
kid: str | None = None
|
||||
@@ -42,12 +42,12 @@ class JwtService(IJwtService):
|
||||
kid = header.get('kid')
|
||||
if not kid:
|
||||
self._logger.warning(f'JWT header missing kid header={header}')
|
||||
raise ApplicationException(status_code=401, message='Missing token header: kid')
|
||||
raise JwtException(message='Missing token header: kid')
|
||||
|
||||
received_alg = header.get('alg')
|
||||
if received_alg != settings.JWT_ALGORITHM:
|
||||
self._logger.warning(f'JWT invalid algorithm kid={kid} received_alg={received_alg} expected_alg={settings.JWT_ALGORITHM}')
|
||||
raise ApplicationException(status_code=401, message='Invalid token algorithm')
|
||||
raise JwtException(message='Invalid token algorithm')
|
||||
|
||||
public_pem = await self._key_store.get_public_key_for_kid(str(kid))
|
||||
|
||||
@@ -58,7 +58,7 @@ class JwtService(IJwtService):
|
||||
|
||||
if not public_pem:
|
||||
self._logger.warning(f'JWT unknown kid kid={kid}')
|
||||
raise ApplicationException(status_code=401, message='Unknown token kid')
|
||||
raise JwtException(message='Unknown token kid')
|
||||
|
||||
options = {
|
||||
'verify_signature': True,
|
||||
@@ -85,25 +85,25 @@ class JwtService(IJwtService):
|
||||
|
||||
if 'sid' not in payload:
|
||||
self._logger.warning(f'JWT missing sid claim kid={kid}')
|
||||
raise ApplicationException(status_code=401, message='Missing token claim: sid')
|
||||
raise JwtException(message='Missing token claim: sid')
|
||||
|
||||
if 'type' not in payload:
|
||||
self._logger.warning(f'JWT missing type claim kid={kid}')
|
||||
raise ApplicationException(status_code=401, message='Missing token claim: type')
|
||||
raise JwtException(message='Missing token claim: type')
|
||||
|
||||
return payload
|
||||
|
||||
except ExpiredSignatureError as exception:
|
||||
self._logger.info(f'JWT expired kid={kid} error={str(exception)}')
|
||||
raise ApplicationException(status_code=401, message='Token expired')
|
||||
raise JwtException(message='Token expired')
|
||||
|
||||
except ApplicationException:
|
||||
raise
|
||||
|
||||
except JWTError as exception:
|
||||
self._logger.warning(f'JWT decode failed kid={kid} error={str(exception)}')
|
||||
raise ApplicationException(status_code=401, message='Invalid token')
|
||||
raise JwtException(message='Invalid token')
|
||||
|
||||
except Exception as exception:
|
||||
self._logger.error(f'Unexpected JWT decode error kid={kid} error={str(exception)}')
|
||||
raise ApplicationException(status_code=500, message='JWT decode failed')
|
||||
raise InternalServerException(message='JWT decode failed')
|
||||
@@ -1,3 +1,3 @@
|
||||
from src.infrastructure.vault.utils import read_kv2_secret, create_hvac_client
|
||||
from src.infrastructure.vault.utils import read_kv2_secret,create_hvac_client_from_approle
|
||||
from src.infrastructure.vault.keys import JwtKeyStore
|
||||
from src.infrastructure.vault.scheduler import start_jwt_keys_scheduler
|
||||
@@ -2,8 +2,8 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
from src.application.domain.dto import JwtPublicKeySet, JwtPublicKey
|
||||
from src.application.domain.exceptions import ApplicationException
|
||||
from src.infrastructure.vault import create_hvac_client, read_kv2_secret
|
||||
from src.application.domain.exceptions import InternalServerException
|
||||
from src.infrastructure.vault import create_hvac_client_from_approle,read_kv2_secret
|
||||
|
||||
|
||||
class JwtKeyStore:
|
||||
@@ -19,7 +19,9 @@ class JwtKeyStore:
|
||||
self,
|
||||
*,
|
||||
vault_addr: str,
|
||||
vault_token: str,
|
||||
vault_role_id: str,
|
||||
vault_secret_id: str,
|
||||
vault_namespace: str | None,
|
||||
mount_point: str,
|
||||
kid_path: str = 'jwt/kid',
|
||||
kids_prefix: str = 'jwt/kids',
|
||||
@@ -30,7 +32,9 @@ class JwtKeyStore:
|
||||
return
|
||||
|
||||
self._vault_addr = vault_addr
|
||||
self._vault_token = vault_token
|
||||
self._vault_role_id = vault_role_id
|
||||
self._vault_secret_id = vault_secret_id
|
||||
self._vault_namespace = vault_namespace
|
||||
self._timeout = timeout_seconds
|
||||
|
||||
self._mount = mount_point
|
||||
@@ -48,11 +52,17 @@ class JwtKeyStore:
|
||||
@classmethod
|
||||
def get_instance(cls) -> 'JwtKeyStore':
|
||||
if cls._instance is None:
|
||||
raise ApplicationException(status_code=500, message='JwtKeyStore not initialized')
|
||||
raise InternalServerException(message='JwtKeyStore not initialized')
|
||||
return cls._instance
|
||||
|
||||
def _read_keyset_sync(self) -> JwtPublicKeySet:
|
||||
client = create_hvac_client(url=self._vault_addr, token=self._vault_token, timeout=self._timeout)
|
||||
client = create_hvac_client_from_approle(
|
||||
url=self._vault_addr,
|
||||
role_id=self._vault_role_id,
|
||||
secret_id=self._vault_secret_id,
|
||||
namespace=self._vault_namespace,
|
||||
timeout=self._timeout,
|
||||
)
|
||||
|
||||
kids = read_kv2_secret(client=client, mount_point=self._mount, path=self._kid_path)
|
||||
active_kid = kids.get('active')
|
||||
|
||||
@@ -2,10 +2,23 @@ from __future__ import annotations
|
||||
import hvac
|
||||
|
||||
|
||||
def create_hvac_client(*, url: str, token: str, timeout: int = 5) -> hvac.Client:
|
||||
client = hvac.Client(url=url, token=token, timeout=timeout)
|
||||
def create_hvac_client_from_approle(
|
||||
*,
|
||||
url: str,
|
||||
role_id: str,
|
||||
secret_id: str,
|
||||
namespace: str | None = None,
|
||||
timeout: int = 5,
|
||||
) -> hvac.Client:
|
||||
kwargs: dict = {'url': url, 'timeout': timeout}
|
||||
if namespace:
|
||||
kwargs['namespace'] = namespace
|
||||
client = hvac.Client(**kwargs)
|
||||
client.auth.approle.login(role_id=role_id, secret_id=secret_id)
|
||||
if not client.is_authenticated():
|
||||
raise RuntimeError("Vault authentication failed. Check VAULT_ADDR / VAULT_TOKEN")
|
||||
raise RuntimeError(
|
||||
'Vault AppRole authentication failed. Check VAULT_ADDR, VAULT_ROLE_ID, VAULT_SECRET_ID'
|
||||
)
|
||||
return client
|
||||
|
||||
|
||||
|
||||
90
src/main.py
90
src/main.py
@@ -2,20 +2,23 @@ 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, Response
|
||||
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.metrics import PrometheusMetricsMiddleware, metrics_response
|
||||
from src.infrastructure.config import settings
|
||||
from src.presentation.handlers import application_exception_handler, unhandled_exception_handler
|
||||
from src.presentation.handler import application_exception_handler, unhandled_exception_handler
|
||||
from src.presentation.messaging import crypto_transfer_router,sbp_withdrawal_messaging_router
|
||||
from src.presentation.middleware import TraceIDMiddleware, SecurityHeadersMiddleware
|
||||
from src.presentation.routing import order_router
|
||||
from src.presentation.routing import order_router,orders_router,payment_router,payments_router,sbp_withdrawal_router
|
||||
|
||||
security = HTTPBasic()
|
||||
|
||||
@@ -24,8 +27,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 +41,38 @@ 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_market = create_redis_client(settings.KEYDB_MARKET_URL)
|
||||
app.state.redis_remote = app.state.redis_market
|
||||
app.state.redis_cache = None
|
||||
app.state.redis = None
|
||||
try:
|
||||
await app.state.redis_market.ping()
|
||||
logger.info('Market KeyDB connection established')
|
||||
except Exception as exception:
|
||||
logger.error(f'Market KeyDB connection failed: {exception}')
|
||||
await app.state.redis_market.aclose()
|
||||
raise
|
||||
|
||||
cache_url = settings.KEYDB_CACHE_URL
|
||||
if cache_url:
|
||||
app.state.redis_cache = create_redis_client(cache_url)
|
||||
app.state.redis = app.state.redis_cache
|
||||
try:
|
||||
await app.state.redis_cache.ping()
|
||||
logger.info('Cache KeyDB connection established')
|
||||
except Exception as exception:
|
||||
logger.error(f'Cache KeyDB connection failed: {exception}')
|
||||
await app.state.redis_cache.aclose()
|
||||
await app.state.redis_market.aclose()
|
||||
raise
|
||||
else:
|
||||
logger.info('Cache KeyDB is not configured')
|
||||
|
||||
jwt_store = JwtKeyStore(
|
||||
vault_addr=settings.VAULT_ADDR,
|
||||
vault_token=settings.VAULT_TOKEN,
|
||||
vault_role_id=settings.VAULT_ROLE_ID,
|
||||
vault_secret_id=settings.VAULT_SECRET_ID,
|
||||
vault_namespace=settings.VAULT_NAMESPACE,
|
||||
mount_point=settings.VAULT_MOUNT_POINT,
|
||||
kid_path=settings.VAULT_JWT_KID_PATH,
|
||||
kids_prefix=settings.VAULT_JWT_KIDS_PREFIX,
|
||||
@@ -55,25 +84,49 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
|
||||
app.state.jwt_key_store = jwt_store
|
||||
app.state.jwt_keys_scheduler = jwt_scheduler
|
||||
try:
|
||||
yield
|
||||
await app.state.redis.aclose()
|
||||
logger.info(f'Users service instance ended with id {instance_id}')
|
||||
finally:
|
||||
sched = getattr(app.state,'jwt_keys_scheduler',None)
|
||||
if sched:
|
||||
sched.shutdown(wait=False)
|
||||
redis_cache = getattr(app.state,'redis_cache',None)
|
||||
if redis_cache is not None:
|
||||
await redis_cache.aclose()
|
||||
await app.state.redis_market.aclose()
|
||||
logger.info(f'Pay service instance ended with id {instance_id}')
|
||||
|
||||
|
||||
app: FastAPI = FastAPI(
|
||||
redoc_url=None,
|
||||
docs_url=None,
|
||||
lifespan=lifespan,
|
||||
title='Elcsa Users Service'
|
||||
title='Elcsa. Pay service Service'
|
||||
)
|
||||
|
||||
app.add_exception_handler(ApplicationException, application_exception_handler)
|
||||
app.add_exception_handler(Exception, unhandled_exception_handler)
|
||||
|
||||
app.add_middleware(PrometheusMetricsMiddleware)
|
||||
|
||||
app.include_router(order_router)
|
||||
app.include_router(orders_router)
|
||||
app.include_router(payment_router)
|
||||
app.include_router(payments_router)
|
||||
app.include_router(sbp_withdrawal_router)
|
||||
app.include_router(crypto_transfer_router)
|
||||
app.include_router(sbp_withdrawal_messaging_router)
|
||||
|
||||
|
||||
# Added middleware
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[],
|
||||
allow_origin_regex=settings.CORS_ALLOW_ORIGIN_REGEX,
|
||||
allow_credentials=True,
|
||||
allow_methods=['*'],
|
||||
allow_headers=['*'],
|
||||
)
|
||||
app.add_middleware(TraceIDMiddleware, logger=logger)
|
||||
app.add_middleware(
|
||||
SecurityHeadersMiddleware,
|
||||
@@ -81,7 +134,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'",
|
||||
)
|
||||
|
||||
|
||||
@@ -107,9 +160,22 @@ async def custom_redoc_html(_credentials: HTTPBasicCredentials = Depends(verify_
|
||||
)
|
||||
|
||||
|
||||
@app.get('/metrics', include_in_schema=False)
|
||||
async def metrics() -> Response:
|
||||
return await metrics_response()
|
||||
|
||||
|
||||
@app.post('/ping')
|
||||
async def ping() -> dict[str, str]:
|
||||
return {
|
||||
'message': 'pong',
|
||||
'status': 'ok',
|
||||
}
|
||||
|
||||
@app.get('/healthcheck')
|
||||
@app.get('/health')
|
||||
async def health() -> dict[str, str]:
|
||||
return {
|
||||
'status': 'ok',
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from fastapi import Depends, Request
|
||||
from fastapi.security.utils import get_authorization_scheme_param
|
||||
from src.application.contracts import IJwtService
|
||||
from src.application.domain.exceptions import ApplicationException
|
||||
from src.application.domain.exceptions import UnauthorizedException
|
||||
from src.application.domain.dto import AccessTokenPayload, AuthContext
|
||||
from src.presentation.dependencies import get_jwt_service
|
||||
|
||||
@@ -27,10 +27,10 @@ async def require_access_token(
|
||||
) -> AuthContext:
|
||||
token = _extract_access_token(request)
|
||||
if not token:
|
||||
raise ApplicationException(status_code=401, message='Not authenticated')
|
||||
raise UnauthorizedException(message='Not authenticated')
|
||||
|
||||
payload: AccessTokenPayload = await jwt_service.decode_access_token(token)
|
||||
if payload.type != 'access':
|
||||
raise ApplicationException(status_code=401, message='Invalid token type')
|
||||
raise UnauthorizedException(message='Invalid token type')
|
||||
|
||||
return AuthContext(user_id=payload.sub, sid=payload.sid, token=payload)
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
from __future__ import annotations
|
||||
import functools
|
||||
import orjson
|
||||
from typing import Any,Awaitable,Callable
|
||||
from fastapi import Request
|
||||
from fastapi.responses import ORJSONResponse
|
||||
from src.infrastructure.cache import KeydbCache
|
||||
from src.infrastructure.logger import get_logger
|
||||
from src.presentation.dependencies.cache import get_redis
|
||||
from src.presentation.dependencies.cache import get_cache_remote
|
||||
|
||||
|
||||
def cached(*, prefix: str) -> Callable:
|
||||
def _to_cache_payload(value: Any) -> Any:
|
||||
if hasattr(value,'model_dump'):
|
||||
return value.model_dump(mode='json')
|
||||
return value
|
||||
|
||||
|
||||
def cached(*,prefix: str,ttl: int = 300,user_scoped: bool = True) -> Callable:
|
||||
def decorator(func: Callable[..., Awaitable[Any]]):
|
||||
@functools.wraps(func)
|
||||
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
@@ -25,22 +30,34 @@ def cached(*, prefix: str) -> Callable:
|
||||
auth = kwargs.get('auth')
|
||||
user_id = getattr(auth, 'user_id', None) if auth else None
|
||||
|
||||
if request is None or user_id is None:
|
||||
if request is None:
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
if user_scoped:
|
||||
if user_id is None:
|
||||
return await func(*args, **kwargs)
|
||||
cache_key = f'{prefix}:{user_id}'
|
||||
else:
|
||||
cache_key = prefix
|
||||
|
||||
try:
|
||||
redis = get_redis(request)
|
||||
cache = KeydbCache(redis)
|
||||
hit = await cache.get_user(user_id)
|
||||
cache = get_cache_remote(request)
|
||||
hit = await cache.get(cache_key)
|
||||
if hit is not None:
|
||||
logger.debug(f'Cache hit key={cache_key}')
|
||||
return ORJSONResponse(status_code=200, content=hit)
|
||||
return ORJSONResponse(status_code=200,content=orjson.loads(hit))
|
||||
except Exception as e:
|
||||
logger.warning(f'Cache read failed key={cache_key} error={e}')
|
||||
|
||||
return await func(*args, **kwargs)
|
||||
result = await func(*args, **kwargs)
|
||||
try:
|
||||
cache = get_cache_remote(request)
|
||||
payload = _to_cache_payload(result)
|
||||
await cache.set(cache_key,orjson.dumps(payload).decode(),ttl)
|
||||
logger.debug(f'Cache set key={cache_key} ttl={ttl}')
|
||||
except Exception as e:
|
||||
logger.warning(f'Cache write failed key={cache_key} error={e}')
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
@@ -3,7 +3,7 @@ import inspect
|
||||
from functools import wraps
|
||||
from typing import Callable, Awaitable, Any, Optional, Annotated
|
||||
from fastapi import Request, Header
|
||||
from src.application.domain.exceptions import ApplicationException
|
||||
from src.application.domain.exceptions import InternalServerException
|
||||
from src.infrastructure.security import CsrfService
|
||||
|
||||
|
||||
@@ -39,10 +39,7 @@ def csrf_protect(
|
||||
break
|
||||
|
||||
if request is None:
|
||||
raise ApplicationException(
|
||||
status_code=500,
|
||||
message='Request is required for CSRF protection',
|
||||
)
|
||||
raise InternalServerException(message='Request is required for CSRF protection')
|
||||
|
||||
csrf = CsrfService()
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ from typing import Any, Awaitable, Callable, Literal, Optional, Protocol, runtim
|
||||
from fastapi import Request
|
||||
from redis.asyncio.client import Redis
|
||||
from src.application.contracts import ILogger
|
||||
from src.application.domain.exceptions import ApplicationException
|
||||
from src.application.domain.exceptions import InternalServerException,RateLimitException
|
||||
from src.infrastructure.logger import get_logger
|
||||
from src.presentation.dependencies import get_redis
|
||||
|
||||
@@ -124,7 +124,7 @@ def rate_limit(
|
||||
ident = _call_key_builder(key_builder, request, args, kwargs) # type: ignore[arg-type]
|
||||
except Exception as e:
|
||||
logger.error(f'RateLimit key_builder failed error={str(e)}')
|
||||
raise ApplicationException(500, 'Rate limiter key_builder failed')
|
||||
raise InternalServerException(message='Rate limiter key_builder failed')
|
||||
|
||||
route = request.url.path
|
||||
method = request.method
|
||||
@@ -153,13 +153,12 @@ def rate_limit(
|
||||
logger.warning(f'RateLimit fail-open activated key={redis_key}')
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
raise ApplicationException(503, 'Rate limiter unavailable')
|
||||
raise RateLimitException(status_code=503,message='Rate limiter unavailable')
|
||||
|
||||
if count > limit:
|
||||
retry_after = max(ttl, 0)
|
||||
logger.warning(f'RateLimit exceeded key={redis_key} count={count} limit={limit} retry_after={retry_after}')
|
||||
raise ApplicationException(
|
||||
status_code=429,
|
||||
raise RateLimitException(
|
||||
message='Too Many Requests',
|
||||
headers={'Retry-After': str(retry_after)},
|
||||
)
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
from src.presentation.dependencies.commands import (
|
||||
get_get_me_command,
|
||||
get_set_phone_command,
|
||||
get_set_crypto_wallet_start_command,
|
||||
get_set_crypto_wallet_complete_command,
|
||||
get_update_bank_details_start_command,
|
||||
get_update_bank_details_complete_command,
|
||||
get_change_password_start_command,
|
||||
get_change_password_complete_command,
|
||||
get_change_email_start_command,
|
||||
get_change_email_confirm_old_command,
|
||||
get_change_email_complete_command,
|
||||
)
|
||||
from src.presentation.dependencies.security import get_jwt_service
|
||||
from src.presentation.dependencies.cache import get_redis, get_cache
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
from src.presentation.dependencies.cache import get_cache,get_cache_remote,get_redis,get_redis_remote
|
||||
from src.presentation.dependencies.queue_messanger import get_rabbit
|
||||
from src.presentation.dependencies.security import get_jwt_service
|
||||
|
||||
|
||||
__all__=['get_jwt_service','get_redis','get_redis_remote','get_cache','get_cache_remote','get_rabbit']
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user