fix: convert PayKilla invoices to supported currency

This commit is contained in:
3252a8
2026-06-04 15:57:55 +03:00
parent 69985e44dd
commit bdf0622be5
5 changed files with 443 additions and 31 deletions
+280 -16
View File
@@ -5,6 +5,7 @@ import logging
import re
import time
from datetime import datetime, timedelta, timezone
from decimal import Decimal, InvalidOperation
from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import urlencode
@@ -65,7 +66,9 @@ from .shared import (
router = Router(name="user_subscription_payments_paykilla_router")
_LOG = "paykilla"
PAYKILLA_DEFAULT_PAYMENT_CURRENCIES = "USDTTRC,BTC,ETH"
PAYKILLA_DEFAULT_PAYMENT_CURRENCIES = "USDTTRC"
PAYKILLA_DEFAULT_INVOICE_CURRENCIES = "USD,EUR"
PAYKILLA_DEFAULT_EXCHANGE_RATE_URL = "https://open.er-api.com/v6/latest/{source}"
PAYKILLA_DEFAULT_SUPPORTED_CURRENCIES = (
"RUB,USD,EUR,AED,GBP,BTC,ETH,TRX,TON,USDTTRC,USDTETH,USDTBSC,"
"USDCETH,USDCBSC,DAIETH,DAIBSC,BNBBSC,ETHBSC,LINKETH,LINKBSC,"
@@ -178,7 +181,8 @@ class PaykillaConfig(ProviderEnvConfig):
validation_alias=AliasChoices("PAYKILLA_BASE_URL", "PAYKILLA_V2_BASE_URL"),
)
WIDGET_URL: str = Field(default="https://gopay.paykilla.com")
CURRENCY: str = Field(default="RUB")
CURRENCY: str = Field(default="USD")
INVOICE_CURRENCIES: str = Field(default=PAYKILLA_DEFAULT_INVOICE_CURRENCIES)
INVOICE_TYPE: Optional[str] = None
PAYMENT_CURRENCIES: str = Field(default=PAYKILLA_DEFAULT_PAYMENT_CURRENCIES)
SUPPORTED_CURRENCIES: str = Field(default=PAYKILLA_DEFAULT_SUPPORTED_CURRENCIES)
@@ -186,6 +190,8 @@ class PaykillaConfig(ProviderEnvConfig):
RECV_WINDOW_MS: int = Field(default=5000)
USER_PAYS_SERVICE_FEE: bool = Field(default=True)
USER_PAYS_NETWORK_FEE: bool = Field(default=True)
EXCHANGE_RATE_URL: str = Field(default=PAYKILLA_DEFAULT_EXCHANGE_RATE_URL)
EXCHANGE_RATE_CACHE_SECONDS: int = Field(default=3600)
VERIFY_WEBHOOK_SIGNATURE: bool = Field(default=True)
WEBHOOK_URL: Optional[str] = None
TRUSTED_IPS: str = Field(default="")
@@ -212,6 +218,17 @@ class PaykillaConfig(ProviderEnvConfig):
return 5000
return min(60_000, max(1000, value))
@field_validator("EXCHANGE_RATE_CACHE_SECONDS", mode="before")
@classmethod
def _clamp_exchange_rate_cache(cls, v):
if isinstance(v, str):
v = v.strip()
try:
value = int(v)
except (TypeError, ValueError):
return 3600
return min(86_400, max(60, value))
@field_validator(
"API_KEY",
"SECRET_KEY",
@@ -298,6 +315,22 @@ def _payment_currencies(config: PaykillaConfig) -> List[str]:
return currencies or ["USDTTRC"]
def _invoice_currencies(config: PaykillaConfig) -> tuple[str, ...]:
currencies = parse_supported_currency_codes(config.INVOICE_CURRENCIES)
return currencies or parse_supported_currency_codes(PAYKILLA_DEFAULT_INVOICE_CURRENCIES)
def _target_invoice_currency(config: PaykillaConfig, payment_currency: str) -> str:
payment_currency = normalize_payment_currency_code(payment_currency)
invoice_currencies = _invoice_currencies(config)
if payment_currency in invoice_currencies:
return payment_currency
fallback = normalize_payment_currency_code(config.CURRENCY, default="")
if fallback and fallback in invoice_currencies:
return fallback
return invoice_currencies[0] if invoice_currencies else payment_currency
def _invoice_type_for(config: PaykillaConfig, currency: str) -> str:
explicit = (config.INVOICE_TYPE or "").strip().upper()
if explicit in {"FIAT_BASED", "FIXED_AMOUNT", "OPEN_AMOUNT"}:
@@ -346,6 +379,16 @@ def _debug_invoice_body(body: Dict[str, Any]) -> str:
return json.dumps(body, ensure_ascii=True, sort_keys=True)
def _decimal_from_api(value: Any) -> Optional[Decimal]:
try:
decimal_value = Decimal(str(value))
except (InvalidOperation, TypeError, ValueError):
return None
if not decimal_value.is_finite():
return None
return decimal_value
class PaykillaService(HttpClientMixin):
def __init__(
self,
@@ -367,6 +410,8 @@ class PaykillaService(HttpClientMixin):
self.subscription_service = subscription_service
self.referral_service = referral_service
self._default_return_url = default_return_url
self._exchange_rate_cache: Dict[tuple[str, str], tuple[float, Decimal]] = {}
self._currency_cache: tuple[float, List[Dict[str, Any]]] = (0, [])
self._init_http_client(total_timeout=20)
if not self.configured:
@@ -396,7 +441,7 @@ class PaykillaService(HttpClientMixin):
@property
def currency(self) -> str:
return normalize_payment_currency_code(self.config.CURRENCY or "RUB")
return normalize_payment_currency_code(self.config.CURRENCY or "USD")
@property
def verify_webhook_signature(self) -> bool:
@@ -408,11 +453,154 @@ class PaykillaService(HttpClientMixin):
query, signature = _sign_query(timestamp_ms, recv_window_ms, self.secret_key)
return f"{self.base_url}/api/v2/invoice?{query}&signature={signature}"
def _signed_currency_url(self) -> str:
timestamp_ms = int(time.time() * 1000)
recv_window_ms = int(self.config.RECV_WINDOW_MS)
query, signature = _sign_query(timestamp_ms, recv_window_ms, self.secret_key)
return f"{self.base_url}/api/v2/currency?{query}&signature={signature}"
def _exchange_rate_url(self, source_currency: str, target_currency: str) -> str:
template = self.config.EXCHANGE_RATE_URL or PAYKILLA_DEFAULT_EXCHANGE_RATE_URL
return template.format(source=source_currency, target=target_currency)
async def _exchange_rate(self, source_currency: str, target_currency: str) -> Decimal:
source_currency = normalize_payment_currency_code(source_currency)
target_currency = normalize_payment_currency_code(target_currency)
if source_currency == target_currency:
return Decimal("1")
cache_key = (source_currency, target_currency)
cache_seconds = int(self.config.EXCHANGE_RATE_CACHE_SECONDS)
now = time.time()
cache = getattr(self, "_exchange_rate_cache", None)
if cache is None:
cache = {}
self._exchange_rate_cache = cache
cached = cache.get(cache_key)
if cached and now - cached[0] < cache_seconds:
return cached[1]
session = await self._get_session()
url = self._exchange_rate_url(source_currency, target_currency)
async with session.get(url) as response:
response_text = await response.text()
try:
response_data = json.loads(response_text) if response_text else {}
except json.JSONDecodeError as exc:
raise ValueError("exchange_rate_invalid_json") from exc
if response.status != 200 or response_data.get("result") != "success":
logging.error(
"Paykilla exchange rate request failed "
"(status=%s, body=%s, source=%s, target=%s)",
response.status,
response_data,
source_currency,
target_currency,
)
raise ValueError("exchange_rate_unavailable")
rates = response_data.get("rates") if isinstance(response_data, dict) else None
target_rate = rates.get(target_currency) if isinstance(rates, dict) else None
rate = _decimal_from_api(target_rate)
if rate is None or rate <= 0:
raise ValueError("exchange_rate_missing")
cache[cache_key] = (now, rate)
return rate
async def _paykilla_currencies(self) -> List[Dict[str, Any]]:
cache_seconds = int(self.config.EXCHANGE_RATE_CACHE_SECONDS)
now = time.time()
cached_at, cached_data = getattr(self, "_currency_cache", (0, []))
if cached_data and now - cached_at < cache_seconds:
return cached_data
headers = {"X-API-KEY": self.api_key}
session = await self._get_session()
try:
async with session.get(self._signed_currency_url(), headers=headers) as response:
response_text = await response.text()
try:
response_data = json.loads(response_text) if response_text else []
except json.JSONDecodeError:
logging.warning(
"Paykilla currency metadata request returned invalid JSON: %s",
response_text,
)
return cached_data
if response.status != 200 or not isinstance(response_data, list):
logging.warning(
"Paykilla currency metadata request failed "
"(status=%s, body=%s)",
response.status,
response_data,
)
return cached_data
except Exception:
logging.exception("Paykilla currency metadata request failed.")
return cached_data
self._currency_cache = (now, response_data)
return response_data
async def _currency_info_for(self, currency: str) -> Optional[Dict[str, Any]]:
currency = normalize_payment_currency_code(currency)
for item in await self._paykilla_currencies():
if not isinstance(item, dict):
continue
if normalize_payment_currency_code(item.get("ticker"), default="") == currency:
return item
return None
async def _invoice_amount_bounds_error(
self, *, amount: Decimal, currency: str
) -> Optional[Dict[str, Any]]:
info = await self._currency_info_for(currency)
if not info:
return None
minimum = _decimal_from_api(info.get("invoiceMin"))
maximum = _decimal_from_api(info.get("invoiceMax"))
if minimum is not None and amount < minimum:
return {
"message": "invoice_amount_below_minimum",
"currency": currency,
"amount": str(amount),
"minimum": str(format_decimal_amount(minimum)),
}
if maximum is not None and amount > maximum:
return {
"message": "invoice_amount_above_maximum",
"currency": currency,
"amount": str(amount),
"maximum": str(format_decimal_amount(maximum)),
}
return None
async def _invoice_amount_and_currency(
self, *, amount: float, payment_currency: str
) -> tuple[Decimal, str]:
payment_currency = normalize_payment_currency_code(payment_currency or self.currency)
invoice_currency = _target_invoice_currency(self.config, payment_currency)
invoice_amount = format_decimal_amount(amount)
if invoice_currency == payment_currency:
return invoice_amount, invoice_currency
rate = await self._exchange_rate(payment_currency, invoice_currency)
converted_amount = format_decimal_amount(invoice_amount * rate)
logging.info(
"Paykilla invoice currency conversion: payment=%s %s, invoice=%s %s, rate=%s",
invoice_amount,
payment_currency,
converted_amount,
invoice_currency,
rate,
)
return converted_amount, invoice_currency
def _invoice_body(
self,
*,
payment_db_id: int,
amount: float,
amount: Any,
currency: Optional[str],
description: str,
) -> Dict[str, Any]:
@@ -458,10 +646,39 @@ class PaykillaService(HttpClientMixin):
"supported_currencies": list(supported),
}
try:
invoice_amount, invoice_currency = await self._invoice_amount_and_currency(
amount=amount,
payment_currency=currency_code,
)
except Exception as exc:
logging.exception(
"Paykilla create_payment_link: failed to resolve invoice currency "
"(amount=%s currency=%s target=%s).",
amount,
currency_code,
_target_invoice_currency(self.config, currency_code),
)
return False, {"message": str(exc) or "exchange_rate_unavailable"}
bounds_error = await self._invoice_amount_bounds_error(
amount=invoice_amount,
currency=invoice_currency,
)
if bounds_error:
logging.error(
"Paykilla create_payment_link: invoice amount violates PayKilla limits "
"(details=%s, payment_amount=%s, payment_currency=%s)",
bounds_error,
format_decimal_amount(amount),
currency_code,
)
return False, bounds_error
body = self._invoice_body(
payment_db_id=payment_db_id,
amount=amount,
currency=currency_code,
amount=invoice_amount,
currency=invoice_currency,
description=description,
)
headers = {
@@ -613,7 +830,7 @@ class PaykillaService(HttpClientMixin):
invoice_id = str(data.get("id") or "").strip()
client_order_id = data.get("clientOrderId")
amount_raw = data.get("amount") or data.get("expectedAmount")
currency = data.get("currency") or self.currency
invoice_currency = normalize_payment_currency_code(data.get("currency") or self.currency)
if not (invoice_id or client_order_id):
logging.error("Paykilla webhook: missing invoice ids: %s", payload)
@@ -639,7 +856,8 @@ class PaykillaService(HttpClientMixin):
resolved_id = invoice_id or str(payment.payment_id)
if event_type in _SUCCESS_EVENTS:
if amount_raw is not None:
payment_currency = normalize_payment_currency_code(payment.currency)
if amount_raw is not None and invoice_currency == payment_currency:
try:
if not decimal_amounts_equal(amount_raw, payment.amount):
logging.warning(
@@ -655,6 +873,14 @@ class PaykillaService(HttpClientMixin):
payment.payment_id,
exc,
)
elif amount_raw is not None:
logging.info(
"Paykilla webhook: invoice amount is in %s while payment record is in %s; "
"skipping direct amount comparison for payment %s.",
invoice_currency,
payment_currency,
payment.payment_id,
)
try:
await payment_dal.update_provider_payment_and_status(
@@ -688,7 +914,7 @@ class PaykillaService(HttpClientMixin):
payment=payment,
user_id=payment.user_id,
amount=float(payment.amount),
currency=str(currency),
currency=str(payment.currency),
sale_mode=sale_mode,
months=payment_units,
traffic_amount=float(payment_units),
@@ -984,20 +1210,35 @@ _CONFIG_MANIFEST = (
ProviderManifestField(
"PAYKILLA_CURRENCY",
"string",
"Invoice currency",
"Fallback invoice currency",
description=(
"Fallback invoice currency when the payment flow does not provide one. "
"Usually matches the tariff/default currency, e.g. RUB."
"Currency used for PayKilla invoice creation when the tariff currency is not "
"accepted by PayKilla as an invoice currency. Default: USD."
),
placeholder="RUB",
placeholder="USD",
subsection="PayKilla",
attr="CURRENCY",
),
ProviderManifestField(
"PAYKILLA_INVOICE_CURRENCIES",
"string",
"PayKilla invoice currencies",
description=(
"Comma-separated currencies accepted by PayKilla as invoice currency. "
"Payments in other tariff currencies are converted to PAYKILLA_CURRENCY."
),
placeholder=PAYKILLA_DEFAULT_INVOICE_CURRENCIES,
subsection="PayKilla",
attr="INVOICE_CURRENCIES",
),
ProviderManifestField(
"PAYKILLA_SUPPORTED_CURRENCIES",
"string",
"Supported invoice currencies",
description="Comma-separated invoice currencies allowed for PayKilla in this shop.",
"Supported tariff currencies",
description=(
"Comma-separated tariff/payment currencies that may use PayKilla. "
"Unsupported PayKilla invoice currencies are converted before invoice creation."
),
placeholder=PAYKILLA_DEFAULT_SUPPORTED_CURRENCIES,
subsection="PayKilla",
attr="SUPPORTED_CURRENCIES",
@@ -1007,7 +1248,8 @@ _CONFIG_MANIFEST = (
"string",
"Accepted crypto tickers",
description=(
"Comma-separated PayKilla tickers sent as paymentCurrencies, e.g. USDTTRC,BTC,ETH."
"Comma-separated PayKilla tickers sent as paymentCurrencies, e.g. USDTTRC. "
"Add BTC/ETH only when enabled for the merchant account."
),
placeholder=PAYKILLA_DEFAULT_PAYMENT_CURRENCIES,
subsection="PayKilla",
@@ -1061,6 +1303,28 @@ _CONFIG_MANIFEST = (
subsection="PayKilla",
attr="USER_PAYS_NETWORK_FEE",
),
ProviderManifestField(
"PAYKILLA_EXCHANGE_RATE_URL",
"url",
"Exchange rate URL",
description=(
"No-key exchange rate endpoint used when tariff currency must be converted. "
"Supports {source} and {target} placeholders."
),
placeholder=PAYKILLA_DEFAULT_EXCHANGE_RATE_URL,
subsection="PayKilla",
attr="EXCHANGE_RATE_URL",
),
ProviderManifestField(
"PAYKILLA_EXCHANGE_RATE_CACHE_SECONDS",
"int",
"Exchange rate cache (seconds)",
description="How long PayKilla currency conversion rates and PayKilla limits are cached.",
subsection="PayKilla",
min=60,
max=86_400,
attr="EXCHANGE_RATE_CACHE_SECONDS",
),
ProviderManifestField(
"PAYKILLA_VERIFY_WEBHOOK_SIGNATURE",
"bool",