fix: convert PayKilla invoices to supported currency
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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-источников. |
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user