fix: sanitize PayKilla invoice text
This commit is contained in:
@@ -81,6 +81,76 @@ _FAILED_EVENTS = {
|
|||||||
"PAYMENT_CANCELLED",
|
"PAYMENT_CANCELLED",
|
||||||
"COMPLIANCE_FAILED",
|
"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):
|
class PaykillaConfig(ProviderEnvConfig):
|
||||||
@@ -203,14 +273,22 @@ class PaykillaPresentation(ProviderEnvConfig):
|
|||||||
TELEGRAM_EMOJI: Optional[str] = None
|
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 = str(value or "")
|
||||||
|
text = text.translate(_CYRILLIC_TO_LATIN)
|
||||||
text = re.sub(r"[-\u2010-\u2015]", " ", text)
|
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()
|
text = re.sub(r"\s+", " ", text).strip()
|
||||||
if not text:
|
if not text:
|
||||||
text = fallback
|
text = fallback_text
|
||||||
return text[:max_length].strip() or fallback[:max_length]
|
return text[:max_length].strip() or fallback_text[:max_length].strip() or "Payment"
|
||||||
|
|
||||||
|
|
||||||
def _payment_currencies(config: PaykillaConfig) -> List[str]:
|
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 используется для крипто-инвойсов 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:
|
Какие полномочия нужны API key:
|
||||||
|
|
||||||
1. В PayKilla Dashboard откройте **Settings -> API keys**.
|
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 (
|
from bot.payment_providers.paykilla import (
|
||||||
PaykillaConfig,
|
PaykillaConfig,
|
||||||
PaykillaService,
|
PaykillaService,
|
||||||
|
_clean_paykilla_text,
|
||||||
_sign_query,
|
_sign_query,
|
||||||
_webhook_signature,
|
_webhook_signature,
|
||||||
)
|
)
|
||||||
@@ -219,6 +220,7 @@ class PaykillaServiceTests(unittest.TestCase):
|
|||||||
WEBHOOK_BASE_URL="https://shop.example",
|
WEBHOOK_BASE_URL="https://shop.example",
|
||||||
trusted_proxies=["127.0.0.1"],
|
trusted_proxies=["127.0.0.1"],
|
||||||
)
|
)
|
||||||
|
service._default_return_url = "test_bot"
|
||||||
return service
|
return service
|
||||||
|
|
||||||
def test_sign_query_uses_timestamp_and_recv_window_only(self):
|
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(query, "timestamp=1738800000000&recvWindow=5000")
|
||||||
self.assertEqual(signature, expected)
|
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):
|
def test_verify_webhook_signature_accepts_raw_body_signature(self):
|
||||||
service = self._make_service()
|
service = self._make_service()
|
||||||
raw_body = (
|
raw_body = (
|
||||||
|
|||||||
Reference in New Issue
Block a user