fix: sanitize PayKilla invoice text
This commit is contained in:
@@ -81,6 +81,76 @@ _FAILED_EVENTS = {
|
||||
"PAYMENT_CANCELLED",
|
||||
"COMPLIANCE_FAILED",
|
||||
}
|
||||
_CYRILLIC_TO_LATIN = str.maketrans(
|
||||
{
|
||||
"А": "A",
|
||||
"Б": "B",
|
||||
"В": "V",
|
||||
"Г": "G",
|
||||
"Д": "D",
|
||||
"Е": "E",
|
||||
"Ё": "E",
|
||||
"Ж": "Zh",
|
||||
"З": "Z",
|
||||
"И": "I",
|
||||
"Й": "Y",
|
||||
"К": "K",
|
||||
"Л": "L",
|
||||
"М": "M",
|
||||
"Н": "N",
|
||||
"О": "O",
|
||||
"П": "P",
|
||||
"Р": "R",
|
||||
"С": "S",
|
||||
"Т": "T",
|
||||
"У": "U",
|
||||
"Ф": "F",
|
||||
"Х": "H",
|
||||
"Ц": "Ts",
|
||||
"Ч": "Ch",
|
||||
"Ш": "Sh",
|
||||
"Щ": "Sch",
|
||||
"Ъ": "",
|
||||
"Ы": "Y",
|
||||
"Ь": "",
|
||||
"Э": "E",
|
||||
"Ю": "Yu",
|
||||
"Я": "Ya",
|
||||
"а": "a",
|
||||
"б": "b",
|
||||
"в": "v",
|
||||
"г": "g",
|
||||
"д": "d",
|
||||
"е": "e",
|
||||
"ё": "e",
|
||||
"ж": "zh",
|
||||
"з": "z",
|
||||
"и": "i",
|
||||
"й": "y",
|
||||
"к": "k",
|
||||
"л": "l",
|
||||
"м": "m",
|
||||
"н": "n",
|
||||
"о": "o",
|
||||
"п": "p",
|
||||
"р": "r",
|
||||
"с": "s",
|
||||
"т": "t",
|
||||
"у": "u",
|
||||
"ф": "f",
|
||||
"х": "h",
|
||||
"ц": "ts",
|
||||
"ч": "ch",
|
||||
"ш": "sh",
|
||||
"щ": "sch",
|
||||
"ъ": "",
|
||||
"ы": "y",
|
||||
"ь": "",
|
||||
"э": "e",
|
||||
"ю": "yu",
|
||||
"я": "ya",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class PaykillaConfig(ProviderEnvConfig):
|
||||
@@ -203,14 +273,22 @@ class PaykillaPresentation(ProviderEnvConfig):
|
||||
TELEGRAM_EMOJI: Optional[str] = None
|
||||
|
||||
|
||||
def _clean_paykilla_text(value: Any, *, fallback: str, max_length: int = 255) -> str:
|
||||
def _normalize_paykilla_text(value: Any) -> str:
|
||||
text = str(value or "")
|
||||
text = text.translate(_CYRILLIC_TO_LATIN)
|
||||
text = re.sub(r"[-\u2010-\u2015]", " ", text)
|
||||
text = re.sub(r"[^\w\s.,]", "", text, flags=re.UNICODE)
|
||||
text = text.encode("ascii", "ignore").decode("ascii")
|
||||
text = re.sub(r"[^A-Za-z0-9_\s.,]", "", text)
|
||||
return re.sub(r"\s+", " ", text).strip()
|
||||
|
||||
|
||||
def _clean_paykilla_text(value: Any, *, fallback: str, max_length: int = 255) -> str:
|
||||
text = _normalize_paykilla_text(value)
|
||||
fallback_text = _normalize_paykilla_text(fallback) or "Payment"
|
||||
text = re.sub(r"\s+", " ", text).strip()
|
||||
if not text:
|
||||
text = fallback
|
||||
return text[:max_length].strip() or fallback[:max_length]
|
||||
text = fallback_text
|
||||
return text[:max_length].strip() or fallback_text[:max_length].strip() or "Payment"
|
||||
|
||||
|
||||
def _payment_currencies(config: PaykillaConfig) -> List[str]:
|
||||
|
||||
@@ -158,6 +158,8 @@ Heleket используется для крипто-инвойсов с отд
|
||||
|
||||
PayKilla используется для крипто-инвойсов V2 через hosted checkout `https://gopay.paykilla.com/{invoice_id}`. API-запросы подписываются HMAC-SHA256, webhook проверяется по заголовку `X-API-SIGN` и raw body.
|
||||
|
||||
PayKilla строго валидирует текстовые поля invoice. Minishop перед отправкой автоматически заменяет тире пробелами, транслитерирует кириллицу и оставляет только ASCII-буквы, цифры, пробелы, `_`, `.`, `,` в `purpose` и `description`.
|
||||
|
||||
Какие полномочия нужны API key:
|
||||
|
||||
1. В PayKilla Dashboard откройте **Settings -> API keys**.
|
||||
|
||||
@@ -23,6 +23,7 @@ from bot.payment_providers.heleket import HeleketConfig, HeleketService, _comput
|
||||
from bot.payment_providers.paykilla import (
|
||||
PaykillaConfig,
|
||||
PaykillaService,
|
||||
_clean_paykilla_text,
|
||||
_sign_query,
|
||||
_webhook_signature,
|
||||
)
|
||||
@@ -219,6 +220,7 @@ class PaykillaServiceTests(unittest.TestCase):
|
||||
WEBHOOK_BASE_URL="https://shop.example",
|
||||
trusted_proxies=["127.0.0.1"],
|
||||
)
|
||||
service._default_return_url = "test_bot"
|
||||
return service
|
||||
|
||||
def test_sign_query_uses_timestamp_and_recv_window_only(self):
|
||||
@@ -232,6 +234,29 @@ class PaykillaServiceTests(unittest.TestCase):
|
||||
self.assertEqual(query, "timestamp=1738800000000&recvWindow=5000")
|
||||
self.assertEqual(signature, expected)
|
||||
|
||||
def test_clean_text_transliterates_russian_description_for_invoice_fields(self):
|
||||
text = _clean_paykilla_text(
|
||||
"Оплата подписки на 1 мес. - тариф «Базовый» ✅",
|
||||
fallback="Payment 556",
|
||||
)
|
||||
|
||||
self.assertEqual(text, "Oplata podpiski na 1 mes. tarif Bazovyy")
|
||||
self.assertRegex(text, r"^[A-Za-z0-9_\s.,]+$")
|
||||
|
||||
def test_invoice_body_uses_ascii_safe_purpose_and_description(self):
|
||||
service = self._make_service()
|
||||
|
||||
body = service._invoice_body(
|
||||
payment_db_id=556,
|
||||
amount=100,
|
||||
currency="RUB",
|
||||
description="Оплата подписки на 1 мес. - тариф «Базовый» ✅",
|
||||
)
|
||||
|
||||
self.assertEqual(body["purpose"], "Oplata podpiski na 1 mes. tarif Bazovyy")
|
||||
self.assertEqual(body["description"], body["purpose"])
|
||||
self.assertRegex(body["purpose"], r"^[A-Za-z0-9_\s.,]+$")
|
||||
|
||||
def test_verify_webhook_signature_accepts_raw_body_signature(self):
|
||||
service = self._make_service()
|
||||
raw_body = (
|
||||
|
||||
Reference in New Issue
Block a user