from __future__ import annotations import base64 import hashlib import hmac import json from collections.abc import Iterable import structlog from aiogram import Bot from fastapi import APIRouter, Request, Response, status from fastapi.responses import JSONResponse from app.config import settings from app.database.database import get_db from app.external import yookassa_webhook as yookassa_webhook_module from app.external.heleket_webhook import HeleketWebhookHandler from app.external.pal24_client import Pal24APIError from app.external.tribute import TributeService as TributeAPI from app.external.wata_webhook import WataWebhookHandler from app.services.pal24_service import Pal24Service from app.services.payment_service import PaymentService from app.services.tribute_service import TributeService logger = structlog.get_logger(__name__) def _create_cors_response() -> Response: return Response( status_code=status.HTTP_200_OK, headers={ 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'POST, GET, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, trbt-signature, Crypto-Pay-API-Signature, X-MulenPay-Signature, Authorization', }, ) def _extract_header(request: Request, header_names: Iterable[str]) -> str | None: for header_name in header_names: value = request.headers.get(header_name) if value: return value.strip() return None def _verify_mulenpay_signature(request: Request, raw_body: bytes) -> bool: secret_key = settings.MULENPAY_SECRET_KEY display_name = settings.get_mulenpay_display_name() if not secret_key: logger.warning('secret key is not configured', display_name=display_name) return False signature = _extract_header( request, ( 'X-MulenPay-Signature', 'X-Mulenpay-Signature', 'X-MULENPAY-SIGNATURE', 'X-MulenPay-Webhook-Signature', 'X-Mulenpay-Webhook-Signature', 'X-MULENPAY-WEBHOOK-SIGNATURE', 'X-Signature', 'Signature', 'X-MulenPay-Sign', 'X-Mulenpay-Sign', 'X-MULENPAY-SIGN', 'MulenPay-Signature', 'Mulenpay-Signature', 'MULENPAY-SIGNATURE', 'signature', 'sign', ), ) if signature: normalized_signature = signature if normalized_signature.lower().startswith('sha256='): normalized_signature = normalized_signature.split('=', 1)[1].strip() hmac_digest = hmac.new(secret_key.encode('utf-8'), raw_body, hashlib.sha256).digest() expected_hex = hmac_digest.hex() expected_base64 = base64.b64encode(hmac_digest).decode('utf-8').strip() expected_urlsafe = base64.urlsafe_b64encode(hmac_digest).decode('utf-8').strip() normalized_lower = normalized_signature.lower() if hmac.compare_digest(normalized_lower, expected_hex.lower()): return True normalized_no_padding = normalized_signature.rstrip('=') if hmac.compare_digest(normalized_no_padding, expected_base64.rstrip('=')): return True if hmac.compare_digest(normalized_no_padding, expected_urlsafe.rstrip('=')): return True logger.warning('Неверная подпись webhook', display_name=display_name) return False authorization_header = request.headers.get('Authorization') if authorization_header: scheme, _, value = authorization_header.partition(' ') scheme_lower = scheme.lower() token = value.strip() if value else scheme.strip() if scheme_lower in {'bearer', 'token'}: if hmac.compare_digest(token, secret_key): return True logger.warning('Неверный токен webhook', scheme=scheme, display_name=display_name) return False if not value and hmac.compare_digest(token, secret_key): return True fallback_token = _extract_header( request, ( 'X-MulenPay-Token', 'X-Mulenpay-Token', 'X-Webhook-Token', ), ) if fallback_token and hmac.compare_digest(fallback_token, secret_key): return True logger.warning('Отсутствует подпись webhook', display_name=display_name) return False async def _process_payment_service_callback( payment_service: PaymentService, payload: dict, method_name: str, ) -> bool: db_generator = get_db() try: db = await db_generator.__anext__() except StopAsyncIteration: # pragma: no cover - defensive guard return False try: process_callback = getattr(payment_service, method_name) return await process_callback(db, payload) finally: try: await db_generator.__anext__() except StopAsyncIteration: pass async def _parse_pal24_payload(request: Request) -> dict[str, str]: try: if request.headers.get('content-type', '').startswith('application/json'): data = await request.json() if isinstance(data, dict): return {str(k): str(v) for k, v in data.items()} except json.JSONDecodeError: logger.debug('Pal24 webhook JSON payload не удалось распарсить') form = await request.form() if form: return {str(k): str(v) for k, v in form.multi_items()} raw_body = (await request.body()).decode('utf-8') if raw_body: try: data = json.loads(raw_body) if isinstance(data, dict): return {str(k): str(v) for k, v in data.items()} except json.JSONDecodeError: logger.debug('Pal24 webhook body не удалось распарсить как JSON', raw_body=raw_body) return {} def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRouter | None: router = APIRouter() routes_registered = False if settings.TRIBUTE_ENABLED: tribute_service = TributeService(bot) tribute_api = TributeAPI() @router.options(settings.TRIBUTE_WEBHOOK_PATH) async def tribute_options() -> Response: return _create_cors_response() @router.post(settings.TRIBUTE_WEBHOOK_PATH) async def tribute_webhook(request: Request) -> JSONResponse: raw_body = await request.body() if not raw_body: return JSONResponse( {'status': 'error', 'reason': 'empty_body'}, status_code=status.HTTP_400_BAD_REQUEST ) payload = raw_body.decode('utf-8') signature = request.headers.get('trbt-signature') if not signature: return JSONResponse( {'status': 'error', 'reason': 'missing_signature'}, status_code=status.HTTP_401_UNAUTHORIZED, ) if not settings.TRIBUTE_API_KEY: logger.error('Tribute webhook received but API key is not configured, rejecting') return JSONResponse( {'status': 'error', 'reason': 'service_not_configured'}, status_code=status.HTTP_503_SERVICE_UNAVAILABLE, ) if not tribute_api.verify_webhook_signature(payload, signature): return JSONResponse( {'status': 'error', 'reason': 'invalid_signature'}, status_code=status.HTTP_401_UNAUTHORIZED, ) try: json.loads(payload) except json.JSONDecodeError: return JSONResponse( {'status': 'error', 'reason': 'invalid_json'}, status_code=status.HTTP_400_BAD_REQUEST, ) try: result = await tribute_service.process_webhook(payload) if result: return JSONResponse({'status': 'ok', 'result': result}) error = ValueError('Tribute webhook processing returned empty result') logger.error('Tribute webhook processing failed', error=error) return JSONResponse( {'status': 'error', 'reason': 'processing_failed'}, status_code=status.HTTP_400_BAD_REQUEST, ) except Exception as e: logger.exception('Tribute webhook processing error', e=e) return JSONResponse( {'status': 'error', 'reason': 'processing_failed'}, status_code=status.HTTP_400_BAD_REQUEST, ) routes_registered = True if settings.is_mulenpay_enabled(): @router.options(settings.MULENPAY_WEBHOOK_PATH) async def mulenpay_options() -> Response: return _create_cors_response() @router.post(settings.MULENPAY_WEBHOOK_PATH) async def mulenpay_webhook(request: Request) -> JSONResponse: raw_body = await request.body() if not raw_body: return JSONResponse( {'status': 'error', 'reason': 'empty_body'}, status_code=status.HTTP_400_BAD_REQUEST ) if not _verify_mulenpay_signature(request, raw_body): return JSONResponse( {'status': 'error', 'reason': 'invalid_signature'}, status_code=status.HTTP_401_UNAUTHORIZED, ) try: payload = json.loads(raw_body.decode('utf-8')) except json.JSONDecodeError: return JSONResponse( {'status': 'error', 'reason': 'invalid_json'}, status_code=status.HTTP_400_BAD_REQUEST, ) try: success = await _process_payment_service_callback( payment_service, payload, 'process_mulenpay_callback', ) if success: return JSONResponse({'status': 'ok'}) logger.error('MulenPay webhook processing failed', payload=payload) return JSONResponse( {'status': 'error', 'reason': 'processing_failed'}, status_code=status.HTTP_400_BAD_REQUEST, ) except Exception as e: logger.exception('MulenPay webhook processing error', e=e) return JSONResponse( {'status': 'error', 'reason': 'processing_failed'}, status_code=status.HTTP_400_BAD_REQUEST, ) routes_registered = True if settings.is_cryptobot_enabled(): @router.options(settings.CRYPTOBOT_WEBHOOK_PATH) async def cryptobot_options() -> Response: return _create_cors_response() @router.post(settings.CRYPTOBOT_WEBHOOK_PATH) async def cryptobot_webhook(request: Request) -> JSONResponse: raw_body = await request.body() if not raw_body: return JSONResponse( {'status': 'error', 'reason': 'empty_body'}, status_code=status.HTTP_400_BAD_REQUEST ) payload_text = raw_body.decode('utf-8') try: payload = json.loads(payload_text) except json.JSONDecodeError: return JSONResponse( {'status': 'error', 'reason': 'invalid_json'}, status_code=status.HTTP_400_BAD_REQUEST, ) signature = request.headers.get('Crypto-Pay-API-Signature') secret = settings.CRYPTOBOT_API_TOKEN if not secret: logger.error('CryptoBot webhook received but API token is not configured, rejecting') return JSONResponse( {'status': 'error', 'reason': 'service_not_configured'}, status_code=status.HTTP_503_SERVICE_UNAVAILABLE, ) if not signature: return JSONResponse( {'status': 'error', 'reason': 'missing_signature'}, status_code=status.HTTP_401_UNAUTHORIZED, ) from app.external.cryptobot import CryptoBotService if not CryptoBotService().verify_webhook_signature(payload_text, signature): return JSONResponse( {'status': 'error', 'reason': 'invalid_signature'}, status_code=status.HTTP_401_UNAUTHORIZED, ) try: success = await _process_payment_service_callback( payment_service, payload, 'process_cryptobot_webhook', ) if success: return JSONResponse({'status': 'ok'}) logger.error( 'CryptoBot webhook processing failed: invoice_id', payload=payload.get('payload', {}).get('invoice_id'), ) return JSONResponse( {'status': 'error', 'reason': 'processing_failed'}, status_code=status.HTTP_400_BAD_REQUEST, ) except Exception as e: logger.exception('CryptoBot webhook processing error', e=e) return JSONResponse( {'status': 'error', 'reason': 'processing_failed'}, status_code=status.HTTP_400_BAD_REQUEST, ) routes_registered = True if settings.is_yookassa_enabled(): @router.options(settings.YOOKASSA_WEBHOOK_PATH) async def yookassa_options() -> Response: return Response( status_code=status.HTTP_200_OK, headers={ 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'POST, GET, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, X-YooKassa-Signature, Signature', }, ) @router.get(settings.YOOKASSA_WEBHOOK_PATH) async def yookassa_health() -> JSONResponse: return JSONResponse( { 'status': 'ok', 'service': 'yookassa_webhook', 'enabled': settings.is_yookassa_enabled(), } ) @router.post(settings.YOOKASSA_WEBHOOK_PATH) async def yookassa_webhook(request: Request) -> JSONResponse: header_ip_candidates = yookassa_webhook_module.collect_yookassa_ip_candidates( request.headers.get('X-Forwarded-For'), request.headers.get('X-Real-IP'), request.headers.get('Cf-Connecting-Ip'), ) remote_ip = request.client.host if request.client else None client_ip = yookassa_webhook_module.resolve_yookassa_ip( header_ip_candidates, remote=remote_ip, ) if client_ip is None: return JSONResponse( {'status': 'error', 'reason': 'unknown_ip'}, status_code=status.HTTP_403_FORBIDDEN, ) if not yookassa_webhook_module.is_yookassa_ip_allowed(client_ip): return JSONResponse( {'status': 'error', 'reason': 'forbidden_ip'}, status_code=status.HTTP_403_FORBIDDEN, ) body_bytes = await request.body() if not body_bytes: return JSONResponse( {'status': 'error', 'reason': 'empty_body'}, status_code=status.HTTP_400_BAD_REQUEST ) body = body_bytes.decode('utf-8') signature = request.headers.get('Signature') or request.headers.get('X-YooKassa-Signature') if signature: logger.info('ℹ️ Получена подпись YooKassa', signature=signature) try: webhook_data = json.loads(body) except json.JSONDecodeError: return JSONResponse( {'status': 'error', 'reason': 'invalid_json'}, status_code=status.HTTP_400_BAD_REQUEST, ) event_type = webhook_data.get('event') if not event_type: return JSONResponse( {'status': 'error', 'reason': 'missing_event'}, status_code=status.HTTP_400_BAD_REQUEST, ) if event_type not in { 'payment.succeeded', 'payment.waiting_for_capture', 'payment.canceled', }: return JSONResponse({'status': 'ok', 'ignored': event_type}) try: success = await _process_payment_service_callback( payment_service, webhook_data, 'process_yookassa_webhook', ) if success: return JSONResponse({'status': 'ok'}) payment_id = webhook_data.get('object', {}).get('id', 'unknown') logger.error('YooKassa webhook processing failed: payment_id', payment_id=payment_id) return JSONResponse( {'status': 'error', 'reason': 'processing_failed'}, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, ) except Exception as e: logger.exception('YooKassa webhook processing error', e=e) return JSONResponse( {'status': 'error', 'reason': 'processing_failed'}, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, ) routes_registered = True if settings.is_wata_enabled(): wata_handler = WataWebhookHandler(payment_service) @router.options(settings.WATA_WEBHOOK_PATH) async def wata_options() -> Response: return Response( status_code=status.HTTP_200_OK, headers={ 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'POST, GET, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, X-Signature', }, ) @router.get(settings.WATA_WEBHOOK_PATH) async def wata_health() -> JSONResponse: return JSONResponse( { 'status': 'ok', 'service': 'wata_webhook', 'enabled': settings.is_wata_enabled(), } ) @router.post(settings.WATA_WEBHOOK_PATH) async def wata_webhook(request: Request) -> JSONResponse: raw_body = await request.body() if not raw_body: return JSONResponse( {'status': 'error', 'reason': 'empty_body'}, status_code=status.HTTP_400_BAD_REQUEST ) signature = request.headers.get('X-Signature') or '' if not await wata_handler._verify_signature(raw_body.decode('utf-8'), signature): # type: ignore[attr-defined] return JSONResponse( {'status': 'error', 'reason': 'invalid_signature'}, status_code=status.HTTP_401_UNAUTHORIZED, ) try: payload = json.loads(raw_body.decode('utf-8')) except json.JSONDecodeError: return JSONResponse( {'status': 'error', 'reason': 'invalid_json'}, status_code=status.HTTP_400_BAD_REQUEST, ) try: success = await _process_payment_service_callback( payment_service, payload, 'process_wata_webhook', ) if success: return JSONResponse({'status': 'ok'}) order_id = payload.get('orderId') or payload.get('order_id') or 'unknown' logger.error('Wata webhook processing failed: order_id payload', order_id=order_id, payload=payload) return JSONResponse( {'status': 'error', 'reason': 'not_processed'}, status_code=status.HTTP_400_BAD_REQUEST, ) except Exception as e: logger.exception('Wata webhook processing error', e=e) return JSONResponse( {'status': 'error', 'reason': 'not_processed'}, status_code=status.HTTP_400_BAD_REQUEST, ) routes_registered = True if settings.is_heleket_enabled(): heleket_handler = HeleketWebhookHandler(payment_service) @router.options(settings.HELEKET_WEBHOOK_PATH) async def heleket_options() -> Response: return Response( status_code=status.HTTP_200_OK, headers={ 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'POST, GET, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Authorization', }, ) @router.get(settings.HELEKET_WEBHOOK_PATH) async def heleket_health() -> JSONResponse: return JSONResponse( { 'status': 'ok', 'service': 'heleket_webhook', 'enabled': settings.is_heleket_enabled(), } ) @router.post(settings.HELEKET_WEBHOOK_PATH) async def heleket_webhook(request: Request) -> JSONResponse: try: payload = await request.json() except json.JSONDecodeError: return JSONResponse( {'status': 'error', 'reason': 'invalid_json'}, status_code=status.HTTP_400_BAD_REQUEST, ) if not heleket_handler.service.verify_webhook_signature(payload): return JSONResponse( {'status': 'error', 'reason': 'invalid_signature'}, status_code=status.HTTP_401_UNAUTHORIZED, ) try: success = await _process_payment_service_callback( payment_service, payload, 'process_heleket_webhook', ) if success: return JSONResponse({'status': 'ok'}) uuid_val = payload.get('uuid', 'unknown') logger.error('Heleket webhook processing failed: uuid', uuid_val=uuid_val) return JSONResponse( {'status': 'error', 'reason': 'not_processed'}, status_code=status.HTTP_400_BAD_REQUEST, ) except Exception as e: logger.exception('Heleket webhook processing error', e=e) return JSONResponse( {'status': 'error', 'reason': 'not_processed'}, status_code=status.HTTP_400_BAD_REQUEST, ) routes_registered = True if settings.is_pal24_enabled(): pal24_service = Pal24Service() @router.options(settings.PAL24_WEBHOOK_PATH) async def pal24_options() -> Response: return Response( status_code=status.HTTP_200_OK, headers={ 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'POST, GET, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type', }, ) @router.get(settings.PAL24_WEBHOOK_PATH) async def pal24_health() -> JSONResponse: return JSONResponse( { 'status': 'ok', 'service': 'pal24_webhook', 'enabled': settings.is_pal24_enabled(), } ) @router.post(settings.PAL24_WEBHOOK_PATH) async def pal24_webhook(request: Request) -> JSONResponse: if not pal24_service.is_configured: return JSONResponse( {'status': 'error', 'reason': 'service_not_configured'}, status_code=status.HTTP_503_SERVICE_UNAVAILABLE, ) payload = await _parse_pal24_payload(request) if not payload: return JSONResponse( {'status': 'error', 'reason': 'empty_payload'}, status_code=status.HTTP_400_BAD_REQUEST, ) try: parsed_payload = pal24_service.parse_callback(payload) except Pal24APIError as error: return JSONResponse( {'status': 'error', 'reason': str(error)}, status_code=status.HTTP_400_BAD_REQUEST, ) try: success = await _process_payment_service_callback( payment_service, parsed_payload, 'process_pal24_callback', ) if success: return JSONResponse({'status': 'ok'}) bill_id = parsed_payload.get('bill_id', 'unknown') logger.error('Pal24 webhook processing failed: bill_id', bill_id=bill_id) return JSONResponse( {'status': 'error', 'reason': 'not_processed'}, status_code=status.HTTP_400_BAD_REQUEST, ) except Exception as e: logger.exception('Pal24 webhook processing error', e=e) return JSONResponse( {'status': 'error', 'reason': 'not_processed'}, status_code=status.HTTP_400_BAD_REQUEST, ) routes_registered = True if settings.is_platega_enabled(): @router.get(settings.PLATEGA_WEBHOOK_PATH) async def platega_health() -> JSONResponse: return JSONResponse( { 'status': 'ok', 'service': 'platega_webhook', 'enabled': settings.is_platega_enabled(), } ) @router.post(settings.PLATEGA_WEBHOOK_PATH) async def platega_webhook(request: Request) -> JSONResponse: merchant_id = request.headers.get('X-MerchantId', '') secret = request.headers.get('X-Secret', '') raw_body = await request.body() if not merchant_id and not secret and not raw_body.strip(): logger.info('Platega webhook verification ping (no auth headers, empty body)') return JSONResponse({'status': 'ok'}) if not ( hmac.compare_digest(merchant_id, settings.PLATEGA_MERCHANT_ID or '') and hmac.compare_digest(secret, settings.PLATEGA_SECRET or '') ): return JSONResponse( {'status': 'error', 'reason': 'unauthorized'}, status_code=status.HTTP_401_UNAUTHORIZED, ) try: payload = await request.json() except json.JSONDecodeError: return JSONResponse( {'status': 'error', 'reason': 'invalid_json'}, status_code=status.HTTP_400_BAD_REQUEST, ) try: success = await _process_payment_service_callback( payment_service, payload, 'process_platega_webhook', ) if success: return JSONResponse({'status': 'ok'}) transaction_id = ( payload.get('id') or payload.get('transactionId') or payload.get('transaction_id') or 'unknown' ) logger.error('Platega webhook processing failed', transaction_id=transaction_id) return JSONResponse( {'status': 'error', 'reason': 'not_processed'}, status_code=status.HTTP_400_BAD_REQUEST, ) except Exception as e: logger.exception('Platega webhook processing error', e=e) return JSONResponse( {'status': 'error', 'reason': 'not_processed'}, status_code=status.HTTP_400_BAD_REQUEST, ) routes_registered = True if settings.is_cloudpayments_enabled(): from app.services.cloudpayments_service import CloudPaymentsService cloudpayments_service = CloudPaymentsService() @router.options(settings.CLOUDPAYMENTS_WEBHOOK_PATH) async def cloudpayments_options() -> Response: return Response( status_code=status.HTTP_200_OK, headers={ 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'POST, GET, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, X-Content-HMAC', }, ) @router.get(settings.CLOUDPAYMENTS_WEBHOOK_PATH) async def cloudpayments_health() -> JSONResponse: return JSONResponse( { 'status': 'ok', 'service': 'cloudpayments_webhook', 'enabled': settings.is_cloudpayments_enabled(), } ) # CloudPayments Check webhook (перед списанием) @router.post(settings.CLOUDPAYMENTS_WEBHOOK_PATH + '/check') async def cloudpayments_check_webhook(request: Request) -> JSONResponse: """Check webhook - вызывается перед списанием, можно отклонить платёж.""" try: raw_body = await request.body() # Логируем для диагностики logger.info( 'CloudPayments check webhook received, body_len all_headers', raw_body_count=len(raw_body), headers=dict(request.headers), ) # Проверяем подпись если API_SECRET настроен # CloudPayments использует заголовок X-Content-HMAC или Content-HMAC signature = request.headers.get('X-Content-HMAC') or request.headers.get('Content-HMAC') or '' if settings.CLOUDPAYMENTS_API_SECRET: if not signature: logger.warning('CloudPayments webhook: signature header missing, rejecting') return JSONResponse({'code': 13}) if not cloudpayments_service.verify_webhook_signature( raw_body, signature, settings.CLOUDPAYMENTS_API_SECRET ): logger.warning('CloudPayments webhook: invalid signature') return JSONResponse({'code': 13}) # Разрешаем платёж logger.info('CloudPayments check webhook: allowing payment, returning code=0') return JSONResponse({'code': 0}) except Exception as e: logger.exception('CloudPayments check webhook error', e=e) # В случае ошибки отклоняем платёж (fail-closed) return JSONResponse({'code': 13}) # CloudPayments Pay webhook (успешная оплата) @router.post(settings.CLOUDPAYMENTS_WEBHOOK_PATH + '/pay') async def cloudpayments_pay_webhook(request: Request) -> JSONResponse: """Pay webhook - вызывается после успешной оплаты.""" raw_body = await request.body() # Проверяем подпись если API_SECRET настроен signature = request.headers.get('X-Content-HMAC') or request.headers.get('Content-HMAC') or '' if settings.CLOUDPAYMENTS_API_SECRET: if not signature: logger.warning('CloudPayments webhook: signature header missing, rejecting') return JSONResponse({'code': 13}) if not cloudpayments_service.verify_webhook_signature( raw_body, signature, settings.CLOUDPAYMENTS_API_SECRET ): logger.warning('CloudPayments webhook: invalid signature') return JSONResponse({'code': 13}) # Парсим данные формы try: form_data = await request.form() webhook_data = cloudpayments_service.parse_webhook_data(dict(form_data)) except Exception as error: logger.error('CloudPayments pay webhook parse error', error=error) return JSONResponse({'code': 13}) # Обрабатываем платёж await _process_payment_service_callback( payment_service, webhook_data, 'process_cloudpayments_pay_webhook', ) return JSONResponse({'code': 0}) # CloudPayments Fail webhook (неуспешная оплата) @router.post(settings.CLOUDPAYMENTS_WEBHOOK_PATH + '/fail') async def cloudpayments_fail_webhook(request: Request) -> JSONResponse: """Fail webhook - вызывается при неуспешной оплате.""" raw_body = await request.body() # Проверяем подпись если API_SECRET настроен signature = request.headers.get('X-Content-HMAC') or request.headers.get('Content-HMAC') or '' if settings.CLOUDPAYMENTS_API_SECRET: if not signature: logger.warning('CloudPayments webhook: signature header missing, rejecting') return JSONResponse({'code': 13}) if not cloudpayments_service.verify_webhook_signature( raw_body, signature, settings.CLOUDPAYMENTS_API_SECRET ): logger.warning('CloudPayments webhook: invalid signature') return JSONResponse({'code': 13}) # Парсим данные формы try: form_data = await request.form() webhook_data = cloudpayments_service.parse_webhook_data(dict(form_data)) except Exception as error: logger.error('CloudPayments fail webhook parse error', error=error) return JSONResponse({'code': 13}) # Обрабатываем неуспешный платёж await _process_payment_service_callback( payment_service, webhook_data, 'process_cloudpayments_fail_webhook', ) return JSONResponse({'code': 0}) # Универсальный endpoint для всех webhooks @router.post(settings.CLOUDPAYMENTS_WEBHOOK_PATH) async def cloudpayments_webhook(request: Request) -> JSONResponse: """Универсальный webhook endpoint.""" try: raw_body = await request.body() # Логируем для диагностики logger.info( 'CloudPayments universal webhook received, body_len headers', raw_body_count=len(raw_body), headers=dict(request.headers), ) # Проверяем подпись если API_SECRET настроен signature = request.headers.get('X-Content-HMAC') or request.headers.get('Content-HMAC') or '' if settings.CLOUDPAYMENTS_API_SECRET: if not signature: logger.warning('CloudPayments webhook: signature header missing, rejecting') return JSONResponse({'code': 13}) if not cloudpayments_service.verify_webhook_signature( raw_body, signature, settings.CLOUDPAYMENTS_API_SECRET ): logger.warning('CloudPayments webhook: invalid signature') return JSONResponse({'code': 13}) # Парсим данные формы try: form_data = await request.form() webhook_data = cloudpayments_service.parse_webhook_data(dict(form_data)) logger.info('CloudPayments webhook parsed data', webhook_data=webhook_data) except Exception as error: logger.error('CloudPayments webhook parse error', error=error) # Может быть это Check уведомление - просто разрешаем return JSONResponse({'code': 0}) # Определяем тип webhook по статусу и наличию полей status_value = webhook_data.get('status', '') reason = webhook_data.get('reason') auth_code = webhook_data.get('auth_code') # Pay notification имеет Reason="Approved" или AuthCode # Check notification НЕ имеет этих полей is_pay_notification = bool(reason or auth_code) if status_value in ('Declined', 'Cancelled'): # Неуспешная оплата (Fail notification) await _process_payment_service_callback( payment_service, webhook_data, 'process_cloudpayments_fail_webhook', ) elif status_value in ('Completed', 'Authorized') and is_pay_notification: # Успешная оплата (Pay notification) - есть Reason или AuthCode logger.info( 'CloudPayments Pay notification: invoice reason auth_code', webhook_data=webhook_data.get('invoice_id'), reason=reason, auth_code=auth_code, ) await _process_payment_service_callback( payment_service, webhook_data, 'process_cloudpayments_pay_webhook', ) else: # Check notification или другой тип - просто разрешаем (code=0) # Check приходит ДО оплаты для валидации, не зачисляем баланс logger.info( 'CloudPayments Check/other notification: status reason auth_code= - allowing (code=0), NOT crediting balance', status_value=status_value, reason=reason, auth_code=auth_code, ) return JSONResponse({'code': 0}) except Exception as e: logger.exception('CloudPayments universal webhook error', e=e) return JSONResponse({'code': 13}) routes_registered = True if settings.is_freekassa_enabled(): @router.options(settings.FREEKASSA_WEBHOOK_PATH) async def freekassa_options() -> Response: return Response( status_code=status.HTTP_200_OK, headers={ 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'POST, GET, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type', }, ) @router.get(settings.FREEKASSA_WEBHOOK_PATH) async def freekassa_health() -> JSONResponse: return JSONResponse( { 'status': 'ok', 'service': 'freekassa_webhook', 'enabled': settings.is_freekassa_enabled(), } ) @router.post(settings.FREEKASSA_WEBHOOK_PATH) async def freekassa_webhook(request: Request) -> Response: # Use transport-layer IP as primary source; only trust proxy headers # when the direct connection comes from a known proxy. # This prevents X-Forwarded-For spoofing by external attackers. client_ip = request.client.host if request.client else '127.0.0.1' # Получаем данные формы try: form_data = await request.form() except Exception as form_error: logger.error('Freekassa webhook: не удалось прочитать данные формы', form_error=form_error) return Response('Error reading form data', status_code=status.HTTP_400_BAD_REQUEST) # Извлекаем параметры merchant_id = form_data.get('MERCHANT_ID') amount = form_data.get('AMOUNT') order_id = form_data.get('MERCHANT_ORDER_ID') sign = form_data.get('SIGN') intid = form_data.get('intid') cur_id = form_data.get('CUR_ID') if not all([merchant_id, amount, order_id, sign, intid]): logger.warning('Freekassa webhook: отсутствуют обязательные параметры') return Response('Missing parameters', status_code=status.HTTP_400_BAD_REQUEST) # Преобразуем типы try: merchant_id_int = int(merchant_id) amount_float = float(amount) cur_id_int = int(cur_id) if cur_id else None except ValueError: logger.warning('Freekassa webhook: неверный формат параметров') return Response('Invalid parameters format', status_code=status.HTTP_400_BAD_REQUEST) # Обрабатываем callback db_generator = get_db() try: db = await db_generator.__anext__() except StopAsyncIteration: return Response('DB Error', status_code=status.HTTP_500_INTERNAL_SERVER_ERROR) try: success = await payment_service.process_freekassa_webhook( db, merchant_id=merchant_id_int, amount=amount_float, order_id=order_id, sign=sign, intid=intid, cur_id=cur_id_int, client_ip=client_ip, ) if success: return Response('YES', status_code=status.HTTP_200_OK) logger.error('Freekassa webhook processing failed: order_id intid', order_id=order_id, intid=intid) return Response('Error', status_code=status.HTTP_400_BAD_REQUEST) except Exception as e: logger.exception('Freekassa webhook processing error', e=e) return Response('Error', status_code=status.HTTP_400_BAD_REQUEST) finally: try: await db_generator.__anext__() except StopAsyncIteration: pass routes_registered = True # KassaAI webhook if settings.is_kassa_ai_enabled(): @router.get(settings.KASSA_AI_WEBHOOK_PATH) async def kassa_ai_health() -> JSONResponse: return JSONResponse( { 'status': 'ok', 'service': 'kassa_ai_webhook', 'enabled': settings.is_kassa_ai_enabled(), } ) @router.post(settings.KASSA_AI_WEBHOOK_PATH) async def kassa_ai_webhook(request: Request) -> Response: # Получаем данные формы try: form_data = await request.form() except Exception as form_error: logger.error('KassaAI webhook: не удалось прочитать данные формы', form_error=form_error) return Response('Error reading form data', status_code=status.HTTP_400_BAD_REQUEST) # Извлекаем параметры (те же что и у Freekassa) merchant_id = form_data.get('MERCHANT_ID') amount = form_data.get('AMOUNT') order_id = form_data.get('MERCHANT_ORDER_ID') sign = form_data.get('SIGN') intid = form_data.get('intid') cur_id = form_data.get('CUR_ID') if not all([merchant_id, amount, order_id, sign, intid]): logger.warning('KassaAI webhook: отсутствуют обязательные параметры') return Response('Missing parameters', status_code=status.HTTP_400_BAD_REQUEST) try: merchant_id_int = int(merchant_id) amount_float = float(amount) cur_id_int = int(cur_id) if cur_id else None except (ValueError, TypeError) as e: logger.error('KassaAI webhook: некорректные параметры', e=e) return Response('Invalid parameters', status_code=status.HTTP_400_BAD_REQUEST) # Обрабатываем webhook db_generator = get_db() try: db = await db_generator.__anext__() except StopAsyncIteration: return Response('DB Error', status_code=status.HTTP_500_INTERNAL_SERVER_ERROR) try: success = await payment_service.process_kassa_ai_webhook( db, merchant_id=merchant_id_int, amount=amount_float, order_id=order_id, sign=sign, intid=intid, cur_id=cur_id_int, ) if success: return Response('YES', status_code=status.HTTP_200_OK) logger.error('KassaAI webhook processing failed: order_id intid', order_id=order_id, intid=intid) return Response('Error', status_code=status.HTTP_400_BAD_REQUEST) except Exception as e: logger.exception('KassaAI webhook processing error', e=e) return Response('Error', status_code=status.HTTP_400_BAD_REQUEST) finally: try: await db_generator.__anext__() except StopAsyncIteration: pass routes_registered = True # RioPay webhook if settings.is_riopay_enabled(): @router.get(settings.RIOPAY_WEBHOOK_PATH) async def riopay_health() -> JSONResponse: return JSONResponse( { 'status': 'ok', 'service': 'riopay_webhook', 'enabled': settings.is_riopay_enabled(), } ) @router.post(settings.RIOPAY_WEBHOOK_PATH) async def riopay_webhook(request: Request) -> Response: # Получаем JSON тело try: raw_body = await request.body() payload = json.loads(raw_body) except Exception as parse_error: logger.error('RioPay webhook: не удалось прочитать JSON', parse_error=parse_error) return Response('Error reading JSON', status_code=status.HTTP_400_BAD_REQUEST) # Подпись из заголовка (обязательна) signature = request.headers.get('X-Signature') or request.headers.get('x-signature') if not signature: logger.warning('RioPay webhook: отсутствует подпись') return JSONResponse( {'status': 'error', 'reason': 'missing_signature'}, status_code=status.HTTP_403_FORBIDDEN, ) from app.services.riopay_service import riopay_service if not riopay_service.verify_webhook_signature(raw_body, signature): logger.warning('RioPay webhook: неверная подпись') return JSONResponse( {'status': 'error', 'reason': 'invalid_signature'}, status_code=status.HTTP_403_FORBIDDEN, ) # Обрабатываем webhook db_generator = get_db() try: db = await db_generator.__anext__() except StopAsyncIteration: return Response('DB Error', status_code=status.HTTP_500_INTERNAL_SERVER_ERROR) try: success = await payment_service.process_riopay_webhook( db, payload=payload, ) if success: return JSONResponse({'status': 'ok'}, status_code=status.HTTP_200_OK) logger.error( 'RioPay webhook processing failed', order_id=payload.get('id'), status=payload.get('status'), ) return Response('Error', status_code=status.HTTP_400_BAD_REQUEST) except Exception as e: logger.exception('RioPay webhook processing error', e=e) return Response('Error', status_code=status.HTTP_400_BAD_REQUEST) finally: try: await db_generator.__anext__() except StopAsyncIteration: pass routes_registered = True # SeverPay webhook if settings.is_severpay_enabled(): @router.get(settings.SEVERPAY_WEBHOOK_PATH) async def severpay_health() -> JSONResponse: return JSONResponse( { 'status': 'ok', 'service': 'severpay_webhook', 'enabled': settings.is_severpay_enabled(), } ) @router.post(settings.SEVERPAY_WEBHOOK_PATH) async def severpay_webhook(request: Request) -> JSONResponse: try: raw_body = await request.body() payload = json.loads(raw_body) except Exception as parse_error: logger.error('SeverPay webhook: failed to parse JSON', parse_error=parse_error) return JSONResponse({'status': False}, status_code=status.HTTP_400_BAD_REQUEST) from app.services.severpay_service import severpay_service if not severpay_service.verify_webhook_signature(raw_body): logger.warning('SeverPay webhook: invalid signature') return JSONResponse({'status': False}, status_code=status.HTTP_403_FORBIDDEN) try: success = await _process_payment_service_callback( payment_service, payload, 'process_severpay_webhook', ) if not success: logger.error( 'SeverPay webhook processing failed', data=payload.get('data'), ) except Exception as e: logger.exception('SeverPay webhook processing error', error=e) # Always return 200 {"status": true} — SeverPay retries on any non-200 return JSONResponse({'status': True}, status_code=status.HTTP_200_OK) routes_registered = True # PayPear webhook if settings.is_paypear_enabled(): @router.get(settings.PAYPEAR_WEBHOOK_PATH) async def paypear_health() -> JSONResponse: return JSONResponse( { 'status': 'ok', 'service': 'paypear_webhook', 'enabled': settings.is_paypear_enabled(), } ) @router.post(settings.PAYPEAR_WEBHOOK_PATH) async def paypear_webhook(request: Request) -> JSONResponse: try: raw_body = await request.body() payload = json.loads(raw_body) except Exception as parse_error: logger.error('PayPear webhook: failed to parse JSON', parse_error=parse_error) return JSONResponse({'status': False}, status_code=status.HTTP_400_BAD_REQUEST) # Извлекаем подпись из тела webhook received_signature = payload.get('signature', '') from app.services.paypear_service import paypear_service client_ip = ( request.headers.get('x-real-ip') or request.headers.get('x-forwarded-for', '').split(',')[0].strip() or (request.client.host if request.client else None) ) if not paypear_service.verify_webhook_signature(raw_body, received_signature, client_ip=client_ip): logger.warning('PayPear webhook: invalid signature and IP', client_ip=client_ip) return JSONResponse({'status': False}, status_code=status.HTTP_403_FORBIDDEN) try: success = await _process_payment_service_callback( payment_service, payload, 'process_paypear_webhook', ) if not success: logger.error( 'PayPear webhook processing failed', data=payload.get('object'), ) except Exception as e: logger.exception('PayPear webhook processing error', error=e) # Always return 200 — PayPear may retry on non-200 return JSONResponse({'status': True}, status_code=status.HTTP_200_OK) routes_registered = True # RollyPay webhook if settings.is_rollypay_enabled(): @router.get(settings.ROLLYPAY_WEBHOOK_PATH) async def rollypay_health() -> JSONResponse: return JSONResponse( { 'status': 'ok', 'service': 'rollypay_webhook', 'enabled': settings.is_rollypay_enabled(), } ) @router.post(settings.ROLLYPAY_WEBHOOK_PATH) async def rollypay_webhook(request: Request) -> JSONResponse: try: raw_body = await request.body() payload = json.loads(raw_body) except Exception as parse_error: logger.error('RollyPay webhook: failed to parse JSON', parse_error=parse_error) return JSONResponse({'status': False}, status_code=status.HTTP_400_BAD_REQUEST) # Подпись через заголовки X-Signature и X-Timestamp received_signature = request.headers.get('X-Signature', '') timestamp = request.headers.get('X-Timestamp', '') from app.services.rollypay_service import rollypay_service if not rollypay_service.verify_webhook_signature(raw_body, received_signature, timestamp): logger.warning('RollyPay webhook: invalid signature') return JSONResponse({'status': False}, status_code=status.HTTP_403_FORBIDDEN) try: success = await _process_payment_service_callback( payment_service, payload, 'process_rollypay_webhook', ) if not success: logger.error( 'RollyPay webhook processing failed', data=payload.get('payment_id'), ) except Exception as e: logger.exception('RollyPay webhook processing error', error=e) # Always return 200 — RollyPay retries on non-200 with exponential backoff return JSONResponse({'status': True}, status_code=status.HTTP_200_OK) routes_registered = True # Overpay webhook if settings.is_overpay_enabled(): @router.get(settings.OVERPAY_WEBHOOK_PATH) async def overpay_health() -> JSONResponse: return JSONResponse( { 'status': 'ok', 'service': 'overpay_webhook', 'enabled': settings.is_overpay_enabled(), } ) @router.post(settings.OVERPAY_WEBHOOK_PATH) async def overpay_webhook(request: Request) -> JSONResponse: try: raw_body = await request.body() payload = json.loads(raw_body) except Exception as parse_error: logger.error('Overpay webhook: failed to parse JSON', parse_error=parse_error) return JSONResponse({'status': False}, status_code=status.HTTP_400_BAD_REQUEST) # Overpay uses mTLS for authentication — verify payment exists in DB merchant_transaction_id = payload.get('merchantTransactionId') if not merchant_transaction_id: logger.warning('Overpay webhook: missing merchantTransactionId') return JSONResponse({'status': False}, status_code=status.HTTP_400_BAD_REQUEST) # Validate that the payment exists in our DB (basic anti-spoofing) from app.database.crud.overpay import get_overpay_payment_by_order_id db_generator = get_db() try: check_db = await db_generator.__anext__() except StopAsyncIteration: return JSONResponse({'status': False}, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR) try: existing = await get_overpay_payment_by_order_id(check_db, merchant_transaction_id) if not existing: overpay_id = payload.get('id') if overpay_id: from app.database.crud.overpay import get_overpay_payment_by_overpay_id existing = await get_overpay_payment_by_overpay_id(check_db, str(overpay_id)) if not existing: logger.warning( 'Overpay webhook: payment not found in DB', merchant_transaction_id=merchant_transaction_id, ) return JSONResponse({'status': False}, status_code=status.HTTP_404_NOT_FOUND) finally: try: await db_generator.__anext__() except StopAsyncIteration: pass try: success = await _process_payment_service_callback( payment_service, payload, 'process_overpay_webhook', ) if not success: logger.error( 'Overpay webhook processing failed', data=payload.get('id'), ) except Exception as e: logger.exception('Overpay webhook processing error', error=e) # Always return 200 — Overpay expects HTTP 200 return JSONResponse({'status': True}, status_code=status.HTTP_200_OK) routes_registered = True # AuraPay webhook if settings.is_aurapay_enabled(): @router.get(settings.AURAPAY_WEBHOOK_PATH) async def aurapay_health() -> JSONResponse: return JSONResponse( { 'status': 'ok', 'service': 'aurapay_webhook', 'enabled': settings.is_aurapay_enabled(), } ) @router.post(settings.AURAPAY_WEBHOOK_PATH) async def aurapay_webhook(request: Request) -> JSONResponse: try: raw_body = await request.body() payload = json.loads(raw_body) except Exception as parse_error: logger.error('AuraPay webhook: failed to parse JSON', parse_error=parse_error) return JSONResponse({'status': False}, status_code=status.HTTP_400_BAD_REQUEST) # Подпись через заголовок X-SIGNATURE received_signature = request.headers.get('X-SIGNATURE', '') from app.services.aurapay_service import aurapay_service if not aurapay_service.verify_webhook_signature(payload, received_signature): logger.warning('AuraPay webhook: invalid signature') return JSONResponse({'status': False}, status_code=status.HTTP_403_FORBIDDEN) try: success = await _process_payment_service_callback( payment_service, payload, 'process_aurapay_webhook', ) if not success: logger.error( 'AuraPay webhook processing failed', data=payload.get('id'), ) except Exception as e: logger.exception('AuraPay webhook processing error', error=e) # Always return 200 — AuraPay retries on non-200 (5 attempts) return JSONResponse({'status': True}, status_code=status.HTTP_200_OK) routes_registered = True # Etoplatezhi webhook if settings.is_etoplatezhi_enabled(): @router.get(settings.ETOPLATEZHI_WEBHOOK_PATH) async def etoplatezhi_health() -> JSONResponse: return JSONResponse( { 'status': 'ok', 'service': 'etoplatezhi_webhook', 'enabled': settings.is_etoplatezhi_enabled(), } ) @router.post(settings.ETOPLATEZHI_WEBHOOK_PATH) async def etoplatezhi_webhook(request: Request) -> JSONResponse: try: raw_body = await request.body() payload = json.loads(raw_body) except Exception as parse_error: logger.error('Etoplatezhi webhook: failed to parse JSON', parse_error=parse_error) return JSONResponse({'status': False}, status_code=status.HTTP_400_BAD_REQUEST) # Подпись внутри JSON body (поле signature) from app.services.etoplatezhi_service import etoplatezhi_service if not etoplatezhi_service.verify_callback_signature(payload): logger.warning('Etoplatezhi webhook: invalid signature') return JSONResponse({'status': False}, status_code=status.HTTP_400_BAD_REQUEST) try: success = await _process_payment_service_callback( payment_service, payload, 'process_etoplatezhi_callback', ) if not success: logger.error( 'Etoplatezhi webhook processing failed', data=payload.get('payment', {}).get('id'), ) except Exception as e: logger.exception('Etoplatezhi webhook processing error', error=e) # Always return 200 — Etoplatezhi expects 200 for valid signature return JSONResponse({'status': True}, status_code=status.HTTP_200_OK) routes_registered = True # Antilopay webhook if settings.is_antilopay_enabled(): @router.get(settings.ANTILOPAY_WEBHOOK_PATH) async def antilopay_health() -> JSONResponse: return JSONResponse( { 'status': 'ok', 'service': 'antilopay_webhook', 'enabled': settings.is_antilopay_enabled(), } ) @router.post(settings.ANTILOPAY_WEBHOOK_PATH) async def antilopay_webhook(request: Request) -> JSONResponse: try: raw_body = await request.body() payload = json.loads(raw_body) except Exception as parse_error: logger.error('Antilopay webhook: failed to parse JSON', parse_error=parse_error) return JSONResponse({'status': False}, status_code=status.HTTP_400_BAD_REQUEST) # Подпись в заголовке X-Apay-Callback, верифицируется публичным ключом from app.services.antilopay_service import antilopay_service callback_signature = request.headers.get('X-Apay-Callback') or '' if not antilopay_service.verify_callback_signature(raw_body, callback_signature): logger.warning('Antilopay webhook: invalid signature') return JSONResponse({'status': False}, status_code=status.HTTP_400_BAD_REQUEST) try: success = await _process_payment_service_callback( payment_service, payload, 'process_antilopay_callback', ) if not success: logger.error( 'Antilopay webhook processing failed', data=payload.get('payment_id'), ) except Exception as e: logger.exception('Antilopay webhook processing error', error=e) # Always return 200 — Antilopay retries every 3min for 1hr on non-200 return JSONResponse({'status': True}, status_code=status.HTTP_200_OK) routes_registered = True if routes_registered: @router.get('/health/payment-webhooks') async def payment_webhooks_health() -> JSONResponse: return JSONResponse( { 'status': 'ok', 'tribute_enabled': settings.TRIBUTE_ENABLED, 'mulenpay_enabled': settings.is_mulenpay_enabled(), 'cryptobot_enabled': settings.is_cryptobot_enabled(), 'yookassa_enabled': settings.is_yookassa_enabled(), 'wata_enabled': settings.is_wata_enabled(), 'heleket_enabled': settings.is_heleket_enabled(), 'pal24_enabled': settings.is_pal24_enabled(), 'platega_enabled': settings.is_platega_enabled(), 'cloudpayments_enabled': settings.is_cloudpayments_enabled(), 'freekassa_enabled': settings.is_freekassa_enabled(), 'kassa_ai_enabled': settings.is_kassa_ai_enabled(), 'riopay_enabled': settings.is_riopay_enabled(), 'severpay_enabled': settings.is_severpay_enabled(), 'paypear_enabled': settings.is_paypear_enabled(), 'rollypay_enabled': settings.is_rollypay_enabled(), 'overpay_enabled': settings.is_overpay_enabled(), 'aurapay_enabled': settings.is_aurapay_enabled(), 'etoplatezhi_enabled': settings.is_etoplatezhi_enabled(), 'antilopay_enabled': settings.is_antilopay_enabled(), } ) return router if routes_registered else None