187 lines
6.3 KiB
Python
187 lines
6.3 KiB
Python
from __future__ import annotations
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timezone
|
|
from decimal import Decimal, ROUND_DOWN, ROUND_UP
|
|
from src.application.contracts import ICache, ILogger
|
|
from src.application.domain.exceptions import OrderTotalOutOfRangeException, ServiceUnavailableException
|
|
|
|
|
|
_MIN_TOTAL_RUB: Decimal = Decimal('5000')
|
|
|
|
_MAX_TOTAL_RUB: Decimal = Decimal('600000')
|
|
|
|
_FEE_TIERS: tuple[tuple[Decimal, Decimal, Decimal, bool, bool], ...] = (
|
|
(Decimal('0.08'), Decimal('0'), Decimal('30000'), True, True),
|
|
(Decimal('0.06'), Decimal('30000'), Decimal('100000'), False, True),
|
|
(Decimal('0.04'), Decimal('30000'), Decimal('600000'), False, True),
|
|
)
|
|
|
|
|
|
def _total_in_bracket(
|
|
total: Decimal,
|
|
lo: Decimal,
|
|
hi: Decimal,
|
|
*,
|
|
lo_inclusive: bool,
|
|
hi_inclusive: bool,
|
|
) -> bool:
|
|
if lo_inclusive:
|
|
ok_lo = total >= lo
|
|
else:
|
|
ok_lo = total > lo
|
|
if hi_inclusive:
|
|
ok_hi = total <= hi
|
|
else:
|
|
ok_hi = total < hi
|
|
return ok_lo and ok_hi
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class PaymentQuote:
|
|
usdt_amount: Decimal
|
|
usdt_exchange_rate: Decimal
|
|
gas_fee: Decimal
|
|
service_fee: Decimal
|
|
total_price: Decimal
|
|
service_fee_rate: Decimal
|
|
created_at: datetime
|
|
|
|
|
|
class PaymentQuoteService:
|
|
def __init__(self, *, remote_cache: ICache, logger: ILogger):
|
|
self._remote_cache = remote_cache
|
|
self._logger = logger
|
|
|
|
|
|
async def _load_prices(self) -> tuple[Decimal, Decimal]:
|
|
rate_raw = await self._remote_cache.hget('tradex:rub:rate', 'value')
|
|
gas_raw = await self._remote_cache.hget('gwei:eth:last', 'normal_rub')
|
|
self._logger.info(f'Actual market values: rate={rate_raw}, gas={gas_raw}')
|
|
|
|
if rate_raw is None:
|
|
self._logger.error('Exchange rate unavailable')
|
|
raise ServiceUnavailableException(message='Exchange rate unavailable')
|
|
|
|
if gas_raw is None:
|
|
self._logger.error('Exchange gas unavailable')
|
|
raise ServiceUnavailableException(message='Exchange gas unavailable')
|
|
|
|
gas_fee = Decimal(gas_raw).quantize(Decimal('0.00'), rounding=ROUND_UP)
|
|
usdt_exchange_rate = Decimal(rate_raw).quantize(Decimal('0.00'), rounding=ROUND_UP)
|
|
return usdt_exchange_rate, gas_fee
|
|
|
|
|
|
def _compose_quote(
|
|
self,
|
|
usdt_amount: Decimal,
|
|
usdt_exchange_rate: Decimal,
|
|
gas_fee: Decimal,
|
|
) -> PaymentQuote | None:
|
|
base_rub = usdt_amount * usdt_exchange_rate
|
|
chosen_rate: Decimal | None = None
|
|
service_fee: Decimal | None = None
|
|
total_price: Decimal | None = None
|
|
|
|
for fee_rate, lo, hi, li, ri in _FEE_TIERS:
|
|
sf = (base_rub * fee_rate).quantize(Decimal('0.01'))
|
|
tp = (base_rub + sf + gas_fee).quantize(Decimal('0.01'))
|
|
if _total_in_bracket(tp, lo, hi, lo_inclusive=li, hi_inclusive=ri):
|
|
chosen_rate = fee_rate
|
|
service_fee = sf
|
|
total_price = tp
|
|
break
|
|
|
|
if chosen_rate is None or service_fee is None or total_price is None:
|
|
return None
|
|
|
|
return PaymentQuote(
|
|
usdt_amount=usdt_amount,
|
|
usdt_exchange_rate=usdt_exchange_rate,
|
|
gas_fee=gas_fee,
|
|
service_fee=service_fee,
|
|
total_price=total_price,
|
|
service_fee_rate=chosen_rate,
|
|
created_at=datetime.now(timezone.utc),
|
|
)
|
|
|
|
|
|
async def get_reference_quote(self, usdt_amount: Decimal) -> PaymentQuote:
|
|
if usdt_amount <= Decimal('0'):
|
|
raise OrderTotalOutOfRangeException(
|
|
message='Order total is below minimum allowed amount',
|
|
)
|
|
|
|
usdt_exchange_rate, gas_fee = await self._load_prices()
|
|
quote = self._compose_quote(usdt_amount, usdt_exchange_rate, gas_fee)
|
|
if quote is None:
|
|
raise OrderTotalOutOfRangeException(
|
|
message='Order total exceeds maximum allowed amount',
|
|
)
|
|
return quote
|
|
|
|
|
|
async def get_quote(self, usdt_amount: Decimal) -> PaymentQuote:
|
|
quote = await self.get_reference_quote(usdt_amount)
|
|
if quote.total_price < _MIN_TOTAL_RUB:
|
|
raise OrderTotalOutOfRangeException(
|
|
message='Order total is below minimum allowed amount',
|
|
)
|
|
return quote
|
|
|
|
|
|
async def get_quote_from_total_rub(self, total_rub: Decimal) -> PaymentQuote:
|
|
if total_rub < _MIN_TOTAL_RUB:
|
|
raise OrderTotalOutOfRangeException(
|
|
message='Order total is below minimum allowed amount',
|
|
)
|
|
|
|
if total_rub > _MAX_TOTAL_RUB:
|
|
raise OrderTotalOutOfRangeException(
|
|
message='Order total exceeds maximum allowed amount',
|
|
)
|
|
|
|
usdt_exchange_rate, gas_fee = await self._load_prices()
|
|
|
|
u_budget = ((total_rub - gas_fee) / usdt_exchange_rate).quantize(Decimal('0.01'), rounding=ROUND_DOWN)
|
|
u_cap = ((_MAX_TOTAL_RUB - gas_fee) / (usdt_exchange_rate * Decimal('1.04'))).quantize(
|
|
Decimal('0.01'),
|
|
rounding=ROUND_DOWN,
|
|
)
|
|
u_upper = min(u_budget, u_cap)
|
|
n_hi = int((u_upper * 100).to_integral_value(rounding=ROUND_DOWN))
|
|
if n_hi < 1:
|
|
raise OrderTotalOutOfRangeException(
|
|
message='Order total is below minimum allowed amount',
|
|
)
|
|
|
|
n_lo = 1
|
|
best_cent: int | None = None
|
|
while n_lo <= n_hi:
|
|
mid = (n_lo + n_hi) // 2
|
|
u = Decimal(mid) / Decimal(100)
|
|
quote = self._compose_quote(u, usdt_exchange_rate, gas_fee)
|
|
if quote is None:
|
|
n_hi = mid - 1
|
|
continue
|
|
if quote.total_price <= total_rub:
|
|
best_cent = mid
|
|
n_lo = mid + 1
|
|
else:
|
|
n_hi = mid - 1
|
|
|
|
if best_cent is None:
|
|
raise OrderTotalOutOfRangeException(
|
|
message='Order total is below minimum allowed amount',
|
|
)
|
|
|
|
final = self._compose_quote(
|
|
Decimal(best_cent) / Decimal(100),
|
|
usdt_exchange_rate,
|
|
gas_fee,
|
|
)
|
|
if final is None:
|
|
raise OrderTotalOutOfRangeException(
|
|
message='Order total exceeds maximum allowed amount',
|
|
)
|
|
return final
|