From bdf0622be58d5a879fb0ba693dc0bcf1e9f537b8 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Thu, 4 Jun 2026 15:57:55 +0300 Subject: [PATCH] fix: convert PayKilla invoices to supported currency --- backend/bot/payment_providers/paykilla.py | 296 +++++++++++++++++- docs/configuration/env-vars.md | 9 +- docs/features/payments.md | 11 +- .../webapp/settingsManifest.generated.json | 91 +++++- tests/test_security.py | 67 +++- 5 files changed, 443 insertions(+), 31 deletions(-) diff --git a/backend/bot/payment_providers/paykilla.py b/backend/bot/payment_providers/paykilla.py index 7cc45e1..8519f4a 100644 --- a/backend/bot/payment_providers/paykilla.py +++ b/backend/bot/payment_providers/paykilla.py @@ -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", diff --git a/docs/configuration/env-vars.md b/docs/configuration/env-vars.md index b54f97e..0bf7f96 100644 --- a/docs/configuration/env-vars.md +++ b/docs/configuration/env-vars.md @@ -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-источников. | diff --git a/docs/features/payments.md b/docs/features/payments.md index cf0debb..d2834b6 100644 --- a/docs/features/payments.md +++ b/docs/features/payments.md @@ -160,7 +160,9 @@ PayKilla используется для крипто-инвойсов V2 чер PayKilla строго валидирует текстовые поля invoice. Поэтому Minishop отправляет в `purpose` и `description` простой английский текст ` payment `, а локализованное описание платежа оставляет только внутри 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). diff --git a/frontend/src/lib/webapp/settingsManifest.generated.json b/frontend/src/lib/webapp/settingsManifest.generated.json index b4a39be..e810ef8 100644 --- a/frontend/src/lib/webapp/settingsManifest.generated.json +++ b/frontend/src/lib/webapp/settingsManifest.generated.json @@ -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", diff --git a/tests/test_security.py b/tests/test_security.py index 8f1ead9..4ee4074 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -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()