Add Freekassa webhook handling and configuration options

This commit is contained in:
evansvl
2026-01-11 05:06:07 +03:00
parent fae72d7107
commit a54a12825e
4 changed files with 160 additions and 5 deletions

View File

@@ -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,

View File

@@ -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(),
}
)

View File

@@ -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:

View File

@@ -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",