diff --git a/.env.example b/.env.example index a22b853..a046fe5 100644 --- a/.env.example +++ b/.env.example @@ -32,6 +32,14 @@ YOOKASSA_DEFAULT_RECEIPT_EMAIL=your_email@example.com # YOOKASSA_VAT_CODE=1 # VAT code YOOKASSA_AUTOPAYMENTS_ENABLED=False # Auto-renew toggle +# FreeKassa Payment Gateway Configuration +FREEKASSA_ENABLED=True # Turn on FreeKassa +FREEKASSA_MERCHANT_ID=your_shop_id # Your shop ID in FreeKassa +FREEKASSA_API_KEY=your_api_key # API key for REST requests +FREEKASSA_SECOND_SECRET=your_second_secret # Secret word #2 (used to verify notifications) +FREEKASSA_CURRENCY=RUB # Default currency for orders +FREEKASSA_PAYMENT_IP= # Public IP address reported to FreeKassa + # CryptoBot Payment Gateway Configuration CRYPTOPAY_TOKEN= # API token for CryptoPay CRYPTOPAY_NETWORK=mainnet # Network (mainnet or testnet) diff --git a/README.md b/README.md index b8f8c9e..5601547 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ - **Пробная подписка:** Система пробных подписок для новых пользователей (активируется вручную по кнопке). - **Промокоды:** Возможность применять промокоды для получения скидок или бонусных дней. - **Реферальная программа:** Пользователи могут приглашать друзей и получать за это бонусные дни подписки. -- **Оплата:** Поддержка оплаты через YooKassa, FreeKassa, CryptoPay, Telegram Stars и Tribute. +- **Оплата:** Поддержка оплаты через YooKassa, FreeKassa (REST API), CryptoPay, Telegram Stars и Tribute. ### Для администраторов: - **Защищенная админ-панель:** Доступ только для администраторов, указанных в `ADMIN_IDS`. @@ -83,10 +83,11 @@ | `CRYPTOPAY_TOKEN` | Токен из вашего CryptoPay App. | | `FREEKASSA_ENABLED` | Включить/выключить FreeKassa (`true`/`false`). | | `FREEKASSA_MERCHANT_ID` | ID вашего магазина в FreeKassa. | - | `FREEKASSA_FIRST_SECRET` | Секретное слово №1 для формирования ссылок оплаты. | - | `FREEKASSA_SECOND_SECRET` | Секретное слово №2 для проверки уведомлений. | - | `FREEKASSA_PAYMENT_URL` | (Опционально) Базовый URL платёжной формы FreeKassa. По умолчанию `https://pay.freekassa.ru/`. | + | `FREEKASSA_API_KEY` | API-ключ для запросов к FreeKassa REST API. | + | `FREEKASSA_SECOND_SECRET` | Секретное слово №2 — используется для проверки уведомлений от FreeKassa. | + | `FREEKASSA_PAYMENT_URL` | (Опционально, legacy SCI) Базовый URL платёжной формы FreeKassa. По умолчанию `https://pay.freekassa.ru/`. | | `FREEKASSA_CURRENCY` | Код валюты платежа (например, `RUB`). | + | `FREEKASSA_PAYMENT_IP` | Внешний IP вашего сервера, который будет передаваться в запрос оплаты. | | `STARS_ENABLED` | Включить/выключить Telegram Stars (`true`/`false`). | | `TRIBUTE_ENABLED`| Включить/выключить Tribute (`true`/`false`). | diff --git a/bot/handlers/user/subscription/payments.py b/bot/handlers/user/subscription/payments.py index e2b9c24..0b89eda 100644 --- a/bot/handlers/user/subscription/payments.py +++ b/bot/handlers/user/subscription/payments.py @@ -1,5 +1,6 @@ import logging from aiogram import Router, F, types +from aiogram.utils.keyboard import InlineKeyboardBuilder from typing import Optional from sqlalchemy.ext.asyncio import AsyncSession @@ -308,7 +309,7 @@ async def pay_fk_callback_handler( user_id = callback.from_user.id payment_description = get_text("payment_description_subscription", months=months) - currency_code = freekassa_service.currency or settings.DEFAULT_CURRENCY_SYMBOL or "RUB" + currency_code = getattr(freekassa_service, "default_currency", None) or settings.DEFAULT_CURRENCY_SYMBOL or "RUB" payment_record_payload = { "user_id": user_id, @@ -339,40 +340,191 @@ async def pay_fk_callback_handler( pass return - payment_link = None - try: - payment_link = freekassa_service.build_payment_link( - payment_db_id=payment_record.payment_id, - user_id=user_id, - months=months, - amount=price_rub, - ) - except Exception as e_link: - logging.error(f"FreeKassa: failed to build payment link for payment {payment_record.payment_id}: {e_link}", exc_info=True) + method_keyboard = InlineKeyboardBuilder() + method_keyboard.button( + text=get_text("freekassa_method_qr"), + callback_data=f"pay_fk_method:{payment_record.payment_id}:44", + ) + method_keyboard.button( + text=get_text("freekassa_method_card"), + callback_data=f"pay_fk_method:{payment_record.payment_id}:36", + ) + method_keyboard.button( + text=get_text("freekassa_method_sberpay"), + callback_data=f"pay_fk_method:{payment_record.payment_id}:43", + ) + method_keyboard.button( + text=get_text("back_to_main_menu_button"), + callback_data="main_action:subscribe", + ) + method_keyboard.adjust(1) - if payment_link: + try: + await callback.message.edit_text( + get_text("freekassa_choose_method"), + reply_markup=method_keyboard.as_markup(), + ) + except Exception as e_edit: + logging.warning(f"FreeKassa: failed to show method selector ({e_edit}), sending new message.") try: - await callback.message.edit_text( - get_text(key="payment_link_message", months=months), - reply_markup=get_payment_url_keyboard(payment_link, current_lang, i18n), - disable_web_page_preview=False, + await callback.message.answer( + get_text("freekassa_choose_method"), + reply_markup=method_keyboard.as_markup(), ) - except Exception as e_edit: - logging.warning(f"FreeKassa: edit message failed ({e_edit}), sending new message.") - try: - await callback.message.answer( - get_text(key="payment_link_message", months=months), - reply_markup=get_payment_url_keyboard(payment_link, current_lang, i18n), - disable_web_page_preview=False, - ) - except Exception: - pass + except Exception: + pass + try: + await callback.answer() + except Exception: + pass + + +@router.callback_query(F.data.startswith("pay_fk_method:")) +async def pay_fk_method_callback_handler( + callback: types.CallbackQuery, + settings: Settings, + i18n_data: dict, + freekassa_service: FreeKassaService, + session: AsyncSession, +): + current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) + i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") + get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key + + if not i18n or not callback.message: try: - await callback.answer() + await callback.answer(get_text("error_occurred_try_again"), show_alert=True) except Exception: pass return + if not freekassa_service or not freekassa_service.configured: + try: + await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True) + except Exception: + pass + try: + await callback.message.edit_text(get_text("payment_service_unavailable")) + except Exception: + pass + return + + try: + _, payload = callback.data.split(":", 1) + payment_id_str, method_code = payload.split(":") + payment_id = int(payment_id_str) + except (ValueError, IndexError): + logging.error(f"FreeKassa: invalid method payload {callback.data}") + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + try: + payment_record = await payment_dal.get_payment_by_db_id(session, payment_id) + except Exception as e_db: + logging.error(f"FreeKassa: failed to load payment {payment_id}: {e_db}") + payment_record = None + + if not payment_record: + try: + await callback.answer(get_text("error_payment_gateway"), show_alert=True) + except Exception: + pass + return + + if payment_record.user_id != callback.from_user.id: + logging.warning( + f"FreeKassa: user {callback.from_user.id} attempted to access payment {payment_id} owned by {payment_record.user_id}" + ) + try: + await callback.answer(get_text("error_payment_gateway"), show_alert=True) + except Exception: + pass + return + + months = payment_record.subscription_duration_months or 1 + amount = float(payment_record.amount) + + try: + method_code_int = int(method_code) + except (TypeError, ValueError): + logging.error(f"FreeKassa: invalid method code {method_code} for payment {payment_record.payment_id}") + try: + await callback.answer(get_text("error_payment_gateway"), show_alert=True) + except Exception: + pass + return + + success, response_data = await freekassa_service.create_order( + payment_db_id=payment_record.payment_id, + user_id=payment_record.user_id, + months=months, + amount=amount, + currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB", + method_code=method_code_int, + ip_address=freekassa_service.server_ip, + extra_params={ + "us_method": method_code_int, + }, + ) + + if success: + location = response_data.get("location") + provider_identifier = response_data.get("orderHash") or response_data.get("orderId") + + if provider_identifier: + try: + await payment_dal.update_provider_payment_and_status( + session, + payment_record.payment_id, + str(provider_identifier), + payment_record.status, + ) + await session.commit() + except Exception as e_status: + await session.rollback() + logging.error( + f"FreeKassa: failed to store provider order id for payment {payment_record.payment_id}: {e_status}", + exc_info=True, + ) + + if location: + try: + await callback.message.edit_text( + get_text(key="payment_link_message", months=months), + reply_markup=get_payment_url_keyboard(location, current_lang, i18n), + disable_web_page_preview=False, + ) + except Exception as e_edit: + logging.warning(f"FreeKassa: failed to display payment link ({e_edit}), sending new message.") + try: + await callback.message.answer( + get_text(key="payment_link_message", months=months), + reply_markup=get_payment_url_keyboard(location, current_lang, i18n), + disable_web_page_preview=False, + ) + except Exception: + pass + try: + await callback.answer() + except Exception: + pass + return + + logging.error( + "FreeKassa: create_order succeeded but no payment link returned for payment %s. Response: %s", + payment_record.payment_id, + response_data, + ) + else: + logging.error( + "FreeKassa: create_order failed for payment %s with response %s", + payment_record.payment_id, + response_data, + ) + try: await payment_dal.update_payment_status_by_db_id( session, diff --git a/bot/services/freekassa_service.py b/bot/services/freekassa_service.py index bd907aa..76016b7 100644 --- a/bot/services/freekassa_service.py +++ b/bot/services/freekassa_service.py @@ -1,10 +1,13 @@ +import asyncio import hashlib +import hmac +import json import logging +import time from decimal import Decimal, ROUND_HALF_UP -from typing import Optional, Dict, Any -from urllib.parse import urlencode +from typing import Optional, Dict, Any, Tuple -from aiohttp import web +from aiohttp import ClientSession, ClientTimeout, web from aiogram import Bot from sqlalchemy.orm import sessionmaker @@ -36,68 +39,162 @@ class FreeKassaService: self.subscription_service = subscription_service self.referral_service = referral_service - self.merchant_id: Optional[str] = settings.FREEKASSA_MERCHANT_ID - self.first_secret: Optional[str] = settings.FREEKASSA_FIRST_SECRET + self.shop_id: Optional[str] = settings.FREEKASSA_MERCHANT_ID + self.api_key: Optional[str] = settings.FREEKASSA_API_KEY self.second_secret: Optional[str] = settings.FREEKASSA_SECOND_SECRET - self.payment_url: str = settings.FREEKASSA_PAYMENT_URL.rstrip("/") - self.currency: str = settings.FREEKASSA_CURRENCY.upper() if settings.FREEKASSA_CURRENCY else "RUB" + self.default_currency: str = ( + settings.FREEKASSA_CURRENCY or settings.DEFAULT_CURRENCY_SYMBOL or "RUB" + ).upper() + self.server_ip: Optional[str] = settings.FREEKASSA_PAYMENT_IP - self.configured: bool = bool( - settings.FREEKASSA_ENABLED - and self.merchant_id - and self.first_secret - and self.second_secret - ) + self.api_base_url: str = "https://api.fk.life/v1" + self._timeout = ClientTimeout(total=15) + self._session: Optional[ClientSession] = None + self._nonce_lock = asyncio.Lock() + self._last_nonce = int(time.time() * 1000) + + self.configured: bool = bool(settings.FREEKASSA_ENABLED and self.shop_id and self.api_key) if not self.configured: logging.warning("FreeKassaService initialized but not fully configured. Payments disabled.") + if settings.FREEKASSA_ENABLED and not self.server_ip: + logging.warning("FreeKassaService: FREEKASSA_PAYMENT_IP is not set. Requests may be rejected by the provider.") @staticmethod def _format_amount(amount: float) -> str: - """Format amount for signatures with two decimal places.""" + """Format amount for payloads and signature with two decimal places.""" quantized = Decimal(str(amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) return f"{quantized:.2f}" - def build_payment_link( + async def create_order( self, *, payment_db_id: int, user_id: int, months: int, amount: float, + currency: Optional[str], + method_code: int, + email: Optional[str] = None, + ip_address: Optional[str] = None, extra_params: Optional[Dict[str, Any]] = None, - ) -> Optional[str]: + ) -> Tuple[bool, Dict[str, Any]]: if not self.configured: - logging.error("FreeKassaService is not configured. Cannot build payment link.") - return None + logging.error("FreeKassaService is not configured. Cannot create order.") + return False, {"message": "service_not_configured"} + ip_address = ip_address or self.server_ip + if not ip_address: + logging.error("FreeKassaService: payment IP is required but not configured.") + return False, {"message": "missing_ip"} + + email = email or f"{user_id}@telegram.org" amount_str = self._format_amount(amount) - signature_source = f"{self.merchant_id}:{amount_str}:{self.first_secret}:{payment_db_id}" - signature = hashlib.md5(signature_source.encode("utf-8")).hexdigest() + currency_code = (currency or self.default_currency or "RUB").upper() - params: Dict[str, Any] = { - "m": self.merchant_id, - "oa": amount_str, - "o": str(payment_db_id), - "currency": self.currency, - "s": signature, + payload: Dict[str, Any] = { + "shopId": int(self.shop_id), + "nonce": await self._generate_nonce(), + "paymentId": str(payment_db_id), + "i": int(method_code), + "amount": amount_str, + "currency": currency_code, + "email": email, + "ip": ip_address, "us_user_id": str(user_id), "us_months": str(months), + "us_payment_db_id": str(payment_db_id), } + if extra_params: for key, value in extra_params.items(): if value is None: continue - params[f"us_{key}"] = value + payload[key] = value - query_string = urlencode(params, doseq=False, safe=":") - return f"{self.payment_url}?{query_string}" + payload["signature"] = self._sign_payload(payload) - def _validate_signature(self, merchant_order_id: str, amount: str, provided_signature: str) -> bool: - if not self.configured: + session = await self._get_session() + url = f"{self.api_base_url}/orders/create" + try: + async with session.post(url, json=payload) as response: + response_text = await response.text() + try: + response_data = json.loads(response_text) if response_text else {} + except json.JSONDecodeError: + logging.error("FreeKassa create_order: failed to decode JSON: %s", response_text) + return False, {"status": response.status, "message": "invalid_json", "raw": response_text} + + if response.status != 200 or response_data.get("type") != "success": + logging.error( + "FreeKassa create_order: API returned error (status=%s, body=%s)", + response.status, + response_data, + ) + return False, {"status": response.status, "message": response_data} + + return True, response_data + except Exception as exc: + logging.error("FreeKassa create_order: request failed: %s", exc, exc_info=True) + return False, {"message": str(exc)} + + async def _get_session(self) -> ClientSession: + if self._session is None or self._session.closed: + self._session = ClientSession(timeout=self._timeout) + return self._session + + async def _generate_nonce(self) -> int: + async with self._nonce_lock: + candidate = int(time.time() * 1000) + if candidate <= self._last_nonce: + candidate = self._last_nonce + 1 + self._last_nonce = candidate + return candidate + + def _sign_payload(self, payload: Dict[str, Any]) -> str: + if not self.api_key: + raise RuntimeError("FreeKassa API key is not configured.") + items = [ + (key, value) + for key, value in payload.items() + if key != "signature" and value is not None + ] + items.sort(key=lambda pair: pair[0]) + message = "|".join(str(value) for _, value in items) + return hmac.new(self.api_key.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).hexdigest() + + async def close(self) -> None: + if self._session and not self._session.closed: + await self._session.close() + + def _validate_signature( + self, + merchant_order_id: str, + amount: str, + provided_signature: str, + payload: Optional[Dict[str, Any]] = None, + ) -> bool: + if not provided_signature: return False - signature_source = f"{self.merchant_id}:{amount}:{self.second_secret}:{merchant_order_id}" - expected_signature = hashlib.md5(signature_source.encode("utf-8")).hexdigest() - return expected_signature.lower() == provided_signature.lower() + + if self.shop_id and self.second_secret: + signature_source = f"{self.shop_id}:{amount}:{self.second_secret}:{merchant_order_id}" + expected_signature = hashlib.md5(signature_source.encode("utf-8")).hexdigest() + if expected_signature.lower() == provided_signature.lower(): + return True + + if self.api_key and payload: + items = [ + (key, value) + for key, value in payload.items() + if key not in {"signature", "SIGN"} and value is not None + ] + items.sort(key=lambda pair: pair[0]) + message = "|".join(str(value) for _, value in items) + alt_signature = hmac.new(self.api_key.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).hexdigest() + if alt_signature.lower() == provided_signature.lower(): + return True + + return False async def webhook_route(self, request: web.Request) -> web.Response: if not self.configured: @@ -109,21 +206,29 @@ class FreeKassaService: logging.error(f"FreeKassa webhook: failed to read POST data: {e}") return web.Response(status=400, text="bad_request") - if not data: + payload_dict: Dict[str, Any] + if data: + payload_dict = {str(k): v for k, v in data.items()} + else: try: - data = await request.json() + json_payload = await request.json() + payload_dict = {str(k): v for k, v in json_payload.items()} if isinstance(json_payload, dict) else {} + data = json_payload except Exception: + payload_dict = {} data = {} def _get(key: str, default: Optional[str] = None) -> Optional[str]: - return data.get(key) or data.get(key.lower()) or default + if isinstance(data, dict): + return data.get(key) or data.get(key.lower()) or default + return payload_dict.get(key) or payload_dict.get(key.lower()) or default merchant_id = _get("MERCHANT_ID") - if merchant_id != self.merchant_id: + if merchant_id != self.shop_id: logging.error(f"FreeKassa webhook: merchant mismatch (got {merchant_id})") return web.Response(status=403, text="merchant_mismatch") - signature = _get("SIGN") + signature = _get("SIGN") or _get("signature") if not signature: logging.error("FreeKassa webhook: missing signature") return web.Response(status=400, text="missing_signature") @@ -136,7 +241,7 @@ class FreeKassaService: logging.error("FreeKassa webhook: missing order_id or amount") return web.Response(status=400, text="missing_data") - if not self._validate_signature(order_id_str, amount_str, signature): + if not self._validate_signature(order_id_str, amount_str, signature, payload_dict): logging.error("FreeKassa webhook: invalid signature") return web.Response(status=403, text="invalid_signature") diff --git a/config/settings.py b/config/settings.py index 659c3ea..b177561 100644 --- a/config/settings.py +++ b/config/settings.py @@ -50,6 +50,8 @@ class Settings(BaseSettings): FREEKASSA_SECOND_SECRET: Optional[str] = None FREEKASSA_PAYMENT_URL: str = Field(default="https://pay.freekassa.ru/") FREEKASSA_CURRENCY: str = Field(default="RUB") + FREEKASSA_API_KEY: Optional[str] = None + FREEKASSA_PAYMENT_IP: Optional[str] = None YOOKASSA_ENABLED: bool = Field(default=True) STARS_ENABLED: bool = Field(default=True) @@ -386,14 +388,18 @@ def get_settings() -> Settings: logging.warning( "CRITICAL: YooKassa credentials (SHOP_ID or SECRET_KEY) are not set. Payments will not work." ) - if _settings_instance.FREEKASSA_ENABLED and ( - not _settings_instance.FREEKASSA_MERCHANT_ID - or not _settings_instance.FREEKASSA_FIRST_SECRET - or not _settings_instance.FREEKASSA_SECOND_SECRET - ): - logging.warning( - "CRITICAL: FreeKassa is enabled but credentials are incomplete (merchant ID or secret words). FreeKassa payments will not work." - ) + if _settings_instance.FREEKASSA_ENABLED: + if ( + not _settings_instance.FREEKASSA_MERCHANT_ID + or not _settings_instance.FREEKASSA_API_KEY + ): + logging.warning( + "CRITICAL: FreeKassa is enabled but SHOP_ID or API key is missing. FreeKassa payments will not work." + ) + if not _settings_instance.FREEKASSA_SECOND_SECRET: + logging.warning( + "WARNING: FreeKassa second secret is not set. Incoming payment notifications cannot be verified." + ) except ValidationError as e: logging.critical( diff --git a/locales/en.json b/locales/en.json index 344f69e..47ac410 100644 --- a/locales/en.json +++ b/locales/en.json @@ -34,6 +34,10 @@ "pay_with_cryptopay_button": "💎 CryptoBot", "pay_with_tribute_button": "❤️ Tribute", "pay_with_stars_button": "🌟 Telegram Stars", + "freekassa_choose_method": "Choose how to pay with FreeKassa:", + "freekassa_method_qr": "📱 SBP QR (i=44)", + "freekassa_method_card": "💳 Bank Card (i=36)", + "freekassa_method_sberpay": "🏦 SberPay (i=43)", "connect_button": "🔗 Connect", "cancel_button": "❌ Cancel", "payment_description_subscription": "Subscription payment for {months} mo.", diff --git a/locales/ru.json b/locales/ru.json index 89eff6d..d3f6080 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -34,6 +34,10 @@ "pay_with_cryptopay_button": "💎 CryptoBot", "pay_with_tribute_button": "❤️ Tribute", "pay_with_stars_button": "🌟 Звезды Telegram", + "freekassa_choose_method": "Выберите способ оплаты FreeKassa:", + "freekassa_method_qr": "📱 QR по СБП (i=44)", + "freekassa_method_card": "💳 Банковская карта РФ (i=36)", + "freekassa_method_sberpay": "🏦 SberPay (i=43)", "connect_button": "🔗 Подключиться", "cancel_button": "❌ Отмена", "payment_description_subscription": "Оплата подписки на {months} мес.", diff --git a/Документация Freekassa Api.pdf b/Документация Freekassa Api.pdf new file mode 100644 index 0000000..77289d1 Binary files /dev/null and b/Документация Freekassa Api.pdf differ