From 366bdc9515bd002231f09adc39599df14ee6e3e6 Mon Sep 17 00:00:00 2001 From: Noloquideus Date: Thu, 14 May 2026 21:53:20 +0300 Subject: [PATCH] feat: add rub quote --- Dockerfile | 2 +- docker-compose.yml | 2 +- src/application/commands/__init__.py | 2 +- .../commands/payment_read_commands.py | 12 ++ .../services/payment_quote_service.py | 108 ++++++++++++++++-- src/presentation/dependencies/commands.py | 9 +- src/presentation/routing/order.py | 18 ++- 7 files changed, 136 insertions(+), 17 deletions(-) diff --git a/Dockerfile b/Dockerfile index 06c8580..b05211b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,4 +25,4 @@ ENV PATH="/app/.venv/bin:$PATH" \ EXPOSE 8000 -CMD ["sh", "-c", "granian --interface asgi ${APP_MODULE:-src.main:app} --host ${APP_HOST:-0.0.0.0} --port ${APP_PORT:-8000} --workers ${APP_WORKERS:-1} --loop uvloop"] +CMD ["sh", "-c", "granian --interface asgi ${APP_MODULE:-src.main:app} --host ${APP_HOST:-0.0.0.0} --port ${APP_PORT:-8000} --workers ${APP_WORKERS:-2} --loop uvloop"] diff --git a/docker-compose.yml b/docker-compose.yml index 3022062..bdcb07b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,7 +11,7 @@ services: APP_MODULE: "src.main:app" APP_HOST: "0.0.0.0" APP_PORT: "8000" - APP_WORKERS: "1" + APP_WORKERS: "2" env_file: - .env restart: no \ No newline at end of file diff --git a/src/application/commands/__init__.py b/src/application/commands/__init__.py index a107c56..b86bc81 100644 --- a/src/application/commands/__init__.py +++ b/src/application/commands/__init__.py @@ -1,4 +1,4 @@ from src.application.commands.create_order_command import CreateOrderCommand from src.application.commands.create_payment_command import CreatePaymentCommand from src.application.commands.create_crypto_transfer_completed_command import CreateCryptoTransferCompletedCommand -from src.application.commands.payment_read_commands import GetOrderCommand,GetOrderStatusCommand,GetPaymentConfigCommand,GetPaymentQuoteCommand,ListOrdersCommand,ListPaymentsCommand,OrderPaymentResult \ No newline at end of file +from src.application.commands.payment_read_commands import GetOrderCommand,GetOrderStatusCommand,GetPaymentConfigCommand,GetPaymentQuoteCommand,GetPaymentQuoteFromRubCommand,ListOrdersCommand,ListPaymentsCommand,OrderPaymentResult \ No newline at end of file diff --git a/src/application/commands/payment_read_commands.py b/src/application/commands/payment_read_commands.py index 941a12a..d88ef23 100644 --- a/src/application/commands/payment_read_commands.py +++ b/src/application/commands/payment_read_commands.py @@ -39,6 +39,18 @@ class GetPaymentQuoteCommand: 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 diff --git a/src/application/services/payment_quote_service.py b/src/application/services/payment_quote_service.py index ff4b1bc..85e8299 100644 --- a/src/application/services/payment_quote_service.py +++ b/src/application/services/payment_quote_service.py @@ -1,13 +1,15 @@ from __future__ import annotations from dataclasses import dataclass from datetime import datetime, timezone -from decimal import Decimal, ROUND_UP +from decimal import Decimal, ROUND_DOWN, ROUND_UP from src.application.contracts import ICache, ILogger from src.application.domain.exceptions import OrderTotalOutOfRangeException, ServiceUnavailableException _MIN_USDT_AMOUNT: Decimal = Decimal('1') +_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), @@ -51,12 +53,7 @@ class PaymentQuoteService: self._logger = logger - async def get_quote(self, usdt_amount: Decimal) -> PaymentQuote: - if usdt_amount < _MIN_USDT_AMOUNT: - raise OrderTotalOutOfRangeException( - message='Order total is below minimum allowed amount', - ) - + 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}') @@ -71,8 +68,16 @@ class PaymentQuoteService: 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) - base_rub = usdt_amount * usdt_exchange_rate + 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 @@ -87,9 +92,7 @@ class PaymentQuoteService: break if chosen_rate is None or service_fee is None or total_price is None: - raise OrderTotalOutOfRangeException( - message='Order total exceeds maximum allowed amount', - ) + return None return PaymentQuote( usdt_amount=usdt_amount, @@ -100,3 +103,86 @@ class PaymentQuoteService: service_fee_rate=chosen_rate, created_at=datetime.now(timezone.utc), ) + + + async def get_quote(self, usdt_amount: Decimal) -> PaymentQuote: + if usdt_amount < _MIN_USDT_AMOUNT: + 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_from_total_rub(self, total_rub: Decimal) -> PaymentQuote: + if total_rub <= Decimal('0'): + 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() + + if total_rub <= gas_fee: + raise OrderTotalOutOfRangeException( + message='Order total is below minimum allowed amount', + ) + + min_quote = self._compose_quote(_MIN_USDT_AMOUNT, usdt_exchange_rate, gas_fee) + if min_quote is None or min_quote.total_price > total_rub: + raise OrderTotalOutOfRangeException( + message='Order total is below minimum allowed amount', + ) + + u_budget = ((total_rub - gas_fee) / usdt_exchange_rate).quantize(Decimal('0.01'), rounding=ROUND_DOWN) + u_cap = ((_MAX_TOTAL_RUB - gas_fee) / (usdt_exchange_rate * Decimal('1.04'))).quantize( + Decimal('0.01'), + rounding=ROUND_DOWN, + ) + u_upper = min(u_budget, u_cap) + n_hi = int((u_upper * 100).to_integral_value(rounding=ROUND_DOWN)) + if n_hi < 100: + raise OrderTotalOutOfRangeException( + message='Order total is below minimum allowed amount', + ) + + n_lo = 100 + best_cent: int | None = None + while n_lo <= n_hi: + mid = (n_lo + n_hi) // 2 + u = Decimal(mid) / Decimal(100) + quote = self._compose_quote(u, usdt_exchange_rate, gas_fee) + if quote is None: + n_hi = mid - 1 + continue + if quote.total_price <= total_rub: + best_cent = mid + n_lo = mid + 1 + else: + n_hi = mid - 1 + + if best_cent is None: + raise OrderTotalOutOfRangeException( + message='Order total is below minimum allowed amount', + ) + + final = self._compose_quote( + Decimal(best_cent) / Decimal(100), + usdt_exchange_rate, + gas_fee, + ) + if final is None: + raise OrderTotalOutOfRangeException( + message='Order total exceeds maximum allowed amount', + ) + return final diff --git a/src/presentation/dependencies/commands.py b/src/presentation/dependencies/commands.py index b5f0b70..bc0c407 100644 --- a/src/presentation/dependencies/commands.py +++ b/src/presentation/dependencies/commands.py @@ -1,7 +1,7 @@ from __future__ import annotations from fastapi import Depends from src.application.abstractions import IUnitOfWork -from src.application.commands import CreateCryptoTransferCompletedCommand,CreateOrderCommand,CreatePaymentCommand,GetOrderCommand,GetOrderStatusCommand,GetPaymentConfigCommand,GetPaymentQuoteCommand,ListOrdersCommand,ListPaymentsCommand +from src.application.commands import CreateCryptoTransferCompletedCommand,CreateOrderCommand,CreatePaymentCommand,GetOrderCommand,GetOrderStatusCommand,GetPaymentConfigCommand,GetPaymentQuoteCommand,GetPaymentQuoteFromRubCommand,ListOrdersCommand,ListPaymentsCommand from src.application.contracts import ICache,ILogger,IQueueMessanger,IReceipt from src.application.contracts.i_itpay_service import IItPayService from src.application.services import PaymentQuoteService @@ -59,6 +59,13 @@ def get_payment_quote_command( return GetPaymentQuoteCommand(payment_quote_service=payment_quote_service,logger=logger) +def get_payment_quote_from_rub_command( + logger: ILogger = Depends(get_logger), + payment_quote_service: PaymentQuoteService = Depends(get_payment_quote_service), +) -> GetPaymentQuoteFromRubCommand: + return GetPaymentQuoteFromRubCommand(payment_quote_service=payment_quote_service,logger=logger) + + def get_list_orders_command( logger: ILogger = Depends(get_logger), unit_of_work: IUnitOfWork = Depends(get_unit_of_work), diff --git a/src/presentation/routing/order.py b/src/presentation/routing/order.py index 1976b9b..3cc34a2 100644 --- a/src/presentation/routing/order.py +++ b/src/presentation/routing/order.py @@ -6,7 +6,7 @@ from fastapi import APIRouter,Depends,Query,Request,WebSocket,WebSocketDisconnec from fastapi.responses import ORJSONResponse from fastapi.security.utils import get_authorization_scheme_param from ulid import ULID -from src.application.commands import CreateOrderCommand,CreatePaymentCommand,GetOrderCommand,GetOrderStatusCommand,GetPaymentConfigCommand,GetPaymentQuoteCommand,ListOrdersCommand,ListPaymentsCommand +from src.application.commands import CreateOrderCommand,CreatePaymentCommand,GetOrderCommand,GetOrderStatusCommand,GetPaymentConfigCommand,GetPaymentQuoteCommand,GetPaymentQuoteFromRubCommand,ListOrdersCommand,ListPaymentsCommand from src.application.contracts import IJwtService,ILogger from src.application.domain.dto import AccessTokenPayload,AuthContext from src.application.domain.entities import OrderEntity,PaymentEntity @@ -15,7 +15,7 @@ from src.application.domain.exceptions import ApplicationException,ConflictExcep from src.application.services import PaymentQuote from src.infrastructure.context_vars import trace_id_var from src.presentation.decorators import require_access_token, csrf_protect -from src.presentation.dependencies.commands import get_create_order_command,get_create_payment_command,get_list_orders_command,get_list_payments_command,get_order_command,get_order_status_command,get_payment_config_command,get_payment_quote_command +from src.presentation.dependencies.commands import get_create_order_command,get_create_payment_command,get_list_orders_command,get_list_payments_command,get_order_command,get_order_status_command,get_payment_config_command,get_payment_quote_command,get_payment_quote_from_rub_command from src.presentation.dependencies.logger import get_logger from src.presentation.dependencies.security import get_jwt_service from src.presentation.schemas.order import CreateOrder,CreateOrderResponse,ErrorResponse,OrderDetailResponse,OrderPaymentResponse,OrdersResponse,OrderStatusResponse,OrderWithPaymentResponse,PaymentConfigResponse,PaymentQuoteResponse,PaymentResponse,PaymentsResponse @@ -231,6 +231,20 @@ async def payment_quote( return _payment_quote_response(quote) +@payment_router.get( + '/quote/rub', + response_model=PaymentQuoteResponse, + status_code=200, + responses=ERROR_RESPONSES, +) +async def payment_quote_from_rub( + total_rub: Decimal = Query(gt=0, decimal_places=2, max_digits=20), + command: GetPaymentQuoteFromRubCommand = Depends(get_payment_quote_from_rub_command), +) -> PaymentQuoteResponse: + quote = await command(total_rub) + return _payment_quote_response(quote) + + @orders_router.get( '/orders', response_model=OrdersResponse,