Add Freekassa webhook handling and configuration options
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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(),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user