fix: gate PayKilla by minimum payment amount

This commit is contained in:
3252a8
2026-06-04 16:21:10 +03:00
parent bdf0622be5
commit ae7bfb9621
15 changed files with 567 additions and 25 deletions
+16
View File
@@ -1052,6 +1052,22 @@ async def _create_subscription_payment(
"unsupported_currency", "unsupported_currency",
"Payment method does not support this 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( return await provider_spec.create_webapp_payment(
WebAppPaymentContext( WebAppPaymentContext(
request=request, request=request,
+9 -7
View File
@@ -943,13 +943,15 @@ def _serialize_payment_methods(
and spec.is_usable_for_payment_currency(settings, payment_currency) and spec.is_usable_for_payment_currency(settings, payment_currency)
): ):
presentation = resolve_provider_presentation(spec, settings, language=lang) presentation = resolve_provider_presentation(spec, settings, language=lang)
methods.append( payload = {
{ "id": method,
"id": method, "name": presentation.webapp_label,
"name": presentation.webapp_label, "icon": presentation.webapp_icon,
"icon": presentation.webapp_icon, }
} minimum = spec.payment_minimum(settings, payment_currency)
) if minimum:
payload.update(minimum)
methods.append(payload)
return methods return methods
@@ -560,7 +560,7 @@ def get_payment_method_keyboard(
if ( if (
not spec not spec
or not spec.callback_prefix 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( or not spec.is_available_to_user(
settings, settings,
user_id=user_id, user_id=user_id,
+28
View File
@@ -128,6 +128,8 @@ WebhookPathGetter = Callable[[Any], str]
WebhookRoute = Callable[[Any], Awaitable[Any]] WebhookRoute = Callable[[Any], Awaitable[Any]]
WebAppPaymentFactory = Callable[[WebAppPaymentContext], Awaitable[Any]] WebAppPaymentFactory = Callable[[WebAppPaymentContext], Awaitable[Any]]
CurrencySupportResolver = Callable[[Any], Optional[Sequence[str]]] 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: def normalize_payment_currency_code(value: Any, default: str = "RUB") -> str:
@@ -192,6 +194,8 @@ class PaymentProviderSpec:
admin_only_enabled: Optional[EnabledPredicate] = None admin_only_enabled: Optional[EnabledPredicate] = None
supported_currencies: Optional[Sequence[str]] = ("RUB",) supported_currencies: Optional[Sequence[str]] = ("RUB",)
supported_currencies_resolver: Optional[CurrencySupportResolver] = None supported_currencies_resolver: Optional[CurrencySupportResolver] = None
payment_amount_resolver: Optional[PaymentAmountResolver] = None
payment_minimum_resolver: Optional[PaymentMinimumResolver] = None
currency_support_note: str = "" currency_support_note: str = ""
currency_support_url: Optional[str] = None currency_support_url: Optional[str] = None
@@ -306,6 +310,30 @@ class PaymentProviderSpec:
return True return True
return self.supports_currency(source, currency) 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: def is_visible(self, source: Any, app: Any) -> bool:
return self.is_enabled(source) and self.is_service_configured(app) return self.is_enabled(source) and self.is_service_configured(app)
+204 -3
View File
@@ -5,9 +5,10 @@ import logging
import re import re
import time import time
from datetime import datetime, timedelta, timezone 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 typing import Any, Dict, List, Optional, Tuple
from urllib.parse import urlencode from urllib.parse import urlencode
from urllib.request import urlopen
from aiogram import Bot, F, Router, types from aiogram import Bot, F, Router, types
from aiohttp import web from aiohttp import web
@@ -69,6 +70,8 @@ _LOG = "paykilla"
PAYKILLA_DEFAULT_PAYMENT_CURRENCIES = "USDTTRC" PAYKILLA_DEFAULT_PAYMENT_CURRENCIES = "USDTTRC"
PAYKILLA_DEFAULT_INVOICE_CURRENCIES = "USD,EUR" PAYKILLA_DEFAULT_INVOICE_CURRENCIES = "USD,EUR"
PAYKILLA_DEFAULT_EXCHANGE_RATE_URL = "https://open.er-api.com/v6/latest/{source}" 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 = ( PAYKILLA_DEFAULT_SUPPORTED_CURRENCIES = (
"RUB,USD,EUR,AED,GBP,BTC,ETH,TRX,TON,USDTTRC,USDTETH,USDTBSC," "RUB,USD,EUR,AED,GBP,BTC,ETH,TRX,TON,USDTTRC,USDTETH,USDTBSC,"
"USDCETH,USDCBSC,DAIETH,DAIBSC,BNBBSC,ETHBSC,LINKETH,LINKBSC," "USDCETH,USDCBSC,DAIETH,DAIBSC,BNBBSC,ETHBSC,LINKETH,LINKBSC,"
@@ -154,6 +157,7 @@ _CYRILLIC_TO_LATIN = str.maketrans(
"я": "ya", "я": "ya",
} }
) )
_SYNC_EXCHANGE_RATE_CACHE: Dict[tuple[str, str, str], tuple[float, Decimal]] = {}
class PaykillaConfig(ProviderEnvConfig): class PaykillaConfig(ProviderEnvConfig):
@@ -192,6 +196,8 @@ class PaykillaConfig(ProviderEnvConfig):
USER_PAYS_NETWORK_FEE: bool = Field(default=True) USER_PAYS_NETWORK_FEE: bool = Field(default=True)
EXCHANGE_RATE_URL: str = Field(default=PAYKILLA_DEFAULT_EXCHANGE_RATE_URL) EXCHANGE_RATE_URL: str = Field(default=PAYKILLA_DEFAULT_EXCHANGE_RATE_URL)
EXCHANGE_RATE_CACHE_SECONDS: int = Field(default=3600) 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) VERIFY_WEBHOOK_SIGNATURE: bool = Field(default=True)
WEBHOOK_URL: Optional[str] = None WEBHOOK_URL: Optional[str] = None
TRUSTED_IPS: str = Field(default="") TRUSTED_IPS: str = Field(default="")
@@ -229,6 +235,24 @@ class PaykillaConfig(ProviderEnvConfig):
return 3600 return 3600
return min(86_400, max(60, value)) 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( @field_validator(
"API_KEY", "API_KEY",
"SECRET_KEY", "SECRET_KEY",
@@ -389,6 +413,115 @@ def _decimal_from_api(value: Any) -> Optional[Decimal]:
return decimal_value 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): class PaykillaService(HttpClientMixin):
def __init__( def __init__(
self, self,
@@ -460,8 +593,7 @@ class PaykillaService(HttpClientMixin):
return f"{self.base_url}/api/v2/currency?{query}&signature={signature}" return f"{self.base_url}/api/v2/currency?{query}&signature={signature}"
def _exchange_rate_url(self, source_currency: str, target_currency: str) -> str: 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 _exchange_rate_url_for(self.config, source_currency, target_currency)
return template.format(source=source_currency, target=target_currency)
async def _exchange_rate(self, source_currency: str, target_currency: str) -> Decimal: async def _exchange_rate(self, source_currency: str, target_currency: str) -> Decimal:
source_currency = normalize_payment_currency_code(source_currency) source_currency = normalize_payment_currency_code(source_currency)
@@ -596,6 +728,31 @@ class PaykillaService(HttpClientMixin):
) )
return converted_amount, invoice_currency 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( def _invoice_body(
self, self,
*, *,
@@ -647,6 +804,17 @@ class PaykillaService(HttpClientMixin):
} }
try: 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( invoice_amount, invoice_currency = await self._invoice_amount_and_currency(
amount=amount, amount=amount,
payment_currency=currency_code, payment_currency=currency_code,
@@ -1000,6 +1168,15 @@ async def pay_paykilla_callback_handler(
return return
currency_code = default_payment_currency_code_for_settings(settings) 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) payment_description = describe_payment(translator, parts)
record_payload = build_payment_record_payload( record_payload = build_payment_record_payload(
user_id=callback.from_user.id, user_id=callback.from_user.id,
@@ -1325,6 +1502,28 @@ _CONFIG_MANIFEST = (
max=86_400, max=86_400,
attr="EXCHANGE_RATE_CACHE_SECONDS", 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( ProviderManifestField(
"PAYKILLA_VERIFY_WEBHOOK_SIGNATURE", "PAYKILLA_VERIFY_WEBHOOK_SIGNATURE",
"bool", "bool",
@@ -1379,6 +1578,8 @@ SPEC = PaymentProviderSpec(
supported_currencies_resolver=lambda config: getattr( supported_currencies_resolver=lambda config: getattr(
config, "SUPPORTED_CURRENCIES", PAYKILLA_DEFAULT_SUPPORTED_CURRENCIES config, "SUPPORTED_CURRENCIES", PAYKILLA_DEFAULT_SUPPORTED_CURRENCIES
), ),
payment_amount_resolver=_paykilla_payment_amount_supported,
payment_minimum_resolver=_paykilla_payment_minimum_metadata,
currency_support_note=( currency_support_note=(
"PayKilla invoice currency and paymentCurrencies availability can depend on " "PayKilla invoice currency and paymentCurrencies availability can depend on "
"merchant account settings." "merchant account settings."
+2
View File
@@ -387,6 +387,8 @@ Webhook настраивается в PayKilla Dashboard: **Settings -> Webhooks
| `PAYKILLA_USER_PAYS_NETWORK_FEE` | `true`, если пользователь оплачивает network fee. | | `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_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_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_VERIFY_WEBHOOK_SIGNATURE` | Проверять `X-API-SIGN` по raw body webhook. |
| `PAYKILLA_WEBHOOK_URL` | Точный публичный webhook URL для проверки подписи, если он отличается от `WEBHOOK_BASE_URL` + `/webhook/paykilla`. | | `PAYKILLA_WEBHOOK_URL` | Точный публичный webhook URL для проверки подписи, если он отличается от `WEBHOOK_BASE_URL` + `/webhook/paykilla`. |
| `PAYKILLA_TRUSTED_IPS` | Необязательный список доверенных IP webhook-источников. | | `PAYKILLA_TRUSTED_IPS` | Необязательный список доверенных IP webhook-источников. |
+5 -2
View File
@@ -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` для валюты инвойса. 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. Payload создания invoice содержит обязательные поля `type`, `purpose`, `currency`, `totalPrice`, `paymentCurrencies`, служебный `clientOrderId`, а также полезные optional поля `description`, `expiredAt`, `userPaysServiceFee`, `userPaysNetworkFee`. Redirect URLs в PayKilla не отправляются; завершение платежа обрабатывается через webhook.
Какие полномочия нужны API key: Какие полномочия нужны API key:
@@ -187,8 +189,9 @@ Payload создания invoice содержит обязательные по
2. Укажите `PAYKILLA_API_KEY` и `PAYKILLA_SECRET_KEY`. 2. Укажите `PAYKILLA_API_KEY` и `PAYKILLA_SECRET_KEY`.
3. Оставьте `PAYKILLA_CURRENCY=USD`, если PayKilla не принимает валюту тарифов как invoice currency. В `PAYKILLA_INVOICE_CURRENCIES` укажите валюты, доступные в PayKilla для поля `currency`, например `USD,EUR`. 3. Оставьте `PAYKILLA_CURRENCY=USD`, если PayKilla не принимает валюту тарифов как invoice currency. В `PAYKILLA_INVOICE_CURRENCIES` укажите валюты, доступные в PayKilla для поля `currency`, например `USD,EUR`.
4. В `PAYKILLA_PAYMENT_CURRENCIES` начните с `USDTTRC`, а `BTC`, `ETH` и другие тикеры добавляйте только если они доступны в PayKilla Dashboard. 4. В `PAYKILLA_PAYMENT_CURRENCIES` начните с `USDTTRC`, а `BTC`, `ETH` и другие тикеры добавляйте только если они доступны в PayKilla Dashboard.
5. Убедитесь, что webhook `/webhook/paykilla` настроен в PayKilla: Minishop не отправляет redirect URLs в PayKilla и полагается на webhook для активации платежа. 5. Оставьте `PAYKILLA_MIN_PAYMENT_AMOUNT=10` и `PAYKILLA_MIN_PAYMENT_CURRENCY=USD`, если минимальный invoice PayKilla равен `10 USD`.
6. Добавьте `paykilla` в `PAYMENT_METHODS_ORDER`, если хотите задать явный порядок кнопок. 6. Убедитесь, что webhook `/webhook/paykilla` настроен в PayKilla: Minishop не отправляет redirect URLs в PayKilla и полагается на webhook для активации платежа.
7. Добавьте `paykilla` в `PAYMENT_METHODS_ORDER`, если хотите задать явный порядок кнопок.
Справочник переменных: [PayKilla](../configuration/env-vars.md#paykilla). Справочник переменных: [PayKilla](../configuration/env-vars.md#paykilla).
@@ -14,6 +14,11 @@
const iconName = String(method?.icon || "").trim(); const iconName = String(method?.icon || "").trim();
return iconName ? Icons[iconName] || null : null; 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}`;
}
</script> </script>
<div <div
@@ -25,9 +30,12 @@
{@const icon = methodIcon(method)} {@const icon = methodIcon(method)}
<button <button
class:active={selectedMethod === method.id} class:active={selectedMethod === method.id}
class:disabled={method.disabled}
class="method-card" class="method-card"
disabled={method.disabled}
title={disabledTitle(method)}
type="button" type="button"
onclick={() => onSelect(method.id)} onclick={() => !method.disabled && onSelect(method.id)}
> >
<span class="method-card-main"> <span class="method-card-main">
{#if icon} {#if icon}
@@ -4271,6 +4271,57 @@
"updated_at": null, "updated_at": null,
"webhook_base_url_configured": false "webhook_base_url_configured": false
}, },
{
"key": "PAYKILLA_MIN_PAYMENT_AMOUNT",
"type": "float",
"section": "payments",
"section_order": 4,
"subsection": "PayKilla",
"label": "Minimum payment amount",
"description": "Minimum payment amount accepted through PayKilla. The value is interpreted in PAYKILLA_MIN_PAYMENT_CURRENCY and converted for tariff currencies.",
"i18n_label_key": "admin_settings_field_paykilla_min_payment_amount_label",
"i18n_description_key": "admin_settings_field_paykilla_min_payment_amount_description",
"i18n_subsection_key": "admin_settings_subsection_paykilla",
"i18n_placeholder_key": "admin_settings_field_paykilla_min_payment_amount_placeholder",
"placeholder": "10.0",
"optional": true,
"secret": false,
"min": 0,
"provider_id": "paykilla",
"provider_label": "PayKilla",
"webhook_provider_id": "paykilla",
"webhook_path": "/webhook/paykilla",
"webhook_requires_base_url": false,
"value": "",
"overridden": false,
"updated_at": null,
"webhook_base_url_configured": false
},
{
"key": "PAYKILLA_MIN_PAYMENT_CURRENCY",
"type": "string",
"section": "payments",
"section_order": 4,
"subsection": "PayKilla",
"label": "Minimum payment currency",
"description": "Currency for PAYKILLA_MIN_PAYMENT_AMOUNT. Default: USD.",
"i18n_label_key": "admin_settings_field_paykilla_min_payment_currency_label",
"i18n_description_key": "admin_settings_field_paykilla_min_payment_currency_description",
"i18n_subsection_key": "admin_settings_subsection_paykilla",
"i18n_placeholder_key": "admin_settings_field_paykilla_min_payment_currency_placeholder",
"placeholder": "USD",
"optional": true,
"secret": false,
"provider_id": "paykilla",
"provider_label": "PayKilla",
"webhook_provider_id": "paykilla",
"webhook_path": "/webhook/paykilla",
"webhook_requires_base_url": false,
"value": "",
"overridden": false,
"updated_at": null,
"webhook_base_url_configured": false
},
{ {
"key": "PAYKILLA_VERIFY_WEBHOOK_SIGNATURE", "key": "PAYKILLA_VERIFY_WEBHOOK_SIGNATURE",
"type": "bool", "type": "bool",
+37
View File
@@ -59,6 +59,43 @@ export function priceLabel(plan, methodId = "") {
return formatMoney(plan?.price || 0, plan?.currency); return formatMoney(plan?.price || 0, plan?.currency);
} }
export function methodAmountForPlan(method, plan) {
if (!method || !plan) return 0;
if (
String(method?.id || "")
.toLowerCase()
.includes("stars") &&
Number(plan?.stars_price || 0) > 0
) {
return Number(plan.stars_price || 0);
}
return Number(plan?.price || 0);
}
export function methodAvailableForPlan(method, plan) {
if (!method || !plan) return true;
const minimum = Number(method?.min_amount || 0);
const minimumCurrency = String(method?.min_currency || "").toUpperCase();
const planCurrency = String(plan?.currency || "").toUpperCase();
if (!minimum || !minimumCurrency || minimumCurrency !== planCurrency) return true;
return methodAmountForPlan(method, plan) >= minimum;
}
export function methodsForPlan(methods, plan) {
return (methods || []).map((method) => ({
...method,
disabled: !methodAvailableForPlan(method, plan),
}));
}
export function firstAvailableMethod(methods) {
return (methods || []).find((method) => !method?.disabled)?.id || "";
}
export function methodSelectable(methods, methodId) {
return Boolean((methods || []).find((method) => method?.id === methodId && !method?.disabled));
}
export function tariffLimitLabel(tariff, { t }) { export function tariffLimitLabel(tariff, { t }) {
if (!tariff) return ""; if (!tariff) return "";
if (String(tariff.billing_model || "") === "traffic") { if (String(tariff.billing_model || "") === "traffic") {
+13
View File
@@ -1037,6 +1037,19 @@ a {
inset 0 1px 0 var(--inset-highlight); inset 0 1px 0 var(--inset-highlight);
} }
.method-card:disabled,
.method-card.disabled {
cursor: not-allowed;
opacity: 0.46;
}
.method-card:disabled.active,
.method-card.disabled.active {
border-color: var(--border);
background: var(--surface-muted);
box-shadow: inset 0 1px 0 var(--inset-highlight);
}
.total-card { .total-card {
padding: 12px 14px; padding: 12px 14px;
} }
+16 -4
View File
@@ -26,6 +26,9 @@
planUnitHint as planUnitHintFn, planUnitHint as planUnitHintFn,
tariffLimitLabel as tariffLimitLabelFn, tariffLimitLabel as tariffLimitLabelFn,
priceLabel as priceLabelFn, priceLabel as priceLabelFn,
firstAvailableMethod,
methodSelectable,
methodsForPlan,
} from "../lib/webapp/tariffs.js"; } from "../lib/webapp/tariffs.js";
export let createPayment = () => {}; export let createPayment = () => {};
@@ -107,6 +110,15 @@
function paymentPriceLabel(plan) { function paymentPriceLabel(plan) {
return priceLabelFn(planWithSelectedHwidRenewal(plan), selectedMethod); return priceLabelFn(planWithSelectedHwidRenewal(plan), selectedMethod);
} }
$: selectedPlanForPayment = planWithSelectedHwidRenewal(selectedPlan);
$: paymentMethods = methodsForPlan(methods, selectedPlanForPayment);
$: paymentMethodSelected = methodSelectable(paymentMethods, selectedMethod);
$: if (paymentModalOpen && paymentStep === "checkout" && selectedPlan) {
const firstMethod = firstAvailableMethod(paymentMethods);
if (firstMethod && !methodSelectable(paymentMethods, selectedMethod)) {
selectedMethod = firstMethod;
}
}
function hwidRenewalPriceLabel(plan = selectedPlan) { function hwidRenewalPriceLabel(plan = selectedPlan) {
const renewal = hwidRenewalFor(plan); const renewal = hwidRenewalFor(plan);
if (!renewal) return ""; if (!renewal) return "";
@@ -330,7 +342,7 @@
<div class="payment-divider" aria-hidden="true"></div> <div class="payment-divider" aria-hidden="true"></div>
{#if methods.length} {#if methods.length}
<PaymentMethodGrid <PaymentMethodGrid
{methods} methods={paymentMethods}
{selectedMethod} {selectedMethod}
{t} {t}
onSelect={(id) => (selectedMethod = id)} onSelect={(id) => (selectedMethod = id)}
@@ -341,7 +353,7 @@
<Button <Button
class="wide bottom-action payment-submit-button" class="wide bottom-action payment-submit-button"
onclick={createPayment} onclick={createPayment}
disabled={!selectedPlan || !methods.length || payBusy} disabled={!selectedPlan || !paymentMethodSelected || payBusy}
> >
{t("wa_pay")} {t("wa_pay")}
{selectedPlan ? paymentPriceLabel(selectedPlan) : ""} {selectedPlan ? paymentPriceLabel(selectedPlan) : ""}
@@ -421,7 +433,7 @@
<div class="payment-divider" aria-hidden="true"></div> <div class="payment-divider" aria-hidden="true"></div>
{#if methods.length} {#if methods.length}
<PaymentMethodGrid <PaymentMethodGrid
{methods} methods={paymentMethods}
{selectedMethod} {selectedMethod}
{t} {t}
onSelect={(id) => (selectedMethod = id)} onSelect={(id) => (selectedMethod = id)}
@@ -432,7 +444,7 @@
<Button <Button
class="wide bottom-action payment-submit-button" class="wide bottom-action payment-submit-button"
onclick={createPayment} onclick={createPayment}
disabled={!selectedPlan || !methods.length || payBusy} disabled={!selectedPlan || !paymentMethodSelected || payBusy}
> >
{t("wa_pay")} {t("wa_pay")}
{selectedPlan ? paymentPriceLabel(selectedPlan) : ""} {selectedPlan ? paymentPriceLabel(selectedPlan) : ""}
+49 -7
View File
@@ -7,6 +7,9 @@
planUnitHint as planUnitHintFn, planUnitHint as planUnitHintFn,
priceLabel as priceLabelFn, priceLabel as priceLabelFn,
actionKey as actionKeyFn, actionKey as actionKeyFn,
firstAvailableMethod,
methodSelectable,
methodsForPlan,
} from "../lib/webapp/tariffs.js"; } from "../lib/webapp/tariffs.js";
import { premiumTitle as premiumTitleFn } from "../lib/webapp/traffic.js"; import { premiumTitle as premiumTitleFn } from "../lib/webapp/traffic.js";
import { formatCompactNumber } from "../lib/webapp/formatters.js"; import { formatCompactNumber } from "../lib/webapp/formatters.js";
@@ -60,6 +63,31 @@
return actionKeyFn(action); return actionKeyFn(action);
} }
$: changePaymentMethods = methodsForPlan(methods, selectedChangeAction);
$: topupPaymentMethods = methodsForPlan(methods, selectedTopupPlan);
$: devicePaymentMethods = methodsForPlan(methods, selectedDeviceTopupPlan);
$: changePaymentMethodSelected = methodSelectable(changePaymentMethods, selectedMethod);
$: topupPaymentMethodSelected = methodSelectable(topupPaymentMethods, selectedMethod);
$: devicePaymentMethodSelected = methodSelectable(devicePaymentMethods, selectedMethod);
$: if (changeModalOpen && selectedChangeAction?.kind === "payment") {
const firstMethod = firstAvailableMethod(changePaymentMethods);
if (firstMethod && !methodSelectable(changePaymentMethods, selectedMethod)) {
selectedMethod = firstMethod;
}
}
$: if (topupModalOpen && selectedTopupPlan) {
const firstMethod = firstAvailableMethod(topupPaymentMethods);
if (firstMethod && !methodSelectable(topupPaymentMethods, selectedMethod)) {
selectedMethod = firstMethod;
}
}
$: if (deviceTopupModalOpen && selectedDeviceTopupPlan) {
const firstMethod = firstAvailableMethod(devicePaymentMethods);
if (firstMethod && !methodSelectable(devicePaymentMethods, selectedMethod)) {
selectedMethod = firstMethod;
}
}
function changeActionTitle(action) { function changeActionTitle(action) {
const mode = String(action?.mode || ""); const mode = String(action?.mode || "");
if (mode === "recalc_days") { if (mode === "recalc_days") {
@@ -253,7 +281,7 @@
</div> </div>
{#if selectedChangeAction?.kind === "payment"} {#if selectedChangeAction?.kind === "payment"}
<PaymentMethodGrid <PaymentMethodGrid
{methods} methods={changePaymentMethods}
{selectedMethod} {selectedMethod}
{t} {t}
onSelect={(id) => (selectedMethod = id)} onSelect={(id) => (selectedMethod = id)}
@@ -262,7 +290,9 @@
<Button <Button
class="wide bottom-action payment-submit-button" class="wide bottom-action payment-submit-button"
onclick={openTariffChangeConfirm} onclick={openTariffChangeConfirm}
disabled={tariffActionBusy || payBusy} disabled={tariffActionBusy ||
payBusy ||
(selectedChangeAction?.kind === "payment" && !changePaymentMethodSelected)}
> >
{selectedChangeAction?.kind === "payment" ? t("wa_pay") : t("wa_apply")} {selectedChangeAction?.kind === "payment" ? t("wa_pay") : t("wa_apply")}
<ArrowRight size={17} /> <ArrowRight size={17} />
@@ -293,7 +323,9 @@
<Button <Button
class="wide bottom-action payment-submit-button" class="wide bottom-action payment-submit-button"
onclick={applyTariffChange} onclick={applyTariffChange}
disabled={tariffActionBusy || payBusy} disabled={tariffActionBusy ||
payBusy ||
(selectedChangeAction?.kind === "payment" && !changePaymentMethodSelected)}
> >
{selectedChangeAction?.kind === "payment" {selectedChangeAction?.kind === "payment"
? t("wa_confirm_and_pay") ? t("wa_confirm_and_pay")
@@ -354,11 +386,16 @@
{/each} {/each}
</div> </div>
{/if} {/if}
<PaymentMethodGrid {methods} {selectedMethod} {t} onSelect={(id) => (selectedMethod = id)} /> <PaymentMethodGrid
methods={topupPaymentMethods}
{selectedMethod}
{t}
onSelect={(id) => (selectedMethod = id)}
/>
<Button <Button
class="wide bottom-action payment-submit-button" class="wide bottom-action payment-submit-button"
onclick={createTopupPayment} onclick={createTopupPayment}
disabled={!selectedTopupPlan || !methods.length || payBusy} disabled={!selectedTopupPlan || !topupPaymentMethodSelected || payBusy}
> >
{t("wa_buy_traffic")} {t("wa_buy_traffic")}
{selectedTopupPlan ? priceLabel(selectedTopupPlan) : ""} {selectedTopupPlan ? priceLabel(selectedTopupPlan) : ""}
@@ -413,11 +450,16 @@
</button> </button>
{/each} {/each}
</div> </div>
<PaymentMethodGrid {methods} {selectedMethod} {t} onSelect={(id) => (selectedMethod = id)} /> <PaymentMethodGrid
methods={devicePaymentMethods}
{selectedMethod}
{t}
onSelect={(id) => (selectedMethod = id)}
/>
<Button <Button
class="wide bottom-action payment-submit-button" class="wide bottom-action payment-submit-button"
onclick={createDeviceTopupPayment} onclick={createDeviceTopupPayment}
disabled={!selectedDeviceTopupPlan || !methods.length || payBusy} disabled={!selectedDeviceTopupPlan || !devicePaymentMethodSelected || payBusy}
> >
{t("wa_pay")} {t("wa_pay")}
{selectedDeviceTopupPlan ? priceLabel(selectedDeviceTopupPlan) : ""} {selectedDeviceTopupPlan ? priceLabel(selectedDeviceTopupPlan) : ""}
+105
View File
@@ -1,5 +1,6 @@
import asyncio import asyncio
import importlib import importlib
from decimal import Decimal
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock from unittest.mock import AsyncMock
@@ -315,6 +316,67 @@ def test_payment_method_keyboard_filters_providers_by_payment_currency(monkeypat
assert all(not callback.startswith("pay_yk:") for callback in callbacks) assert all(not callback.startswith("pay_yk:") for callback in callbacks)
def test_payment_method_keyboard_filters_paykilla_by_converted_minimum(monkeypatch):
from bot.payment_providers import paykilla
monkeypatch.setenv("PAYKILLA_ENABLED", "True")
monkeypatch.setenv("PAYKILLA_API_KEY", "paykilla-public")
monkeypatch.setenv("PAYKILLA_SECRET_KEY", "paykilla-secret")
monkeypatch.setenv("PAYKILLA_MIN_PAYMENT_AMOUNT", "10")
monkeypatch.setenv("PAYKILLA_MIN_PAYMENT_CURRENCY", "USD")
build_provider_configs(force=True)
monkeypatch.setattr(
paykilla,
"_exchange_rate_sync",
lambda _config, _source, _target: Decimal("0.013586"),
)
settings = Settings(
_env_file=None,
BOT_TOKEN="token",
POSTGRES_USER="app_user",
POSTGRES_PASSWORD="app_password",
TARIFFS_CONFIG_PATH="missing-tariffs.json",
PAYMENT_METHODS_ORDER="paykilla",
STARS_ENABLED=False,
)
i18n = SimpleNamespace(gettext=lambda _lang, key, **_kwargs: key)
below_minimum = get_payment_method_keyboard(
months=1,
price=190,
stars_price=None,
currency_symbol_val="RUB",
lang="en",
i18n_instance=i18n,
settings=settings,
)
above_minimum = get_payment_method_keyboard(
months=1,
price=1000,
stars_price=None,
currency_symbol_val="RUB",
lang="en",
i18n_instance=i18n,
settings=settings,
)
below_callbacks = [
button.callback_data
for row in below_minimum.inline_keyboard
for button in row
if button.callback_data
]
above_callbacks = [
button.callback_data
for row in above_minimum.inline_keyboard
for button in row
if button.callback_data
]
assert all(not callback.startswith("pay_paykilla:") for callback in below_callbacks)
assert "pay_paykilla:1:1000:subscription" in above_callbacks
def test_admin_only_provider_is_visible_only_to_admins(monkeypatch): def test_admin_only_provider_is_visible_only_to_admins(monkeypatch):
from bot.app.web.webapp.serializers import _serialize_payment_methods from bot.app.web.webapp.serializers import _serialize_payment_methods
@@ -398,6 +460,49 @@ def test_webapp_payment_methods_filter_by_default_currency(monkeypatch):
assert [method["id"] for method in methods] == ["wata"] assert [method["id"] for method in methods] == ["wata"]
def test_webapp_payment_methods_include_paykilla_minimum_metadata(monkeypatch):
from bot.app.web.webapp.serializers import _serialize_payment_methods
from bot.payment_providers import paykilla
monkeypatch.setenv("PAYKILLA_ENABLED", "True")
monkeypatch.setenv("PAYKILLA_API_KEY", "paykilla-public")
monkeypatch.setenv("PAYKILLA_SECRET_KEY", "paykilla-secret")
monkeypatch.setenv("PAYKILLA_MIN_PAYMENT_AMOUNT", "10")
monkeypatch.setenv("PAYKILLA_MIN_PAYMENT_CURRENCY", "USD")
build_provider_configs(force=True)
monkeypatch.setattr(
paykilla,
"_exchange_rate_sync",
lambda _config, _source, _target: Decimal("0.013586"),
)
settings = Settings(
_env_file=None,
BOT_TOKEN="token",
POSTGRES_USER="app_user",
POSTGRES_PASSWORD="app_password",
TARIFFS_CONFIG_PATH="missing-tariffs.json",
PAYMENT_METHODS_ORDER="paykilla",
DEFAULT_CURRENCY_SYMBOL="RUB",
STARS_ENABLED=False,
)
app = {"paykilla_service": SimpleNamespace(configured=True)}
methods = _serialize_payment_methods(settings, app, "en", is_admin=False)
assert methods == [
{
"id": "paykilla",
"name": "PayKilla",
"icon": "Bitcoin",
"min_amount": "736.06",
"min_currency": "RUB",
"configured_min_amount": "10.00",
"configured_min_currency": "USD",
}
]
def test_admin_only_provider_toggle_pairs_are_declared(): def test_admin_only_provider_toggle_pairs_are_declared():
pairs = set(provider_admin_only_pairs()) pairs = set(provider_admin_only_pairs())
+22
View File
@@ -353,6 +353,28 @@ class PaykillaServiceTests(unittest.TestCase):
self.assertEqual(error["currency"], "USD") self.assertEqual(error["currency"], "USD")
self.assertEqual(error["minimum"], "10.00") self.assertEqual(error["minimum"], "10.00")
def test_configured_minimum_detects_converted_rub_payment_below_usd_minimum(self):
service = self._make_service()
service.config.MIN_PAYMENT_AMOUNT = Decimal("10")
service.config.MIN_PAYMENT_CURRENCY = "USD"
with patch.object(
service,
"_exchange_rate",
AsyncMock(return_value=Decimal("0.013586")),
):
error = asyncio_run(
service._configured_minimum_error(
amount=190,
payment_currency="RUB",
)
)
self.assertEqual(error["message"], "payment_amount_below_minimum")
self.assertEqual(error["minimum"], "10.00")
self.assertEqual(error["minimum_currency"], "USD")
self.assertEqual(error["converted_amount"], "2.58")
def test_invoice_body_omits_redirect_urls(self): def test_invoice_body_omits_redirect_urls(self):
service = self._make_service() service = self._make_service()