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