fix: gate PayKilla by minimum payment amount
This commit is contained in:
@@ -1052,6 +1052,22 @@ async def _create_subscription_payment(
|
||||
"unsupported_currency",
|
||||
"Payment method does not support this currency",
|
||||
)
|
||||
if not provider_spec.is_usable_for_payment_amount(
|
||||
settings,
|
||||
payment_currency,
|
||||
price,
|
||||
):
|
||||
logger.warning(
|
||||
"WebApp payment method does not support amount: method=%s amount=%s currency=%s",
|
||||
method,
|
||||
price,
|
||||
payment_currency,
|
||||
)
|
||||
return _json_error(
|
||||
400,
|
||||
"payment_amount_below_minimum",
|
||||
"Payment amount is below the provider minimum",
|
||||
)
|
||||
return await provider_spec.create_webapp_payment(
|
||||
WebAppPaymentContext(
|
||||
request=request,
|
||||
|
||||
@@ -943,13 +943,15 @@ def _serialize_payment_methods(
|
||||
and spec.is_usable_for_payment_currency(settings, payment_currency)
|
||||
):
|
||||
presentation = resolve_provider_presentation(spec, settings, language=lang)
|
||||
methods.append(
|
||||
{
|
||||
"id": method,
|
||||
"name": presentation.webapp_label,
|
||||
"icon": presentation.webapp_icon,
|
||||
}
|
||||
)
|
||||
payload = {
|
||||
"id": method,
|
||||
"name": presentation.webapp_label,
|
||||
"icon": presentation.webapp_icon,
|
||||
}
|
||||
minimum = spec.payment_minimum(settings, payment_currency)
|
||||
if minimum:
|
||||
payload.update(minimum)
|
||||
methods.append(payload)
|
||||
return methods
|
||||
|
||||
|
||||
|
||||
@@ -560,7 +560,7 @@ def get_payment_method_keyboard(
|
||||
if (
|
||||
not spec
|
||||
or not spec.callback_prefix
|
||||
or not spec.is_usable_for_payment_currency(settings, currency_symbol_val)
|
||||
or not spec.is_usable_for_payment(settings, currency_symbol_val, price)
|
||||
or not spec.is_available_to_user(
|
||||
settings,
|
||||
user_id=user_id,
|
||||
|
||||
@@ -128,6 +128,8 @@ WebhookPathGetter = Callable[[Any], str]
|
||||
WebhookRoute = Callable[[Any], Awaitable[Any]]
|
||||
WebAppPaymentFactory = Callable[[WebAppPaymentContext], Awaitable[Any]]
|
||||
CurrencySupportResolver = Callable[[Any], Optional[Sequence[str]]]
|
||||
PaymentAmountResolver = Callable[[Any, Any, Any], bool]
|
||||
PaymentMinimumResolver = Callable[[Any, Any], Optional[Mapping[str, Any]]]
|
||||
|
||||
|
||||
def normalize_payment_currency_code(value: Any, default: str = "RUB") -> str:
|
||||
@@ -192,6 +194,8 @@ class PaymentProviderSpec:
|
||||
admin_only_enabled: Optional[EnabledPredicate] = None
|
||||
supported_currencies: Optional[Sequence[str]] = ("RUB",)
|
||||
supported_currencies_resolver: Optional[CurrencySupportResolver] = None
|
||||
payment_amount_resolver: Optional[PaymentAmountResolver] = None
|
||||
payment_minimum_resolver: Optional[PaymentMinimumResolver] = None
|
||||
currency_support_note: str = ""
|
||||
currency_support_url: Optional[str] = None
|
||||
|
||||
@@ -306,6 +310,30 @@ class PaymentProviderSpec:
|
||||
return True
|
||||
return self.supports_currency(source, currency)
|
||||
|
||||
def payment_minimum(self, source: Any, currency: Any) -> Optional[Mapping[str, Any]]:
|
||||
if self.payment_minimum_resolver is None:
|
||||
return None
|
||||
source_for_amount = self._currency_source(source)
|
||||
try:
|
||||
return self.payment_minimum_resolver(source_for_amount, currency)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def is_usable_for_payment_amount(self, source: Any, currency: Any, amount: Any) -> bool:
|
||||
if self.price_source == "stars" or self.payment_amount_resolver is None:
|
||||
return True
|
||||
source_for_amount = self._currency_source(source)
|
||||
try:
|
||||
return bool(self.payment_amount_resolver(source_for_amount, currency, amount))
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
def is_usable_for_payment(self, source: Any, currency: Any, amount: Any) -> bool:
|
||||
return self.is_usable_for_payment_currency(
|
||||
source,
|
||||
currency,
|
||||
) and self.is_usable_for_payment_amount(source, currency, amount)
|
||||
|
||||
def is_visible(self, source: Any, app: Any) -> bool:
|
||||
return self.is_enabled(source) and self.is_service_configured(app)
|
||||
|
||||
|
||||
@@ -5,9 +5,10 @@ import logging
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from decimal import ROUND_CEILING, Decimal, InvalidOperation
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from urllib.parse import urlencode
|
||||
from urllib.request import urlopen
|
||||
|
||||
from aiogram import Bot, F, Router, types
|
||||
from aiohttp import web
|
||||
@@ -69,6 +70,8 @@ _LOG = "paykilla"
|
||||
PAYKILLA_DEFAULT_PAYMENT_CURRENCIES = "USDTTRC"
|
||||
PAYKILLA_DEFAULT_INVOICE_CURRENCIES = "USD,EUR"
|
||||
PAYKILLA_DEFAULT_EXCHANGE_RATE_URL = "https://open.er-api.com/v6/latest/{source}"
|
||||
PAYKILLA_DEFAULT_MIN_PAYMENT_AMOUNT = 10.0
|
||||
PAYKILLA_DEFAULT_MIN_PAYMENT_CURRENCY = "USD"
|
||||
PAYKILLA_DEFAULT_SUPPORTED_CURRENCIES = (
|
||||
"RUB,USD,EUR,AED,GBP,BTC,ETH,TRX,TON,USDTTRC,USDTETH,USDTBSC,"
|
||||
"USDCETH,USDCBSC,DAIETH,DAIBSC,BNBBSC,ETHBSC,LINKETH,LINKBSC,"
|
||||
@@ -154,6 +157,7 @@ _CYRILLIC_TO_LATIN = str.maketrans(
|
||||
"я": "ya",
|
||||
}
|
||||
)
|
||||
_SYNC_EXCHANGE_RATE_CACHE: Dict[tuple[str, str, str], tuple[float, Decimal]] = {}
|
||||
|
||||
|
||||
class PaykillaConfig(ProviderEnvConfig):
|
||||
@@ -192,6 +196,8 @@ class PaykillaConfig(ProviderEnvConfig):
|
||||
USER_PAYS_NETWORK_FEE: bool = Field(default=True)
|
||||
EXCHANGE_RATE_URL: str = Field(default=PAYKILLA_DEFAULT_EXCHANGE_RATE_URL)
|
||||
EXCHANGE_RATE_CACHE_SECONDS: int = Field(default=3600)
|
||||
MIN_PAYMENT_AMOUNT: float = Field(default=PAYKILLA_DEFAULT_MIN_PAYMENT_AMOUNT)
|
||||
MIN_PAYMENT_CURRENCY: str = Field(default=PAYKILLA_DEFAULT_MIN_PAYMENT_CURRENCY)
|
||||
VERIFY_WEBHOOK_SIGNATURE: bool = Field(default=True)
|
||||
WEBHOOK_URL: Optional[str] = None
|
||||
TRUSTED_IPS: str = Field(default="")
|
||||
@@ -229,6 +235,24 @@ class PaykillaConfig(ProviderEnvConfig):
|
||||
return 3600
|
||||
return min(86_400, max(60, value))
|
||||
|
||||
@field_validator("MIN_PAYMENT_AMOUNT", mode="before")
|
||||
@classmethod
|
||||
def _normalize_min_payment_amount(cls, v):
|
||||
if isinstance(v, str):
|
||||
v = v.strip()
|
||||
try:
|
||||
value = Decimal(str(v))
|
||||
except (InvalidOperation, TypeError, ValueError):
|
||||
return PAYKILLA_DEFAULT_MIN_PAYMENT_AMOUNT
|
||||
if not value.is_finite() or value < 0:
|
||||
return PAYKILLA_DEFAULT_MIN_PAYMENT_AMOUNT
|
||||
return float(value)
|
||||
|
||||
@field_validator("MIN_PAYMENT_CURRENCY", mode="before")
|
||||
@classmethod
|
||||
def _normalize_min_payment_currency(cls, v):
|
||||
return normalize_payment_currency_code(v, default=PAYKILLA_DEFAULT_MIN_PAYMENT_CURRENCY)
|
||||
|
||||
@field_validator(
|
||||
"API_KEY",
|
||||
"SECRET_KEY",
|
||||
@@ -389,6 +413,115 @@ def _decimal_from_api(value: Any) -> Optional[Decimal]:
|
||||
return decimal_value
|
||||
|
||||
|
||||
def _config_min_payment_amount(config: PaykillaConfig) -> Decimal:
|
||||
amount = _decimal_from_api(getattr(config, "MIN_PAYMENT_AMOUNT", None))
|
||||
if amount is None or amount < 0:
|
||||
return Decimal(str(PAYKILLA_DEFAULT_MIN_PAYMENT_AMOUNT))
|
||||
return amount
|
||||
|
||||
|
||||
def _config_min_payment_currency(config: PaykillaConfig) -> str:
|
||||
return normalize_payment_currency_code(
|
||||
getattr(config, "MIN_PAYMENT_CURRENCY", None),
|
||||
default=PAYKILLA_DEFAULT_MIN_PAYMENT_CURRENCY,
|
||||
)
|
||||
|
||||
|
||||
def _exchange_rate_url_for(
|
||||
config: PaykillaConfig,
|
||||
source_currency: str,
|
||||
target_currency: str,
|
||||
) -> str:
|
||||
template = getattr(config, "EXCHANGE_RATE_URL", None) or PAYKILLA_DEFAULT_EXCHANGE_RATE_URL
|
||||
return template.format(source=source_currency, target=target_currency)
|
||||
|
||||
|
||||
def _exchange_rate_sync(
|
||||
config: PaykillaConfig, source_currency: str, target_currency: str
|
||||
) -> Optional[Decimal]:
|
||||
source_currency = normalize_payment_currency_code(source_currency)
|
||||
target_currency = normalize_payment_currency_code(target_currency)
|
||||
if source_currency == target_currency:
|
||||
return Decimal("1")
|
||||
|
||||
url = _exchange_rate_url_for(config, source_currency, target_currency)
|
||||
cache_key = (url, source_currency, target_currency)
|
||||
cache_seconds = int(getattr(config, "EXCHANGE_RATE_CACHE_SECONDS", 3600) or 3600)
|
||||
now = time.time()
|
||||
cached = _SYNC_EXCHANGE_RATE_CACHE.get(cache_key)
|
||||
if cached and now - cached[0] < cache_seconds:
|
||||
return cached[1]
|
||||
|
||||
try:
|
||||
with urlopen(url, timeout=5) as response:
|
||||
response_data = json.loads(response.read().decode("utf-8"))
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"Paykilla exchange rate sync lookup failed (source=%s target=%s).",
|
||||
source_currency,
|
||||
target_currency,
|
||||
)
|
||||
return None
|
||||
|
||||
if not isinstance(response_data, dict) or response_data.get("result") != "success":
|
||||
logging.warning(
|
||||
"Paykilla exchange rate sync lookup returned unexpected body: %s",
|
||||
response_data,
|
||||
)
|
||||
return None
|
||||
rates = response_data.get("rates")
|
||||
rate = _decimal_from_api(rates.get(target_currency) if isinstance(rates, dict) else None)
|
||||
if rate is None or rate <= 0:
|
||||
return None
|
||||
_SYNC_EXCHANGE_RATE_CACHE[cache_key] = (now, rate)
|
||||
return rate
|
||||
|
||||
|
||||
def _min_payment_threshold_for_currency(
|
||||
config: PaykillaConfig, payment_currency: Any
|
||||
) -> Optional[Decimal]:
|
||||
min_amount = _config_min_payment_amount(config)
|
||||
if min_amount <= 0:
|
||||
return None
|
||||
min_currency = _config_min_payment_currency(config)
|
||||
payment_currency = normalize_payment_currency_code(payment_currency)
|
||||
if payment_currency == min_currency:
|
||||
return format_decimal_amount(min_amount)
|
||||
rate = _exchange_rate_sync(config, payment_currency, min_currency)
|
||||
if rate is None or rate <= 0:
|
||||
return None
|
||||
return (min_amount / rate).quantize(Decimal("0.01"), rounding=ROUND_CEILING)
|
||||
|
||||
|
||||
def _paykilla_payment_minimum_metadata(
|
||||
config: PaykillaConfig, payment_currency: Any
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
payment_currency = normalize_payment_currency_code(payment_currency)
|
||||
threshold = _min_payment_threshold_for_currency(config, payment_currency)
|
||||
if threshold is None:
|
||||
return None
|
||||
return {
|
||||
"min_amount": str(threshold),
|
||||
"min_currency": payment_currency,
|
||||
"configured_min_amount": str(format_decimal_amount(_config_min_payment_amount(config))),
|
||||
"configured_min_currency": _config_min_payment_currency(config),
|
||||
}
|
||||
|
||||
|
||||
def _paykilla_payment_amount_supported(
|
||||
config: PaykillaConfig,
|
||||
payment_currency: Any,
|
||||
amount: Any,
|
||||
) -> bool:
|
||||
threshold = _min_payment_threshold_for_currency(config, payment_currency)
|
||||
if threshold is None:
|
||||
return True
|
||||
value = _decimal_from_api(amount)
|
||||
if value is None:
|
||||
return True
|
||||
return format_decimal_amount(value) >= threshold
|
||||
|
||||
|
||||
class PaykillaService(HttpClientMixin):
|
||||
def __init__(
|
||||
self,
|
||||
@@ -460,8 +593,7 @@ class PaykillaService(HttpClientMixin):
|
||||
return f"{self.base_url}/api/v2/currency?{query}&signature={signature}"
|
||||
|
||||
def _exchange_rate_url(self, source_currency: str, target_currency: str) -> str:
|
||||
template = self.config.EXCHANGE_RATE_URL or PAYKILLA_DEFAULT_EXCHANGE_RATE_URL
|
||||
return template.format(source=source_currency, target=target_currency)
|
||||
return _exchange_rate_url_for(self.config, source_currency, target_currency)
|
||||
|
||||
async def _exchange_rate(self, source_currency: str, target_currency: str) -> Decimal:
|
||||
source_currency = normalize_payment_currency_code(source_currency)
|
||||
@@ -596,6 +728,31 @@ class PaykillaService(HttpClientMixin):
|
||||
)
|
||||
return converted_amount, invoice_currency
|
||||
|
||||
async def _configured_minimum_error(
|
||||
self, *, amount: float, payment_currency: str
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
min_amount = _config_min_payment_amount(self.config)
|
||||
if min_amount <= 0:
|
||||
return None
|
||||
min_currency = _config_min_payment_currency(self.config)
|
||||
payment_currency = normalize_payment_currency_code(payment_currency)
|
||||
payment_amount = format_decimal_amount(amount)
|
||||
if payment_currency == min_currency:
|
||||
comparable_amount = payment_amount
|
||||
else:
|
||||
rate = await self._exchange_rate(payment_currency, min_currency)
|
||||
comparable_amount = format_decimal_amount(payment_amount * rate)
|
||||
if comparable_amount >= min_amount:
|
||||
return None
|
||||
return {
|
||||
"message": "payment_amount_below_minimum",
|
||||
"currency": payment_currency,
|
||||
"amount": str(payment_amount),
|
||||
"minimum": str(format_decimal_amount(min_amount)),
|
||||
"minimum_currency": min_currency,
|
||||
"converted_amount": str(comparable_amount),
|
||||
}
|
||||
|
||||
def _invoice_body(
|
||||
self,
|
||||
*,
|
||||
@@ -647,6 +804,17 @@ class PaykillaService(HttpClientMixin):
|
||||
}
|
||||
|
||||
try:
|
||||
minimum_error = await self._configured_minimum_error(
|
||||
amount=amount,
|
||||
payment_currency=currency_code,
|
||||
)
|
||||
if minimum_error:
|
||||
logging.error(
|
||||
"Paykilla create_payment_link: payment amount below configured minimum "
|
||||
"(details=%s)",
|
||||
minimum_error,
|
||||
)
|
||||
return False, minimum_error
|
||||
invoice_amount, invoice_currency = await self._invoice_amount_and_currency(
|
||||
amount=amount,
|
||||
payment_currency=currency_code,
|
||||
@@ -1000,6 +1168,15 @@ async def pay_paykilla_callback_handler(
|
||||
return
|
||||
|
||||
currency_code = default_payment_currency_code_for_settings(settings)
|
||||
if not SPEC.is_usable_for_payment_amount(settings, currency_code, parts.price):
|
||||
logging.warning(
|
||||
"Paykilla callback rejected below-minimum payment (amount=%s currency=%s user=%s).",
|
||||
parts.price,
|
||||
currency_code,
|
||||
callback.from_user.id,
|
||||
)
|
||||
await notify_service_unavailable(callback, translator)
|
||||
return
|
||||
payment_description = describe_payment(translator, parts)
|
||||
record_payload = build_payment_record_payload(
|
||||
user_id=callback.from_user.id,
|
||||
@@ -1325,6 +1502,28 @@ _CONFIG_MANIFEST = (
|
||||
max=86_400,
|
||||
attr="EXCHANGE_RATE_CACHE_SECONDS",
|
||||
),
|
||||
ProviderManifestField(
|
||||
"PAYKILLA_MIN_PAYMENT_AMOUNT",
|
||||
"float",
|
||||
"Minimum payment amount",
|
||||
description=(
|
||||
"Minimum payment amount accepted through PayKilla. The value is interpreted "
|
||||
"in PAYKILLA_MIN_PAYMENT_CURRENCY and converted for tariff currencies."
|
||||
),
|
||||
placeholder=str(PAYKILLA_DEFAULT_MIN_PAYMENT_AMOUNT),
|
||||
subsection="PayKilla",
|
||||
min=0,
|
||||
attr="MIN_PAYMENT_AMOUNT",
|
||||
),
|
||||
ProviderManifestField(
|
||||
"PAYKILLA_MIN_PAYMENT_CURRENCY",
|
||||
"string",
|
||||
"Minimum payment currency",
|
||||
description="Currency for PAYKILLA_MIN_PAYMENT_AMOUNT. Default: USD.",
|
||||
placeholder=PAYKILLA_DEFAULT_MIN_PAYMENT_CURRENCY,
|
||||
subsection="PayKilla",
|
||||
attr="MIN_PAYMENT_CURRENCY",
|
||||
),
|
||||
ProviderManifestField(
|
||||
"PAYKILLA_VERIFY_WEBHOOK_SIGNATURE",
|
||||
"bool",
|
||||
@@ -1379,6 +1578,8 @@ SPEC = PaymentProviderSpec(
|
||||
supported_currencies_resolver=lambda config: getattr(
|
||||
config, "SUPPORTED_CURRENCIES", PAYKILLA_DEFAULT_SUPPORTED_CURRENCIES
|
||||
),
|
||||
payment_amount_resolver=_paykilla_payment_amount_supported,
|
||||
payment_minimum_resolver=_paykilla_payment_minimum_metadata,
|
||||
currency_support_note=(
|
||||
"PayKilla invoice currency and paymentCurrencies availability can depend on "
|
||||
"merchant account settings."
|
||||
|
||||
Reference in New Issue
Block a user