From bdade47758bd54c1b922cf9b562fec3717a75c46 Mon Sep 17 00:00:00 2001 From: raufakchurin Date: Sat, 11 Oct 2025 18:06:49 +0500 Subject: [PATCH] v_0.0 --- README.md | 13 +- bot/app/factories/build_services.py | 11 +- bot/app/web/web_server.py | 8 +- bot/handlers/admin/payments.py | 7 +- bot/handlers/admin/statistics.py | 3 +- bot/handlers/user/subscription/payments.py | 134 +++++++++- bot/keyboards/inline/user_keyboards.py | 3 + bot/main_bot.py | 1 + bot/services/freekassa_service.py | 289 +++++++++++++++++++++ bot/services/notification_service.py | 1 + config/settings.py | 28 ++ locales/en.json | 1 + locales/ru.json | 1 + 13 files changed, 489 insertions(+), 11 deletions(-) create mode 100644 bot/services/freekassa_service.py diff --git a/README.md b/README.md index d5d100d..b8f8c9e 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ - **Пробная подписка:** Система пробных подписок для новых пользователей (активируется вручную по кнопке). - **Промокоды:** Возможность применять промокоды для получения скидок или бонусных дней. - **Реферальная программа:** Пользователи могут приглашать друзей и получать за это бонусные дни подписки. -- **Оплата:** Поддержка оплаты через YooKassa, CryptoPay, Telegram Stars и Tribute. +- **Оплата:** Поддержка оплаты через YooKassa, FreeKassa, 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,12 @@ | `YOOKASSA_SECRET_KEY`| Секретный ключ магазина YooKassa. | | `CRYPTOPAY_ENABLED` | Включить/выключить CryptoPay (`true`/`false`). | | `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_CURRENCY` | Код валюты платежа (например, `RUB`). | | `STARS_ENABLED` | Включить/выключить Telegram Stars (`true`/`false`). | | `TRIBUTE_ENABLED`| Включить/выключить Tribute (`true`/`false`). | @@ -125,12 +131,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..e2b9c24 100644 --- a/bot/handlers/user/subscription/payments.py +++ b/bot/handlers/user/subscription/payments.py @@ -6,6 +6,7 @@ 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 +262,138 @@ 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 = freekassa_service.currency 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 + + 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) + + if payment_link: + 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, + ) + 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 + try: + await callback.answer() + except Exception: + pass + return + + 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 +568,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..bd907aa --- /dev/null +++ b/bot/services/freekassa_service.py @@ -0,0 +1,289 @@ +import hashlib +import logging +from decimal import Decimal, ROUND_HALF_UP +from typing import Optional, Dict, Any +from urllib.parse import urlencode + +from aiohttp import 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.merchant_id: Optional[str] = settings.FREEKASSA_MERCHANT_ID + self.first_secret: Optional[str] = settings.FREEKASSA_FIRST_SECRET + 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.configured: bool = bool( + settings.FREEKASSA_ENABLED + and self.merchant_id + and self.first_secret + and self.second_secret + ) + if not self.configured: + logging.warning("FreeKassaService initialized but not fully configured. Payments disabled.") + + @staticmethod + def _format_amount(amount: float) -> str: + """Format amount for signatures with two decimal places.""" + quantized = Decimal(str(amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + return f"{quantized:.2f}" + + def build_payment_link( + self, + *, + payment_db_id: int, + user_id: int, + months: int, + amount: float, + extra_params: Optional[Dict[str, Any]] = None, + ) -> Optional[str]: + if not self.configured: + logging.error("FreeKassaService is not configured. Cannot build payment link.") + return None + + 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() + + params: Dict[str, Any] = { + "m": self.merchant_id, + "oa": amount_str, + "o": str(payment_db_id), + "currency": self.currency, + "s": signature, + "us_user_id": str(user_id), + "us_months": str(months), + } + if extra_params: + for key, value in extra_params.items(): + if value is None: + continue + params[f"us_{key}"] = value + + query_string = urlencode(params, doseq=False, safe=":") + return f"{self.payment_url}?{query_string}" + + def _validate_signature(self, merchant_order_id: str, amount: str, provided_signature: str) -> bool: + if not self.configured: + 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() + + 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") + + if not data: + try: + data = await request.json() + except Exception: + data = {} + + def _get(key: str, default: Optional[str] = None) -> Optional[str]: + return data.get(key) or data.get(key.lower()) or default + + merchant_id = _get("MERCHANT_ID") + if merchant_id != self.merchant_id: + logging.error(f"FreeKassa webhook: merchant mismatch (got {merchant_id})") + return web.Response(status=403, text="merchant_mismatch") + + signature = _get("SIGN") + 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): + 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..659c3ea 100644 --- a/config/settings.py +++ b/config/settings.py @@ -44,6 +44,13 @@ 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") + YOOKASSA_ENABLED: bool = Field(default=True) STARS_ENABLED: bool = Field(default=True) TRIBUTE_ENABLED: bool = Field(default=True) @@ -236,6 +243,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 +386,14 @@ 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." + ) except ValidationError as e: logging.critical( diff --git a/locales/en.json b/locales/en.json index 6d6b0a3..344f69e 100644 --- a/locales/en.json +++ b/locales/en.json @@ -30,6 +30,7 @@ "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", diff --git a/locales/ru.json b/locales/ru.json index 686bc23..89eff6d 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -30,6 +30,7 @@ "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",