diff --git a/src/application/domain/exceptions/__init__.py b/src/application/domain/exceptions/__init__.py index ba34044..e22fcf5 100644 --- a/src/application/domain/exceptions/__init__.py +++ b/src/application/domain/exceptions/__init__.py @@ -7,6 +7,7 @@ from src.application.domain.exceptions.forbidden_exception import ForbiddenExcep 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 @@ -26,6 +27,7 @@ __all__ = [ 'InternalServerException', 'JwtException', 'NotFoundException', + 'OrderTotalOutOfRangeException', 'PaymentMetadataException', 'PaymentProviderException', 'PriceChangedException', diff --git a/src/application/domain/exceptions/order_total_out_of_range_exception.py b/src/application/domain/exceptions/order_total_out_of_range_exception.py new file mode 100644 index 0000000..b36a09b --- /dev/null +++ b/src/application/domain/exceptions/order_total_out_of_range_exception.py @@ -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) diff --git a/src/application/services/payment_quote_service.py b/src/application/services/payment_quote_service.py index ccb7742..2bd006f 100644 --- a/src/application/services/payment_quote_service.py +++ b/src/application/services/payment_quote_service.py @@ -1,10 +1,35 @@ from __future__ import annotations from dataclasses import dataclass -from datetime import datetime,timezone -from decimal import Decimal,ROUND_UP -from src.application.contracts import ICache,ILogger -from src.application.domain.exceptions import ServiceUnavailableException -from src.infrastructure.config import settings +from datetime import datetime, timezone +from decimal import Decimal, ROUND_UP +from src.application.contracts import ICache, ILogger +from src.application.domain.exceptions import OrderTotalOutOfRangeException, ServiceUnavailableException + + +_FEE_TIERS: tuple[tuple[Decimal, Decimal, Decimal, bool, bool], ...] = ( + (Decimal('0.08'), Decimal('5000'), Decimal('30000'), True, True), + (Decimal('0.06'), Decimal('30000'), Decimal('100000'), False, True), + (Decimal('0.04'), Decimal('100000'), 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) @@ -25,8 +50,8 @@ class PaymentQuoteService: async def get_quote(self, usdt_amount: Decimal) -> PaymentQuote: - rate_raw = await self._remote_cache.hget('tradex:rub:rate','value') - gas_raw = await self._remote_cache.hget('gwei:eth:last','normal_rub') + 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: @@ -39,8 +64,41 @@ 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) - service_fee = (usdt_amount * usdt_exchange_rate * settings.PAYMENT_SERVICE_FEE_RATE).quantize(Decimal('0.01')) - total_price = (usdt_amount * usdt_exchange_rate + service_fee + gas_fee).quantize(Decimal('0.01')) + 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: + total_if_lowest_fee = ( + base_rub + + (base_rub * Decimal('0.04')).quantize(Decimal('0.01')) + + gas_fee + ).quantize(Decimal('0.01')) + if total_if_lowest_fee > Decimal('600000'): + raise OrderTotalOutOfRangeException( + message='Order total exceeds maximum allowed amount', + ) + total_if_highest_fee = ( + base_rub + + (base_rub * Decimal('0.08')).quantize(Decimal('0.01')) + + gas_fee + ).quantize(Decimal('0.01')) + if total_if_highest_fee < Decimal('5000'): + raise OrderTotalOutOfRangeException( + message='Order total is below minimum allowed amount', + ) + raise OrderTotalOutOfRangeException() return PaymentQuote( usdt_amount=usdt_amount, @@ -48,6 +106,6 @@ class PaymentQuoteService: gas_fee=gas_fee, service_fee=service_fee, total_price=total_price, - service_fee_rate=settings.PAYMENT_SERVICE_FEE_RATE, + service_fee_rate=chosen_rate, created_at=datetime.now(timezone.utc), ) diff --git a/src/infrastructure/config/settings.py b/src/infrastructure/config/settings.py index affc5c5..92f0316 100644 --- a/src/infrastructure/config/settings.py +++ b/src/infrastructure/config/settings.py @@ -1,6 +1,5 @@ from __future__ import annotations -from decimal import Decimal from functools import lru_cache from typing import List, Literal import os @@ -95,8 +94,6 @@ class Settings(BaseSettings): ITPAY_PUBLIC_ID: str ITPAY_API_SECRET: str - PAYMENT_SERVICE_FEE_RATE: Decimal = Field(gt=0) - CLOUD_KASSIR_PUBLIC_ID: str = '' CLOUD_KASSIR_API_SECRET: str = ''