Handle YooKassa cancellations in FastAPI webhook
This commit is contained in:
@@ -242,7 +242,6 @@ YOOKASSA_PAYMENT_SUBJECT=service
|
|||||||
YOOKASSA_WEBHOOK_PATH=/yookassa-webhook
|
YOOKASSA_WEBHOOK_PATH=/yookassa-webhook
|
||||||
YOOKASSA_WEBHOOK_HOST=0.0.0.0
|
YOOKASSA_WEBHOOK_HOST=0.0.0.0
|
||||||
YOOKASSA_WEBHOOK_PORT=8082
|
YOOKASSA_WEBHOOK_PORT=8082
|
||||||
YOOKASSA_WEBHOOK_SECRET=your_webhook_secret
|
|
||||||
|
|
||||||
# Лимиты сумм пополнения через YooKassa (в копейках)
|
# Лимиты сумм пополнения через YooKassa (в копейках)
|
||||||
YOOKASSA_MIN_AMOUNT_KOPEKS=5000
|
YOOKASSA_MIN_AMOUNT_KOPEKS=5000
|
||||||
|
|||||||
@@ -238,7 +238,6 @@ REMNAWAVE_API_KEY=your_api_key
|
|||||||
ADMIN_IDS=123456789,987654321
|
ADMIN_IDS=123456789,987654321
|
||||||
|
|
||||||
# Webhook секреты - генерируйте случайные значения
|
# Webhook секреты - генерируйте случайные значения
|
||||||
YOOKASSA_WEBHOOK_SECRET=random_secret_here
|
|
||||||
TRIBUTE_WEBHOOK_SECRET=another_random_secret
|
TRIBUTE_WEBHOOK_SECRET=another_random_secret
|
||||||
CRYPTOBOT_WEBHOOK_SECRET=yet_another_secret
|
CRYPTOBOT_WEBHOOK_SECRET=yet_another_secret
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -187,7 +187,6 @@ class Settings(BaseSettings):
|
|||||||
YOOKASSA_WEBHOOK_PATH: str = "/yookassa-webhook"
|
YOOKASSA_WEBHOOK_PATH: str = "/yookassa-webhook"
|
||||||
YOOKASSA_WEBHOOK_HOST: str = "0.0.0.0"
|
YOOKASSA_WEBHOOK_HOST: str = "0.0.0.0"
|
||||||
YOOKASSA_WEBHOOK_PORT: int = 8082
|
YOOKASSA_WEBHOOK_PORT: int = 8082
|
||||||
YOOKASSA_WEBHOOK_SECRET: Optional[str] = None
|
|
||||||
YOOKASSA_TRUSTED_PROXY_NETWORKS: str = ""
|
YOOKASSA_TRUSTED_PROXY_NETWORKS: str = ""
|
||||||
YOOKASSA_MIN_AMOUNT_KOPEKS: int = 5000
|
YOOKASSA_MIN_AMOUNT_KOPEKS: int = 5000
|
||||||
YOOKASSA_MAX_AMOUNT_KOPEKS: int = 1000000
|
YOOKASSA_MAX_AMOUNT_KOPEKS: int = 1000000
|
||||||
|
|||||||
Vendored
+10
-89
@@ -1,9 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import json
|
import json
|
||||||
import hashlib
|
|
||||||
import hmac
|
|
||||||
import base64
|
|
||||||
from ipaddress import (
|
from ipaddress import (
|
||||||
IPv4Address,
|
IPv4Address,
|
||||||
IPv4Network,
|
IPv4Network,
|
||||||
@@ -36,6 +33,13 @@ YOOKASSA_ALLOWED_IP_NETWORKS: tuple[IPNetwork, ...] = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
YOOKASSA_ALLOWED_EVENTS: tuple[str, ...] = (
|
||||||
|
"payment.succeeded",
|
||||||
|
"payment.waiting_for_capture",
|
||||||
|
"payment.canceled",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def collect_yookassa_ip_candidates(*values: Optional[str]) -> List[str]:
|
def collect_yookassa_ip_candidates(*values: Optional[str]) -> List[str]:
|
||||||
candidates: List[str] = []
|
candidates: List[str] = []
|
||||||
for value in values:
|
for value in values:
|
||||||
@@ -164,72 +168,6 @@ def is_yookassa_ip_allowed(ip_object: IPAddress) -> bool:
|
|||||||
|
|
||||||
class YooKassaWebhookHandler:
|
class YooKassaWebhookHandler:
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def verify_webhook_signature(body: str, signature: str, secret: str) -> bool:
|
|
||||||
try:
|
|
||||||
signature_parts = signature.strip().split(' ')
|
|
||||||
|
|
||||||
if len(signature_parts) < 4:
|
|
||||||
logger.error(f"Неверный формат подписи YooKassa: {signature}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
version = signature_parts[0]
|
|
||||||
payment_id = signature_parts[1]
|
|
||||||
timestamp = signature_parts[2]
|
|
||||||
received_signature = signature_parts[3]
|
|
||||||
|
|
||||||
if version != "v1":
|
|
||||||
logger.error(f"Неподдерживаемая версия подписи: {version}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
logger.info(f"Проверка подписи v1 для платежа {payment_id}, timestamp: {timestamp}")
|
|
||||||
|
|
||||||
|
|
||||||
expected_signature_1 = hmac.new(
|
|
||||||
secret.encode('utf-8'),
|
|
||||||
body.encode('utf-8'),
|
|
||||||
hashlib.sha256
|
|
||||||
).digest()
|
|
||||||
expected_signature_1_b64 = base64.b64encode(expected_signature_1).decode('utf-8')
|
|
||||||
|
|
||||||
signed_payload_2 = f"{payment_id}.{timestamp}.{body}"
|
|
||||||
expected_signature_2 = hmac.new(
|
|
||||||
secret.encode('utf-8'),
|
|
||||||
signed_payload_2.encode('utf-8'),
|
|
||||||
hashlib.sha256
|
|
||||||
).digest()
|
|
||||||
expected_signature_2_b64 = base64.b64encode(expected_signature_2).decode('utf-8')
|
|
||||||
|
|
||||||
signed_payload_3 = f"{timestamp}.{body}"
|
|
||||||
expected_signature_3 = hmac.new(
|
|
||||||
secret.encode('utf-8'),
|
|
||||||
signed_payload_3.encode('utf-8'),
|
|
||||||
hashlib.sha256
|
|
||||||
).digest()
|
|
||||||
expected_signature_3_b64 = base64.b64encode(expected_signature_3).decode('utf-8')
|
|
||||||
|
|
||||||
logger.debug(f"Получена подпись: {received_signature}")
|
|
||||||
logger.debug(f"Ожидаемая подпись (вариант 1): {expected_signature_1_b64}")
|
|
||||||
logger.debug(f"Ожидаемая подпись (вариант 2): {expected_signature_2_b64}")
|
|
||||||
logger.debug(f"Ожидаемая подпись (вариант 3): {expected_signature_3_b64}")
|
|
||||||
|
|
||||||
is_valid = (
|
|
||||||
hmac.compare_digest(received_signature, expected_signature_1_b64) or
|
|
||||||
hmac.compare_digest(received_signature, expected_signature_2_b64) or
|
|
||||||
hmac.compare_digest(received_signature, expected_signature_3_b64)
|
|
||||||
)
|
|
||||||
|
|
||||||
if is_valid:
|
|
||||||
logger.info("✅ Подпись YooKassa webhook проверена успешно")
|
|
||||||
else:
|
|
||||||
logger.warning("⚠️ Подпись YooKassa webhook не совпадает ни с одним вариантом")
|
|
||||||
|
|
||||||
return is_valid
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Ошибка проверки подписи YooKassa: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def __init__(self, payment_service: PaymentService):
|
def __init__(self, payment_service: PaymentService):
|
||||||
self.payment_service = payment_service
|
self.payment_service = payment_service
|
||||||
|
|
||||||
@@ -274,25 +212,8 @@ class YooKassaWebhookHandler:
|
|||||||
logger.info(f"📄 Body: {body}")
|
logger.info(f"📄 Body: {body}")
|
||||||
|
|
||||||
signature = request.headers.get('Signature') or request.headers.get('X-YooKassa-Signature')
|
signature = request.headers.get('Signature') or request.headers.get('X-YooKassa-Signature')
|
||||||
|
if signature:
|
||||||
if settings.YOOKASSA_WEBHOOK_SECRET:
|
logger.info("ℹ️ Получена подпись YooKassa: %s", signature)
|
||||||
if not signature:
|
|
||||||
logger.warning("⚠️ Webhook без подписи, но секрет настроен")
|
|
||||||
return web.Response(status=401, text="Missing signature")
|
|
||||||
|
|
||||||
logger.info(f"🔐 Получена подпись: {signature}")
|
|
||||||
|
|
||||||
if not YooKassaWebhookHandler.verify_webhook_signature(
|
|
||||||
body,
|
|
||||||
signature,
|
|
||||||
settings.YOOKASSA_WEBHOOK_SECRET,
|
|
||||||
):
|
|
||||||
logger.warning("❌ Неверная подпись YooKassa webhook")
|
|
||||||
return web.Response(status=401, text="Invalid signature")
|
|
||||||
elif signature:
|
|
||||||
logger.info("ℹ️ Подпись получена, но проверка отключена (YOOKASSA_WEBHOOK_SECRET не настроен)")
|
|
||||||
else:
|
|
||||||
logger.info("ℹ️ Проверка подписи отключена")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
webhook_data = json.loads(body)
|
webhook_data = json.loads(body)
|
||||||
@@ -308,7 +229,7 @@ class YooKassaWebhookHandler:
|
|||||||
logger.warning("⚠️ Webhook YooKassa без типа события")
|
logger.warning("⚠️ Webhook YooKassa без типа события")
|
||||||
return web.Response(status=400, text="No event type")
|
return web.Response(status=400, text="No event type")
|
||||||
|
|
||||||
if event_type not in ["payment.succeeded", "payment.waiting_for_capture"]:
|
if event_type not in YOOKASSA_ALLOWED_EVENTS:
|
||||||
logger.info(f"ℹ️ Игнорируем событие YooKassa: {event_type}")
|
logger.info(f"ℹ️ Игнорируем событие YooKassa: {event_type}")
|
||||||
return web.Response(status=200, text="OK")
|
return web.Response(status=200, text="OK")
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,66 @@ if TYPE_CHECKING:
|
|||||||
class YooKassaPaymentMixin:
|
class YooKassaPaymentMixin:
|
||||||
"""Mixin с операциями по созданию и подтверждению платежей YooKassa."""
|
"""Mixin с операциями по созданию и подтверждению платежей YooKassa."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_amount_value(value: Any) -> str:
|
||||||
|
"""Форматирует сумму для хранения в webhook-объекте."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
quantized = Decimal(str(value)).quantize(Decimal("0.00"))
|
||||||
|
return format(quantized, "f")
|
||||||
|
except (InvalidOperation, ValueError, TypeError):
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _merge_remote_yookassa_payload(
|
||||||
|
cls,
|
||||||
|
event_object: Dict[str, Any],
|
||||||
|
remote_data: Dict[str, Any],
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Объединяет локальные данные вебхука с ответом API YooKassa."""
|
||||||
|
|
||||||
|
merged: Dict[str, Any] = dict(event_object)
|
||||||
|
|
||||||
|
status = remote_data.get("status")
|
||||||
|
if status:
|
||||||
|
merged["status"] = status
|
||||||
|
|
||||||
|
if "paid" in remote_data:
|
||||||
|
merged["paid"] = bool(remote_data.get("paid"))
|
||||||
|
|
||||||
|
if "refundable" in remote_data:
|
||||||
|
merged["refundable"] = bool(remote_data.get("refundable"))
|
||||||
|
|
||||||
|
payment_method_type = remote_data.get("payment_method_type")
|
||||||
|
if payment_method_type:
|
||||||
|
payment_method = dict(merged.get("payment_method") or {})
|
||||||
|
payment_method["type"] = payment_method_type
|
||||||
|
merged["payment_method"] = payment_method
|
||||||
|
|
||||||
|
amount_value = remote_data.get("amount_value")
|
||||||
|
amount_currency = remote_data.get("amount_currency")
|
||||||
|
if amount_value is not None or amount_currency:
|
||||||
|
merged_amount = dict(merged.get("amount") or {})
|
||||||
|
if amount_value is not None:
|
||||||
|
merged_amount["value"] = cls._format_amount_value(amount_value)
|
||||||
|
if amount_currency:
|
||||||
|
merged_amount["currency"] = str(amount_currency).upper()
|
||||||
|
merged["amount"] = merged_amount
|
||||||
|
|
||||||
|
for datetime_field in ("captured_at", "created_at"):
|
||||||
|
value = remote_data.get(datetime_field)
|
||||||
|
if value:
|
||||||
|
merged[datetime_field] = value
|
||||||
|
|
||||||
|
metadata = remote_data.get("metadata")
|
||||||
|
if metadata:
|
||||||
|
try:
|
||||||
|
merged["metadata"] = dict(metadata) # type: ignore[arg-type]
|
||||||
|
except TypeError:
|
||||||
|
merged["metadata"] = metadata
|
||||||
|
|
||||||
|
return merged
|
||||||
|
|
||||||
async def create_yookassa_payment(
|
async def create_yookassa_payment(
|
||||||
self,
|
self,
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
@@ -726,6 +786,32 @@ class YooKassaPaymentMixin:
|
|||||||
logger.warning("Webhook без payment id: %s", event)
|
logger.warning("Webhook без payment id: %s", event)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
remote_data: Optional[Dict[str, Any]] = None
|
||||||
|
if getattr(self, "yookassa_service", None):
|
||||||
|
try:
|
||||||
|
remote_data = await self.yookassa_service.get_payment_info( # type: ignore[union-attr]
|
||||||
|
yookassa_payment_id
|
||||||
|
)
|
||||||
|
except Exception as error: # pragma: no cover - диагностический лог
|
||||||
|
logger.warning(
|
||||||
|
"Не удалось запросить актуальный статус платежа YooKassa %s: %s",
|
||||||
|
yookassa_payment_id,
|
||||||
|
error,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if remote_data:
|
||||||
|
previous_status = event_object.get("status")
|
||||||
|
event_object = self._merge_remote_yookassa_payload(event_object, remote_data)
|
||||||
|
if previous_status and event_object.get("status") != previous_status:
|
||||||
|
logger.info(
|
||||||
|
"Статус платежа YooKassa %s скорректирован по данным API: %s → %s",
|
||||||
|
yookassa_payment_id,
|
||||||
|
previous_status,
|
||||||
|
event_object.get("status"),
|
||||||
|
)
|
||||||
|
event["object"] = event_object
|
||||||
|
|
||||||
payment_module = import_module("app.services.payment_service")
|
payment_module = import_module("app.services.payment_service")
|
||||||
|
|
||||||
payment = await payment_module.get_yookassa_payment_by_id(db, yookassa_payment_id)
|
payment = await payment_module.get_yookassa_payment_by_id(db, yookassa_payment_id)
|
||||||
|
|||||||
@@ -383,27 +383,8 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
|
|||||||
body = body_bytes.decode("utf-8")
|
body = body_bytes.decode("utf-8")
|
||||||
|
|
||||||
signature = request.headers.get("Signature") or request.headers.get("X-YooKassa-Signature")
|
signature = request.headers.get("Signature") or request.headers.get("X-YooKassa-Signature")
|
||||||
|
if signature:
|
||||||
if settings.YOOKASSA_WEBHOOK_SECRET:
|
logger.info("ℹ️ Получена подпись YooKassa: %s", signature)
|
||||||
if not signature:
|
|
||||||
logger.warning("⚠️ YooKassa webhook без подписи при настроенном секрете")
|
|
||||||
return JSONResponse(
|
|
||||||
{"status": "error", "reason": "missing_signature"},
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not yookassa_webhook_module.YooKassaWebhookHandler.verify_webhook_signature(
|
|
||||||
body,
|
|
||||||
signature,
|
|
||||||
settings.YOOKASSA_WEBHOOK_SECRET,
|
|
||||||
):
|
|
||||||
logger.warning("❌ Неверная подпись YooKassa webhook")
|
|
||||||
return JSONResponse(
|
|
||||||
{"status": "error", "reason": "invalid_signature"},
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
)
|
|
||||||
elif signature:
|
|
||||||
logger.info("ℹ️ Получена подпись YooKassa, но проверка отключена (YOOKASSA_WEBHOOK_SECRET не настроен)")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
webhook_data = json.loads(body)
|
webhook_data = json.loads(body)
|
||||||
@@ -420,7 +401,11 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
|
|||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
|
|
||||||
if event_type not in {"payment.succeeded", "payment.waiting_for_capture"}:
|
if event_type not in {
|
||||||
|
"payment.succeeded",
|
||||||
|
"payment.waiting_for_capture",
|
||||||
|
"payment.canceled",
|
||||||
|
}:
|
||||||
return JSONResponse({"status": "ok", "ignored": event_type})
|
return JSONResponse({"status": "ok", "ignored": event_type})
|
||||||
|
|
||||||
success = await _process_payment_service_callback(
|
success = await _process_payment_service_callback(
|
||||||
|
|||||||
+48
-55
@@ -1,6 +1,3 @@
|
|||||||
import base64
|
|
||||||
import hashlib
|
|
||||||
import hmac
|
|
||||||
import json
|
import json
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock
|
from unittest.mock import AsyncMock
|
||||||
@@ -22,22 +19,11 @@ ALLOWED_IP = "185.71.76.10"
|
|||||||
class DummyDB:
|
class DummyDB:
|
||||||
async def close(self) -> None: # pragma: no cover - simple stub
|
async def close(self) -> None: # pragma: no cover - simple stub
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _generate_signature(body: str, secret: str) -> str:
|
|
||||||
payment_id = "test-payment"
|
|
||||||
timestamp = "2024-01-01T00:00:00.000Z"
|
|
||||||
payload = f"{payment_id}.{timestamp}.{body}".encode("utf-8")
|
|
||||||
digest = hmac.new(secret.encode("utf-8"), payload, hashlib.sha256).digest()
|
|
||||||
return f"v1 {payment_id} {timestamp} {base64.b64encode(digest).decode('utf-8')}"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def configure_settings(monkeypatch: pytest.MonkeyPatch) -> None:
|
def configure_settings(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
monkeypatch.setattr(settings, "YOOKASSA_ENABLED", True, raising=False)
|
monkeypatch.setattr(settings, "YOOKASSA_ENABLED", True, raising=False)
|
||||||
monkeypatch.setattr(settings, "YOOKASSA_SHOP_ID", "shop", raising=False)
|
monkeypatch.setattr(settings, "YOOKASSA_SHOP_ID", "shop", raising=False)
|
||||||
monkeypatch.setattr(settings, "YOOKASSA_SECRET_KEY", "key", raising=False)
|
monkeypatch.setattr(settings, "YOOKASSA_SECRET_KEY", "key", raising=False)
|
||||||
monkeypatch.setattr(settings, "YOOKASSA_WEBHOOK_SECRET", "secret", raising=False)
|
|
||||||
monkeypatch.setattr(settings, "YOOKASSA_WEBHOOK_PATH", "/yookassa-webhook", raising=False)
|
monkeypatch.setattr(settings, "YOOKASSA_WEBHOOK_PATH", "/yookassa-webhook", raising=False)
|
||||||
monkeypatch.setattr(settings, "YOOKASSA_TRUSTED_PROXY_NETWORKS", "", raising=False)
|
monkeypatch.setattr(settings, "YOOKASSA_TRUSTED_PROXY_NETWORKS", "", raising=False)
|
||||||
|
|
||||||
@@ -129,45 +115,7 @@ def _patch_get_db(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_handle_webhook_missing_signature(monkeypatch: pytest.MonkeyPatch) -> None:
|
async def test_handle_webhook_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
_patch_get_db(monkeypatch)
|
|
||||||
|
|
||||||
service = SimpleNamespace(process_yookassa_webhook=AsyncMock())
|
|
||||||
|
|
||||||
app = create_yookassa_webhook_app(service)
|
|
||||||
async with TestClient(TestServer(app)) as client:
|
|
||||||
response = await _post_webhook(client, {"event": "payment.succeeded"})
|
|
||||||
status = response.status
|
|
||||||
body = await response.text()
|
|
||||||
|
|
||||||
assert status == 401
|
|
||||||
assert body == "Missing signature"
|
|
||||||
service.process_yookassa_webhook.assert_not_awaited()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_handle_webhook_invalid_signature(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
_patch_get_db(monkeypatch)
|
|
||||||
|
|
||||||
service = SimpleNamespace(process_yookassa_webhook=AsyncMock())
|
|
||||||
|
|
||||||
app = create_yookassa_webhook_app(service)
|
|
||||||
async with TestClient(TestServer(app)) as client:
|
|
||||||
response = await _post_webhook(
|
|
||||||
client,
|
|
||||||
{"event": "payment.succeeded"},
|
|
||||||
Signature="v1 test-payment 2024-01-01T00:00:00.000Z invalid",
|
|
||||||
)
|
|
||||||
status = response.status
|
|
||||||
body = await response.text()
|
|
||||||
|
|
||||||
assert status == 401
|
|
||||||
assert body == "Invalid signature"
|
|
||||||
service.process_yookassa_webhook.assert_not_awaited()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_handle_webhook_valid_signature(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
_patch_get_db(monkeypatch)
|
_patch_get_db(monkeypatch)
|
||||||
|
|
||||||
process_mock = AsyncMock(return_value=True)
|
process_mock = AsyncMock(return_value=True)
|
||||||
@@ -177,11 +125,10 @@ async def test_handle_webhook_valid_signature(monkeypatch: pytest.MonkeyPatch) -
|
|||||||
async with TestClient(TestServer(app)) as client:
|
async with TestClient(TestServer(app)) as client:
|
||||||
payload = {"event": "payment.succeeded"}
|
payload = {"event": "payment.succeeded"}
|
||||||
body = json.dumps(payload, ensure_ascii=False)
|
body = json.dumps(payload, ensure_ascii=False)
|
||||||
signature = _generate_signature(body, settings.YOOKASSA_WEBHOOK_SECRET)
|
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
settings.YOOKASSA_WEBHOOK_PATH,
|
settings.YOOKASSA_WEBHOOK_PATH,
|
||||||
data=body.encode("utf-8"),
|
data=body.encode("utf-8"),
|
||||||
headers=_build_headers(Signature=signature),
|
headers=_build_headers(),
|
||||||
)
|
)
|
||||||
status = response.status
|
status = response.status
|
||||||
text = await response.text()
|
text = await response.text()
|
||||||
@@ -189,3 +136,49 @@ async def test_handle_webhook_valid_signature(monkeypatch: pytest.MonkeyPatch) -
|
|||||||
assert status == 200
|
assert status == 200
|
||||||
assert text == "OK"
|
assert text == "OK"
|
||||||
process_mock.assert_awaited_once()
|
process_mock.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_handle_webhook_with_optional_signature(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
_patch_get_db(monkeypatch)
|
||||||
|
|
||||||
|
process_mock = AsyncMock(return_value=True)
|
||||||
|
service = SimpleNamespace(process_yookassa_webhook=process_mock)
|
||||||
|
|
||||||
|
app = create_yookassa_webhook_app(service)
|
||||||
|
async with TestClient(TestServer(app)) as client:
|
||||||
|
payload = {"event": "payment.succeeded"}
|
||||||
|
body = json.dumps(payload, ensure_ascii=False)
|
||||||
|
response = await client.post(
|
||||||
|
settings.YOOKASSA_WEBHOOK_PATH,
|
||||||
|
data=body.encode("utf-8"),
|
||||||
|
headers=_build_headers(Signature="test-signature"),
|
||||||
|
)
|
||||||
|
status = response.status
|
||||||
|
text = await response.text()
|
||||||
|
|
||||||
|
assert status == 200
|
||||||
|
assert text == "OK"
|
||||||
|
process_mock.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_handle_webhook_accepts_canceled_event(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
_patch_get_db(monkeypatch)
|
||||||
|
|
||||||
|
process_mock = AsyncMock(return_value=True)
|
||||||
|
service = SimpleNamespace(process_yookassa_webhook=process_mock)
|
||||||
|
|
||||||
|
app = create_yookassa_webhook_app(service)
|
||||||
|
async with TestClient(TestServer(app)) as client:
|
||||||
|
payload = {"event": "payment.canceled", "object": {"id": "yk_1"}}
|
||||||
|
response = await client.post(
|
||||||
|
settings.YOOKASSA_WEBHOOK_PATH,
|
||||||
|
data=json.dumps(payload).encode("utf-8"),
|
||||||
|
headers=_build_headers(),
|
||||||
|
)
|
||||||
|
|
||||||
|
status = response.status
|
||||||
|
|
||||||
|
assert status == 200
|
||||||
|
process_mock.assert_awaited_once()
|
||||||
|
|||||||
@@ -584,6 +584,171 @@ async def test_process_yookassa_webhook_success(monkeypatch: pytest.MonkeyPatch)
|
|||||||
assert admin_calls
|
assert admin_calls
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio("asyncio")
|
||||||
|
async def test_process_yookassa_webhook_uses_remote_status(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
bot = DummyBot()
|
||||||
|
service = _make_service(bot)
|
||||||
|
fake_session = FakeSession()
|
||||||
|
payment = SimpleNamespace(
|
||||||
|
yookassa_payment_id="yk_789",
|
||||||
|
user_id=42,
|
||||||
|
amount_kopeks=20000,
|
||||||
|
transaction_id=None,
|
||||||
|
status="pending",
|
||||||
|
is_paid=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def fake_get_payment(db, payment_id):
|
||||||
|
return payment
|
||||||
|
|
||||||
|
async def fake_update(db, payment_id, status, is_paid, is_captured, captured_at, payment_method_type):
|
||||||
|
payment.status = status
|
||||||
|
payment.is_paid = is_paid
|
||||||
|
payment.captured_at = captured_at
|
||||||
|
payment.payment_method_type = payment_method_type
|
||||||
|
return payment
|
||||||
|
|
||||||
|
async def fake_link(db, payment_id, transaction_id):
|
||||||
|
payment.transaction_id = transaction_id
|
||||||
|
|
||||||
|
yk_module = ModuleType("app.database.crud.yookassa")
|
||||||
|
yk_module.get_yookassa_payment_by_id = fake_get_payment
|
||||||
|
yk_module.update_yookassa_payment_status = fake_update
|
||||||
|
yk_module.link_yookassa_payment_to_transaction = fake_link
|
||||||
|
monkeypatch.setitem(sys.modules, "app.database.crud.yookassa", yk_module)
|
||||||
|
|
||||||
|
transactions: list[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
async def fake_create_transaction(db, **kwargs):
|
||||||
|
transactions.append(kwargs)
|
||||||
|
return SimpleNamespace(id=555, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(payment_service_module, "create_transaction", fake_create_transaction)
|
||||||
|
|
||||||
|
user = SimpleNamespace(
|
||||||
|
id=42,
|
||||||
|
telegram_id=4200,
|
||||||
|
balance_kopeks=0,
|
||||||
|
has_made_first_topup=False,
|
||||||
|
promo_group=None,
|
||||||
|
subscription=None,
|
||||||
|
referred_by_id=None,
|
||||||
|
referrer=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def fake_get_user(db, user_id):
|
||||||
|
return user
|
||||||
|
|
||||||
|
monkeypatch.setattr(payment_service_module, "get_user_by_id", fake_get_user)
|
||||||
|
monkeypatch.setattr(type(settings), "format_price", lambda self, amount: f"{amount / 100:.2f}₽", raising=False)
|
||||||
|
|
||||||
|
referral_mock = SimpleNamespace(process_referral_topup=AsyncMock())
|
||||||
|
monkeypatch.setitem(sys.modules, "app.services.referral_service", referral_mock)
|
||||||
|
|
||||||
|
admin_calls: list[Any] = []
|
||||||
|
|
||||||
|
class DummyAdminService:
|
||||||
|
def __init__(self, bot):
|
||||||
|
self.bot = bot
|
||||||
|
|
||||||
|
async def send_balance_topup_notification(self, *args, **kwargs):
|
||||||
|
admin_calls.append((args, kwargs))
|
||||||
|
|
||||||
|
monkeypatch.setitem(
|
||||||
|
sys.modules,
|
||||||
|
"app.services.admin_notification_service",
|
||||||
|
SimpleNamespace(AdminNotificationService=lambda bot: DummyAdminService(bot)),
|
||||||
|
)
|
||||||
|
|
||||||
|
service.build_topup_success_keyboard = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
remote_payload = {
|
||||||
|
"id": "yk_789",
|
||||||
|
"status": "succeeded",
|
||||||
|
"paid": True,
|
||||||
|
"amount_value": 200.0,
|
||||||
|
"amount_currency": "rub",
|
||||||
|
"payment_method_type": "bank_card",
|
||||||
|
"refundable": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
get_info_mock = AsyncMock(return_value=remote_payload)
|
||||||
|
service.yookassa_service = SimpleNamespace(get_payment_info=get_info_mock)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"object": {
|
||||||
|
"id": "yk_789",
|
||||||
|
"status": "pending",
|
||||||
|
"paid": False,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await service.process_yookassa_webhook(fake_session, payload)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
assert payment.status == "succeeded"
|
||||||
|
assert payment.is_paid is True
|
||||||
|
assert transactions and transactions[0]["amount_kopeks"] == 20000
|
||||||
|
assert payment.transaction_id == 555
|
||||||
|
get_info_mock.assert_awaited_once_with("yk_789")
|
||||||
|
assert admin_calls
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio("asyncio")
|
||||||
|
async def test_process_yookassa_webhook_handles_cancellation(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
bot = DummyBot()
|
||||||
|
service = _make_service(bot)
|
||||||
|
fake_session = FakeSession()
|
||||||
|
payment = SimpleNamespace(
|
||||||
|
yookassa_payment_id="yk_cancel",
|
||||||
|
user_id=77,
|
||||||
|
amount_kopeks=5000,
|
||||||
|
transaction_id=None,
|
||||||
|
status="pending",
|
||||||
|
is_paid=False,
|
||||||
|
captured_at=None,
|
||||||
|
payment_method_type=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def fake_get_payment(db, payment_id):
|
||||||
|
return payment
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
payment_service_module,
|
||||||
|
"get_yookassa_payment_by_id",
|
||||||
|
fake_get_payment,
|
||||||
|
)
|
||||||
|
|
||||||
|
get_info_mock = AsyncMock(
|
||||||
|
return_value={
|
||||||
|
"id": "yk_cancel",
|
||||||
|
"status": "canceled",
|
||||||
|
"paid": False,
|
||||||
|
"amount_value": 50.0,
|
||||||
|
"amount_currency": "RUB",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
service.yookassa_service = SimpleNamespace(get_payment_info=get_info_mock)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"object": {
|
||||||
|
"id": "yk_cancel",
|
||||||
|
"status": "pending",
|
||||||
|
"paid": False,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await service.process_yookassa_webhook(fake_session, payload)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
assert payment.status == "canceled"
|
||||||
|
assert payment.is_paid is False
|
||||||
|
assert fake_session.commits == 1
|
||||||
|
assert fake_session.refreshed and fake_session.refreshed[0] is payment
|
||||||
|
assert bot.sent_messages == []
|
||||||
|
get_info_mock.assert_awaited_once_with("yk_cancel")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio("asyncio")
|
@pytest.mark.anyio("asyncio")
|
||||||
async def test_process_yookassa_webhook_restores_missing_payment(
|
async def test_process_yookassa_webhook_restores_missing_payment(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import base64
|
import base64
|
||||||
import hashlib
|
|
||||||
import hmac
|
|
||||||
import json
|
import json
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock
|
from unittest.mock import AsyncMock
|
||||||
@@ -14,16 +12,6 @@ from app.webserver.payments import create_payment_router
|
|||||||
|
|
||||||
class DummyBot:
|
class DummyBot:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _generate_yookassa_signature(body: str, secret: str) -> str:
|
|
||||||
payment_id = "test-payment"
|
|
||||||
timestamp = "2024-01-01T00:00:00.000Z"
|
|
||||||
payload = f"{payment_id}.{timestamp}.{body}".encode("utf-8")
|
|
||||||
digest = hmac.new(secret.encode("utf-8"), payload, hashlib.sha256).digest()
|
|
||||||
return f"v1 {payment_id} {timestamp} {base64.b64encode(digest).decode('utf-8')}"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def reset_settings(monkeypatch: pytest.MonkeyPatch) -> None:
|
def reset_settings(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
monkeypatch.setattr(settings, "TRIBUTE_ENABLED", False, raising=False)
|
monkeypatch.setattr(settings, "TRIBUTE_ENABLED", False, raising=False)
|
||||||
@@ -36,7 +24,6 @@ def reset_settings(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||||||
monkeypatch.setattr(settings, "CRYPTOBOT_WEBHOOK_SECRET", None, raising=False)
|
monkeypatch.setattr(settings, "CRYPTOBOT_WEBHOOK_SECRET", None, raising=False)
|
||||||
monkeypatch.setattr(settings, "YOOKASSA_ENABLED", False, raising=False)
|
monkeypatch.setattr(settings, "YOOKASSA_ENABLED", False, raising=False)
|
||||||
monkeypatch.setattr(settings, "YOOKASSA_WEBHOOK_PATH", "/yookassa", raising=False)
|
monkeypatch.setattr(settings, "YOOKASSA_WEBHOOK_PATH", "/yookassa", raising=False)
|
||||||
monkeypatch.setattr(settings, "YOOKASSA_WEBHOOK_SECRET", None, raising=False)
|
|
||||||
monkeypatch.setattr(settings, "YOOKASSA_SHOP_ID", "shop", raising=False)
|
monkeypatch.setattr(settings, "YOOKASSA_SHOP_ID", "shop", raising=False)
|
||||||
monkeypatch.setattr(settings, "YOOKASSA_SECRET_KEY", "key", raising=False)
|
monkeypatch.setattr(settings, "YOOKASSA_SECRET_KEY", "key", raising=False)
|
||||||
monkeypatch.setattr(settings, "YOOKASSA_TRUSTED_PROXY_NETWORKS", "", raising=False)
|
monkeypatch.setattr(settings, "YOOKASSA_TRUSTED_PROXY_NETWORKS", "", raising=False)
|
||||||
@@ -341,59 +328,8 @@ async def test_yookassa_allowed_via_trusted_public_proxy(monkeypatch: pytest.Mon
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_yookassa_missing_signature_when_secret_configured(monkeypatch: pytest.MonkeyPatch) -> None:
|
async def test_yookassa_webhook_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
monkeypatch.setattr(settings, "YOOKASSA_ENABLED", True, raising=False)
|
monkeypatch.setattr(settings, "YOOKASSA_ENABLED", True, raising=False)
|
||||||
monkeypatch.setattr(settings, "YOOKASSA_WEBHOOK_SECRET", "secret", raising=False)
|
|
||||||
|
|
||||||
service = SimpleNamespace(process_yookassa_webhook=AsyncMock())
|
|
||||||
|
|
||||||
router = create_payment_router(DummyBot(), service)
|
|
||||||
assert router is not None
|
|
||||||
|
|
||||||
route = _get_route(router, settings.YOOKASSA_WEBHOOK_PATH)
|
|
||||||
request = _build_request(
|
|
||||||
settings.YOOKASSA_WEBHOOK_PATH,
|
|
||||||
body=json.dumps({"event": "payment.succeeded"}).encode("utf-8"),
|
|
||||||
headers={},
|
|
||||||
)
|
|
||||||
|
|
||||||
response = await route.endpoint(request)
|
|
||||||
|
|
||||||
assert response.status_code == 401
|
|
||||||
payload = json.loads(response.body.decode("utf-8"))
|
|
||||||
assert payload["reason"] == "missing_signature"
|
|
||||||
service.process_yookassa_webhook.assert_not_awaited()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_yookassa_invalid_signature(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
monkeypatch.setattr(settings, "YOOKASSA_ENABLED", True, raising=False)
|
|
||||||
monkeypatch.setattr(settings, "YOOKASSA_WEBHOOK_SECRET", "secret", raising=False)
|
|
||||||
|
|
||||||
service = SimpleNamespace(process_yookassa_webhook=AsyncMock())
|
|
||||||
|
|
||||||
router = create_payment_router(DummyBot(), service)
|
|
||||||
assert router is not None
|
|
||||||
|
|
||||||
route = _get_route(router, settings.YOOKASSA_WEBHOOK_PATH)
|
|
||||||
request = _build_request(
|
|
||||||
settings.YOOKASSA_WEBHOOK_PATH,
|
|
||||||
body=json.dumps({"event": "payment.succeeded"}).encode("utf-8"),
|
|
||||||
headers={"Signature": "v1 test invalid"},
|
|
||||||
)
|
|
||||||
|
|
||||||
response = await route.endpoint(request)
|
|
||||||
|
|
||||||
assert response.status_code == 401
|
|
||||||
payload = json.loads(response.body.decode("utf-8"))
|
|
||||||
assert payload["reason"] == "invalid_signature"
|
|
||||||
service.process_yookassa_webhook.assert_not_awaited()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_yookassa_valid_signature(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
monkeypatch.setattr(settings, "YOOKASSA_ENABLED", True, raising=False)
|
|
||||||
monkeypatch.setattr(settings, "YOOKASSA_WEBHOOK_SECRET", "secret", raising=False)
|
|
||||||
|
|
||||||
async def fake_get_db():
|
async def fake_get_db():
|
||||||
yield SimpleNamespace()
|
yield SimpleNamespace()
|
||||||
@@ -409,11 +345,74 @@ async def test_yookassa_valid_signature(monkeypatch: pytest.MonkeyPatch) -> None
|
|||||||
route = _get_route(router, settings.YOOKASSA_WEBHOOK_PATH)
|
route = _get_route(router, settings.YOOKASSA_WEBHOOK_PATH)
|
||||||
payload = {"event": "payment.succeeded"}
|
payload = {"event": "payment.succeeded"}
|
||||||
body = json.dumps(payload).encode("utf-8")
|
body = json.dumps(payload).encode("utf-8")
|
||||||
signature = _generate_yookassa_signature(body.decode("utf-8"), settings.YOOKASSA_WEBHOOK_SECRET)
|
|
||||||
request = _build_request(
|
request = _build_request(
|
||||||
settings.YOOKASSA_WEBHOOK_PATH,
|
settings.YOOKASSA_WEBHOOK_PATH,
|
||||||
body=body,
|
body=body,
|
||||||
headers={"Signature": signature},
|
headers={},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await route.endpoint(request)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = json.loads(response.body.decode("utf-8"))
|
||||||
|
assert payload["status"] == "ok"
|
||||||
|
process_mock.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_yookassa_webhook_cancellation(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(settings, "YOOKASSA_ENABLED", True, raising=False)
|
||||||
|
|
||||||
|
async def fake_get_db():
|
||||||
|
yield SimpleNamespace()
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.webserver.payments.get_db", fake_get_db)
|
||||||
|
|
||||||
|
process_mock = AsyncMock(return_value=True)
|
||||||
|
service = SimpleNamespace(process_yookassa_webhook=process_mock)
|
||||||
|
|
||||||
|
router = create_payment_router(DummyBot(), service)
|
||||||
|
assert router is not None
|
||||||
|
|
||||||
|
route = _get_route(router, settings.YOOKASSA_WEBHOOK_PATH)
|
||||||
|
payload = {"event": "payment.canceled"}
|
||||||
|
body = json.dumps(payload).encode("utf-8")
|
||||||
|
request = _build_request(
|
||||||
|
settings.YOOKASSA_WEBHOOK_PATH,
|
||||||
|
body=body,
|
||||||
|
headers={},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await route.endpoint(request)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = json.loads(response.body.decode("utf-8"))
|
||||||
|
assert payload["status"] == "ok"
|
||||||
|
process_mock.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_yookassa_webhook_with_signature(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(settings, "YOOKASSA_ENABLED", True, raising=False)
|
||||||
|
|
||||||
|
async def fake_get_db():
|
||||||
|
yield SimpleNamespace()
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.webserver.payments.get_db", fake_get_db)
|
||||||
|
|
||||||
|
process_mock = AsyncMock(return_value=True)
|
||||||
|
service = SimpleNamespace(process_yookassa_webhook=process_mock)
|
||||||
|
|
||||||
|
router = create_payment_router(DummyBot(), service)
|
||||||
|
assert router is not None
|
||||||
|
|
||||||
|
route = _get_route(router, settings.YOOKASSA_WEBHOOK_PATH)
|
||||||
|
payload = {"event": "payment.succeeded"}
|
||||||
|
body = json.dumps(payload).encode("utf-8")
|
||||||
|
request = _build_request(
|
||||||
|
settings.YOOKASSA_WEBHOOK_PATH,
|
||||||
|
body=body,
|
||||||
|
headers={"Signature": "dummy"},
|
||||||
)
|
)
|
||||||
|
|
||||||
response = await route.endpoint(request)
|
response = await route.endpoint(request)
|
||||||
|
|||||||
Reference in New Issue
Block a user