fix: gate PayKilla by minimum payment amount
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
@@ -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-источников. |
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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") {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) : ""}
|
||||||
|
|||||||
@@ -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) : ""}
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user