feat: add rub quote

This commit is contained in:
2026-05-14 21:53:20 +03:00
parent 6130188a4f
commit 366bdc9515
7 changed files with 136 additions and 17 deletions

View File

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

View File

@@ -11,7 +11,7 @@ services:
APP_MODULE: "src.main:app" APP_MODULE: "src.main:app"
APP_HOST: "0.0.0.0" APP_HOST: "0.0.0.0"
APP_PORT: "8000" APP_PORT: "8000"
APP_WORKERS: "1" APP_WORKERS: "2"
env_file: env_file:
- .env - .env
restart: no restart: no

View File

@@ -1,4 +1,4 @@
from src.application.commands.create_order_command import CreateOrderCommand from src.application.commands.create_order_command import CreateOrderCommand
from src.application.commands.create_payment_command import CreatePaymentCommand from src.application.commands.create_payment_command import CreatePaymentCommand
from src.application.commands.create_crypto_transfer_completed_command import CreateCryptoTransferCompletedCommand 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 from src.application.commands.payment_read_commands import GetOrderCommand,GetOrderStatusCommand,GetPaymentConfigCommand,GetPaymentQuoteCommand,GetPaymentQuoteFromRubCommand,ListOrdersCommand,ListPaymentsCommand,OrderPaymentResult

View File

@@ -39,6 +39,18 @@ class GetPaymentQuoteCommand:
return quote 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: class ListOrdersCommand:
def __init__(self, *, unit_of_work: IUnitOfWork, logger: ILogger): def __init__(self, *, unit_of_work: IUnitOfWork, logger: ILogger):
self._unit_of_work = unit_of_work self._unit_of_work = unit_of_work

View File

@@ -1,13 +1,15 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone 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.contracts import ICache, ILogger
from src.application.domain.exceptions import OrderTotalOutOfRangeException, ServiceUnavailableException from src.application.domain.exceptions import OrderTotalOutOfRangeException, ServiceUnavailableException
_MIN_USDT_AMOUNT: Decimal = Decimal('1') _MIN_USDT_AMOUNT: Decimal = Decimal('1')
_MAX_TOTAL_RUB: Decimal = Decimal('600000')
_FEE_TIERS: tuple[tuple[Decimal, Decimal, Decimal, bool, bool], ...] = ( _FEE_TIERS: tuple[tuple[Decimal, Decimal, Decimal, bool, bool], ...] = (
(Decimal('0.08'), Decimal('0'), Decimal('30000'), True, True), (Decimal('0.08'), Decimal('0'), Decimal('30000'), True, True),
(Decimal('0.06'), Decimal('30000'), Decimal('100000'), False, True), (Decimal('0.06'), Decimal('30000'), Decimal('100000'), False, True),
@@ -51,12 +53,7 @@ class PaymentQuoteService:
self._logger = logger self._logger = logger
async def get_quote(self, usdt_amount: Decimal) -> PaymentQuote: async def _load_prices(self) -> tuple[Decimal, Decimal]:
if usdt_amount < _MIN_USDT_AMOUNT:
raise OrderTotalOutOfRangeException(
message='Order total is below minimum allowed amount',
)
rate_raw = await self._remote_cache.hget('tradex:rub:rate', 'value') rate_raw = await self._remote_cache.hget('tradex:rub:rate', 'value')
gas_raw = await self._remote_cache.hget('gwei:eth:last', 'normal_rub') 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}') 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) 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) 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 chosen_rate: Decimal | None = None
service_fee: Decimal | None = None service_fee: Decimal | None = None
total_price: Decimal | None = None total_price: Decimal | None = None
@@ -87,9 +92,7 @@ class PaymentQuoteService:
break break
if chosen_rate is None or service_fee is None or total_price is None: if chosen_rate is None or service_fee is None or total_price is None:
raise OrderTotalOutOfRangeException( return None
message='Order total exceeds maximum allowed amount',
)
return PaymentQuote( return PaymentQuote(
usdt_amount=usdt_amount, usdt_amount=usdt_amount,
@@ -100,3 +103,86 @@ class PaymentQuoteService:
service_fee_rate=chosen_rate, service_fee_rate=chosen_rate,
created_at=datetime.now(timezone.utc), 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

View File

@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from fastapi import Depends from fastapi import Depends
from src.application.abstractions import IUnitOfWork from src.application.abstractions import IUnitOfWork
from src.application.commands import 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 import ICache,ILogger,IQueueMessanger,IReceipt
from src.application.contracts.i_itpay_service import IItPayService from src.application.contracts.i_itpay_service import IItPayService
from src.application.services import PaymentQuoteService 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) 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( def get_list_orders_command(
logger: ILogger = Depends(get_logger), logger: ILogger = Depends(get_logger),
unit_of_work: IUnitOfWork = Depends(get_unit_of_work), unit_of_work: IUnitOfWork = Depends(get_unit_of_work),

View File

@@ -6,7 +6,7 @@ from fastapi import APIRouter,Depends,Query,Request,WebSocket,WebSocketDisconnec
from fastapi.responses import ORJSONResponse from fastapi.responses import ORJSONResponse
from fastapi.security.utils import get_authorization_scheme_param from fastapi.security.utils import get_authorization_scheme_param
from ulid import ULID 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.contracts import IJwtService,ILogger
from src.application.domain.dto import AccessTokenPayload,AuthContext from src.application.domain.dto import AccessTokenPayload,AuthContext
from src.application.domain.entities import OrderEntity,PaymentEntity 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.application.services import PaymentQuote
from src.infrastructure.context_vars import trace_id_var from src.infrastructure.context_vars import trace_id_var
from src.presentation.decorators import require_access_token, csrf_protect 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.logger import get_logger
from src.presentation.dependencies.security import get_jwt_service 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 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) 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_router.get(
'/orders', '/orders',
response_model=OrdersResponse, response_model=OrdersResponse,