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 d5d100d..5601547 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ - **Пробная подписка:** Система пробных подписок для новых пользователей (активируется вручную по кнопке). - **Промокоды:** Возможность применять промокоды для получения скидок или бонусных дней. - **Реферальная программа:** Пользователи могут приглашать друзей и получать за это бонусные дни подписки. -- **Оплата:** Поддержка оплаты через YooKassa, CryptoPay, Telegram Stars и Tribute. +- **Оплата:** Поддержка оплаты через YooKassa, FreeKassa (REST API), CryptoPay, Telegram Stars и Tribute. ### Для администраторов: - **Защищенная админ-панель:** Доступ только для администраторов, указанных в `ADMIN_IDS`. @@ -27,7 +27,7 @@ - **Aiogram 3.x:** Асинхронный фреймворк для Telegram ботов. - **aiohttp:** Для запуска веб-сервера (вебхуки). - **SQLAlchemy 2.x & asyncpg:** Асинхронная работа с базой данных PostgreSQL. -- **YooKassa, aiocryptopay:** SDK для интеграции с платежными системами. +- **YooKassa, FreeKassa API, aiocryptopay:** Интеграции с платежными системами. - **Pydantic:** Для управления настройками из `.env` файла. - **Docker & Docker Compose:** Для контейнеризации и развертывания. @@ -81,6 +81,13 @@ | `YOOKASSA_SECRET_KEY`| Секретный ключ магазина YooKassa. | | `CRYPTOPAY_ENABLED` | Включить/выключить CryptoPay (`true`/`false`). | | `CRYPTOPAY_TOKEN` | Токен из вашего CryptoPay App. | + | `FREEKASSA_ENABLED` | Включить/выключить FreeKassa (`true`/`false`). | + | `FREEKASSA_MERCHANT_ID` | ID вашего магазина в FreeKassa. | + | `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`). | @@ -125,12 +132,13 @@ Эта команда скачает образ и запустит сервис в фоновом режиме. 4. **Настройка вебхуков (Обязательно):** - Вебхуки являются **обязательным** компонентом для работы бота, так как они используются для получения уведомлений от платежных систем (YooKassa, CryptoPay, Tribute) и панели Remnawave. + Вебхуки являются **обязательным** компонентом для работы бота, так как они используются для получения уведомлений от платежных систем (YooKassa, FreeKassa, CryptoPay, Tribute) и панели Remnawave. Вам понадобится обратный прокси (например, Nginx) для обработки HTTPS-трафика и перенаправления запросов на контейнер с ботом. **Пути для перенаправления:** - `https://<ваш_домен>/webhook/yookassa` → `http://remnawave-tg-shop:/webhook/yookassa` + - `https://<ваш_домен>/webhook/freekassa` → `http://remnawave-tg-shop:/webhook/freekassa` - `https://<ваш_домен>/webhook/cryptopay` → `http://remnawave-tg-shop:/webhook/cryptopay` - `https://<ваш_домен>/webhook/tribute` → `http://remnawave-tg-shop:/webhook/tribute` - `https://<ваш_домен>/webhook/panel` → `http://remnawave-tg-shop:/webhook/panel` diff --git a/bot/app/factories/build_services.py b/bot/app/factories/build_services.py index 68b770b..b261445 100644 --- a/bot/app/factories/build_services.py +++ b/bot/app/factories/build_services.py @@ -12,6 +12,7 @@ from bot.services.stars_service import StarsService from bot.services.tribute_service import TributeService from bot.services.crypto_pay_service import CryptoPayService from bot.services.panel_webhook_service import PanelWebhookService +from bot.services.freekassa_service import FreeKassaService def build_core_services( @@ -36,6 +37,14 @@ def build_core_services( subscription_service, referral_service, ) + freekassa_service = FreeKassaService( + bot=bot, + settings=settings, + i18n=i18n, + async_session_factory=async_session_factory, + subscription_service=subscription_service, + referral_service=referral_service, + ) tribute_service = TributeService( bot, settings, @@ -70,9 +79,9 @@ def build_core_services( "promo_code_service": promo_code_service, "stars_service": stars_service, "cryptopay_service": cryptopay_service, + "freekassa_service": freekassa_service, "tribute_service": tribute_service, "panel_webhook_service": panel_webhook_service, "yookassa_service": yookassa_service, } - diff --git a/bot/app/web/web_server.py b/bot/app/web/web_server.py index afb2a43..0604ff4 100644 --- a/bot/app/web/web_server.py +++ b/bot/app/web/web_server.py @@ -27,6 +27,7 @@ async def build_and_start_web_app( "referral_service", "panel_service", "stars_service", + "freekassa_service", "cryptopay_service", "tribute_service", "panel_webhook_service", @@ -50,6 +51,7 @@ async def build_and_start_web_app( from bot.services.tribute_service import tribute_webhook_route from bot.services.crypto_pay_service import cryptopay_webhook_route from bot.services.panel_webhook_service import panel_webhook_route + from bot.services.freekassa_service import freekassa_webhook_route tribute_path = settings.tribute_webhook_path if tribute_path.startswith("/"): @@ -61,6 +63,11 @@ async def build_and_start_web_app( app.router.add_post(cp_path, cryptopay_webhook_route) logging.info(f"CryptoPay webhook route configured at: [POST] {cp_path}") + fk_path = settings.freekassa_webhook_path + if fk_path.startswith("/"): + app.router.add_post(fk_path, freekassa_webhook_route) + logging.info(f"FreeKassa webhook route configured at: [POST] {fk_path}") + # YooKassa webhook (register only when base URL present and path configured) yk_path = settings.yookassa_webhook_path if settings.WEBHOOK_BASE_URL and yk_path and yk_path.startswith("/"): @@ -88,4 +95,3 @@ async def build_and_start_web_app( # Run until cancelled await asyncio.Event().wait() - diff --git a/bot/handlers/admin/payments.py b/bot/handlers/admin/payments.py index d203e98..19aad73 100644 --- a/bot/handlers/admin/payments.py +++ b/bot/handlers/admin/payments.py @@ -39,7 +39,7 @@ def format_payment_text(payment: Payment, i18n: JsonI18n, lang: str) -> str: _ = lambda key, **kwargs: i18n.gettext(lang, key, **kwargs) status_emoji = "✅" if payment.status == 'succeeded' else ( - "⏳" if payment.status in ['pending', 'pending_yookassa'] else "❌" + "⏳" if payment.status in ['pending', 'pending_yookassa', 'pending_freekassa'] else "❌" ) user_info = f"User {payment.user_id}" @@ -54,7 +54,8 @@ def format_payment_text(payment: Payment, i18n: JsonI18n, lang: str) -> str: 'yookassa': 'YooKassa', 'tribute': 'Tribute', 'telegram_stars': 'Telegram Stars', - 'cryptopay': 'CryptoPay' + 'cryptopay': 'CryptoPay', + 'freekassa': 'FreeKassa', }.get(payment.provider, payment.provider or 'Unknown') return ( @@ -246,4 +247,4 @@ async def export_payments_csv_handler(callback: types.CallbackQuery, i18n_data: @router.callback_query(F.data == "noop") async def noop_handler(callback: types.CallbackQuery): """Handle no-op callback (for pagination display).""" - await callback.answer() \ No newline at end of file + await callback.answer() diff --git a/bot/handlers/admin/statistics.py b/bot/handlers/admin/statistics.py index 34561c3..726b462 100644 --- a/bot/handlers/admin/statistics.py +++ b/bot/handlers/admin/statistics.py @@ -163,8 +163,7 @@ async def show_statistics_handler(callback: types.CallbackQuery, f"\n{_('admin_stats_recent_payments_header')}") for payment in last_payments_models: status_emoji = "✅" if payment.status == 'succeeded' else ( - "⏳" if payment.status == 'pending' - or payment.status == 'pending_yookassa' else "❌") + "⏳" if payment.status in ['pending', 'pending_yookassa', 'pending_freekassa'] else "❌") user_info = f"User {payment.user_id}" if payment.user and payment.user.username: diff --git a/bot/handlers/user/subscription/payments.py b/bot/handlers/user/subscription/payments.py index 3222a35..0b89eda 100644 --- a/bot/handlers/user/subscription/payments.py +++ b/bot/handlers/user/subscription/payments.py @@ -1,11 +1,13 @@ import logging from aiogram import Router, F, types +from aiogram.utils.keyboard import InlineKeyboardBuilder from typing import Optional from sqlalchemy.ext.asyncio import AsyncSession from config.settings import Settings from bot.keyboards.inline.user_keyboards import get_payment_method_keyboard, get_payment_url_keyboard from bot.services.yookassa_service import YooKassaService +from bot.services.freekassa_service import FreeKassaService from bot.services.crypto_pay_service import CryptoPayService from bot.services.stars_service import StarsService from bot.middlewares.i18n import JsonI18n @@ -261,6 +263,289 @@ async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Setti pass +@router.callback_query(F.data.startswith("pay_fk:")) +async def pay_fk_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(get_text("error_occurred_try_again"), show_alert=True) + except Exception: + pass + return + + if not freekassa_service or not freekassa_service.configured: + logging.error("FreeKassa service is not configured or unavailable.") + 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: + _, data_payload = callback.data.split(":", 1) + months_str, price_str = data_payload.split(":") + months = int(months_str) + price_rub = float(price_str) + except (ValueError, IndexError): + logging.error(f"Invalid pay_fk data in callback: {callback.data}") + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + user_id = callback.from_user.id + payment_description = get_text("payment_description_subscription", months=months) + currency_code = getattr(freekassa_service, "default_currency", None) or settings.DEFAULT_CURRENCY_SYMBOL or "RUB" + + payment_record_payload = { + "user_id": user_id, + "amount": price_rub, + "currency": currency_code, + "status": "pending_freekassa", + "description": payment_description, + "subscription_duration_months": months, + "provider": "freekassa", + } + + try: + payment_record = await payment_dal.create_payment_record(session, payment_record_payload) + await session.commit() + except Exception as e_db_create: + await session.rollback() + logging.error( + f"FreeKassa: failed to create payment record for user {user_id}: {e_db_create}", + exc_info=True, + ) + try: + await callback.message.edit_text(get_text("error_creating_payment_record")) + except Exception: + pass + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + 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) + + 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.answer( + get_text("freekassa_choose_method"), + reply_markup=method_keyboard.as_markup(), + ) + 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(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, + payment_record.payment_id, + "failed_creation", + ) + await session.commit() + except Exception as e_status: + await session.rollback() + logging.error(f"FreeKassa: failed to mark payment {payment_record.payment_id} as failed_creation: {e_status}", exc_info=True) + + try: + await callback.message.edit_text(get_text("error_payment_gateway")) + except Exception: + pass + try: + await callback.answer(get_text("error_payment_gateway"), show_alert=True) + except Exception: + pass + + @router.callback_query(F.data.startswith("pay_crypto:")) async def pay_crypto_callback_handler( callback: types.CallbackQuery, @@ -435,4 +720,3 @@ async def handle_successful_stars_payment( stars_amount=stars_amount, i18n_data=i18n_data, ) - diff --git a/bot/keyboards/inline/user_keyboards.py b/bot/keyboards/inline/user_keyboards.py index 456eae4..dde31e3 100644 --- a/bot/keyboards/inline/user_keyboards.py +++ b/bot/keyboards/inline/user_keyboards.py @@ -126,6 +126,9 @@ def get_payment_method_keyboard(months: int, price: float, if settings.YOOKASSA_ENABLED: builder.button(text=_("pay_with_yookassa_button"), callback_data=f"pay_yk:{months}:{price}") + if settings.FREEKASSA_ENABLED: + builder.button(text=_("pay_with_freekassa_button"), + callback_data=f"pay_fk:{months}:{price}") if settings.CRYPTOPAY_ENABLED: builder.button(text=_("pay_with_cryptopay_button"), callback_data=f"pay_crypto:{months}:{price}") diff --git a/bot/main_bot.py b/bot/main_bot.py index e25a0dc..0a7fa10 100644 --- a/bot/main_bot.py +++ b/bot/main_bot.py @@ -199,6 +199,7 @@ async def on_shutdown_configured(dispatcher: Dispatcher): for service_key in ( "panel_service", "cryptopay_service", + "freekassa_service", "tribute_service", "panel_webhook_service", "yookassa_service", diff --git a/bot/services/freekassa_service.py b/bot/services/freekassa_service.py new file mode 100644 index 0000000..76016b7 --- /dev/null +++ b/bot/services/freekassa_service.py @@ -0,0 +1,394 @@ +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, Tuple + +from aiohttp import ClientSession, ClientTimeout, web +from aiogram import Bot +from sqlalchemy.orm import sessionmaker + +from config.settings import Settings +from bot.middlewares.i18n import JsonI18n +from bot.services.subscription_service import SubscriptionService +from bot.services.referral_service import ReferralService +from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard +from bot.services.notification_service import NotificationService +from db.dal import payment_dal, user_dal +from bot.utils.text_sanitizer import sanitize_display_name, username_for_display + + +class FreeKassaService: + def __init__( + self, + *, + bot: Bot, + settings: Settings, + i18n: JsonI18n, + async_session_factory: sessionmaker, + subscription_service: SubscriptionService, + referral_service: ReferralService, + ): + self.bot = bot + self.settings = settings + self.i18n = i18n + self.async_session_factory = async_session_factory + self.subscription_service = subscription_service + self.referral_service = referral_service + + 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.default_currency: str = ( + settings.FREEKASSA_CURRENCY or settings.DEFAULT_CURRENCY_SYMBOL or "RUB" + ).upper() + self.server_ip: Optional[str] = settings.FREEKASSA_PAYMENT_IP + + 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 payloads and signature with two decimal places.""" + quantized = Decimal(str(amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + return f"{quantized:.2f}" + + 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, + ) -> Tuple[bool, Dict[str, Any]]: + if not self.configured: + 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) + currency_code = (currency or self.default_currency or "RUB").upper() + + 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 + payload[key] = value + + payload["signature"] = self._sign_payload(payload) + + 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 + + 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: + return web.Response(status=503, text="freekassa_disabled") + + try: + data = await request.post() + except Exception as e: + logging.error(f"FreeKassa webhook: failed to read POST data: {e}") + return web.Response(status=400, text="bad_request") + + payload_dict: Dict[str, Any] + if data: + payload_dict = {str(k): v for k, v in data.items()} + else: + try: + 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]: + 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.shop_id: + logging.error(f"FreeKassa webhook: merchant mismatch (got {merchant_id})") + return web.Response(status=403, text="merchant_mismatch") + + signature = _get("SIGN") or _get("signature") + if not signature: + logging.error("FreeKassa webhook: missing signature") + return web.Response(status=400, text="missing_signature") + + order_id_str = _get("MERCHANT_ORDER_ID") or _get("ORDER_ID") or _get("o") + amount_str = _get("AMOUNT") or _get("OA") or _get("amount") + provider_payment_id = _get("intid") or _get("payment_id") or _get("transaction_id") + + if not order_id_str or not amount_str: + 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, payload_dict): + logging.error("FreeKassa webhook: invalid signature") + return web.Response(status=403, text="invalid_signature") + + try: + payment_db_id = int(order_id_str) + except (TypeError, ValueError): + logging.error(f"FreeKassa webhook: invalid order_id value '{order_id_str}'") + return web.Response(status=400, text="invalid_order_id") + + async with self.async_session_factory() as session: + payment = await payment_dal.get_payment_by_db_id(session, payment_db_id) + if not payment: + logging.error(f"FreeKassa webhook: payment {payment_db_id} not found") + return web.Response(status=404, text="payment_not_found") + + if payment.status == "succeeded": + logging.info(f"FreeKassa webhook: payment {payment_db_id} already succeeded") + return web.Response(text="YES") + + # Optional amount verification + try: + amount_decimal = Decimal(amount_str) + expected_amount = Decimal(str(payment.amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + if amount_decimal.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) != expected_amount: + logging.warning( + f"FreeKassa webhook: amount mismatch for payment {payment_db_id} " + f"(expected {expected_amount}, got {amount_decimal})" + ) + except Exception as e: + logging.warning(f"FreeKassa webhook: failed to compare amount for payment {payment_db_id}: {e}") + + activation = None + referral_bonus = None + try: + await payment_dal.update_provider_payment_and_status( + session=session, + payment_db_id=payment.payment_id, + provider_payment_id=str(provider_payment_id or f"freekassa:{order_id_str}"), + new_status="succeeded", + ) + + months = payment.subscription_duration_months or 1 + + activation = await self.subscription_service.activate_subscription( + session, + payment.user_id, + months, + float(payment.amount), + payment.payment_id, + provider="freekassa", + ) + + referral_bonus = await self.referral_service.apply_referral_bonuses_for_payment( + session, + payment.user_id, + months, + current_payment_db_id=payment.payment_id, + skip_if_active_before_payment=False, + ) + + await session.commit() + except Exception as e: + await session.rollback() + logging.error(f"FreeKassa webhook: failed to process payment {payment_db_id}: {e}", exc_info=True) + return web.Response(status=500, text="processing_error") + + db_user = payment.user or await user_dal.get_user_by_id(session, payment.user_id) + lang = db_user.language_code if db_user and db_user.language_code else self.settings.DEFAULT_LANGUAGE + _ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k + + config_link = None + final_end = None + months = payment.subscription_duration_months or 1 + if activation: + config_link = activation.get("subscription_url") + final_end = activation.get("end_date") + + applied_days = 0 + if referral_bonus and referral_bonus.get("referee_new_end_date"): + final_end = referral_bonus["referee_new_end_date"] + applied_days = referral_bonus.get("referee_bonus_applied_days", 0) + + if not final_end and activation and activation.get("end_date"): + final_end = activation["end_date"] + + if not config_link: + config_link = _("config_link_not_available") + if final_end: + end_date_str = final_end.strftime("%Y-%m-%d") + else: + end_date_str = _("config_link_not_available") + + if applied_days: + inviter_name_display = _("friend_placeholder") + if db_user and db_user.referred_by_id: + inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id) + if inviter: + safe_name = sanitize_display_name(inviter.first_name) if inviter.first_name else None + if safe_name: + inviter_name_display = safe_name + elif inviter.username: + inviter_name_display = username_for_display(inviter.username, with_at=False) + text = _( + "payment_successful_with_referral_bonus_full", + months=months, + base_end_date=activation["end_date"].strftime("%Y-%m-%d") if activation and activation.get("end_date") else end_date_str, + bonus_days=applied_days, + final_end_date=end_date_str, + inviter_name=inviter_name_display, + config_link=config_link, + ) + else: + text = _( + "payment_successful_full", + months=months, + end_date=end_date_str, + config_link=config_link, + ) + + markup = get_connect_and_main_keyboard(lang, self.i18n, self.settings, config_link) + try: + await self.bot.send_message( + payment.user_id, + text, + reply_markup=markup, + parse_mode="HTML", + disable_web_page_preview=True, + ) + except Exception as e: + logging.error(f"FreeKassa notification: failed to send message to user {payment.user_id}: {e}") + + try: + notification_service = NotificationService(self.bot, self.settings, self.i18n) + await notification_service.notify_payment_received( + user_id=payment.user_id, + amount=float(payment.amount), + currency=self.currency or self.settings.DEFAULT_CURRENCY_SYMBOL, + months=months, + payment_provider="freekassa", + username=db_user.username if db_user else None, + ) + except Exception as e: + logging.error(f"FreeKassa notification: failed to notify admins: {e}") + + return web.Response(text="YES") + + +async def freekassa_webhook_route(request: web.Request) -> web.Response: + service: FreeKassaService = request.app["freekassa_service"] + return await service.webhook_route(request) diff --git a/bot/services/notification_service.py b/bot/services/notification_service.py index 9a774b8..24f1a39 100644 --- a/bot/services/notification_service.py +++ b/bot/services/notification_service.py @@ -158,6 +158,7 @@ class NotificationService: provider_emoji = { "yookassa": "💳", + "freekassa": "💳", "cryptopay": "₿", "stars": "⭐", "tribute": "💎" diff --git a/config/settings.py b/config/settings.py index 21878b4..b177561 100644 --- a/config/settings.py +++ b/config/settings.py @@ -44,6 +44,15 @@ class Settings(BaseSettings): CRYPTOPAY_ASSET: str = Field(default="RUB") CRYPTOPAY_ENABLED: bool = Field(default=True) + FREEKASSA_ENABLED: bool = Field(default=False) + FREEKASSA_MERCHANT_ID: Optional[str] = None + FREEKASSA_FIRST_SECRET: Optional[str] = None + 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) TRIBUTE_ENABLED: bool = Field(default=True) @@ -236,6 +245,19 @@ class Settings(BaseSettings): return f"{base.rstrip('/')}{self.cryptopay_webhook_path}" return None + @computed_field + @property + def freekassa_webhook_path(self) -> str: + return "/webhook/freekassa" + + @computed_field + @property + def freekassa_full_webhook_url(self) -> Optional[str]: + base = self.WEBHOOK_BASE_URL + if base: + return f"{base.rstrip('/')}{self.freekassa_webhook_path}" + return None + # Computed YooKassa receipt fields based on recurring toggle @computed_field @property @@ -366,6 +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: + 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 43dc91b..4fe5c49 100644 --- a/locales/en.json +++ b/locales/en.json @@ -30,9 +30,14 @@ "choose_payment_method": "Choose payment method:", "pay_button": "💳 Pay", "pay_with_yookassa_button": "💳 YooKassa", + "pay_with_freekassa_button": "💳 FreeKassa", "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 0fd7895..d667d29 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -30,9 +30,14 @@ "choose_payment_method": "Выберите способ оплаты:", "pay_button": "💳 Оплатить", "pay_with_yookassa_button": "💳 ЮKassa", + "pay_with_freekassa_button": "💳 FreeKassa", "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