From 4e59d0a071efd581d6c371e33099edfcced219ab Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 8 Jan 2026 03:18:31 +0300 Subject: [PATCH] Update freekassa_service.py --- app/services/freekassa_service.py | 119 +++++++++++++++++++++++------- 1 file changed, 92 insertions(+), 27 deletions(-) diff --git a/app/services/freekassa_service.py b/app/services/freekassa_service.py index 3afc917d..6a94d91b 100644 --- a/app/services/freekassa_service.py +++ b/app/services/freekassa_service.py @@ -1,6 +1,7 @@ """Сервис для работы с API Freekassa.""" import hashlib +import hmac import time import logging from typing import Optional, Dict, Any, Set @@ -55,15 +56,31 @@ class FreekassaService: self._secret2 = settings.FREEKASSA_SECRET_WORD_2 return self._secret2 or "" + def _generate_api_signature_hmac(self, params: Dict[str, Any]) -> str: + """ + Генерирует подпись для API запроса (HMAC-SHA256). + Используется для API методов (создание заказа и т.д.) + """ + # Исключаем signature из параметров и сортируем по ключу + sign_data = {k: v for k, v in params.items() if k != "signature"} + sorted_items = sorted(sign_data.items()) + + # Формируем строку: значения через | + msg = "|".join(str(v) for _, v in sorted_items) + + # HMAC-SHA256 + return hmac.new( + self.api_key.encode("utf-8"), + msg.encode("utf-8"), + hashlib.sha256 + ).hexdigest() + def _generate_api_signature(self, params: Dict[str, Any]) -> str: """ Генерирует подпись для API запроса. - Сортировка по ключам, конкатенация значений через | + Для новых API методов используется HMAC-SHA256. """ - sorted_keys = sorted(params.keys()) - values = [str(params[k]) for k in sorted_keys if params[k] is not None] - sign_string = "|".join(values) - return hashlib.md5(sign_string.encode()).hexdigest() + return self._generate_api_signature_hmac(params) def generate_form_signature( self, amount: float, currency: str, order_id: str @@ -72,7 +89,9 @@ class FreekassaService: Генерирует подпись для платежной формы. Формат: MD5(shop_id:amount:secret1:currency:order_id) """ - sign_string = f"{self.shop_id}:{amount}:{self.secret1}:{currency}:{order_id}" + # Приводим amount к int, если это целое число + final_amount = int(amount) if float(amount).is_integer() else amount + sign_string = f"{self.shop_id}:{final_amount}:{self.secret1}:{currency}:{order_id}" return hashlib.md5(sign_string.encode()).hexdigest() def verify_webhook_signature( @@ -82,8 +101,10 @@ class FreekassaService: Проверяет подпись webhook уведомления. Формат: MD5(shop_id:amount:secret2:order_id) """ + # Приводим amount к int, если это целое число + final_amount = int(amount) if float(amount).is_integer() else amount expected_sign = hashlib.md5( - f"{shop_id}:{amount}:{self.secret2}:{order_id}".encode() + f"{shop_id}:{final_amount}:{self.secret2}:{order_id}".encode() ).hexdigest() return sign.lower() == expected_sign.lower() @@ -102,13 +123,16 @@ class FreekassaService: lang: str = "ru", ) -> str: """ - Формирует URL для перенаправления на оплату. + Формирует URL для перенаправления на оплату (форма выбора). + Используется когда FREEKASSA_USE_API = False. """ - signature = self.generate_form_signature(amount, currency, order_id) + # Приводим amount к int, если это целое число + final_amount = int(amount) if float(amount).is_integer() else amount + signature = self.generate_form_signature(final_amount, currency, order_id) params = { "m": self.shop_id, - "oa": amount, + "oa": final_amount, "currency": currency, "o": order_id, "s": signature, @@ -119,8 +143,11 @@ class FreekassaService: params["em"] = email if phone: params["phone"] = phone - if payment_system_id: - params["i"] = payment_system_id + + # Используем payment_system_id из настроек, если не передан явно + ps_id = payment_system_id or settings.FREEKASSA_PAYMENT_SYSTEM_ID + if ps_id: + params["i"] = ps_id query = "&".join(f"{k}={v}" for k, v in params.items()) return f"https://pay.freekassa.ru/?{query}" @@ -140,27 +167,32 @@ class FreekassaService: """ Создает заказ через API Freekassa. POST /orders/create + + Используется для NSPK СБП (payment_system_id=44) и других методов. + Возвращает словарь с 'location' (ссылка на оплату). """ + # Приводим 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 or 1 + params = { "shopId": self.shop_id, - "nonce": int(time.time() * 1000), - "paymentId": order_id, - "i": payment_system_id or 1, + "nonce": int(time.time_ns()), # Наносекунды для уникальности + "paymentId": str(order_id), + "i": ps_id, "email": email or "user@example.com", "ip": ip or "127.0.0.1", - "amount": amount, + "amount": final_amount, "currency": currency, } - if success_url: - params["success_url"] = success_url - if failure_url: - params["failure_url"] = failure_url - if notification_url: - params["notification_url"] = notification_url - + # Генерируем подпись HMAC-SHA256 params["signature"] = self._generate_api_signature(params) + logger.info(f"Freekassa API create_order params: {params}") + try: async with aiohttp.ClientSession() as session: async with session.post( @@ -169,6 +201,9 @@ class FreekassaService: headers={"Content-Type": "application/json"}, timeout=aiohttp.ClientTimeout(total=30), ) as response: + text = await response.text() + logger.info(f"Freekassa API response: {text}") + data = await response.json() if response.status != 200 or data.get("type") == "error": @@ -182,6 +217,32 @@ class FreekassaService: logger.exception(f"Freekassa API connection error: {e}") raise + async def create_order_and_get_url( + self, + order_id: str, + amount: float, + currency: str = "RUB", + email: Optional[str] = None, + ip: Optional[str] = None, + payment_system_id: Optional[int] = None, + ) -> str: + """ + Создает заказ через API и возвращает URL для оплаты. + Удобный метод для получения только ссылки. + """ + result = await self.create_order( + order_id=order_id, + amount=amount, + currency=currency, + email=email, + ip=ip, + payment_system_id=payment_system_id, + ) + location = result.get("location") + if not location: + raise Exception("Freekassa API did not return payment URL (location)") + return location + async def get_order_status(self, order_id: str) -> Dict[str, Any]: """ Получает статус заказа. @@ -189,11 +250,13 @@ class FreekassaService: """ params = { "shopId": self.shop_id, - "nonce": int(time.time() * 1000), - "paymentId": order_id, + "nonce": int(time.time_ns()), + "paymentId": str(order_id), } params["signature"] = self._generate_api_signature(params) + logger.info(f"Freekassa get_order_status params: {params}") + try: async with aiohttp.ClientSession() as session: async with session.post( @@ -202,6 +265,8 @@ class FreekassaService: headers={"Content-Type": "application/json"}, timeout=aiohttp.ClientTimeout(total=30), ) as response: + text = await response.text() + logger.info(f"Freekassa get_order_status response: {text}") return await response.json() except aiohttp.ClientError as e: logger.exception(f"Freekassa API connection error: {e}") @@ -211,7 +276,7 @@ class FreekassaService: """Получает баланс магазина.""" params = { "shopId": self.shop_id, - "nonce": int(time.time() * 1000), + "nonce": int(time.time_ns()), } params["signature"] = self._generate_api_signature(params) @@ -232,7 +297,7 @@ class FreekassaService: """Получает список доступных платежных систем.""" params = { "shopId": self.shop_id, - "nonce": int(time.time() * 1000), + "nonce": int(time.time_ns()), } params["signature"] = self._generate_api_signature(params)