diff --git a/app/services/freekassa_service.py b/app/services/freekassa_service.py index 65cb855b..0066ab4e 100644 --- a/app/services/freekassa_service.py +++ b/app/services/freekassa_service.py @@ -5,6 +5,8 @@ import hmac import time import logging import asyncio +import json +import urllib.request from typing import Optional, Dict, Any, Set import aiohttp @@ -182,6 +184,7 @@ class FreekassaService: phone: Optional[str] = None, payment_system_id: Optional[int] = None, lang: str = "ru", + ip: Optional[str] = None, ) -> str: """ Формирует URL для перенаправления на оплату (форма выбора). @@ -189,6 +192,60 @@ class FreekassaService: """ # Приводим amount к int, если это целое число final_amount = int(amount) if float(amount).is_integer() else amount + + # Используем payment_system_id из настроек, если не передан явно + ps_id = payment_system_id or settings.FREEKASSA_PAYMENT_SYSTEM_ID + + # Специальная обработка для метода оплаты 44 (NSPK), чтобы работало как в старой версии + if ps_id == 44: + try: + # Определяем IP (важно для API запроса) - здесь синхронно, поэтому лучше иметь передачу IP + # Если IP не передан, используем fallback + target_ip = ip or "185.92.183.173" + target_email = email or "test@example.com" + + params = { + "shopId": self.shop_id, + "nonce": int(time.time_ns()), + "paymentId": str(order_id), + "i": 44, + "email": target_email, + "ip": target_ip, + "amount": final_amount, + "currency": "RUB" + } + + # Генерация подписи + params["signature"] = self._generate_api_signature(params) + + logger.info(f"Freekassa synchronous build_payment_url for 44: {params}") + + data_json = json.dumps(params).encode('utf-8') + req = urllib.request.Request( + f"{API_BASE_URL}/orders/create", + data=data_json, + headers={"Content-Type": "application/json"} + ) + + with urllib.request.urlopen(req, timeout=30) as response: + resp_body = response.read().decode('utf-8') + data = json.loads(resp_body) + + if data.get("type") == "error": + logger.error(f"Freekassa build_payment_url error: {data}") + # Fallback to standard flow if error? Or raise? + # User wants it to work. Raise to see error is safer. + # raise Exception(f"Freekassa API Error: {data.get('message')}") + # Но чтобы не ломать полностью, можно попробовать вернуть обычную ссылку, + # если API не сработал? Нет, вернем ошибку или ссылку из data. + + if data.get("location"): + return data.get("location") + except Exception as e: + logger.error(f"Failed to create order 44 via sync API: {e}") + # Если не получилось, попробуем сгенерировать обычную ссылку как fallback + pass + signature = self.generate_form_signature(final_amount, currency, order_id) params = { @@ -205,8 +262,6 @@ class FreekassaService: if phone: params["phone"] = phone - # Используем payment_system_id из настроек, если не передан явно - ps_id = payment_system_id or settings.FREEKASSA_PAYMENT_SYSTEM_ID if ps_id: params["i"] = ps_id @@ -238,15 +293,17 @@ class FreekassaService: # Используем payment_system_id из настроек, если не передан явно ps_id = payment_system_id or settings.FREEKASSA_PAYMENT_SYSTEM_ID or 1 - # Определяем публичный IP сервера (127.0.0.1 отклоняется API) - server_ip = ip or await get_public_ip() + target_email = email or "test@example.com" + + # Определяем публичный IP сервера + server_ip = ip or await get_public_ip() params = { "shopId": self.shop_id, "nonce": int(time.time_ns()), # Наносекунды для уникальности "paymentId": str(order_id), "i": ps_id, - "email": email or "user@example.com", + "email": target_email, "ip": server_ip, "amount": final_amount, "currency": currency, diff --git a/app/webserver/payments.py b/app/webserver/payments.py index 113b1ac5..fd10c53f 100644 --- a/app/webserver/payments.py +++ b/app/webserver/payments.py @@ -798,6 +798,100 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute 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: + # Получаем IP клиента с учетом прокси + x_forwarded_for = request.headers.get("X-Forwarded-For") + if x_forwarded_for: + client_ip = x_forwarded_for.split(",")[0].strip() + else: + real_ip = request.headers.get("X-Real-IP") + if real_ip: + client_ip = real_ip.strip() + else: + client_ip = request.client.host if request.client else "127.0.0.1" + + # Получаем данные формы + try: + form_data = await request.form() + except Exception: + logger.error("Freekassa webhook: не удалось прочитать данные формы") + 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, + ) + finally: + try: + await db_generator.__anext__() + except StopAsyncIteration: + pass + + if success: + return Response("YES", status_code=status.HTTP_200_OK) + + return Response("Error", status_code=status.HTTP_400_BAD_REQUEST) + + routes_registered = True + if routes_registered: @router.get("/health/payment-webhooks") async def payment_webhooks_health() -> JSONResponse: @@ -813,6 +907,7 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute "pal24_enabled": settings.is_pal24_enabled(), "platega_enabled": settings.is_platega_enabled(), "cloudpayments_enabled": settings.is_cloudpayments_enabled(), + "freekassa_enabled": settings.is_freekassa_enabled(), } ) diff --git a/app/webserver/unified_app.py b/app/webserver/unified_app.py index 50375e50..d93a52b7 100644 --- a/app/webserver/unified_app.py +++ b/app/webserver/unified_app.py @@ -120,6 +120,7 @@ def create_unified_app( "pal24": settings.is_pal24_enabled(), "wata": settings.is_wata_enabled(), "heleket": settings.is_heleket_enabled(), + "freekassa": settings.is_freekassa_enabled(), } if enable_telegram_webhook: diff --git a/main.py b/main.py index 0d622271..07e992bd 100644 --- a/main.py +++ b/main.py @@ -648,6 +648,8 @@ async def main(): webhook_lines.append(f"WATA: {_fmt(settings.WATA_WEBHOOK_PATH)}") if settings.is_heleket_enabled(): webhook_lines.append(f"Heleket: {_fmt(settings.HELEKET_WEBHOOK_PATH)}") + if settings.is_freekassa_enabled(): + webhook_lines.append(f"Freekassa: {_fmt(settings.FREEKASSA_WEBHOOK_PATH)}") timeline.log_section( "Активные webhook endpoints",