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 re
import time import time
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from decimal import 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
@@ -65,7 +66,9 @@ from .shared import (
router = Router(name="user_subscription_payments_paykilla_router") router = Router(name="user_subscription_payments_paykilla_router")
_LOG = "paykilla" _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 = ( 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,"
@@ -178,7 +181,8 @@ class PaykillaConfig(ProviderEnvConfig):
validation_alias=AliasChoices("PAYKILLA_BASE_URL", "PAYKILLA_V2_BASE_URL"), validation_alias=AliasChoices("PAYKILLA_BASE_URL", "PAYKILLA_V2_BASE_URL"),
) )
WIDGET_URL: str = Field(default="https://gopay.paykilla.com") 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 INVOICE_TYPE: Optional[str] = None
PAYMENT_CURRENCIES: str = Field(default=PAYKILLA_DEFAULT_PAYMENT_CURRENCIES) PAYMENT_CURRENCIES: str = Field(default=PAYKILLA_DEFAULT_PAYMENT_CURRENCIES)
SUPPORTED_CURRENCIES: str = Field(default=PAYKILLA_DEFAULT_SUPPORTED_CURRENCIES) SUPPORTED_CURRENCIES: str = Field(default=PAYKILLA_DEFAULT_SUPPORTED_CURRENCIES)
@@ -186,6 +190,8 @@ class PaykillaConfig(ProviderEnvConfig):
RECV_WINDOW_MS: int = Field(default=5000) RECV_WINDOW_MS: int = Field(default=5000)
USER_PAYS_SERVICE_FEE: bool = Field(default=True) USER_PAYS_SERVICE_FEE: bool = Field(default=True)
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_CACHE_SECONDS: int = Field(default=3600)
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="")
@@ -212,6 +218,17 @@ class PaykillaConfig(ProviderEnvConfig):
return 5000 return 5000
return min(60_000, max(1000, value)) 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( @field_validator(
"API_KEY", "API_KEY",
"SECRET_KEY", "SECRET_KEY",
@@ -298,6 +315,22 @@ def _payment_currencies(config: PaykillaConfig) -> List[str]:
return currencies or ["USDTTRC"] 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: def _invoice_type_for(config: PaykillaConfig, currency: str) -> str:
explicit = (config.INVOICE_TYPE or "").strip().upper() explicit = (config.INVOICE_TYPE or "").strip().upper()
if explicit in {"FIAT_BASED", "FIXED_AMOUNT", "OPEN_AMOUNT"}: 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) 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): class PaykillaService(HttpClientMixin):
def __init__( def __init__(
self, self,
@@ -367,6 +410,8 @@ class PaykillaService(HttpClientMixin):
self.subscription_service = subscription_service self.subscription_service = subscription_service
self.referral_service = referral_service self.referral_service = referral_service
self._default_return_url = default_return_url 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) self._init_http_client(total_timeout=20)
if not self.configured: if not self.configured:
@@ -396,7 +441,7 @@ class PaykillaService(HttpClientMixin):
@property @property
def currency(self) -> str: 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 @property
def verify_webhook_signature(self) -> bool: 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) query, signature = _sign_query(timestamp_ms, recv_window_ms, self.secret_key)
return f"{self.base_url}/api/v2/invoice?{query}&signature={signature}" 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( def _invoice_body(
self, self,
*, *,
payment_db_id: int, payment_db_id: int,
amount: float, amount: Any,
currency: Optional[str], currency: Optional[str],
description: str, description: str,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
@@ -458,10 +646,39 @@ class PaykillaService(HttpClientMixin):
"supported_currencies": list(supported), "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( body = self._invoice_body(
payment_db_id=payment_db_id, payment_db_id=payment_db_id,
amount=amount, amount=invoice_amount,
currency=currency_code, currency=invoice_currency,
description=description, description=description,
) )
headers = { headers = {
@@ -613,7 +830,7 @@ class PaykillaService(HttpClientMixin):
invoice_id = str(data.get("id") or "").strip() invoice_id = str(data.get("id") or "").strip()
client_order_id = data.get("clientOrderId") client_order_id = data.get("clientOrderId")
amount_raw = data.get("amount") or data.get("expectedAmount") 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): if not (invoice_id or client_order_id):
logging.error("Paykilla webhook: missing invoice ids: %s", payload) 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) resolved_id = invoice_id or str(payment.payment_id)
if event_type in _SUCCESS_EVENTS: 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: try:
if not decimal_amounts_equal(amount_raw, payment.amount): if not decimal_amounts_equal(amount_raw, payment.amount):
logging.warning( logging.warning(
@@ -655,6 +873,14 @@ class PaykillaService(HttpClientMixin):
payment.payment_id, payment.payment_id,
exc, 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: try:
await payment_dal.update_provider_payment_and_status( await payment_dal.update_provider_payment_and_status(
@@ -688,7 +914,7 @@ class PaykillaService(HttpClientMixin):
payment=payment, payment=payment,
user_id=payment.user_id, user_id=payment.user_id,
amount=float(payment.amount), amount=float(payment.amount),
currency=str(currency), currency=str(payment.currency),
sale_mode=sale_mode, sale_mode=sale_mode,
months=payment_units, months=payment_units,
traffic_amount=float(payment_units), traffic_amount=float(payment_units),
@@ -984,20 +1210,35 @@ _CONFIG_MANIFEST = (
ProviderManifestField( ProviderManifestField(
"PAYKILLA_CURRENCY", "PAYKILLA_CURRENCY",
"string", "string",
"Invoice currency", "Fallback invoice currency",
description=( description=(
"Fallback invoice currency when the payment flow does not provide one. " "Currency used for PayKilla invoice creation when the tariff currency is not "
"Usually matches the tariff/default currency, e.g. RUB." "accepted by PayKilla as an invoice currency. Default: USD."
), ),
placeholder="RUB", placeholder="USD",
subsection="PayKilla", subsection="PayKilla",
attr="CURRENCY", 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( ProviderManifestField(
"PAYKILLA_SUPPORTED_CURRENCIES", "PAYKILLA_SUPPORTED_CURRENCIES",
"string", "string",
"Supported invoice currencies", "Supported tariff currencies",
description="Comma-separated invoice currencies allowed for PayKilla in this shop.", description=(
"Comma-separated tariff/payment currencies that may use PayKilla. "
"Unsupported PayKilla invoice currencies are converted before invoice creation."
),
placeholder=PAYKILLA_DEFAULT_SUPPORTED_CURRENCIES, placeholder=PAYKILLA_DEFAULT_SUPPORTED_CURRENCIES,
subsection="PayKilla", subsection="PayKilla",
attr="SUPPORTED_CURRENCIES", attr="SUPPORTED_CURRENCIES",
@@ -1007,7 +1248,8 @@ _CONFIG_MANIFEST = (
"string", "string",
"Accepted crypto tickers", "Accepted crypto tickers",
description=( 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, placeholder=PAYKILLA_DEFAULT_PAYMENT_CURRENCIES,
subsection="PayKilla", subsection="PayKilla",
@@ -1061,6 +1303,28 @@ _CONFIG_MANIFEST = (
subsection="PayKilla", subsection="PayKilla",
attr="USER_PAYS_NETWORK_FEE", 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( ProviderManifestField(
"PAYKILLA_VERIFY_WEBHOOK_SIGNATURE", "PAYKILLA_VERIFY_WEBHOOK_SIGNATURE",
"bool", "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_WIDGET_URL` | URL hosted checkout, по умолчанию `https://gopay.paykilla.com`. |
| `PAYKILLA_API_KEY` / `PAYKILLA_V2_API_KEY` | Public HMAC key с правом `INVOICE`. | | `PAYKILLA_API_KEY` / `PAYKILLA_V2_API_KEY` | Public HMAC key с правом `INVOICE`. |
| `PAYKILLA_SECRET_KEY` / `PAYKILLA_V2_SECRET_KEY` | Secret HMAC key для подписи API-запросов и проверки webhook. | | `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_CURRENCY` | Резервная валюта инвойса PayKilla для платежей, чья валюта тарифа не входит в `PAYKILLA_INVOICE_CURRENCIES`. По умолчанию `USD`. |
| `PAYKILLA_PAYMENT_CURRENCIES` | Crypto tickers для оплаты, например `USDTTRC,BTC,ETH`. | | `PAYKILLA_INVOICE_CURRENCIES` | Валюты, которые PayKilla принимает в поле `currency` при создании invoice. По умолчанию `USD,EUR`. Если тариф в `RUB`, Minishop конвертирует сумму в `PAYKILLA_CURRENCY`. |
| `PAYKILLA_SUPPORTED_CURRENCIES` | Валюты инвойса, разрешенные в этом магазине. | | `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_INVOICE_TYPE` | Необязательный override: `FIAT_BASED`, `FIXED_AMOUNT` или `OPEN_AMOUNT`. |
| `PAYKILLA_LIFETIME_SECONDS` | TTL инвойса, отправляется как `expiredAt`. | | `PAYKILLA_LIFETIME_SECONDS` | TTL инвойса, отправляется как `expiredAt`. |
| `PAYKILLA_RECV_WINDOW_MS` | `recvWindow` для подписанных API-запросов. | | `PAYKILLA_RECV_WINDOW_MS` | `recvWindow` для подписанных API-запросов. |
| `PAYKILLA_USER_PAYS_SERVICE_FEE` | `true`, если пользователь оплачивает service fee. | | `PAYKILLA_USER_PAYS_SERVICE_FEE` | `true`, если пользователь оплачивает service fee. |
| `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_CACHE_SECONDS` | Кэш курса и PayKilla currency limits в секундах. По умолчанию `3600`. |
| `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-источников. |
+7 -4
View File
@@ -160,7 +160,9 @@ PayKilla используется для крипто-инвойсов V2 чер
PayKilla строго валидирует текстовые поля invoice. Поэтому Minishop отправляет в `purpose` и `description` простой английский текст `<WEBAPP_TITLE> payment <id>`, а локализованное описание платежа оставляет только внутри Minishop. Дополнительно эти поля проходят ASCII-safe sanitizer: допускаются ASCII-буквы, цифры, пробелы, `_`, `.`, `,`. 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: Какие полномочия нужны API key:
@@ -183,9 +185,10 @@ PayKilla строго валидирует текстовые поля invoice.
1. Включите `PAYKILLA_ENABLED`. 1. Включите `PAYKILLA_ENABLED`.
2. Укажите `PAYKILLA_API_KEY` и `PAYKILLA_SECRET_KEY`. 2. Укажите `PAYKILLA_API_KEY` и `PAYKILLA_SECRET_KEY`.
3. Проверьте валюту тарифов/`DEFAULT_CURRENCY_SYMBOL` и `PAYKILLA_CURRENCY`, например `RUB`; в `PAYKILLA_PAYMENT_CURRENCIES` укажите crypto tickers, например `USDTTRC,BTC,ETH`. 3. Оставьте `PAYKILLA_CURRENCY=USD`, если PayKilla не принимает валюту тарифов как invoice currency. В `PAYKILLA_INVOICE_CURRENCIES` укажите валюты, доступные в PayKilla для поля `currency`, например `USD,EUR`.
4. Убедитесь, что webhook `/webhook/paykilla` настроен в PayKilla: Minishop не отправляет redirect URLs в PayKilla и полагается на webhook для активации платежа. 4. В `PAYKILLA_PAYMENT_CURRENCIES` начните с `USDTTRC`, а `BTC`, `ETH` и другие тикеры добавляйте только если они доступны в PayKilla Dashboard.
5. Добавьте `paykilla` в `PAYMENT_METHODS_ORDER`, если хотите задать явный порядок кнопок. 5. Убедитесь, что webhook `/webhook/paykilla` настроен в PayKilla: Minishop не отправляет redirect URLs в PayKilla и полагается на webhook для активации платежа.
6. Добавьте `paykilla` в `PAYMENT_METHODS_ORDER`, если хотите задать явный порядок кнопок.
Справочник переменных: [PayKilla](../configuration/env-vars.md#paykilla). Справочник переменных: [PayKilla](../configuration/env-vars.md#paykilla).
@@ -3974,13 +3974,38 @@
"section": "payments", "section": "payments",
"section_order": 4, "section_order": 4,
"subsection": "PayKilla", "subsection": "PayKilla",
"label": "Invoice currency", "label": "Fallback invoice currency",
"description": "Fallback invoice currency when the payment flow does not provide one. Usually matches the tariff/default currency, e.g. RUB.", "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_label_key": "admin_settings_field_paykilla_currency_label",
"i18n_description_key": "admin_settings_field_paykilla_currency_description", "i18n_description_key": "admin_settings_field_paykilla_currency_description",
"i18n_subsection_key": "admin_settings_subsection_paykilla", "i18n_subsection_key": "admin_settings_subsection_paykilla",
"i18n_placeholder_key": "admin_settings_field_paykilla_currency_placeholder", "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, "optional": true,
"secret": false, "secret": false,
"provider_id": "paykilla", "provider_id": "paykilla",
@@ -3999,8 +4024,8 @@
"section": "payments", "section": "payments",
"section_order": 4, "section_order": 4,
"subsection": "PayKilla", "subsection": "PayKilla",
"label": "Supported invoice currencies", "label": "Supported tariff currencies",
"description": "Comma-separated invoice currencies allowed for PayKilla in this shop.", "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_label_key": "admin_settings_field_paykilla_supported_currencies_label",
"i18n_description_key": "admin_settings_field_paykilla_supported_currencies_description", "i18n_description_key": "admin_settings_field_paykilla_supported_currencies_description",
"i18n_subsection_key": "admin_settings_subsection_paykilla", "i18n_subsection_key": "admin_settings_subsection_paykilla",
@@ -4025,12 +4050,12 @@
"section_order": 4, "section_order": 4,
"subsection": "PayKilla", "subsection": "PayKilla",
"label": "Accepted crypto tickers", "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_label_key": "admin_settings_field_paykilla_payment_currencies_label",
"i18n_description_key": "admin_settings_field_paykilla_payment_currencies_description", "i18n_description_key": "admin_settings_field_paykilla_payment_currencies_description",
"i18n_subsection_key": "admin_settings_subsection_paykilla", "i18n_subsection_key": "admin_settings_subsection_paykilla",
"i18n_placeholder_key": "admin_settings_field_paykilla_payment_currencies_placeholder", "i18n_placeholder_key": "admin_settings_field_paykilla_payment_currencies_placeholder",
"placeholder": "USDTTRC,BTC,ETH", "placeholder": "USDTTRC",
"optional": true, "optional": true,
"secret": false, "secret": false,
"provider_id": "paykilla", "provider_id": "paykilla",
@@ -4194,6 +4219,58 @@
"updated_at": null, "updated_at": null,
"webhook_base_url_configured": false "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", "key": "PAYKILLA_VERIFY_WEBHOOK_SIGNATURE",
"type": "bool", "type": "bool",
+66 -1
View File
@@ -3,6 +3,7 @@ import hmac
import json import json
import time import time
import unittest import unittest
from decimal import Decimal
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
from urllib.parse import parse_qs, urlsplit 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["purpose"], "Tunnel Shop payment 556")
self.assertEqual(body["description"], body["purpose"])
self.assertRegex(body["purpose"], r"^[A-Za-z0-9_\s.,]+$") 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) self.assertNotIn("urls", body)
def test_invoice_body_uses_payment_currency_before_configured_fallback(self): 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["currency"], "USD")
self.assertEqual(body["type"], "FIAT_BASED") 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): def test_invoice_body_omits_redirect_urls(self):
service = self._make_service() service = self._make_service()