fix: convert PayKilla invoices to supported currency

This commit is contained in:
3252a8
2026-06-04 15:57:55 +03:00
parent 69985e44dd
commit bdf0622be5
5 changed files with 443 additions and 31 deletions
+280 -16
View File
@@ -5,6 +5,7 @@ import logging
import re
import time
from datetime import datetime, timedelta, timezone
from decimal import Decimal, InvalidOperation
from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import urlencode
@@ -65,7 +66,9 @@ from .shared import (
router = Router(name="user_subscription_payments_paykilla_router")
_LOG = "paykilla"
PAYKILLA_DEFAULT_PAYMENT_CURRENCIES = "USDTTRC,BTC,ETH"
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_SUPPORTED_CURRENCIES = (
"RUB,USD,EUR,AED,GBP,BTC,ETH,TRX,TON,USDTTRC,USDTETH,USDTBSC,"
"USDCETH,USDCBSC,DAIETH,DAIBSC,BNBBSC,ETHBSC,LINKETH,LINKBSC,"
@@ -178,7 +181,8 @@ class PaykillaConfig(ProviderEnvConfig):
validation_alias=AliasChoices("PAYKILLA_BASE_URL", "PAYKILLA_V2_BASE_URL"),
)
WIDGET_URL: str = Field(default="https://gopay.paykilla.com")
CURRENCY: str = Field(default="RUB")
CURRENCY: str = Field(default="USD")
INVOICE_CURRENCIES: str = Field(default=PAYKILLA_DEFAULT_INVOICE_CURRENCIES)
INVOICE_TYPE: Optional[str] = None
PAYMENT_CURRENCIES: str = Field(default=PAYKILLA_DEFAULT_PAYMENT_CURRENCIES)
SUPPORTED_CURRENCIES: str = Field(default=PAYKILLA_DEFAULT_SUPPORTED_CURRENCIES)
@@ -186,6 +190,8 @@ class PaykillaConfig(ProviderEnvConfig):
RECV_WINDOW_MS: int = Field(default=5000)
USER_PAYS_SERVICE_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_CACHE_SECONDS: int = Field(default=3600)
VERIFY_WEBHOOK_SIGNATURE: bool = Field(default=True)
WEBHOOK_URL: Optional[str] = None
TRUSTED_IPS: str = Field(default="")
@@ -212,6 +218,17 @@ class PaykillaConfig(ProviderEnvConfig):
return 5000
return min(60_000, max(1000, value))
@field_validator("EXCHANGE_RATE_CACHE_SECONDS", mode="before")
@classmethod
def _clamp_exchange_rate_cache(cls, v):
if isinstance(v, str):
v = v.strip()
try:
value = int(v)
except (TypeError, ValueError):
return 3600
return min(86_400, max(60, value))
@field_validator(
"API_KEY",
"SECRET_KEY",
@@ -298,6 +315,22 @@ def _payment_currencies(config: PaykillaConfig) -> List[str]:
return currencies or ["USDTTRC"]
def _invoice_currencies(config: PaykillaConfig) -> tuple[str, ...]:
currencies = parse_supported_currency_codes(config.INVOICE_CURRENCIES)
return currencies or parse_supported_currency_codes(PAYKILLA_DEFAULT_INVOICE_CURRENCIES)
def _target_invoice_currency(config: PaykillaConfig, payment_currency: str) -> str:
payment_currency = normalize_payment_currency_code(payment_currency)
invoice_currencies = _invoice_currencies(config)
if payment_currency in invoice_currencies:
return payment_currency
fallback = normalize_payment_currency_code(config.CURRENCY, default="")
if fallback and fallback in invoice_currencies:
return fallback
return invoice_currencies[0] if invoice_currencies else payment_currency
def _invoice_type_for(config: PaykillaConfig, currency: str) -> str:
explicit = (config.INVOICE_TYPE or "").strip().upper()
if explicit in {"FIAT_BASED", "FIXED_AMOUNT", "OPEN_AMOUNT"}:
@@ -346,6 +379,16 @@ def _debug_invoice_body(body: Dict[str, Any]) -> str:
return json.dumps(body, ensure_ascii=True, sort_keys=True)
def _decimal_from_api(value: Any) -> Optional[Decimal]:
try:
decimal_value = Decimal(str(value))
except (InvalidOperation, TypeError, ValueError):
return None
if not decimal_value.is_finite():
return None
return decimal_value
class PaykillaService(HttpClientMixin):
def __init__(
self,
@@ -367,6 +410,8 @@ class PaykillaService(HttpClientMixin):
self.subscription_service = subscription_service
self.referral_service = referral_service
self._default_return_url = default_return_url
self._exchange_rate_cache: Dict[tuple[str, str], tuple[float, Decimal]] = {}
self._currency_cache: tuple[float, List[Dict[str, Any]]] = (0, [])
self._init_http_client(total_timeout=20)
if not self.configured:
@@ -396,7 +441,7 @@ class PaykillaService(HttpClientMixin):
@property
def currency(self) -> str:
return normalize_payment_currency_code(self.config.CURRENCY or "RUB")
return normalize_payment_currency_code(self.config.CURRENCY or "USD")
@property
def verify_webhook_signature(self) -> bool:
@@ -408,11 +453,154 @@ class PaykillaService(HttpClientMixin):
query, signature = _sign_query(timestamp_ms, recv_window_ms, self.secret_key)
return f"{self.base_url}/api/v2/invoice?{query}&signature={signature}"
def _signed_currency_url(self) -> str:
timestamp_ms = int(time.time() * 1000)
recv_window_ms = int(self.config.RECV_WINDOW_MS)
query, signature = _sign_query(timestamp_ms, recv_window_ms, self.secret_key)
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)
async def _exchange_rate(self, source_currency: str, target_currency: str) -> 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")
cache_key = (source_currency, target_currency)
cache_seconds = int(self.config.EXCHANGE_RATE_CACHE_SECONDS)
now = time.time()
cache = getattr(self, "_exchange_rate_cache", None)
if cache is None:
cache = {}
self._exchange_rate_cache = cache
cached = cache.get(cache_key)
if cached and now - cached[0] < cache_seconds:
return cached[1]
session = await self._get_session()
url = self._exchange_rate_url(source_currency, target_currency)
async with session.get(url) as response:
response_text = await response.text()
try:
response_data = json.loads(response_text) if response_text else {}
except json.JSONDecodeError as exc:
raise ValueError("exchange_rate_invalid_json") from exc
if response.status != 200 or response_data.get("result") != "success":
logging.error(
"Paykilla exchange rate request failed "
"(status=%s, body=%s, source=%s, target=%s)",
response.status,
response_data,
source_currency,
target_currency,
)
raise ValueError("exchange_rate_unavailable")
rates = response_data.get("rates") if isinstance(response_data, dict) else None
target_rate = rates.get(target_currency) if isinstance(rates, dict) else None
rate = _decimal_from_api(target_rate)
if rate is None or rate <= 0:
raise ValueError("exchange_rate_missing")
cache[cache_key] = (now, rate)
return rate
async def _paykilla_currencies(self) -> List[Dict[str, Any]]:
cache_seconds = int(self.config.EXCHANGE_RATE_CACHE_SECONDS)
now = time.time()
cached_at, cached_data = getattr(self, "_currency_cache", (0, []))
if cached_data and now - cached_at < cache_seconds:
return cached_data
headers = {"X-API-KEY": self.api_key}
session = await self._get_session()
try:
async with session.get(self._signed_currency_url(), headers=headers) as response:
response_text = await response.text()
try:
response_data = json.loads(response_text) if response_text else []
except json.JSONDecodeError:
logging.warning(
"Paykilla currency metadata request returned invalid JSON: %s",
response_text,
)
return cached_data
if response.status != 200 or not isinstance(response_data, list):
logging.warning(
"Paykilla currency metadata request failed "
"(status=%s, body=%s)",
response.status,
response_data,
)
return cached_data
except Exception:
logging.exception("Paykilla currency metadata request failed.")
return cached_data
self._currency_cache = (now, response_data)
return response_data
async def _currency_info_for(self, currency: str) -> Optional[Dict[str, Any]]:
currency = normalize_payment_currency_code(currency)
for item in await self._paykilla_currencies():
if not isinstance(item, dict):
continue
if normalize_payment_currency_code(item.get("ticker"), default="") == currency:
return item
return None
async def _invoice_amount_bounds_error(
self, *, amount: Decimal, currency: str
) -> Optional[Dict[str, Any]]:
info = await self._currency_info_for(currency)
if not info:
return None
minimum = _decimal_from_api(info.get("invoiceMin"))
maximum = _decimal_from_api(info.get("invoiceMax"))
if minimum is not None and amount < minimum:
return {
"message": "invoice_amount_below_minimum",
"currency": currency,
"amount": str(amount),
"minimum": str(format_decimal_amount(minimum)),
}
if maximum is not None and amount > maximum:
return {
"message": "invoice_amount_above_maximum",
"currency": currency,
"amount": str(amount),
"maximum": str(format_decimal_amount(maximum)),
}
return None
async def _invoice_amount_and_currency(
self, *, amount: float, payment_currency: str
) -> tuple[Decimal, str]:
payment_currency = normalize_payment_currency_code(payment_currency or self.currency)
invoice_currency = _target_invoice_currency(self.config, payment_currency)
invoice_amount = format_decimal_amount(amount)
if invoice_currency == payment_currency:
return invoice_amount, invoice_currency
rate = await self._exchange_rate(payment_currency, invoice_currency)
converted_amount = format_decimal_amount(invoice_amount * rate)
logging.info(
"Paykilla invoice currency conversion: payment=%s %s, invoice=%s %s, rate=%s",
invoice_amount,
payment_currency,
converted_amount,
invoice_currency,
rate,
)
return converted_amount, invoice_currency
def _invoice_body(
self,
*,
payment_db_id: int,
amount: float,
amount: Any,
currency: Optional[str],
description: str,
) -> Dict[str, Any]:
@@ -458,10 +646,39 @@ class PaykillaService(HttpClientMixin):
"supported_currencies": list(supported),
}
try:
invoice_amount, invoice_currency = await self._invoice_amount_and_currency(
amount=amount,
payment_currency=currency_code,
)
except Exception as exc:
logging.exception(
"Paykilla create_payment_link: failed to resolve invoice currency "
"(amount=%s currency=%s target=%s).",
amount,
currency_code,
_target_invoice_currency(self.config, currency_code),
)
return False, {"message": str(exc) or "exchange_rate_unavailable"}
bounds_error = await self._invoice_amount_bounds_error(
amount=invoice_amount,
currency=invoice_currency,
)
if bounds_error:
logging.error(
"Paykilla create_payment_link: invoice amount violates PayKilla limits "
"(details=%s, payment_amount=%s, payment_currency=%s)",
bounds_error,
format_decimal_amount(amount),
currency_code,
)
return False, bounds_error
body = self._invoice_body(
payment_db_id=payment_db_id,
amount=amount,
currency=currency_code,
amount=invoice_amount,
currency=invoice_currency,
description=description,
)
headers = {
@@ -613,7 +830,7 @@ class PaykillaService(HttpClientMixin):
invoice_id = str(data.get("id") or "").strip()
client_order_id = data.get("clientOrderId")
amount_raw = data.get("amount") or data.get("expectedAmount")
currency = data.get("currency") or self.currency
invoice_currency = normalize_payment_currency_code(data.get("currency") or self.currency)
if not (invoice_id or client_order_id):
logging.error("Paykilla webhook: missing invoice ids: %s", payload)
@@ -639,7 +856,8 @@ class PaykillaService(HttpClientMixin):
resolved_id = invoice_id or str(payment.payment_id)
if event_type in _SUCCESS_EVENTS:
if amount_raw is not None:
payment_currency = normalize_payment_currency_code(payment.currency)
if amount_raw is not None and invoice_currency == payment_currency:
try:
if not decimal_amounts_equal(amount_raw, payment.amount):
logging.warning(
@@ -655,6 +873,14 @@ class PaykillaService(HttpClientMixin):
payment.payment_id,
exc,
)
elif amount_raw is not None:
logging.info(
"Paykilla webhook: invoice amount is in %s while payment record is in %s; "
"skipping direct amount comparison for payment %s.",
invoice_currency,
payment_currency,
payment.payment_id,
)
try:
await payment_dal.update_provider_payment_and_status(
@@ -688,7 +914,7 @@ class PaykillaService(HttpClientMixin):
payment=payment,
user_id=payment.user_id,
amount=float(payment.amount),
currency=str(currency),
currency=str(payment.currency),
sale_mode=sale_mode,
months=payment_units,
traffic_amount=float(payment_units),
@@ -984,20 +1210,35 @@ _CONFIG_MANIFEST = (
ProviderManifestField(
"PAYKILLA_CURRENCY",
"string",
"Invoice currency",
"Fallback invoice currency",
description=(
"Fallback invoice currency when the payment flow does not provide one. "
"Usually matches the tariff/default currency, e.g. RUB."
"Currency used for PayKilla invoice creation when the tariff currency is not "
"accepted by PayKilla as an invoice currency. Default: USD."
),
placeholder="RUB",
placeholder="USD",
subsection="PayKilla",
attr="CURRENCY",
),
ProviderManifestField(
"PAYKILLA_INVOICE_CURRENCIES",
"string",
"PayKilla invoice currencies",
description=(
"Comma-separated currencies accepted by PayKilla as invoice currency. "
"Payments in other tariff currencies are converted to PAYKILLA_CURRENCY."
),
placeholder=PAYKILLA_DEFAULT_INVOICE_CURRENCIES,
subsection="PayKilla",
attr="INVOICE_CURRENCIES",
),
ProviderManifestField(
"PAYKILLA_SUPPORTED_CURRENCIES",
"string",
"Supported invoice currencies",
description="Comma-separated invoice currencies allowed for PayKilla in this shop.",
"Supported tariff currencies",
description=(
"Comma-separated tariff/payment currencies that may use PayKilla. "
"Unsupported PayKilla invoice currencies are converted before invoice creation."
),
placeholder=PAYKILLA_DEFAULT_SUPPORTED_CURRENCIES,
subsection="PayKilla",
attr="SUPPORTED_CURRENCIES",
@@ -1007,7 +1248,8 @@ _CONFIG_MANIFEST = (
"string",
"Accepted crypto tickers",
description=(
"Comma-separated PayKilla tickers sent as paymentCurrencies, e.g. USDTTRC,BTC,ETH."
"Comma-separated PayKilla tickers sent as paymentCurrencies, e.g. USDTTRC. "
"Add BTC/ETH only when enabled for the merchant account."
),
placeholder=PAYKILLA_DEFAULT_PAYMENT_CURRENCIES,
subsection="PayKilla",
@@ -1061,6 +1303,28 @@ _CONFIG_MANIFEST = (
subsection="PayKilla",
attr="USER_PAYS_NETWORK_FEE",
),
ProviderManifestField(
"PAYKILLA_EXCHANGE_RATE_URL",
"url",
"Exchange rate URL",
description=(
"No-key exchange rate endpoint used when tariff currency must be converted. "
"Supports {source} and {target} placeholders."
),
placeholder=PAYKILLA_DEFAULT_EXCHANGE_RATE_URL,
subsection="PayKilla",
attr="EXCHANGE_RATE_URL",
),
ProviderManifestField(
"PAYKILLA_EXCHANGE_RATE_CACHE_SECONDS",
"int",
"Exchange rate cache (seconds)",
description="How long PayKilla currency conversion rates and PayKilla limits are cached.",
subsection="PayKilla",
min=60,
max=86_400,
attr="EXCHANGE_RATE_CACHE_SECONDS",
),
ProviderManifestField(
"PAYKILLA_VERIFY_WEBHOOK_SIGNATURE",
"bool",
+6 -3
View File
@@ -376,14 +376,17 @@ Webhook настраивается в PayKilla Dashboard: **Settings -> Webhooks
| `PAYKILLA_WIDGET_URL` | URL hosted checkout, по умолчанию `https://gopay.paykilla.com`. |
| `PAYKILLA_API_KEY` / `PAYKILLA_V2_API_KEY` | Public HMAC key с правом `INVOICE`. |
| `PAYKILLA_SECRET_KEY` / `PAYKILLA_V2_SECRET_KEY` | Secret HMAC key для подписи API-запросов и проверки webhook. |
| `PAYKILLA_CURRENCY` | Резервная валюта инвойса PayKilla, если платежный поток не передал валюту тарифа. Обычно совпадает с `DEFAULT_CURRENCY_SYMBOL`, например `RUB`. Для RUB/USD/EUR/AED/GBP используется `FIAT_BASED`, для остальных - `FIXED_AMOUNT`. |
| `PAYKILLA_PAYMENT_CURRENCIES` | Crypto tickers для оплаты, например `USDTTRC,BTC,ETH`. |
| `PAYKILLA_SUPPORTED_CURRENCIES` | Валюты инвойса, разрешенные в этом магазине. |
| `PAYKILLA_CURRENCY` | Резервная валюта инвойса PayKilla для платежей, чья валюта тарифа не входит в `PAYKILLA_INVOICE_CURRENCIES`. По умолчанию `USD`. |
| `PAYKILLA_INVOICE_CURRENCIES` | Валюты, которые PayKilla принимает в поле `currency` при создании invoice. По умолчанию `USD,EUR`. Если тариф в `RUB`, Minishop конвертирует сумму в `PAYKILLA_CURRENCY`. |
| `PAYKILLA_PAYMENT_CURRENCIES` | Crypto tickers для оплаты. Рекомендуемый стартовый вариант: `USDTTRC`; добавляйте `BTC`, `ETH` и другие тикеры только если они доступны в PayKilla Dashboard для merchant account. |
| `PAYKILLA_SUPPORTED_CURRENCIES` | Валюты тарифов/платежей, которым разрешено использовать PayKilla в этом магазине. |
| `PAYKILLA_INVOICE_TYPE` | Необязательный override: `FIAT_BASED`, `FIXED_AMOUNT` или `OPEN_AMOUNT`. |
| `PAYKILLA_LIFETIME_SECONDS` | TTL инвойса, отправляется как `expiredAt`. |
| `PAYKILLA_RECV_WINDOW_MS` | `recvWindow` для подписанных API-запросов. |
| `PAYKILLA_USER_PAYS_SERVICE_FEE` | `true`, если пользователь оплачивает service 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_CACHE_SECONDS` | Кэш курса и PayKilla currency limits в секундах. По умолчанию `3600`. |
| `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-источников. |
+7 -4
View File
@@ -160,7 +160,9 @@ PayKilla используется для крипто-инвойсов V2 чер
PayKilla строго валидирует текстовые поля invoice. Поэтому Minishop отправляет в `purpose` и `description` простой английский текст `<WEBAPP_TITLE> payment <id>`, а локализованное описание платежа оставляет только внутри Minishop. Дополнительно эти поля проходят ASCII-safe sanitizer: допускаются ASCII-буквы, цифры, пробелы, `_`, `.`, `,`.
Для `FIAT_BASED` инвойса Minishop отправляет сумму и валюту тарифа как есть, например `190.00 RUB`. PayKilla рассчитывает сумму к оплате в выбранной криптовалюте из `paymentCurrencies`.
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` для валюты инвойса.
Payload создания invoice содержит обязательные поля `type`, `purpose`, `currency`, `totalPrice`, `paymentCurrencies`, служебный `clientOrderId`, а также полезные optional поля `description`, `expiredAt`, `userPaysServiceFee`, `userPaysNetworkFee`. Redirect URLs в PayKilla не отправляются; завершение платежа обрабатывается через webhook.
Какие полномочия нужны API key:
@@ -183,9 +185,10 @@ PayKilla строго валидирует текстовые поля invoice.
1. Включите `PAYKILLA_ENABLED`.
2. Укажите `PAYKILLA_API_KEY` и `PAYKILLA_SECRET_KEY`.
3. Проверьте валюту тарифов/`DEFAULT_CURRENCY_SYMBOL` и `PAYKILLA_CURRENCY`, например `RUB`; в `PAYKILLA_PAYMENT_CURRENCIES` укажите crypto tickers, например `USDTTRC,BTC,ETH`.
4. Убедитесь, что webhook `/webhook/paykilla` настроен в PayKilla: Minishop не отправляет redirect URLs в PayKilla и полагается на webhook для активации платежа.
5. Добавьте `paykilla` в `PAYMENT_METHODS_ORDER`, если хотите задать явный порядок кнопок.
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`, если хотите задать явный порядок кнопок.
Справочник переменных: [PayKilla](../configuration/env-vars.md#paykilla).
@@ -3974,13 +3974,38 @@
"section": "payments",
"section_order": 4,
"subsection": "PayKilla",
"label": "Invoice currency",
"description": "Fallback invoice currency when the payment flow does not provide one. Usually matches the tariff/default currency, e.g. RUB.",
"label": "Fallback invoice currency",
"description": "Currency used for PayKilla invoice creation when the tariff currency is not accepted by PayKilla as an invoice currency. Default: USD.",
"i18n_label_key": "admin_settings_field_paykilla_currency_label",
"i18n_description_key": "admin_settings_field_paykilla_currency_description",
"i18n_subsection_key": "admin_settings_subsection_paykilla",
"i18n_placeholder_key": "admin_settings_field_paykilla_currency_placeholder",
"placeholder": "RUB",
"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_INVOICE_CURRENCIES",
"type": "string",
"section": "payments",
"section_order": 4,
"subsection": "PayKilla",
"label": "PayKilla invoice currencies",
"description": "Comma-separated currencies accepted by PayKilla as invoice currency. Payments in other tariff currencies are converted to PAYKILLA_CURRENCY.",
"i18n_label_key": "admin_settings_field_paykilla_invoice_currencies_label",
"i18n_description_key": "admin_settings_field_paykilla_invoice_currencies_description",
"i18n_subsection_key": "admin_settings_subsection_paykilla",
"i18n_placeholder_key": "admin_settings_field_paykilla_invoice_currencies_placeholder",
"placeholder": "USD,EUR",
"optional": true,
"secret": false,
"provider_id": "paykilla",
@@ -3999,8 +4024,8 @@
"section": "payments",
"section_order": 4,
"subsection": "PayKilla",
"label": "Supported invoice currencies",
"description": "Comma-separated invoice currencies allowed for PayKilla in this shop.",
"label": "Supported tariff currencies",
"description": "Comma-separated tariff/payment currencies that may use PayKilla. Unsupported PayKilla invoice currencies are converted before invoice creation.",
"i18n_label_key": "admin_settings_field_paykilla_supported_currencies_label",
"i18n_description_key": "admin_settings_field_paykilla_supported_currencies_description",
"i18n_subsection_key": "admin_settings_subsection_paykilla",
@@ -4025,12 +4050,12 @@
"section_order": 4,
"subsection": "PayKilla",
"label": "Accepted crypto tickers",
"description": "Comma-separated PayKilla tickers sent as paymentCurrencies, e.g. USDTTRC,BTC,ETH.",
"description": "Comma-separated PayKilla tickers sent as paymentCurrencies, e.g. USDTTRC. Add BTC/ETH only when enabled for the merchant account.",
"i18n_label_key": "admin_settings_field_paykilla_payment_currencies_label",
"i18n_description_key": "admin_settings_field_paykilla_payment_currencies_description",
"i18n_subsection_key": "admin_settings_subsection_paykilla",
"i18n_placeholder_key": "admin_settings_field_paykilla_payment_currencies_placeholder",
"placeholder": "USDTTRC,BTC,ETH",
"placeholder": "USDTTRC",
"optional": true,
"secret": false,
"provider_id": "paykilla",
@@ -4194,6 +4219,58 @@
"updated_at": null,
"webhook_base_url_configured": false
},
{
"key": "PAYKILLA_EXCHANGE_RATE_URL",
"type": "url",
"section": "payments",
"section_order": 4,
"subsection": "PayKilla",
"label": "Exchange rate URL",
"description": "No-key exchange rate endpoint used when tariff currency must be converted. Supports {source} and {target} placeholders.",
"i18n_label_key": "admin_settings_field_paykilla_exchange_rate_url_label",
"i18n_description_key": "admin_settings_field_paykilla_exchange_rate_url_description",
"i18n_subsection_key": "admin_settings_subsection_paykilla",
"i18n_placeholder_key": "admin_settings_field_paykilla_exchange_rate_url_placeholder",
"placeholder": "https://open.er-api.com/v6/latest/{source}",
"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_EXCHANGE_RATE_CACHE_SECONDS",
"type": "int",
"section": "payments",
"section_order": 4,
"subsection": "PayKilla",
"label": "Exchange rate cache (seconds)",
"description": "How long PayKilla currency conversion rates and PayKilla limits are cached.",
"i18n_label_key": "admin_settings_field_paykilla_exchange_rate_cache_seconds_label",
"i18n_description_key": "admin_settings_field_paykilla_exchange_rate_cache_seconds_description",
"i18n_subsection_key": "admin_settings_subsection_paykilla",
"i18n_placeholder_key": null,
"placeholder": "",
"optional": true,
"secret": false,
"min": 60,
"max": 86400,
"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",
"type": "bool",
+66 -1
View File
@@ -3,6 +3,7 @@ import hmac
import json
import time
import unittest
from decimal import Decimal
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from urllib.parse import parse_qs, urlsplit
@@ -256,8 +257,12 @@ class PaykillaServiceTests(unittest.TestCase):
)
self.assertEqual(body["purpose"], "Tunnel Shop payment 556")
self.assertEqual(body["description"], body["purpose"])
self.assertRegex(body["purpose"], r"^[A-Za-z0-9_\s.,]+$")
self.assertEqual(body["paymentCurrencies"], ["USDTTRC"])
self.assertEqual(body["description"], body["purpose"])
self.assertTrue(body["expiredAt"].endswith("Z"))
self.assertTrue(body["userPaysNetworkFee"])
self.assertTrue(body["userPaysServiceFee"])
self.assertNotIn("urls", body)
def test_invoice_body_uses_payment_currency_before_configured_fallback(self):
@@ -288,6 +293,66 @@ class PaykillaServiceTests(unittest.TestCase):
self.assertEqual(body["currency"], "USD")
self.assertEqual(body["type"], "FIAT_BASED")
def test_invoice_amount_converts_unsupported_tariff_currency_to_fallback(self):
service = self._make_service()
service.config.CURRENCY = "USD"
service.config.INVOICE_CURRENCIES = "USD,EUR"
with patch.object(
service,
"_exchange_rate",
AsyncMock(return_value=Decimal("0.013586")),
) as exchange_rate:
amount, currency = asyncio_run(
service._invoice_amount_and_currency(
amount=190,
payment_currency="RUB",
)
)
self.assertEqual(currency, "USD")
self.assertEqual(amount, Decimal("2.58"))
exchange_rate.assert_awaited_once_with("RUB", "USD")
def test_invoice_amount_keeps_enabled_paykilla_invoice_currency(self):
service = self._make_service()
service.config.CURRENCY = "USD"
service.config.INVOICE_CURRENCIES = "RUB,USD"
with patch.object(
service,
"_exchange_rate",
AsyncMock(side_effect=AssertionError("conversion must not run")),
):
amount, currency = asyncio_run(
service._invoice_amount_and_currency(
amount=190,
payment_currency="RUB",
)
)
self.assertEqual(currency, "RUB")
self.assertEqual(amount, Decimal("190.00"))
def test_invoice_amount_bounds_detects_paykilla_minimum(self):
service = self._make_service()
with patch.object(
service,
"_currency_info_for",
AsyncMock(return_value={"invoiceMin": "10", "invoiceMax": "500000"}),
):
error = asyncio_run(
service._invoice_amount_bounds_error(
amount=Decimal("2.58"),
currency="USD",
)
)
self.assertEqual(error["message"], "invoice_amount_below_minimum")
self.assertEqual(error["currency"], "USD")
self.assertEqual(error["minimum"], "10.00")
def test_invoice_body_omits_redirect_urls(self):
service = self._make_service()