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
+62 -5
View File
@@ -5,6 +5,8 @@ import hmac
import time import time
import logging import logging
import asyncio import asyncio
import json
import urllib.request
from typing import Optional, Dict, Any, Set from typing import Optional, Dict, Any, Set
import aiohttp import aiohttp
@@ -182,6 +184,7 @@ class FreekassaService:
phone: Optional[str] = None, phone: Optional[str] = None,
payment_system_id: Optional[int] = None, payment_system_id: Optional[int] = None,
lang: str = "ru", lang: str = "ru",
ip: Optional[str] = None,
) -> str: ) -> str:
""" """
Формирует URL для перенаправления на оплату (форма выбора). Формирует URL для перенаправления на оплату (форма выбора).
@@ -189,6 +192,60 @@ class FreekassaService:
""" """
# Приводим amount к int, если это целое число # Приводим amount к int, если это целое число
final_amount = int(amount) if float(amount).is_integer() else amount 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) signature = self.generate_form_signature(final_amount, currency, order_id)
params = { params = {
@@ -205,8 +262,6 @@ class FreekassaService:
if phone: if phone:
params["phone"] = phone params["phone"] = phone
# Используем payment_system_id из настроек, если не передан явно
ps_id = payment_system_id or settings.FREEKASSA_PAYMENT_SYSTEM_ID
if ps_id: if ps_id:
params["i"] = ps_id params["i"] = ps_id
@@ -238,15 +293,17 @@ class FreekassaService:
# Используем payment_system_id из настроек, если не передан явно # Используем payment_system_id из настроек, если не передан явно
ps_id = payment_system_id or settings.FREEKASSA_PAYMENT_SYSTEM_ID or 1 ps_id = payment_system_id or settings.FREEKASSA_PAYMENT_SYSTEM_ID or 1
# Определяем публичный IP сервера (127.0.0.1 отклоняется API) target_email = email or "test@example.com"
server_ip = ip or await get_public_ip()
# Определяем публичный IP сервера
server_ip = ip or await get_public_ip()
params = { params = {
"shopId": self.shop_id, "shopId": self.shop_id,
"nonce": int(time.time_ns()), # Наносекунды для уникальности "nonce": int(time.time_ns()), # Наносекунды для уникальности
"paymentId": str(order_id), "paymentId": str(order_id),
"i": ps_id, "i": ps_id,
"email": email or "user@example.com", "email": target_email,
"ip": server_ip, "ip": server_ip,
"amount": final_amount, "amount": final_amount,
"currency": currency, "currency": currency,
+95
View File
@@ -798,6 +798,100 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
routes_registered = True 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: if routes_registered:
@router.get("/health/payment-webhooks") @router.get("/health/payment-webhooks")
async def payment_webhooks_health() -> JSONResponse: 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(), "pal24_enabled": settings.is_pal24_enabled(),
"platega_enabled": settings.is_platega_enabled(), "platega_enabled": settings.is_platega_enabled(),
"cloudpayments_enabled": settings.is_cloudpayments_enabled(), "cloudpayments_enabled": settings.is_cloudpayments_enabled(),
"freekassa_enabled": settings.is_freekassa_enabled(),
} }
) )
+1
View File
@@ -120,6 +120,7 @@ def create_unified_app(
"pal24": settings.is_pal24_enabled(), "pal24": settings.is_pal24_enabled(),
"wata": settings.is_wata_enabled(), "wata": settings.is_wata_enabled(),
"heleket": settings.is_heleket_enabled(), "heleket": settings.is_heleket_enabled(),
"freekassa": settings.is_freekassa_enabled(),
} }
if enable_telegram_webhook: if enable_telegram_webhook:
+2
View File
@@ -648,6 +648,8 @@ async def main():
webhook_lines.append(f"WATA: {_fmt(settings.WATA_WEBHOOK_PATH)}") webhook_lines.append(f"WATA: {_fmt(settings.WATA_WEBHOOK_PATH)}")
if settings.is_heleket_enabled(): if settings.is_heleket_enabled():
webhook_lines.append(f"Heleket: {_fmt(settings.HELEKET_WEBHOOK_PATH)}") 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( timeline.log_section(
"Активные webhook endpoints", "Активные webhook endpoints",