From ae7bfb9621e715d96c43c6564e2b73f96c62fe27 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Thu, 4 Jun 2026 16:21:10 +0300 Subject: [PATCH] fix: gate PayKilla by minimum payment amount --- backend/bot/app/web/webapp/billing.py | 16 ++ backend/bot/app/web/webapp/serializers.py | 16 +- .../bot/keyboards/inline/user_keyboards.py | 2 +- backend/bot/payment_providers/base.py | 28 +++ backend/bot/payment_providers/paykilla.py | 207 +++++++++++++++++- docs/configuration/env-vars.md | 2 + docs/features/payments.md | 7 +- .../patterns/webapp/PaymentMethodGrid.svelte | 10 +- .../webapp/settingsManifest.generated.json | 51 +++++ frontend/src/lib/webapp/tariffs.js | 37 ++++ frontend/src/styles/webapp.css | 13 ++ frontend/src/webapp/PaymentDialogs.svelte | 20 +- frontend/src/webapp/TariffDialogs.svelte | 56 ++++- tests/test_payment_provider_registry.py | 105 +++++++++ tests/test_security.py | 22 ++ 15 files changed, 567 insertions(+), 25 deletions(-) diff --git a/backend/bot/app/web/webapp/billing.py b/backend/bot/app/web/webapp/billing.py index 9408c74..b119884 100644 --- a/backend/bot/app/web/webapp/billing.py +++ b/backend/bot/app/web/webapp/billing.py @@ -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, diff --git a/backend/bot/app/web/webapp/serializers.py b/backend/bot/app/web/webapp/serializers.py index cffbd28..7be1ce3 100644 --- a/backend/bot/app/web/webapp/serializers.py +++ b/backend/bot/app/web/webapp/serializers.py @@ -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 diff --git a/backend/bot/keyboards/inline/user_keyboards.py b/backend/bot/keyboards/inline/user_keyboards.py index 064f786..fa5d830 100644 --- a/backend/bot/keyboards/inline/user_keyboards.py +++ b/backend/bot/keyboards/inline/user_keyboards.py @@ -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, diff --git a/backend/bot/payment_providers/base.py b/backend/bot/payment_providers/base.py index 9f5cf65..dc2e670 100644 --- a/backend/bot/payment_providers/base.py +++ b/backend/bot/payment_providers/base.py @@ -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) diff --git a/backend/bot/payment_providers/paykilla.py b/backend/bot/payment_providers/paykilla.py index 8519f4a..4142b15 100644 --- a/backend/bot/payment_providers/paykilla.py +++ b/backend/bot/payment_providers/paykilla.py @@ -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." diff --git a/docs/configuration/env-vars.md b/docs/configuration/env-vars.md index 0bf7f96..7bf5ba7 100644 --- a/docs/configuration/env-vars.md +++ b/docs/configuration/env-vars.md @@ -387,6 +387,8 @@ Webhook настраивается в PayKilla Dashboard: **Settings -> Webhooks | `PAYKILLA_USER_PAYS_NETWORK_FEE` | `true`, если пользователь оплачивает network fee. | | `PAYKILLA_EXCHANGE_RATE_URL` | Бесплатный no-key endpoint курса для конвертации валюты тарифа в валюту инвойса. По умолчанию `https://open.er-api.com/v6/latest/{source}`. Поддерживает placeholders `{source}` и `{target}`. | | `PAYKILLA_EXCHANGE_RATE_CACHE_SECONDS` | Кэш курса и PayKilla currency limits в секундах. По умолчанию `3600`. | +| `PAYKILLA_MIN_PAYMENT_AMOUNT` | Минимальная сумма платежа через PayKilla. По умолчанию `10`. | +| `PAYKILLA_MIN_PAYMENT_CURRENCY` | Валюта для `PAYKILLA_MIN_PAYMENT_AMOUNT`. По умолчанию `USD`; для рублевых тарифов порог конвертируется по `PAYKILLA_EXCHANGE_RATE_URL`. | | `PAYKILLA_VERIFY_WEBHOOK_SIGNATURE` | Проверять `X-API-SIGN` по raw body webhook. | | `PAYKILLA_WEBHOOK_URL` | Точный публичный webhook URL для проверки подписи, если он отличается от `WEBHOOK_BASE_URL` + `/webhook/paykilla`. | | `PAYKILLA_TRUSTED_IPS` | Необязательный список доверенных IP webhook-источников. | diff --git a/docs/features/payments.md b/docs/features/payments.md index d2834b6..b430da7 100644 --- a/docs/features/payments.md +++ b/docs/features/payments.md @@ -162,6 +162,8 @@ PayKilla строго валидирует текстовые поля invoice. Minishop создает invoice в валюте, которую PayKilla принимает в поле `currency`. Если валюта тарифа входит в `PAYKILLA_INVOICE_CURRENCIES`, сумма отправляется как есть. Если валюта тарифа не входит в этот список, сумма конвертируется в `PAYKILLA_CURRENCY`; по умолчанию рублевые тарифы конвертируются в `USD` через no-key endpoint ExchangeRate-API `https://open.er-api.com/v6/latest/{source}` с кэшем `PAYKILLA_EXCHANGE_RATE_CACHE_SECONDS`. Перед созданием invoice Minishop читает `GET /api/v2/currency` и проверяет `invoiceMin`/`invoiceMax` для валюты инвойса. +Минимальная сумма платежа задается настройками `PAYKILLA_MIN_PAYMENT_AMOUNT` и `PAYKILLA_MIN_PAYMENT_CURRENCY`; по умолчанию это `10 USD`. Если выбранный тариф/пакет ниже этого порога после конвертации, Telegram bot не показывает кнопку PayKilla, WebApp показывает метод неактивным, а API создания платежа возвращает ошибку `payment_amount_below_minimum`. + Payload создания invoice содержит обязательные поля `type`, `purpose`, `currency`, `totalPrice`, `paymentCurrencies`, служебный `clientOrderId`, а также полезные optional поля `description`, `expiredAt`, `userPaysServiceFee`, `userPaysNetworkFee`. Redirect URLs в PayKilla не отправляются; завершение платежа обрабатывается через webhook. Какие полномочия нужны API key: @@ -187,8 +189,9 @@ Payload создания invoice содержит обязательные по 2. Укажите `PAYKILLA_API_KEY` и `PAYKILLA_SECRET_KEY`. 3. Оставьте `PAYKILLA_CURRENCY=USD`, если PayKilla не принимает валюту тарифов как invoice currency. В `PAYKILLA_INVOICE_CURRENCIES` укажите валюты, доступные в PayKilla для поля `currency`, например `USD,EUR`. 4. В `PAYKILLA_PAYMENT_CURRENCIES` начните с `USDTTRC`, а `BTC`, `ETH` и другие тикеры добавляйте только если они доступны в PayKilla Dashboard. -5. Убедитесь, что webhook `/webhook/paykilla` настроен в PayKilla: Minishop не отправляет redirect URLs в PayKilla и полагается на webhook для активации платежа. -6. Добавьте `paykilla` в `PAYMENT_METHODS_ORDER`, если хотите задать явный порядок кнопок. +5. Оставьте `PAYKILLA_MIN_PAYMENT_AMOUNT=10` и `PAYKILLA_MIN_PAYMENT_CURRENCY=USD`, если минимальный invoice PayKilla равен `10 USD`. +6. Убедитесь, что webhook `/webhook/paykilla` настроен в PayKilla: Minishop не отправляет redirect URLs в PayKilla и полагается на webhook для активации платежа. +7. Добавьте `paykilla` в `PAYMENT_METHODS_ORDER`, если хотите задать явный порядок кнопок. Справочник переменных: [PayKilla](../configuration/env-vars.md#paykilla). diff --git a/frontend/src/lib/components/patterns/webapp/PaymentMethodGrid.svelte b/frontend/src/lib/components/patterns/webapp/PaymentMethodGrid.svelte index 7ad6b3e..967456b 100644 --- a/frontend/src/lib/components/patterns/webapp/PaymentMethodGrid.svelte +++ b/frontend/src/lib/components/patterns/webapp/PaymentMethodGrid.svelte @@ -14,6 +14,11 @@ const iconName = String(method?.icon || "").trim(); return iconName ? Icons[iconName] || null : null; } + + function disabledTitle(method) { + if (!method?.disabled || !method?.min_amount || !method?.min_currency) return ""; + return `Minimum ${method.min_amount} ${method.min_currency}`; + }