From 719369057ff217b4807c0c1c6a95be8824e025cb Mon Sep 17 00:00:00 2001 From: machka pasla Date: Thu, 4 Dec 2025 12:13:17 +0300 Subject: [PATCH 01/16] fix privacy error --- bot/handlers/admin/user_management.py | 32 +++---------- bot/services/notification_service.py | 48 +++++++++++++++---- bot/utils/message_queue.py | 69 ++++++++++++++++++++++++--- bot/utils/telegram_markup.py | 39 +++++++++++++++ 4 files changed, 148 insertions(+), 40 deletions(-) create mode 100644 bot/utils/telegram_markup.py diff --git a/bot/handlers/admin/user_management.py b/bot/handlers/admin/user_management.py index 3431b8f..07f092d 100644 --- a/bot/handlers/admin/user_management.py +++ b/bot/handlers/admin/user_management.py @@ -24,6 +24,10 @@ from bot.utils.text_sanitizer import ( sanitize_username, username_for_display, ) +from bot.utils.telegram_markup import ( + is_profile_link_error, + remove_profile_link_buttons, +) router = Router(name="admin_user_management_router") USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{5,32}$") @@ -174,27 +178,6 @@ def get_user_card_keyboard(user_id: int, i18n_instance, lang: str, return builder -def _remove_profile_link_buttons( - markup: Optional[types.InlineKeyboardMarkup]) -> Optional[types.InlineKeyboardMarkup]: - """Drop buttons that rely on tg://user links to avoid BUTTON_USER_INVALID errors.""" - if not markup or not markup.inline_keyboard: - return None - - cleaned_rows = [] - for row in markup.inline_keyboard: - filtered_row = [ - button for button in row - if not (getattr(button, "url", None) and button.url.startswith("tg://user?id=")) - ] - if filtered_row: - cleaned_rows.append(filtered_row) - - if not cleaned_rows: - return None - - return types.InlineKeyboardMarkup(inline_keyboard=cleaned_rows) - - async def _send_with_profile_link_fallback( sender: Callable[..., Awaitable[Any]], *, @@ -210,16 +193,15 @@ async def _send_with_profile_link_fallback( try: await sender(**send_kwargs) except TelegramBadRequest as exc: - message = getattr(exc, "message", "") or str(exc) - if "BUTTON_USER_INVALID" not in message: + if not is_profile_link_error(exc): raise logging.warning( "Telegram rejected profile buttons for user %s: %s. Retrying without tg:// links.", user_id, - message, + getattr(exc, "message", "") or str(exc), ) - fallback_markup = _remove_profile_link_buttons(markup) + fallback_markup = remove_profile_link_buttons(markup) send_kwargs["reply_markup"] = fallback_markup await sender(**send_kwargs) diff --git a/bot/services/notification_service.py b/bot/services/notification_service.py index 8a1e6c3..f2d6db5 100644 --- a/bot/services/notification_service.py +++ b/bot/services/notification_service.py @@ -3,7 +3,7 @@ import asyncio from aiogram import Bot from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from aiogram.utils.text_decorations import html_decoration as hd -from aiogram.exceptions import TelegramRetryAfter +from aiogram.exceptions import TelegramBadRequest from datetime import datetime, timezone from typing import Optional, Union, Dict, Any, Callable @@ -15,6 +15,10 @@ from bot.utils.text_sanitizer import ( display_name_or_fallback, username_for_display, ) +from bot.utils.telegram_markup import ( + is_profile_link_error, + remove_profile_link_buttons, +) class NotificationService: @@ -81,14 +85,42 @@ class NotificationService: queue_manager = get_queue_manager() if not queue_manager: logging.warning("Message queue manager not available, falling back to direct send") + final_thread_id = thread_id or self.settings.LOG_THREAD_ID + + def _build_kwargs(markup: Optional[InlineKeyboardMarkup]) -> Dict[str, Any]: + kwargs: Dict[str, Any] = { + "chat_id": self.settings.LOG_CHAT_ID, + "text": message, + "parse_mode": "HTML", + "disable_web_page_preview": True, + } + if markup: + kwargs["reply_markup"] = markup + if final_thread_id: + kwargs["message_thread_id"] = final_thread_id + return kwargs + try: - await self.bot.send_message( - chat_id=self.settings.LOG_CHAT_ID, - text=message, - parse_mode="HTML", - disable_web_page_preview=True, - reply_markup=reply_markup, - message_thread_id=thread_id or self.settings.LOG_THREAD_ID + await self.bot.send_message(**_build_kwargs(reply_markup)) + except TelegramBadRequest as exc: + if is_profile_link_error(exc): + fallback_markup = remove_profile_link_buttons(reply_markup) + logging.warning( + "Telegram rejected profile buttons for log chat %s: %s. " + "Retrying without tg:// links.", + self.settings.LOG_CHAT_ID, + getattr(exc, "message", "") or str(exc), + ) + try: + await self.bot.send_message(**_build_kwargs(fallback_markup)) + except Exception as retry_exc: + logging.error( + "Failed to send notification without profile buttons to log " + f"channel {self.settings.LOG_CHAT_ID}: {retry_exc}" + ) + return + logging.error( + f"Failed to send notification to log channel {self.settings.LOG_CHAT_ID}: {exc}" ) except Exception as e: logging.error(f"Failed to send notification to log channel {self.settings.LOG_CHAT_ID}: {e}") diff --git a/bot/utils/message_queue.py b/bot/utils/message_queue.py index 0f9314c..47fe979 100644 --- a/bot/utils/message_queue.py +++ b/bot/utils/message_queue.py @@ -5,6 +5,12 @@ from dataclasses import dataclass from datetime import datetime, timedelta from collections import deque from aiogram import Bot +from aiogram.exceptions import TelegramBadRequest + +from bot.utils.telegram_markup import ( + is_profile_link_error, + remove_profile_link_buttons, +) @dataclass @@ -51,14 +57,31 @@ class MessageQueue: message = self.queue.popleft() try: await self._send_message(message) - self.last_send_times.append(datetime.now()) - self.total_sent += 1 + self._record_send_time() + + except TelegramBadRequest as exc: + fallback_message = self._build_profile_link_fallback(message, exc) + if fallback_message: + logging.warning( + "Telegram rejected profile buttons for chat %s: %s. " + "Retrying without tg:// links.", + message.chat_id, + getattr(exc, "message", "") or str(exc), + ) + try: + await self._send_message(fallback_message) + self._record_send_time() + continue + except Exception as retry_exc: + self.total_failed += 1 + logging.error( + f"Failed to send fallback message to {message.chat_id}: {retry_exc}" + ) + continue + + self.total_failed += 1 + logging.error(f"Failed to send queued message to {message.chat_id}: {exc}") - # Keep only recent send times (last minute) - cutoff_time = datetime.now() - timedelta(seconds=60) - while self.last_send_times and self.last_send_times[0] < cutoff_time: - self.last_send_times.popleft() - except Exception as e: self.total_failed += 1 logging.error(f"Failed to send queued message to {message.chat_id}: {e}") @@ -77,6 +100,38 @@ class MessageQueue: if time_since_last < self.delay_between_messages: wait_time = self.delay_between_messages - time_since_last await asyncio.sleep(wait_time) + + def _record_send_time(self) -> None: + """Track sent message timestamps and purge old entries for rate limiting.""" + now = datetime.now() + self.last_send_times.append(now) + self.total_sent += 1 + + cutoff_time = now - timedelta(seconds=60) + while self.last_send_times and self.last_send_times[0] < cutoff_time: + self.last_send_times.popleft() + + def _build_profile_link_fallback( + self, message: QueuedMessage, exc: Exception + ) -> Optional[QueuedMessage]: + """Create a fallback message without tg://user buttons when Telegram rejects them.""" + if not is_profile_link_error(exc): + return None + + markup = message.kwargs.get("reply_markup") + if markup is None: + return None + + safe_markup = remove_profile_link_buttons(markup) + fallback_kwargs = dict(message.kwargs) + fallback_kwargs["reply_markup"] = safe_markup + + return QueuedMessage( + chat_id=message.chat_id, + method_name=message.method_name, + kwargs=fallback_kwargs, + callback=message.callback, + ) async def _send_message(self, message: QueuedMessage) -> Any: """Send a single message - to be implemented by subclass""" diff --git a/bot/utils/telegram_markup.py b/bot/utils/telegram_markup.py new file mode 100644 index 0000000..04aaddf --- /dev/null +++ b/bot/utils/telegram_markup.py @@ -0,0 +1,39 @@ +from typing import Optional + +from aiogram import types + +PROFILE_BUTTON_ERROR_CODES = ("BUTTON_USER_INVALID", "BUTTON_USER_PRIVACY_RESTRICTED") +TG_USER_LINK_PREFIX = "tg://user?id=" + + +def remove_profile_link_buttons( + markup: Optional[types.InlineKeyboardMarkup], +) -> Optional[types.InlineKeyboardMarkup]: + """Remove buttons that point to tg://user links to avoid privacy-related errors.""" + inline_keyboard = getattr(markup, "inline_keyboard", None) + if not markup or not inline_keyboard: + return None + + cleaned_rows = [] + for row in inline_keyboard: + filtered_row = [ + button + for button in row + if not ( + getattr(button, "url", None) + and str(button.url).startswith(TG_USER_LINK_PREFIX) + ) + ] + if filtered_row: + cleaned_rows.append(filtered_row) + + if not cleaned_rows: + return None + + return types.InlineKeyboardMarkup(inline_keyboard=cleaned_rows) + + +def is_profile_link_error(exc: BaseException) -> bool: + """Return True if Telegram rejected markup because of profile link buttons.""" + message = getattr(exc, "message", "") or str(exc) + return any(code in message for code in PROFILE_BUTTON_ERROR_CODES) From fb06fbd0e16b208f6ae06b3ab3feee7e38515aeb Mon Sep 17 00:00:00 2001 From: machka pasla Date: Sun, 7 Dec 2025 20:24:24 +0300 Subject: [PATCH 02/16] added platega.io --- .env.example | 9 + README.md | 35 ++- bot/app/factories/build_services.py | 12 +- bot/app/web/web_server.py | 8 +- bot/handlers/user/subscription/payments.py | 183 ++++++++++++ bot/keyboards/inline/user_keyboards.py | 3 + bot/main_bot.py | 1 + bot/services/notification_service.py | 3 +- bot/services/platega_service.py | 322 +++++++++++++++++++++ config/settings.py | 38 ++- locales/en.json | 1 + locales/ru.json | 1 + 12 files changed, 598 insertions(+), 18 deletions(-) create mode 100644 bot/services/platega_service.py diff --git a/.env.example b/.env.example index 7538c8b..2ed40f7 100644 --- a/.env.example +++ b/.env.example @@ -36,6 +36,7 @@ FREEKASSA_ENABLED=True # STARS_ENABLED=True # Turn on STARS TRIBUTE_ENABLED=True # Turn on TRIBUTE CRYPTOPAY_ENABLED=True # Turn on CRYPTOPAY +PLATEGA_ENABLED=False # Turn on PLATEGA # YooKassa Payment Gateway Configuration YOOKASSA_SHOP_ID=your_shop_id # Your store ID in YooKassa @@ -59,6 +60,14 @@ CRYPTOPAY_NETWORK=mainnet # CRYPTOPAY_CURRENCY_TYPE=fiat # Currency type (fiat or crypto) CRYPTOPAY_ASSET=RUB # Asset, e.g., RUB, BTC, USDT +# Platega Payment Gateway Configuration +PLATEGA_BASE_URL=https://app.platega.io # Base API URL +PLATEGA_MERCHANT_ID= # Your MerchantId from Platega +PLATEGA_SECRET= # API secret from Platega +PLATEGA_PAYMENT_METHOD=2 # Payment method ID (2=SBP QR, 10=RU cards, 12=International, 13=Crypto) +PLATEGA_RETURN_URL= # Optional: redirect after successful payment (defaults to bot link) +PLATEGA_FAILED_URL= # Optional: redirect after failed/cancelled payment (defaults to return URL) + # Tribute Payment Gateway Configuration TRIBUTE_API_KEY= # API key for verifying Tribute webhook signatures TRIBUTE_SKIP_NOTIFICATIONS=True # Skip renewal notifications for Tribute payments diff --git a/README.md b/README.md index 5d62ad4..1b9bd6f 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ - **Пробная подписка:** Система пробных подписок для новых пользователей (активируется вручную по кнопке). - **Промокоды:** Возможность применять промокоды для получения скидок или бонусных дней. - **Реферальная программа:** Пользователи могут приглашать друзей и получать за это бонусные дни подписки. -- **Оплата:** Поддержка оплаты через YooKassa, FreeKassa (REST API), CryptoPay, Telegram Stars и Tribute. +- **Оплата:** Поддержка оплаты через YooKassa, FreeKassa (REST API), Platega, CryptoPay, Telegram Stars и Tribute. ### Для администраторов: - **Защищенная админ-панель:** Доступ только для администраторов, указанных в `ADMIN_IDS`. @@ -28,7 +28,7 @@ - **Aiogram 3.x:** Асинхронный фреймворк для Telegram ботов. - **aiohttp:** Для запуска веб-сервера (вебхуки). - **SQLAlchemy 2.x & asyncpg:** Асинхронная работа с базой данных PostgreSQL. -- **YooKassa, FreeKassa API, aiocryptopay:** Интеграции с платежными системами. +- **YooKassa, FreeKassa API, Platega, aiocryptopay:** Интеграции с платежными системами. - **Pydantic:** Для управления настройками из `.env` файла. - **Docker & Docker Compose:** Для контейнеризации и развертывания. @@ -85,18 +85,24 @@ | `YOOKASSA_SECRET_KEY`| Секретный ключ магазина YooKassa. | | `YOOKASSA_AUTOPAYMENTS_ENABLED` | Включить автопродление (сохранение карт, автосписания, управление способами оплаты). | | `YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING` | Требовать обязательную привязку карты при оплате с автосписанием. Установите `false`, чтобы пользователю показывался чекбокс «Сохранить карту». | - | `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_PAYMENT_IP` | Внешний IP вашего сервера, который будет передаваться в запрос оплаты. | - | `FREEKASSA_PAYMENT_METHOD_ID` | ID метода оплаты через магазин FreeKassa. По умолчанию `44`. | - | `STARS_ENABLED` | Включить/выключить Telegram Stars (`true`/`false`). | - | `TRIBUTE_ENABLED`| Включить/выключить Tribute (`true`/`false`). | - +| `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_PAYMENT_IP` | Внешний IP вашего сервера, который будет передаваться в запрос оплаты. | +| `FREEKASSA_PAYMENT_METHOD_ID` | ID метода оплаты через магазин FreeKassa. По умолчанию `44`. | +| `STARS_ENABLED` | Включить/выключить Telegram Stars (`true`/`false`). | +| `TRIBUTE_ENABLED`| Включить/выключить Tribute (`true`/`false`). | +| `PLATEGA_ENABLED`| Включить/выключить Platega (`true`/`false`). | +| `PLATEGA_MERCHANT_ID`| MerchantId из личного кабинета Platega. | +| `PLATEGA_SECRET`| API секрет для запросов Platega. | +| `PLATEGA_PAYMENT_METHOD`| ID способа оплаты (2 — SBP QR, 10 — РФ карты, 12 — международные карты, 13 — crypto). | +| `PLATEGA_RETURN_URL`| (Опционально) URL редиректа после успешной оплаты. По умолчанию ссылка на бота. | +| `PLATEGA_FAILED_URL`| (Опционально) URL редиректа при ошибке/отмене. По умолчанию как `PLATEGA_RETURN_URL`. | +
Настройки подписок @@ -149,6 +155,7 @@ **Пути для перенаправления:** - `https://<ваш_домен>/webhook/yookassa` → `http://remnawave-tg-shop:/webhook/yookassa` - `https://<ваш_домен>/webhook/freekassa` → `http://remnawave-tg-shop:/webhook/freekassa` + - `https://<ваш_домен>/webhook/platega` → `http://remnawave-tg-shop:/webhook/platega` - `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 b261445..f3a896b 100644 --- a/bot/app/factories/build_services.py +++ b/bot/app/factories/build_services.py @@ -13,6 +13,7 @@ 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 +from bot.services.platega_service import PlategaService def build_core_services( @@ -54,6 +55,15 @@ def build_core_services( subscription_service, referral_service, ) + platega_service = PlategaService( + bot=bot, + settings=settings, + i18n=i18n, + async_session_factory=async_session_factory, + subscription_service=subscription_service, + referral_service=referral_service, + default_return_url=bot_username_for_default_return, + ) panel_webhook_service = PanelWebhookService(bot, settings, i18n, async_session_factory, panel_service) yookassa_service = YooKassaService( shop_id=settings.YOOKASSA_SHOP_ID, @@ -83,5 +93,5 @@ def build_core_services( "tribute_service": tribute_service, "panel_webhook_service": panel_webhook_service, "yookassa_service": yookassa_service, + "platega_service": platega_service, } - diff --git a/bot/app/web/web_server.py b/bot/app/web/web_server.py index 0604ff4..c0a5631 100644 --- a/bot/app/web/web_server.py +++ b/bot/app/web/web_server.py @@ -31,6 +31,7 @@ async def build_and_start_web_app( "cryptopay_service", "tribute_service", "panel_webhook_service", + "platega_service", ): # Access dispatcher workflow_data directly to avoid sequence protocol issues if hasattr(dp, "workflow_data") and key in dp.workflow_data: # type: ignore @@ -52,6 +53,7 @@ async def build_and_start_web_app( 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 + from bot.services.platega_service import platega_webhook_route tribute_path = settings.tribute_webhook_path if tribute_path.startswith("/"): @@ -68,6 +70,11 @@ async def build_and_start_web_app( app.router.add_post(fk_path, freekassa_webhook_route) logging.info(f"FreeKassa webhook route configured at: [POST] {fk_path}") + pg_path = settings.platega_webhook_path + if pg_path.startswith("/"): + app.router.add_post(pg_path, platega_webhook_route) + logging.info(f"Platega webhook route configured at: [POST] {pg_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("/"): @@ -94,4 +101,3 @@ async def build_and_start_web_app( # Run until cancelled await asyncio.Event().wait() - diff --git a/bot/handlers/user/subscription/payments.py b/bot/handlers/user/subscription/payments.py index 5157552..639c5b7 100644 --- a/bot/handlers/user/subscription/payments.py +++ b/bot/handlers/user/subscription/payments.py @@ -1,3 +1,4 @@ +import json import logging from datetime import datetime from aiogram import Router, F, types @@ -17,6 +18,7 @@ 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.services.platega_service import PlategaService from bot.middlewares.i18n import JsonI18n from db.dal import payment_dal, user_billing_dal @@ -990,6 +992,187 @@ async def pay_fk_callback_handler( pass +@router.callback_query(F.data.startswith("pay_platega:")) +async def pay_platega_callback_handler( + callback: types.CallbackQuery, + settings: Settings, + i18n_data: dict, + platega_service: PlategaService, + 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 platega_service or not platega_service.configured: + logging.error("Platega 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_platega 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 = settings.DEFAULT_CURRENCY_SYMBOL or "RUB" + + payment_record_payload = { + "user_id": user_id, + "amount": price_rub, + "currency": currency_code, + "status": "pending_platega", + "description": payment_description, + "subscription_duration_months": months, + "provider": "platega", + } + + 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"Platega: 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 + + payload_meta = json.dumps( + { + "payment_db_id": payment_record.payment_id, + "user_id": user_id, + "months": months, + } + ) + + success, response_data = await platega_service.create_transaction( + payment_db_id=payment_record.payment_id, + user_id=user_id, + months=months, + amount=price_rub, + currency=currency_code, + description=payment_description, + payload=payload_meta, + ) + + if success: + transaction_id = response_data.get("transactionId") or response_data.get("id") + redirect_url = ( + response_data.get("redirect") + or response_data.get("url") + or response_data.get("paymentUrl") + ) + provider_status = response_data.get("status", payment_record.status) + + if transaction_id and redirect_url: + try: + await payment_dal.update_provider_payment_and_status( + session, + payment_record.payment_id, + str(transaction_id), + str(provider_status), + ) + await session.commit() + except Exception as e_status: + await session.rollback() + logging.error( + f"Platega: failed to store transaction id for payment {payment_record.payment_id}: {e_status}", + exc_info=True, + ) + + try: + await callback.message.edit_text( + get_text(key="payment_link_message", months=months), + reply_markup=get_payment_url_keyboard( + redirect_url, + current_lang, + i18n, + back_callback=f"subscribe_period:{months}", + back_text_key="back_to_payment_methods_button", + ), + disable_web_page_preview=False, + ) + except Exception as e_edit: + logging.warning(f"Platega: 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( + redirect_url, + current_lang, + i18n, + back_callback=f"subscribe_period:{months}", + back_text_key="back_to_payment_methods_button", + ), + disable_web_page_preview=False, + ) + except Exception: + pass + try: + await callback.answer() + except Exception: + pass + return + + logging.error( + "Platega: transaction created but missing transaction id or payment link for payment %s. 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"Platega: 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, diff --git a/bot/keyboards/inline/user_keyboards.py b/bot/keyboards/inline/user_keyboards.py index 96a43a2..3f18d3b 100644 --- a/bot/keyboards/inline/user_keyboards.py +++ b/bot/keyboards/inline/user_keyboards.py @@ -121,6 +121,9 @@ def get_payment_method_keyboard(months: int, price: float, if settings.FREEKASSA_ENABLED: builder.button(text=_("pay_with_sbp_button"), callback_data=f"pay_fk:{months}:{price}") + if settings.PLATEGA_ENABLED: + builder.button(text=_("pay_with_platega_button"), + callback_data=f"pay_platega:{months}:{price}") if settings.YOOKASSA_ENABLED: builder.button(text=_("pay_with_yookassa_button"), callback_data=f"pay_yk:{months}:{price}") diff --git a/bot/main_bot.py b/bot/main_bot.py index 0a7fa10..5850061 100644 --- a/bot/main_bot.py +++ b/bot/main_bot.py @@ -207,6 +207,7 @@ async def on_shutdown_configured(dispatcher: Dispatcher): "stars_service", "subscription_service", "referral_service", + "platega_service", ): await close_service(service_key) diff --git a/bot/services/notification_service.py b/bot/services/notification_service.py index f2d6db5..6cf1c88 100644 --- a/bot/services/notification_service.py +++ b/bot/services/notification_service.py @@ -240,7 +240,8 @@ class NotificationService: "freekassa": "💳", "cryptopay": "₿", "stars": "⭐", - "tribute": "💎" + "tribute": "💎", + "platega": "💳", }.get(payment_provider.lower(), "💰") message = _( diff --git a/bot/services/platega_service.py b/bot/services/platega_service.py new file mode 100644 index 0000000..c449034 --- /dev/null +++ b/bot/services/platega_service.py @@ -0,0 +1,322 @@ +import json +import logging +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 PlategaService: + def __init__( + self, + *, + bot: Bot, + settings: Settings, + i18n: JsonI18n, + async_session_factory: sessionmaker, + subscription_service: SubscriptionService, + referral_service: ReferralService, + default_return_url: str, + ): + 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.base_url = (settings.PLATEGA_BASE_URL or "https://app.platega.io").rstrip("/") + self.merchant_id = settings.PLATEGA_MERCHANT_ID + self.secret = settings.PLATEGA_SECRET + self.payment_method = settings.PLATEGA_PAYMENT_METHOD + self.return_url = settings.PLATEGA_RETURN_URL or f"https://t.me/{default_return_url}" + self.failed_url = settings.PLATEGA_FAILED_URL or self.return_url + + self._timeout = ClientTimeout(total=20) + self._session: Optional[ClientSession] = None + self._auth_headers = { + "X-MerchantId": self.merchant_id or "", + "X-Secret": self.secret or "", + "Content-Type": "application/json", + } + self.configured: bool = bool( + settings.PLATEGA_ENABLED and self.merchant_id and self.secret + ) + if not self.configured: + logging.warning("PlategaService initialized but not fully configured. Payments disabled.") + + 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 close(self) -> None: + if self._session and not self._session.closed: + await self._session.close() + + async def create_transaction( + self, + *, + payment_db_id: int, + user_id: int, + months: int, + amount: float, + currency: Optional[str], + description: str, + payload: Optional[str] = None, + ) -> Tuple[bool, Dict[str, Any]]: + if not self.configured: + logging.error("PlategaService is not configured. Cannot create transaction.") + return False, {"message": "service_not_configured"} + + session = await self._get_session() + url = f"{self.base_url}/transaction/process" + currency_code = (currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper() + + body: Dict[str, Any] = { + "paymentMethod": int(self.payment_method), + "paymentDetails": {"amount": float(amount), "currency": currency_code}, + "description": description, + "return": self.return_url, + "failedUrl": self.failed_url, + "payload": payload, + } + + # Remove optional keys with falsy values to avoid validation errors + clean_body = {k: v for k, v in body.items() if v not in (None, "")} + + try: + async with session.post(url, json=clean_body, headers=self._auth_headers) as response: + response_text = await response.text() + try: + response_data = json.loads(response_text) if response_text else {} + except json.JSONDecodeError: + logging.error("Platega create_transaction: invalid JSON response: %s", response_text) + return False, { + "status": response.status, + "message": "invalid_json", + "raw": response_text, + } + + if response.status != 200: + logging.error( + "Platega create_transaction: 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("Platega create_transaction: request failed: %s", exc, exc_info=True) + return False, {"message": str(exc)} + + async def webhook_route(self, request: web.Request) -> web.Response: + if not self.configured: + return web.Response(status=503, text="platega_disabled") + + try: + data = await request.json() + except Exception as exc: + logging.error("Platega webhook: failed to parse JSON: %s", exc) + return web.Response(status=400, text="bad_request") + + header_merchant = request.headers.get("X-MerchantId") + header_secret = request.headers.get("X-Secret") + if header_merchant != self.merchant_id or header_secret != self.secret: + logging.error("Platega webhook: invalid auth headers") + return web.Response(status=403, text="forbidden") + + transaction_id = str(data.get("id") or data.get("transactionId") or "").strip() + status = str(data.get("status") or "").upper() + amount_raw = data.get("amount") + currency = data.get("currency") or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB" + + if not transaction_id or not status: + logging.error("Platega webhook: missing transaction id or status in payload: %s", data) + return web.Response(status=400, text="missing_fields") + + async with self.async_session_factory() as session: + payment = await payment_dal.get_payment_by_provider_payment_id(session, transaction_id) + if not payment: + logging.error("Platega webhook: payment not found for transaction %s", transaction_id) + return web.Response(status=404, text="payment_not_found") + + if payment.status == "succeeded" and status == "CONFIRMED": + return web.Response(text="ok") + + payment_months = payment.subscription_duration_months or 1 + + if status == "CONFIRMED": + if amount_raw is not None: + try: + incoming_amount = Decimal(str(amount_raw)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + expected_amount = Decimal(str(payment.amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + if incoming_amount != expected_amount: + logging.warning( + "Platega webhook: amount mismatch for payment %s (expected %s, got %s)", + payment.payment_id, + expected_amount, + incoming_amount, + ) + except Exception as exc: + logging.warning("Platega webhook: failed to compare amounts for %s: %s", payment.payment_id, exc) + + try: + await payment_dal.update_provider_payment_and_status( + session, + payment.payment_id, + transaction_id, + "succeeded", + ) + + activation = await self.subscription_service.activate_subscription( + session, + payment.user_id, + payment_months, + float(payment.amount), + payment.payment_id, + provider="platega", + ) + + referral_bonus = await self.referral_service.apply_referral_bonuses_for_payment( + session, + payment.user_id, + payment_months, + current_payment_db_id=payment.payment_id, + skip_if_active_before_payment=False, + ) + + await session.commit() + except Exception as exc: + await session.rollback() + logging.error("Platega webhook: failed to process payment %s: %s", transaction_id, exc, 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 = ( + activation.get("subscription_url") + if activation + else None + ) or _("config_link_not_available") + final_end = activation.get("end_date") if activation else None + applied_days = 0 + applied_promo_days = activation.get("applied_promo_bonus_days", 0) if activation else 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 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=payment_months, + base_end_date=activation["end_date"].strftime("%Y-%m-%d") if activation and activation.get("end_date") else final_end.strftime("%Y-%m-%d") if final_end else "", + bonus_days=applied_days, + final_end_date=final_end.strftime("%Y-%m-%d") if final_end else "", + inviter_name=inviter_name_display, + config_link=config_link, + ) + elif applied_promo_days and final_end: + text = _( + "payment_successful_with_promo_full", + months=payment_months, + bonus_days=applied_promo_days, + end_date=final_end.strftime("%Y-%m-%d"), + config_link=config_link, + ) + else: + text = _( + "payment_successful_full", + months=payment_months, + end_date=final_end.strftime("%Y-%m-%d") if final_end else "", + config_link=config_link, + ) + + markup = get_connect_and_main_keyboard( + lang, + self.i18n, + self.settings, + config_link, + preserve_message=True, + ) + try: + await self.bot.send_message( + payment.user_id, + text, + reply_markup=markup, + parse_mode="HTML", + disable_web_page_preview=True, + ) + except Exception as exc: + logging.error("Platega webhook: failed to notify user %s: %s", payment.user_id, exc) + + 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=currency, + months=payment_months, + payment_provider="platega", + username=db_user.username if db_user else None, + ) + except Exception as exc: + logging.error("Platega webhook: failed to notify admins: %s", exc) + + return web.Response(text="ok") + + if status in {"CANCELED", "CANCELLED", "CHARGEBACKED"}: + try: + await payment_dal.update_provider_payment_and_status( + session, + payment.payment_id, + transaction_id, + "canceled", + ) + await session.commit() + except Exception as exc: + await session.rollback() + logging.error("Platega webhook: failed to cancel payment %s: %s", transaction_id, exc) + 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 + try: + await self.bot.send_message(payment.user_id, _("payment_failed")) + except Exception: + pass + return web.Response(text="ok_canceled") + + logging.warning("Platega webhook: unhandled status '%s' for transaction %s", status, transaction_id) + return web.Response(status=202, text="status_ignored") + + +async def platega_webhook_route(request: web.Request) -> web.Response: + service: PlategaService = request.app["platega_service"] + return await service.webhook_route(request) diff --git a/config/settings.py b/config/settings.py index c0b0298..1c81e88 100644 --- a/config/settings.py +++ b/config/settings.py @@ -53,6 +53,15 @@ class Settings(BaseSettings): CRYPTOPAY_CURRENCY_TYPE: str = Field(default="fiat") CRYPTOPAY_ASSET: str = Field(default="RUB") CRYPTOPAY_ENABLED: bool = Field(default=True) + PLATEGA_ENABLED: bool = Field(default=False) + PLATEGA_BASE_URL: str = Field(default="https://app.platega.io") + PLATEGA_MERCHANT_ID: Optional[str] = None + PLATEGA_SECRET: Optional[str] = None + PLATEGA_PAYMENT_METHOD: int = Field( + default=2, description="Platega payment method ID (e.g., 2 for SBP QR)" + ) + PLATEGA_RETURN_URL: Optional[str] = Field(default=None) + PLATEGA_FAILED_URL: Optional[str] = Field(default=None) FREEKASSA_ENABLED: bool = Field(default=False) FREEKASSA_MERCHANT_ID: Optional[str] = None @@ -294,6 +303,19 @@ class Settings(BaseSettings): return f"{base.rstrip('/')}{self.freekassa_webhook_path}" return None + @computed_field + @property + def platega_webhook_path(self) -> str: + return "/webhook/platega" + + @computed_field + @property + def platega_full_webhook_url(self) -> Optional[str]: + base = self.WEBHOOK_BASE_URL + if base: + return f"{base.rstrip('/')}{self.platega_webhook_path}" + return None + # Computed YooKassa receipt fields based on recurring toggle @computed_field @property @@ -390,7 +412,12 @@ class Settings(BaseSettings): return None return v - @field_validator('REQUIRED_CHANNEL_LINK', mode='before') + @field_validator( + 'REQUIRED_CHANNEL_LINK', + 'PLATEGA_RETURN_URL', + 'PLATEGA_FAILED_URL', + mode='before', + ) @classmethod def sanitize_optional_link(cls, v): if isinstance(v, str) and not v.strip(): @@ -457,6 +484,15 @@ def get_settings() -> Settings: "CRITICAL: FreeKassa is enabled but no subscription prices are configured (RUB_PRICE_*). Users will not see payment buttons." ) + if _settings_instance.PLATEGA_ENABLED: + if ( + not _settings_instance.PLATEGA_MERCHANT_ID + or not _settings_instance.PLATEGA_SECRET + ): + logging.warning( + "CRITICAL: Platega is enabled but merchant credentials (PLATEGA_MERCHANT_ID/PLATEGA_SECRET) are missing. Platega payments will not work." + ) + except ValidationError as e: logging.critical( f"Pydantic validation error while loading settings: {e}") diff --git a/locales/en.json b/locales/en.json index 553f069..77c7749 100644 --- a/locales/en.json +++ b/locales/en.json @@ -37,6 +37,7 @@ "back_to_autopay_method_choice_button": "⬅️ Back", "yookassa_autopay_charge_initiated": "Charge request sent to the selected card. We'll notify you once the payment completes.", "pay_with_sbp_button": "📱 SBP", + "pay_with_platega_button": "💳 Platega (SBP/Cards)", "back_to_payment_methods_button": "⬅️ Back", "pay_with_cryptopay_button": "💎 CryptoBot", "pay_with_tribute_button": "❤️ Tribute", diff --git a/locales/ru.json b/locales/ru.json index b3d4714..eb9d86a 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -37,6 +37,7 @@ "back_to_autopay_method_choice_button": "⬅️ Назад", "yookassa_autopay_charge_initiated": "Запрос на списание с выбранной карты отправлен. Сообщим, как только платёж завершится.", "pay_with_sbp_button": "📱 СБП", + "pay_with_platega_button": "💳 Platega (СБП/карты)", "back_to_payment_methods_button": "⬅️ Назад", "pay_with_cryptopay_button": "💎 CryptoBot", "pay_with_tribute_button": "❤️ Tribute", From b24a685066147dfc12410889b32094e77ec21103 Mon Sep 17 00:00:00 2001 From: machka pasla Date: Tue, 9 Dec 2025 19:50:14 +0300 Subject: [PATCH 03/16] added severpay.io --- .env.example | 70 ++-- README.md | 28 +- bot/app/factories/build_services.py | 11 + bot/app/web/web_server.py | 7 + bot/handlers/admin/payments.py | 12 +- bot/handlers/admin/statistics.py | 17 +- bot/handlers/user/subscription/payments.py | 173 ++++++++++ bot/keyboards/inline/user_keyboards.py | 50 ++- bot/main_bot.py | 1 + bot/services/notification_service.py | 1 + bot/services/severpay_service.py | 354 +++++++++++++++++++++ config/settings.py | 59 +++- locales/en.json | 1 + locales/ru.json | 1 + 14 files changed, 724 insertions(+), 61 deletions(-) create mode 100644 bot/services/severpay_service.py diff --git a/.env.example b/.env.example index 2ed40f7..8afef7d 100644 --- a/.env.example +++ b/.env.example @@ -37,6 +37,9 @@ STARS_ENABLED=True # TRIBUTE_ENABLED=True # Turn on TRIBUTE CRYPTOPAY_ENABLED=True # Turn on CRYPTOPAY PLATEGA_ENABLED=False # Turn on PLATEGA +SEVERPAY_ENABLED=False # Turn on SeverPay +# Order of payment methods (top to bottom). Supported: severpay, freekassa, platega, yookassa, tribute, stars, cryptopay +PAYMENT_METHODS_ORDER=severpay,yookassa,cryptopay,freekassa,platega,tribute,stars # YooKassa Payment Gateway Configuration YOOKASSA_SHOP_ID=your_shop_id # Your store ID in YooKassa @@ -61,12 +64,19 @@ CRYPTOPAY_CURRENCY_TYPE=fiat # CRYPTOPAY_ASSET=RUB # Asset, e.g., RUB, BTC, USDT # Platega Payment Gateway Configuration -PLATEGA_BASE_URL=https://app.platega.io # Base API URL -PLATEGA_MERCHANT_ID= # Your MerchantId from Platega -PLATEGA_SECRET= # API secret from Platega -PLATEGA_PAYMENT_METHOD=2 # Payment method ID (2=SBP QR, 10=RU cards, 12=International, 13=Crypto) -PLATEGA_RETURN_URL= # Optional: redirect after successful payment (defaults to bot link) -PLATEGA_FAILED_URL= # Optional: redirect after failed/cancelled payment (defaults to return URL) +PLATEGA_BASE_URL=https://app.platega.io # Base API URL +PLATEGA_MERCHANT_ID= # Your MerchantId from Platega +PLATEGA_SECRET= # API secret from Platega +PLATEGA_PAYMENT_METHOD=2 # Payment method ID (2=SBP QR, 10=RU cards, 12=International, 13=Crypto) +PLATEGA_RETURN_URL= # Optional: redirect after successful payment (defaults to bot link) +PLATEGA_FAILED_URL= # Optional: redirect after failed/cancelled payment (defaults to return URL) + +# SeverPay Payment Gateway Configuration +SEVERPAY_BASE_URL=https://severpay.io/api/merchant # Base API URL +SEVERPAY_MID= # Your MID from SeverPay +SEVERPAY_TOKEN= # API token/secret for signing requests +SEVERPAY_RETURN_URL= # Optional: redirect URL after payment (defaults to bot link) +SEVERPAY_LIFETIME_MINUTES= # Optional: payment link lifetime in minutes (30-4320, leave empty for default) # Tribute Payment Gateway Configuration TRIBUTE_API_KEY= # API key for verifying Tribute webhook signatures @@ -95,14 +105,14 @@ STARS_PRICE_12_MONTHS=0 TRIBUTE_LINK_12_MONTHS= # Subscription Notifications -SUBSCRIPTION_NOTIFICATIONS_ENABLED=True # Enable subscription -SUBSCRIPTION_NOTIFY_ON_EXPIRE=True # Notify on subscription -SUBSCRIPTION_NOTIFY_AFTER_EXPIRE=True # Notify after -SUBSCRIPTION_NOTIFY_DAYS_BEFORE=3 # Days before expiration to notify +SUBSCRIPTION_NOTIFICATIONS_ENABLED=True # Enable subscription +SUBSCRIPTION_NOTIFY_ON_EXPIRE=True # Notify on subscription +SUBSCRIPTION_NOTIFY_AFTER_EXPIRE=True # Notify after +SUBSCRIPTION_NOTIFY_DAYS_BEFORE=3 # Days before expiration to notify -REFERRAL_ONE_BONUS_PER_REFEREE=False # Give a bonus only once per referee -LEGACY_REFS=true # Allow ref_ links. Leave unset/true unless you want to disable old links +REFERRAL_ONE_BONUS_PER_REFEREE=False # Give a bonus only once per referee +LEGACY_REFS=true # Allow ref_ links. Leave unset/true unless you want to disable old links # Referral Bonus Days # Bonus for the inviting user REFERRAL_BONUS_DAYS_1_MONTH=3 @@ -116,41 +126,41 @@ REFEREE_BONUS_DAYS_6_MONTHS=7 REFEREE_BONUS_DAYS_12_MONTHS=15 # Panel API Configuration -PANEL_API_URL=http://your_panel_api_url/api # URL of the panel API -PANEL_API_KEY=your_panel_api_key # Panel API key -PANEL_WEBHOOK_SECRET= # secret used to verify panel webhook signatures +PANEL_API_URL=http://your_panel_api_url/api # URL of the panel API +PANEL_API_KEY=your_panel_api_key # Panel API key +PANEL_WEBHOOK_SECRET= # secret used to verify panel webhook signatures # User traffic limits (applied for all users) # 0 means unlimited -USER_TRAFFIC_LIMIT_GB=0 # Traffic limit for users (0 unlimited) -USER_TRAFFIC_STRATEGY="NO_RESET" # Traffic reset strategy (NO_RESET, WEEK, MONTH) +USER_TRAFFIC_LIMIT_GB=0 # Traffic limit for users (0 unlimited) +USER_TRAFFIC_STRATEGY="NO_RESET" # Traffic reset strategy (NO_RESET, WEEK, MONTH) # Default Internal Squads for Users (Optional, comma-separated UUIDs) USER_SQUAD_UUIDS=uuid1,uuid2,uuid3 # Default External Squad for Users (Optional, single UUID) -USER_EXTERNAL_SQUAD_UUID= # Optional: UUID from Remnawave External Squads to auto-link new panel users +USER_EXTERNAL_SQUAD_UUID= # Optional: UUID from Remnawave External Squads to auto-link new panel users # Trial Settings -TRIAL_ENABLED=True # Enable the trial period -TRIAL_DURATION_DAYS=5 # Duration of the trial period in days -TRIAL_TRAFFIC_LIMIT_GB=0 # Traffic limit for the trial period (0 = unlimited) -TRIAL_TRAFFIC_STRATEGY="NO_RESET" # Traffic reset strategy for the trial period (NO_RESET, WEEK, MONTH) +TRIAL_ENABLED=True # Enable the trial period +TRIAL_DURATION_DAYS=5 # Duration of the trial period in days +TRIAL_TRAFFIC_LIMIT_GB=0 # Traffic limit for the trial period (0 = unlimited) +TRIAL_TRAFFIC_STRATEGY="NO_RESET" # Traffic reset strategy for the trial period (NO_RESET, WEEK, MONTH) # Web Server Settings (for handling webhooks) WEB_SERVER_HOST="0.0.0.0" WEB_SERVER_PORT=8080 # Admin Panel Log Pagination -LOGS_PAGE_SIZE=10 # Number of events in the log +LOGS_PAGE_SIZE=10 # Number of events in the log # Admin Logging Configuration -LOG_CHAT_ID=-1001234567890 # Telegram chat/group ID for admin notifications -LOG_THREAD_ID= # Optional: Thread ID for supergroup messages -LOG_NEW_USERS=True # Log new user registrations -LOG_PAYMENTS=True # Log payments -LOG_PROMO_ACTIVATIONS=True # Log promo code activations -LOG_TRIAL_ACTIVATIONS=True # Log trial activations -LOG_SUSPICIOUS_ACTIVITY=True # Log suspicious activity +LOG_CHAT_ID=-1001234567890 # Telegram chat/group ID for admin notifications +LOG_THREAD_ID= # Optional: Thread ID for supergroup messages +LOG_NEW_USERS=True # Log new user registrations +LOG_PAYMENTS=True # Log payments +LOG_PROMO_ACTIVATIONS=True # Log promo code activations +LOG_TRIAL_ACTIVATIONS=True # Log trial activations +LOG_SUSPICIOUS_ACTIVITY=True # Log suspicious activity # Embedded mode thumbnails. Please don't touch this if you don't know what it is. INLINE_REFERRAL_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/1077/1077114.png diff --git a/README.md b/README.md index 1b9bd6f..d9e937d 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ - **Пробная подписка:** Система пробных подписок для новых пользователей (активируется вручную по кнопке). - **Промокоды:** Возможность применять промокоды для получения скидок или бонусных дней. - **Реферальная программа:** Пользователи могут приглашать друзей и получать за это бонусные дни подписки. -- **Оплата:** Поддержка оплаты через YooKassa, FreeKassa (REST API), Platega, CryptoPay, Telegram Stars и Tribute. + - **Оплата:** Поддержка оплаты через YooKassa, FreeKassa (REST API), Platega, SeverPay, CryptoPay, Telegram Stars и Tribute. ### Для администраторов: - **Защищенная админ-панель:** Доступ только для администраторов, указанных в `ADMIN_IDS`. @@ -28,7 +28,7 @@ - **Aiogram 3.x:** Асинхронный фреймворк для Telegram ботов. - **aiohttp:** Для запуска веб-сервера (вебхуки). - **SQLAlchemy 2.x & asyncpg:** Асинхронная работа с базой данных PostgreSQL. -- **YooKassa, FreeKassa API, Platega, aiocryptopay:** Интеграции с платежными системами. +- **YooKassa, FreeKassa API, Platega, SeverPay, aiocryptopay:** Интеграции с платежными системами. - **Pydantic:** Для управления настройками из `.env` файла. - **Docker & Docker Compose:** Для контейнеризации и развертывания. @@ -77,14 +77,15 @@ | Переменная | Описание | | --- | --- | - | `WEBHOOK_BASE_URL`| **Обязательно.** Базовый URL для вебхуков, например `https://your.domain.com`. | - | `WEB_SERVER_HOST` | Хост для веб-сервера. | `0.0.0.0` | - | `WEB_SERVER_PORT` | Порт для веб-сервера. | `8080` | - | `YOOKASSA_ENABLED` | Включить/выключить YooKassa (`true`/`false`). | - | `YOOKASSA_SHOP_ID` | ID вашего магазина в YooKassa. | - | `YOOKASSA_SECRET_KEY`| Секретный ключ магазина YooKassa. | - | `YOOKASSA_AUTOPAYMENTS_ENABLED` | Включить автопродление (сохранение карт, автосписания, управление способами оплаты). | - | `YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING` | Требовать обязательную привязку карты при оплате с автосписанием. Установите `false`, чтобы пользователю показывался чекбокс «Сохранить карту». | +| `WEBHOOK_BASE_URL`| **Обязательно.** Базовый URL для вебхуков, например `https://your.domain.com`. | +| `WEB_SERVER_HOST` | Хост для веб-сервера. | `0.0.0.0` | +| `WEB_SERVER_PORT` | Порт для веб-сервера. | `8080` | +| `PAYMENT_METHODS_ORDER` | (Опционально) Порядок отображения кнопок оплаты через запятую. Поддерживаемые ключи: `severpay`, `freekassa`, `platega`, `yookassa`, `tribute`, `stars`, `cryptopay`. Первый будет сверху. | +| `YOOKASSA_ENABLED` | Включить/выключить YooKassa (`true`/`false`). | +| `YOOKASSA_SHOP_ID` | ID вашего магазина в YooKassa. | +| `YOOKASSA_SECRET_KEY`| Секретный ключ магазина YooKassa. | +| `YOOKASSA_AUTOPAYMENTS_ENABLED` | Включить автопродление (сохранение карт, автосписания, управление способами оплаты). | +| `YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING` | Требовать обязательную привязку карты при оплате с автосписанием. Установите `false`, чтобы пользователю показывался чекбокс «Сохранить карту». | | `CRYPTOPAY_ENABLED` | Включить/выключить CryptoPay (`true`/`false`). | | `CRYPTOPAY_TOKEN` | Токен из вашего CryptoPay App. | | `FREEKASSA_ENABLED` | Включить/выключить FreeKassa (`true`/`false`). | @@ -102,6 +103,12 @@ | `PLATEGA_PAYMENT_METHOD`| ID способа оплаты (2 — SBP QR, 10 — РФ карты, 12 — международные карты, 13 — crypto). | | `PLATEGA_RETURN_URL`| (Опционально) URL редиректа после успешной оплаты. По умолчанию ссылка на бота. | | `PLATEGA_FAILED_URL`| (Опционально) URL редиректа при ошибке/отмене. По умолчанию как `PLATEGA_RETURN_URL`. | +| `SEVERPAY_ENABLED` | Включить/выключить SeverPay (`true`/`false`). | +| `SEVERPAY_MID` | MID магазина в SeverPay. | +| `SEVERPAY_TOKEN` | Секрет/токен для подписи запросов SeverPay. | +| `SEVERPAY_BASE_URL` | (Опционально) Базовый URL API SeverPay. По умолчанию `https://severpay.io/api/merchant`. | +| `SEVERPAY_RETURN_URL` | (Опционально) URL редиректа после оплаты (по умолчанию ссылка на бота). | +| `SEVERPAY_LIFETIME_MINUTES` | (Опционально) Время жизни платежной ссылки в минутах (30–4320). |
@@ -156,6 +163,7 @@ - `https://<ваш_домен>/webhook/yookassa` → `http://remnawave-tg-shop:/webhook/yookassa` - `https://<ваш_домен>/webhook/freekassa` → `http://remnawave-tg-shop:/webhook/freekassa` - `https://<ваш_домен>/webhook/platega` → `http://remnawave-tg-shop:/webhook/platega` + - `https://<ваш_домен>/webhook/severpay` → `http://remnawave-tg-shop:/webhook/severpay` - `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 f3a896b..394bb0f 100644 --- a/bot/app/factories/build_services.py +++ b/bot/app/factories/build_services.py @@ -14,6 +14,7 @@ from bot.services.crypto_pay_service import CryptoPayService from bot.services.panel_webhook_service import PanelWebhookService from bot.services.freekassa_service import FreeKassaService from bot.services.platega_service import PlategaService +from bot.services.severpay_service import SeverPayService def build_core_services( @@ -64,6 +65,15 @@ def build_core_services( referral_service=referral_service, default_return_url=bot_username_for_default_return, ) + severpay_service = SeverPayService( + bot=bot, + settings=settings, + i18n=i18n, + async_session_factory=async_session_factory, + subscription_service=subscription_service, + referral_service=referral_service, + default_return_url=bot_username_for_default_return, + ) panel_webhook_service = PanelWebhookService(bot, settings, i18n, async_session_factory, panel_service) yookassa_service = YooKassaService( shop_id=settings.YOOKASSA_SHOP_ID, @@ -94,4 +104,5 @@ def build_core_services( "panel_webhook_service": panel_webhook_service, "yookassa_service": yookassa_service, "platega_service": platega_service, + "severpay_service": severpay_service, } diff --git a/bot/app/web/web_server.py b/bot/app/web/web_server.py index c0a5631..c9745e9 100644 --- a/bot/app/web/web_server.py +++ b/bot/app/web/web_server.py @@ -32,6 +32,7 @@ async def build_and_start_web_app( "tribute_service", "panel_webhook_service", "platega_service", + "severpay_service", ): # Access dispatcher workflow_data directly to avoid sequence protocol issues if hasattr(dp, "workflow_data") and key in dp.workflow_data: # type: ignore @@ -54,6 +55,7 @@ async def build_and_start_web_app( from bot.services.panel_webhook_service import panel_webhook_route from bot.services.freekassa_service import freekassa_webhook_route from bot.services.platega_service import platega_webhook_route + from bot.services.severpay_service import severpay_webhook_route tribute_path = settings.tribute_webhook_path if tribute_path.startswith("/"): @@ -75,6 +77,11 @@ async def build_and_start_web_app( app.router.add_post(pg_path, platega_webhook_route) logging.info(f"Platega webhook route configured at: [POST] {pg_path}") + sp_path = settings.severpay_webhook_path + if sp_path.startswith("/"): + app.router.add_post(sp_path, severpay_webhook_route) + logging.info(f"SeverPay webhook route configured at: [POST] {sp_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("/"): diff --git a/bot/handlers/admin/payments.py b/bot/handlers/admin/payments.py index 19aad73..36fb4e7 100644 --- a/bot/handlers/admin/payments.py +++ b/bot/handlers/admin/payments.py @@ -38,8 +38,16 @@ def format_payment_text(payment: Payment, i18n: JsonI18n, lang: str) -> str: """Format single payment info as text.""" _ = lambda key, **kwargs: i18n.gettext(lang, key, **kwargs) + pending_statuses = [ + 'pending', + 'pending_yookassa', + 'pending_freekassa', + 'pending_platega', + 'pending_severpay', + 'pending_cryptopay', + ] status_emoji = "✅" if payment.status == 'succeeded' else ( - "⏳" if payment.status in ['pending', 'pending_yookassa', 'pending_freekassa'] else "❌" + "⏳" if payment.status in pending_statuses else "❌" ) user_info = f"User {payment.user_id}" @@ -56,6 +64,8 @@ def format_payment_text(payment: Payment, i18n: JsonI18n, lang: str) -> str: 'telegram_stars': 'Telegram Stars', 'cryptopay': 'CryptoPay', 'freekassa': 'FreeKassa', + 'severpay': 'SeverPay', + 'platega': 'Platega', }.get(payment.provider, payment.provider or 'Unknown') return ( diff --git a/bot/handlers/admin/statistics.py b/bot/handlers/admin/statistics.py index 726b462..ac58d30 100644 --- a/bot/handlers/admin/statistics.py +++ b/bot/handlers/admin/statistics.py @@ -162,8 +162,21 @@ async def show_statistics_handler(callback: types.CallbackQuery, stats_text_parts.append( f"\n{_('admin_stats_recent_payments_header')}") for payment in last_payments_models: - status_emoji = "✅" if payment.status == 'succeeded' else ( - "⏳" if payment.status in ['pending', 'pending_yookassa', 'pending_freekassa'] else "❌") + pending_statuses = [ + "pending", + "pending_yookassa", + "pending_freekassa", + "pending_platega", + "pending_severpay", + "pending_cryptopay", + ] + status_emoji = ( + "✅" + if payment.status == "succeeded" + else "⏳" + if payment.status in pending_statuses + 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 639c5b7..22b1f4f 100644 --- a/bot/handlers/user/subscription/payments.py +++ b/bot/handlers/user/subscription/payments.py @@ -19,6 +19,7 @@ from bot.services.freekassa_service import FreeKassaService from bot.services.crypto_pay_service import CryptoPayService from bot.services.stars_service import StarsService from bot.services.platega_service import PlategaService +from bot.services.severpay_service import SeverPayService from bot.middlewares.i18n import JsonI18n from db.dal import payment_dal, user_billing_dal @@ -1173,6 +1174,178 @@ async def pay_platega_callback_handler( pass +@router.callback_query(F.data.startswith("pay_severpay:")) +async def pay_severpay_callback_handler( + callback: types.CallbackQuery, + settings: Settings, + i18n_data: dict, + severpay_service: SeverPayService, + 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 severpay_service or not severpay_service.configured: + logging.error("SeverPay 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_severpay 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 = settings.DEFAULT_CURRENCY_SYMBOL or "RUB" + + payment_record_payload = { + "user_id": user_id, + "amount": price_rub, + "currency": currency_code, + "status": "pending_severpay", + "description": payment_description, + "subscription_duration_months": months, + "provider": "severpay", + } + + 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"SeverPay: 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 + + success, response_data = await severpay_service.create_payment( + payment_db_id=payment_record.payment_id, + user_id=user_id, + months=months, + amount=price_rub, + currency=currency_code, + description=payment_description, + ) + + if success: + payment_link = ( + response_data.get("url") + or response_data.get("payment_url") + or response_data.get("paymentUrl") + ) + provider_identifier = response_data.get("id") or response_data.get("uid") + + 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"SeverPay: failed to store provider payment id for payment {payment_record.payment_id}: {e_status}", + 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, + back_callback=f"subscribe_period:{months}", + back_text_key="back_to_payment_methods_button", + ), + disable_web_page_preview=False, + ) + except Exception as e_edit: + logging.warning(f"SeverPay: 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( + payment_link, + current_lang, + i18n, + back_callback=f"subscribe_period:{months}", + back_text_key="back_to_payment_methods_button", + ), + disable_web_page_preview=False, + ) + except Exception: + pass + try: + await callback.answer() + except Exception: + pass + return + + logging.error( + "SeverPay: payment created but missing payment link for payment %s. 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"SeverPay: 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, diff --git a/bot/keyboards/inline/user_keyboards.py b/bot/keyboards/inline/user_keyboards.py index 3f18d3b..333464d 100644 --- a/bot/keyboards/inline/user_keyboards.py +++ b/bot/keyboards/inline/user_keyboards.py @@ -118,23 +118,39 @@ def get_payment_method_keyboard(months: int, price: float, i18n_instance, settings: Settings) -> InlineKeyboardMarkup: _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) builder = InlineKeyboardBuilder() - if settings.FREEKASSA_ENABLED: - builder.button(text=_("pay_with_sbp_button"), - callback_data=f"pay_fk:{months}:{price}") - if settings.PLATEGA_ENABLED: - builder.button(text=_("pay_with_platega_button"), - callback_data=f"pay_platega:{months}:{price}") - if settings.YOOKASSA_ENABLED: - builder.button(text=_("pay_with_yookassa_button"), - callback_data=f"pay_yk:{months}:{price}") - if settings.TRIBUTE_ENABLED and tribute_url: - builder.button(text=_("pay_with_tribute_button"), url=tribute_url) - if settings.STARS_ENABLED and stars_price is not None: - builder.button(text=_("pay_with_stars_button"), - callback_data=f"pay_stars:{months}:{stars_price}") - if settings.CRYPTOPAY_ENABLED: - builder.button(text=_("pay_with_cryptopay_button"), - callback_data=f"pay_crypto:{months}:{price}") + for method in settings.payment_methods_order: + if method == "severpay" and getattr(settings, "SEVERPAY_ENABLED", False): + builder.button( + text=_("pay_with_severpay_button"), + callback_data=f"pay_severpay:{months}:{price}", + ) + elif method == "freekassa" and settings.FREEKASSA_ENABLED: + builder.button( + text=_("pay_with_sbp_button"), + callback_data=f"pay_fk:{months}:{price}", + ) + elif method == "platega" and settings.PLATEGA_ENABLED: + builder.button( + text=_("pay_with_platega_button"), + callback_data=f"pay_platega:{months}:{price}", + ) + elif method == "yookassa" and settings.YOOKASSA_ENABLED: + builder.button( + text=_("pay_with_yookassa_button"), + callback_data=f"pay_yk:{months}:{price}", + ) + elif method == "tribute" and settings.TRIBUTE_ENABLED and tribute_url: + builder.button(text=_("pay_with_tribute_button"), url=tribute_url) + elif method == "stars" and settings.STARS_ENABLED and stars_price is not None: + builder.button( + text=_("pay_with_stars_button"), + callback_data=f"pay_stars:{months}:{stars_price}", + ) + elif method == "cryptopay" and settings.CRYPTOPAY_ENABLED: + builder.button( + text=_("pay_with_cryptopay_button"), + callback_data=f"pay_crypto:{months}:{price}", + ) builder.button(text=_(key="cancel_button"), callback_data="main_action:subscribe") builder.adjust(1) diff --git a/bot/main_bot.py b/bot/main_bot.py index 5850061..a91b55b 100644 --- a/bot/main_bot.py +++ b/bot/main_bot.py @@ -208,6 +208,7 @@ async def on_shutdown_configured(dispatcher: Dispatcher): "subscription_service", "referral_service", "platega_service", + "severpay_service", ): await close_service(service_key) diff --git a/bot/services/notification_service.py b/bot/services/notification_service.py index 6cf1c88..a4ef9bf 100644 --- a/bot/services/notification_service.py +++ b/bot/services/notification_service.py @@ -242,6 +242,7 @@ class NotificationService: "stars": "⭐", "tribute": "💎", "platega": "💳", + "severpay": "💳", }.get(payment_provider.lower(), "💰") message = _( diff --git a/bot/services/severpay_service.py b/bot/services/severpay_service.py new file mode 100644 index 0000000..a49aff7 --- /dev/null +++ b/bot/services/severpay_service.py @@ -0,0 +1,354 @@ +import json +import logging +import secrets +import hmac +import hashlib +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.services.notification_service import NotificationService +from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard +from db.dal import payment_dal, user_dal +from bot.utils.text_sanitizer import sanitize_display_name, username_for_display + + +class SeverPayService: + def __init__( + self, + *, + bot: Bot, + settings: Settings, + i18n: JsonI18n, + async_session_factory: sessionmaker, + subscription_service: SubscriptionService, + referral_service: ReferralService, + default_return_url: str, + ): + 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.base_url = (settings.SEVERPAY_BASE_URL or "https://severpay.io/api/merchant").rstrip("/") + self.mid = settings.SEVERPAY_MID + self.token = settings.SEVERPAY_TOKEN or "" + self.return_url = settings.SEVERPAY_RETURN_URL or f"https://t.me/{default_return_url}" + self.lifetime_minutes = settings.SEVERPAY_LIFETIME_MINUTES + + self._timeout = ClientTimeout(total=15) + self._session: Optional[ClientSession] = None + + self.configured: bool = bool(settings.SEVERPAY_ENABLED and self.mid and self.token) + if not self.configured: + logging.warning("SeverPayService initialized but not fully configured. Payments disabled.") + + 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 close(self) -> None: + if self._session and not self._session.closed: + await self._session.close() + + @staticmethod + def _format_amount(amount: float) -> str: + quantized = Decimal(str(amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + return f"{quantized:.2f}" + + def _sign_payload(self, payload: Dict[str, Any]) -> str: + message = json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + return hmac.new(self.token.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).hexdigest() + + def _build_signed_body(self, extra: Dict[str, Any]) -> Dict[str, Any]: + body: Dict[str, Any] = { + "mid": self.mid, + "salt": secrets.token_hex(8), + } + body.update(extra) + sorted_body = dict(sorted(body.items())) + sorted_body["sign"] = self._sign_payload(sorted_body) + return sorted_body + + def _validate_signature(self, payload: Dict[str, Any]) -> bool: + provided_sign = str(payload.get("sign") or "") + if not provided_sign or not self.token: + return False + data = {k: v for k, v in payload.items() if k != "sign"} + sorted_body = dict(sorted(data.items())) + expected_sign = self._sign_payload(sorted_body) + return hmac.compare_digest(provided_sign, expected_sign) + + async def create_payment( + self, + *, + payment_db_id: int, + user_id: int, + months: int, + amount: float, + currency: Optional[str], + description: str, + ) -> Tuple[bool, Dict[str, Any]]: + if not self.configured: + logging.error("SeverPayService is not configured. Cannot create payment.") + return False, {"message": "service_not_configured"} + + session = await self._get_session() + url = f"{self.base_url}/payin/create" + currency_code = (currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper() + amount_str = self._format_amount(amount) + + body = { + "order_id": str(payment_db_id), + "amount": amount_str, + "currency": currency_code, + "client_email": f"{user_id}@telegram.org", + "client_id": str(user_id), + "url_return": self.return_url, + } + + if self.lifetime_minutes: + body["lifetime"] = int(self.lifetime_minutes) + + signed_body = self._build_signed_body(body) + + try: + async with session.post(url, json=signed_body) as response: + response_text = await response.text() + try: + response_data = json.loads(response_text) if response_text else {} + except json.JSONDecodeError: + logging.error("SeverPay create_payment: invalid JSON response: %s", response_text) + return False, {"status": response.status, "message": "invalid_json", "raw": response_text} + + if response.status != 200 or not response_data.get("status"): + logging.error( + "SeverPay create_payment: API returned error (status=%s, body=%s)", + response.status, + response_data, + ) + return False, {"status": response.status, "message": response_data} + + return True, response_data.get("data") or response_data + except Exception as exc: + logging.error("SeverPay create_payment: request failed: %s", exc, exc_info=True) + return False, {"message": str(exc)} + + async def webhook_route(self, request: web.Request) -> web.Response: + if not self.configured: + return web.json_response({"status": False, "msg": "severpay_disabled"}, status=503) + + try: + payload = await request.json() + except Exception as exc: + logging.error("SeverPay webhook: failed to parse JSON: %s", exc) + return web.json_response({"status": False, "msg": "bad_request"}, status=400) + + if not isinstance(payload, dict) or not self._validate_signature(payload): + logging.error("SeverPay webhook: invalid signature or payload.") + return web.json_response({"status": False, "msg": "invalid_signature"}, status=403) + + event_type = str(payload.get("type") or "").lower() + data = payload.get("data") or {} + + if event_type != "payin" or not isinstance(data, dict): + logging.warning("SeverPay webhook: unsupported event type '%s'", event_type) + return web.json_response({"status": True}) + + provider_payment_id = str(data.get("id") or data.get("uid") or "") + order_id_raw = data.get("order_id") + status = str(data.get("status") or "").lower() + + payment_db_id: Optional[int] = None + try: + if isinstance(order_id_raw, int): + payment_db_id = order_id_raw + elif isinstance(order_id_raw, str) and order_id_raw.isdigit(): + payment_db_id = int(order_id_raw) + except Exception: + payment_db_id = None + + async with self.async_session_factory() as session: + payment = None + if payment_db_id is not None: + payment = await payment_dal.get_payment_by_db_id(session, payment_db_id) + if not payment and provider_payment_id: + payment = await payment_dal.get_payment_by_provider_payment_id(session, provider_payment_id) + + if not payment: + logging.error("SeverPay webhook: payment not found (order_id=%s, provider_id=%s)", order_id_raw, provider_payment_id) + return web.json_response({"status": False, "msg": "payment_not_found"}, status=404) + + payment_months = payment.subscription_duration_months or 1 + if status == "success": + try: + await payment_dal.update_provider_payment_and_status( + session, + payment.payment_id, + provider_payment_id or str(payment.payment_id), + "succeeded", + ) + + activation = await self.subscription_service.activate_subscription( + session, + payment.user_id, + payment_months, + float(payment.amount), + payment.payment_id, + provider="severpay", + ) + + referral_bonus = await self.referral_service.apply_referral_bonuses_for_payment( + session, + payment.user_id, + payment_months, + current_payment_db_id=payment.payment_id, + skip_if_active_before_payment=False, + ) + + await session.commit() + except Exception as exc: + await session.rollback() + logging.error("SeverPay webhook: failed to process payment %s: %s", provider_payment_id, exc, exc_info=True) + return web.json_response({"status": False, "msg": "processing_error"}, status=500) + + 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 = ( + activation.get("subscription_url") + if activation + else None + ) or _("config_link_not_available") + final_end = activation.get("end_date") if activation else None + applied_days = 0 + applied_promo_days = activation.get("applied_promo_bonus_days", 0) if activation else 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 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=payment_months, + base_end_date=activation["end_date"].strftime("%Y-%m-%d") if activation and activation.get("end_date") else final_end.strftime("%Y-%m-%d") if final_end else "", + bonus_days=applied_days, + final_end_date=final_end.strftime("%Y-%m-%d") if final_end else "", + inviter_name=inviter_name_display, + config_link=config_link, + ) + elif applied_promo_days and final_end: + text = _( + "payment_successful_with_promo_full", + months=payment_months, + bonus_days=applied_promo_days, + end_date=final_end.strftime("%Y-%m-%d"), + config_link=config_link, + ) + else: + text = _( + "payment_successful_full", + months=payment_months, + end_date=final_end.strftime("%Y-%m-%d") if final_end else "", + config_link=config_link, + ) + + markup = get_connect_and_main_keyboard( + lang, + self.i18n, + self.settings, + config_link, + preserve_message=True, + ) + try: + await self.bot.send_message( + payment.user_id, + text, + reply_markup=markup, + parse_mode="HTML", + disable_web_page_preview=True, + ) + except Exception as exc: + logging.error("SeverPay webhook: failed to notify user %s: %s", payment.user_id, exc) + + 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=payment.currency, + months=payment_months, + payment_provider="severpay", + username=db_user.username if db_user else None, + ) + except Exception as exc: + logging.error("SeverPay webhook: failed to notify admins: %s", exc) + + return web.json_response({"status": True}) + + if status in {"fail", "decline"}: + try: + await payment_dal.update_provider_payment_and_status( + session, + payment.payment_id, + provider_payment_id or str(payment.payment_id), + "failed", + ) + await session.commit() + except Exception as exc: + await session.rollback() + logging.error("SeverPay webhook: failed to mark payment %s as failed: %s", provider_payment_id, exc) + return web.json_response({"status": False, "msg": "processing_error"}, status=500) + + 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 + try: + await self.bot.send_message(payment.user_id, _("payment_failed")) + except Exception: + pass + return web.json_response({"status": True}) + + if status in {"process", "new"}: + try: + await payment_dal.update_provider_payment_and_status( + session, + payment.payment_id, + provider_payment_id or str(payment.payment_id), + "pending_severpay", + ) + await session.commit() + except Exception as exc: + await session.rollback() + logging.error("SeverPay webhook: failed to update pending status for %s: %s", provider_payment_id, exc) + return web.json_response({"status": True}) + + logging.warning("SeverPay webhook: unhandled status '%s' for payment %s", status, provider_payment_id) + return web.json_response({"status": True}) + + +async def severpay_webhook_route(request: web.Request) -> web.Response: + service: SeverPayService = request.app["severpay_service"] + return await service.webhook_route(request) diff --git a/config/settings.py b/config/settings.py index 1c81e88..319fcbe 100644 --- a/config/settings.py +++ b/config/settings.py @@ -72,9 +72,23 @@ class Settings(BaseSettings): FREEKASSA_PAYMENT_IP: Optional[str] = None FREEKASSA_PAYMENT_METHOD_ID: Optional[int] = None + SEVERPAY_ENABLED: bool = Field(default=False) + SEVERPAY_MID: Optional[int] = None + SEVERPAY_TOKEN: Optional[str] = None + SEVERPAY_RETURN_URL: Optional[str] = None + SEVERPAY_BASE_URL: str = Field(default="https://severpay.io/api/merchant") + SEVERPAY_LIFETIME_MINUTES: Optional[int] = Field( + default=None, + description="Lifetime of the payment link in minutes (30-4320, defaults to provider value)", + ) + YOOKASSA_ENABLED: bool = Field(default=True) STARS_ENABLED: bool = Field(default=True) TRIBUTE_ENABLED: bool = Field(default=True) + PAYMENT_METHODS_ORDER: Optional[str] = Field( + default=None, + description="Comma-separated list of payment methods to show (e.g., severpay,freekassa,yookassa,platega,stars,cryptopay,tribute)", + ) MONTH_1_ENABLED: bool = Field(default=True, alias="1_MONTH_ENABLED") MONTH_3_ENABLED: bool = Field(default=True, alias="3_MONTHS_ENABLED") @@ -303,6 +317,19 @@ class Settings(BaseSettings): return f"{base.rstrip('/')}{self.freekassa_webhook_path}" return None + @computed_field + @property + def severpay_webhook_path(self) -> str: + return "/webhook/severpay" + + @computed_field + @property + def severpay_full_webhook_url(self) -> Optional[str]: + base = self.WEBHOOK_BASE_URL + if base: + return f"{base.rstrip('/')}{self.severpay_webhook_path}" + return None + @computed_field @property def platega_webhook_path(self) -> str: @@ -399,6 +426,30 @@ class Settings(BaseSettings): if self.REFERRAL_BONUS_DAYS_REFEREE_12_MONTHS is not None: bonuses[12] = self.REFERRAL_BONUS_DAYS_REFEREE_12_MONTHS return bonuses + + @computed_field + @property + def payment_methods_order(self) -> List[str]: + """ + Ordered list of payment providers to show in the subscription payment keyboard. + """ + default_order = [ + "freekassa", + "platega", + "severpay", + "yookassa", + "tribute", + "stars", + "cryptopay", + ] + if not self.PAYMENT_METHODS_ORDER: + return default_order + methods = [] + for item in self.PAYMENT_METHODS_ORDER.split(","): + slug = item.strip().lower() + if slug: + methods.append(slug) + return methods or default_order # Logging Configuration LOG_CHAT_ID: Optional[int] = Field(default=None, description="Telegram chat/group ID for sending notifications") @@ -416,6 +467,7 @@ class Settings(BaseSettings): 'REQUIRED_CHANNEL_LINK', 'PLATEGA_RETURN_URL', 'PLATEGA_FAILED_URL', + 'SEVERPAY_RETURN_URL', mode='before', ) @classmethod @@ -424,7 +476,7 @@ class Settings(BaseSettings): return None return v - @field_validator('USER_HWID_DEVICE_LIMIT', mode='before') + @field_validator('USER_HWID_DEVICE_LIMIT', 'SEVERPAY_MID', 'SEVERPAY_LIFETIME_MINUTES', mode='before') @classmethod def validate_optional_int(cls, v): if isinstance(v, str): @@ -492,6 +544,11 @@ def get_settings() -> Settings: logging.warning( "CRITICAL: Platega is enabled but merchant credentials (PLATEGA_MERCHANT_ID/PLATEGA_SECRET) are missing. Platega payments will not work." ) + if _settings_instance.SEVERPAY_ENABLED: + if not _settings_instance.SEVERPAY_MID or not _settings_instance.SEVERPAY_TOKEN: + logging.warning( + "CRITICAL: SeverPay is enabled but MID or TOKEN is missing. SeverPay payments will not work." + ) except ValidationError as e: logging.critical( diff --git a/locales/en.json b/locales/en.json index 77c7749..fe93a59 100644 --- a/locales/en.json +++ b/locales/en.json @@ -38,6 +38,7 @@ "yookassa_autopay_charge_initiated": "Charge request sent to the selected card. We'll notify you once the payment completes.", "pay_with_sbp_button": "📱 SBP", "pay_with_platega_button": "💳 Platega (SBP/Cards)", + "pay_with_severpay_button": "💳 SeverPay", "back_to_payment_methods_button": "⬅️ Back", "pay_with_cryptopay_button": "💎 CryptoBot", "pay_with_tribute_button": "❤️ Tribute", diff --git a/locales/ru.json b/locales/ru.json index eb9d86a..b8bd4b7 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -38,6 +38,7 @@ "yookassa_autopay_charge_initiated": "Запрос на списание с выбранной карты отправлен. Сообщим, как только платёж завершится.", "pay_with_sbp_button": "📱 СБП", "pay_with_platega_button": "💳 Platega (СБП/карты)", + "pay_with_severpay_button": "💳 SeverPay", "back_to_payment_methods_button": "⬅️ Назад", "pay_with_cryptopay_button": "💎 CryptoBot", "pay_with_tribute_button": "❤️ Tribute", From 56a32d493f3ada0f037faddef34f2c57f4215b06 Mon Sep 17 00:00:00 2001 From: machka pasla Date: Tue, 9 Dec 2025 20:17:46 +0300 Subject: [PATCH 04/16] refactor --- bot/handlers/user/subscription/payments.py | 1559 +---------------- .../user/subscription/payments_crypto.py | 100 ++ .../user/subscription/payments_freekassa.py | 200 +++ .../user/subscription/payments_platega.py | 195 +++ .../user/subscription/payments_severpay.py | 185 ++ .../user/subscription/payments_stars.py | 123 ++ .../subscription/payments_subscription.py | 78 + .../user/subscription/payments_yookassa.py | 737 ++++++++ bot/services/panel_api_service.py | 26 +- bot/services/subscription_service.py | 5 +- 10 files changed, 1652 insertions(+), 1556 deletions(-) create mode 100644 bot/handlers/user/subscription/payments_crypto.py create mode 100644 bot/handlers/user/subscription/payments_freekassa.py create mode 100644 bot/handlers/user/subscription/payments_platega.py create mode 100644 bot/handlers/user/subscription/payments_severpay.py create mode 100644 bot/handlers/user/subscription/payments_stars.py create mode 100644 bot/handlers/user/subscription/payments_subscription.py create mode 100644 bot/handlers/user/subscription/payments_yookassa.py diff --git a/bot/handlers/user/subscription/payments.py b/bot/handlers/user/subscription/payments.py index 22b1f4f..da1507a 100644 --- a/bot/handlers/user/subscription/payments.py +++ b/bot/handlers/user/subscription/payments.py @@ -1,1546 +1,21 @@ -import json -import logging -from datetime import datetime -from aiogram import Router, F, types -from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup -from typing import Optional, List, Tuple -from sqlalchemy.ext.asyncio import AsyncSession +from aiogram import Router -from config.settings import Settings -from bot.keyboards.inline.user_keyboards import ( - get_payment_method_keyboard, - get_payment_url_keyboard, - get_yk_autopay_choice_keyboard, - get_yk_saved_cards_keyboard, - get_back_to_main_menu_markup, -) -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.services.platega_service import PlategaService -from bot.services.severpay_service import SeverPayService -from bot.middlewares.i18n import JsonI18n -from db.dal import payment_dal, user_billing_dal +from .payments_crypto import router as crypto_router +from .payments_freekassa import router as freekassa_router +from .payments_platega import router as platega_router +from .payments_severpay import router as severpay_router +from .payments_stars import router as stars_router +from .payments_subscription import router as subscription_selection_router +from .payments_yookassa import router as yookassa_router router = Router(name="user_subscription_payments_router") - -def _parse_months_and_price(payload: str) -> Optional[Tuple[int, float]]: - try: - months_str, price_str = payload.split(":") - return int(months_str), float(price_str) - except (ValueError, IndexError): - return None - - -def _format_saved_payment_method_title(get_text, network: Optional[str], last4: Optional[str], is_default: bool) -> str: - def _is_yoomoney_network(name: Optional[str]) -> bool: - s = (name or "").lower() - return "yoomoney" in s or "yoo money" in s or "yoo-money" in s - - def _extract_last4(text: str) -> Optional[str]: - digits = "".join(ch for ch in text if ch.isdigit()) - return digits[-4:] if len(digits) >= 4 else None - - if _is_yoomoney_network(network): - inferred_last4 = last4 or (_extract_last4(network or "") or "****") - title = get_text("payment_method_wallet_title", last4=inferred_last4) - elif last4: - network_name = network or get_text("payment_network_card") - title = get_text("payment_method_card_title", network=network_name, last4=last4) - else: - network_name = network or get_text("payment_network_generic") - title = get_text("payment_method_generic_title", network=network_name) - return f"⭐ {title}" if is_default else title - - -async def _initiate_yk_payment( - callback: types.CallbackQuery, - *, - settings: Settings, - session: AsyncSession, - yookassa_service: YooKassaService, - i18n: Optional[JsonI18n], - current_lang: str, - get_text, - user_id: int, - months: int, - price_rub: float, - currency_code_for_yk: str, - save_payment_method: bool, - back_callback: str, - payment_method_id: Optional[str] = None, - selected_method_internal_id: Optional[int] = None, -) -> bool: - """Create payment record and initiate YooKassa payment (new card or saved card).""" - if not callback.message: - return False - - payment_description = get_text("payment_description_subscription", months=months) - payment_record_data = { - "user_id": user_id, - "amount": price_rub, - "currency": currency_code_for_yk, - "status": "pending_yookassa", - "description": payment_description, - "subscription_duration_months": months, - } - - db_payment_record = None - try: - db_payment_record = await payment_dal.create_payment_record(session, payment_record_data) - await session.commit() - logging.info( - f"Payment record {db_payment_record.payment_id} created for user {user_id} with status 'pending_yookassa'." - ) - except Exception as e_db_payment: - await session.rollback() - logging.error( - f"Failed to create payment record in DB for user {user_id}: {e_db_payment}", - exc_info=True, - ) - try: - await callback.message.edit_text(get_text("error_creating_payment_record")) - except Exception: - pass - return False - - if not db_payment_record: - try: - await callback.message.edit_text(get_text("error_creating_payment_record")) - except Exception: - pass - return False - - yookassa_metadata = { - "user_id": str(user_id), - "subscription_months": str(months), - "payment_db_id": str(db_payment_record.payment_id), - } - if payment_method_id: - yookassa_metadata["used_saved_payment_method_id"] = payment_method_id - - receipt_email_for_yk = settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL - - payment_response_yk = await yookassa_service.create_payment( - amount=price_rub, - currency=currency_code_for_yk, - description=payment_description, - metadata=yookassa_metadata, - receipt_email=receipt_email_for_yk, - save_payment_method=save_payment_method, - payment_method_id=payment_method_id, - ) - - if payment_response_yk and payment_response_yk.get("confirmation_url"): - pm = payment_response_yk.get("payment_method") - try: - if pm and pm.get("id"): - pm_type = pm.get("type") - title = pm.get("title") - card = pm.get("card") or {} - account_number = pm.get("account_number") or pm.get("account") - if isinstance(card, dict) and (pm_type or "").lower() in {"bank_card", "bank-card", "card"}: - display_network = card.get("card_type") or title or "Card" - display_last4 = card.get("last4") - elif (pm_type or "").lower() in {"yoo_money", "yoomoney", "yoo-money", "wallet"}: - display_network = "YooMoney" - display_last4 = ( - account_number[-4:] - if isinstance(account_number, str) and len(account_number) >= 4 - else None - ) - else: - display_network = title or (pm_type.upper() if pm_type else "Payment method") - display_last4 = None - await user_billing_dal.upsert_yk_payment_method( - session, - user_id=user_id, - payment_method_id=pm["id"], - card_last4=display_last4, - card_network=display_network, - ) - try: - await user_billing_dal.upsert_user_payment_method( - session, - user_id=user_id, - provider_payment_method_id=pm["id"], - provider="yookassa", - card_last4=display_last4, - card_network=display_network, - set_default=save_payment_method, - ) - except Exception: - pass - await session.commit() - except Exception: - await session.rollback() - logging.exception("Failed to save YooKassa payment method preliminarily") - try: - await payment_dal.update_payment_status_by_db_id( - session, - payment_db_id=db_payment_record.payment_id, - new_status=payment_response_yk.get("status", "pending"), - yk_payment_id=payment_response_yk.get("id"), - ) - if selected_method_internal_id is not None: - try: - await user_billing_dal.set_user_default_payment_method( - session, user_id, selected_method_internal_id - ) - except Exception: - logging.exception("Failed to set default payment method after initiating payment") - await session.commit() - except Exception as e_db_update_ykid: - await session.rollback() - logging.error( - f"Failed to update payment record {db_payment_record.payment_id} with YK ID: {e_db_update_ykid}", - exc_info=True, - ) - try: - await callback.message.edit_text(get_text("error_payment_gateway_link_failed")) - except Exception: - pass - return False - - try: - await callback.message.edit_text( - get_text(key="payment_link_message", months=months), - reply_markup=get_payment_url_keyboard( - payment_response_yk["confirmation_url"], - current_lang, - i18n, - back_callback=back_callback, - back_text_key="back_to_payment_methods_button", - ), - disable_web_page_preview=False, - ) - except Exception as e_edit: - logging.warning( - f"Edit message for payment link failed: {e_edit}. Sending new one." - ) - try: - await callback.message.answer( - get_text(key="payment_link_message", months=months), - reply_markup=get_payment_url_keyboard( - payment_response_yk["confirmation_url"], - current_lang, - i18n, - back_callback=back_callback, - back_text_key="back_to_payment_methods_button", - ), - disable_web_page_preview=False, - ) - except Exception: - pass - return True - - if payment_response_yk and payment_method_id: - status_to_store = payment_response_yk.get("status", "pending") - try: - await payment_dal.update_payment_status_by_db_id( - session, - payment_db_id=db_payment_record.payment_id, - new_status=status_to_store, - yk_payment_id=payment_response_yk.get("id"), - ) - if selected_method_internal_id is not None: - try: - await user_billing_dal.set_user_default_payment_method( - session, user_id, selected_method_internal_id - ) - except Exception: - logging.exception("Failed to set default payment method after saved-card payment start") - await session.commit() - except Exception as e_db_update_saved: - await session.rollback() - logging.error( - f"Failed to update saved-card payment record {db_payment_record.payment_id}: {e_db_update_saved}", - exc_info=True, - ) - try: - await callback.message.edit_text(get_text("error_payment_gateway")) - except Exception: - pass - return False - - message_text = get_text("yookassa_autopay_charge_initiated") - try: - await callback.message.edit_text( - message_text, - reply_markup=get_back_to_main_menu_markup(current_lang, i18n), - ) - except Exception as e_edit: - logging.warning(f"Failed to notify about saved-card charge start: {e_edit}") - try: - await callback.message.answer( - message_text, - reply_markup=get_back_to_main_menu_markup(current_lang, i18n), - ) - except Exception: - pass - return True - - try: - await payment_dal.update_payment_status_by_db_id( - session, db_payment_record.payment_id, "failed_creation" - ) - await session.commit() - except Exception as e_db_fail_create: - await session.rollback() - logging.error( - f"Additionally failed to update payment record to 'failed_creation': {e_db_fail_create}", - exc_info=True, - ) - logging.error( - f"Failed to create payment in YooKassa for user {user_id}, payment_db_id {db_payment_record.payment_id}. Response: {payment_response_yk}" - ) - try: - await callback.message.edit_text(get_text("error_payment_gateway")) - except Exception: - pass - return False - - -@router.callback_query(F.data.startswith("subscribe_period:")) -async def select_subscription_period_callback_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, 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 - - try: - months = int(callback.data.split(":")[-1]) - except (ValueError, IndexError): - logging.error(f"Invalid subscription period in callback_data: {callback.data}") - try: - await callback.answer(get_text("error_try_again"), show_alert=True) - except Exception: - pass - return - - price_rub = settings.subscription_options.get(months) - if price_rub is None: - logging.error( - f"Price not found for {months} months subscription period in settings.subscription_options." - ) - try: - await callback.answer(get_text("error_try_again"), show_alert=True) - except Exception: - pass - return - - currency_symbol_val = settings.DEFAULT_CURRENCY_SYMBOL - text_content = get_text("choose_payment_method") - tribute_url = settings.tribute_payment_links.get(months) - stars_price = settings.stars_subscription_options.get(months) - reply_markup = get_payment_method_keyboard( - months, - price_rub, - tribute_url, - stars_price, - currency_symbol_val, - current_lang, - i18n, - settings, - ) - - try: - await callback.message.edit_text(text_content, reply_markup=reply_markup) - except Exception as e_edit: - logging.warning( - f"Edit message for payment method selection failed: {e_edit}. Sending new one." - ) - await callback.message.answer(text_content, reply_markup=reply_markup) - try: - await callback.answer() - except Exception: - pass - - -@router.callback_query(F.data.startswith("pay_yk:")) -async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, yookassa_service: YooKassaService, 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 yookassa_service or not yookassa_service.configured: - logging.error("YooKassa service is not configured or unavailable.") - target_msg_edit = callback.message - await target_msg_edit.edit_text(get_text("payment_service_unavailable")) - try: - await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True) - except Exception: - pass - return - - try: - _, data_payload = callback.data.split(":", 1) - except ValueError: - logging.error(f"Invalid pay_yk data in callback: {callback.data}") - try: - await callback.answer(get_text("error_try_again"), show_alert=True) - except Exception: - pass - return - - parsed = _parse_months_and_price(data_payload) - if not parsed: - logging.error(f"Invalid pay_yk payload structure: {callback.data}") - try: - await callback.answer(get_text("error_try_again"), show_alert=True) - except Exception: - pass - return - - months, price_rub = parsed - user_id = callback.from_user.id - currency_code_for_yk = "RUB" - autopay_enabled = bool(getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False)) - autopay_require_binding = bool( - getattr(settings, 'YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING', True) - ) - saved_methods: List = [] - if autopay_enabled: - try: - saved_methods = await user_billing_dal.list_user_payment_methods( - session, user_id, provider="yookassa" - ) - except Exception as e_list: - logging.exception(f"Failed to load saved payment methods for user {user_id}: {e_list}") - saved_methods = [] - - if autopay_enabled and saved_methods: - try: - await callback.message.edit_text( - get_text("yookassa_autopay_flow_prompt"), - reply_markup=get_yk_autopay_choice_keyboard( - months, - price_rub, - current_lang, - i18n, - has_saved_cards=True, - ), - ) - except Exception as e_edit: - logging.warning(f"Failed to show autopay choice: {e_edit}. Sending new message.") - try: - await callback.message.answer( - get_text("yookassa_autopay_flow_prompt"), - reply_markup=get_yk_autopay_choice_keyboard( - months, - price_rub, - current_lang, - i18n, - has_saved_cards=True, - ), - ) - except Exception: - pass - try: - await callback.answer() - except Exception: - pass - return - - await _initiate_yk_payment( - callback, - settings=settings, - session=session, - yookassa_service=yookassa_service, - i18n=i18n, - current_lang=current_lang, - get_text=get_text, - user_id=user_id, - months=months, - price_rub=price_rub, - currency_code_for_yk=currency_code_for_yk, - save_payment_method=autopay_enabled and autopay_require_binding, - back_callback=f"subscribe_period:{months}", - ) - try: - await callback.answer() - except Exception: - pass - - -@router.callback_query(F.data.startswith("pay_yk_new:")) -async def pay_yk_new_card_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, yookassa_service: YooKassaService, 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 yookassa_service or not yookassa_service.configured: - logging.error("YooKassa service unavailable for pay_yk_new.") - 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) - except ValueError: - logging.error(f"Invalid pay_yk_new data in callback: {callback.data}") - try: - await callback.answer(get_text("error_try_again"), show_alert=True) - except Exception: - pass - return - - parsed = _parse_months_and_price(data_payload) - if not parsed: - logging.error(f"Invalid pay_yk_new payload structure: {callback.data}") - try: - await callback.answer(get_text("error_try_again"), show_alert=True) - except Exception: - pass - return - - months, price_rub = parsed - user_id = callback.from_user.id - currency_code_for_yk = "RUB" - autopay_enabled = bool(getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False)) - autopay_require_binding = bool( - getattr(settings, 'YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING', True) - ) - - await _initiate_yk_payment( - callback, - settings=settings, - session=session, - yookassa_service=yookassa_service, - i18n=i18n, - current_lang=current_lang, - get_text=get_text, - user_id=user_id, - months=months, - price_rub=price_rub, - currency_code_for_yk=currency_code_for_yk, - save_payment_method=autopay_enabled and autopay_require_binding, - back_callback=f"subscribe_period:{months}", - ) - try: - await callback.answer() - except Exception: - pass - - -@router.callback_query(F.data.startswith("pay_yk_saved_list:")) -async def pay_yk_saved_list_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, yookassa_service: YooKassaService, 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 - - autopay_enabled = bool(getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False)) - if not autopay_enabled: - try: - await callback.answer(get_text("error_try_again"), show_alert=True) - except Exception: - pass - return - - try: - _, data_payload = callback.data.split(":", 1) - except ValueError: - logging.error(f"Invalid pay_yk_saved_list data: {callback.data}") - try: - await callback.answer(get_text("error_try_again"), show_alert=True) - except Exception: - pass - return - - parts = data_payload.split(":") - if len(parts) < 2: - logging.error(f"pay_yk_saved_list payload missing components: {callback.data}") - try: - await callback.answer(get_text("error_try_again"), show_alert=True) - except Exception: - pass - return - - try: - months = int(parts[0]) - price_rub = float(parts[1]) - page = int(parts[2]) if len(parts) > 2 else 0 - except (ValueError, IndexError): - logging.error(f"pay_yk_saved_list payload parsing error: {callback.data}") - try: - await callback.answer(get_text("error_try_again"), show_alert=True) - except Exception: - pass - return - - user_id = callback.from_user.id - try: - saved_methods = await user_billing_dal.list_user_payment_methods( - session, user_id, provider="yookassa" - ) - except Exception as e_list: - logging.exception(f"Failed to list saved payment methods for user {user_id}: {e_list}") - saved_methods = [] - - if not saved_methods: - try: - await callback.message.edit_text( - get_text("yookassa_autopay_no_saved_cards"), - reply_markup=get_yk_autopay_choice_keyboard( - months, - price_rub, - current_lang, - i18n, - has_saved_cards=False, - ), - ) - except Exception as e_edit: - logging.warning(f"Failed to display no-saved-card notice: {e_edit}") - try: - await callback.message.answer( - get_text("yookassa_autopay_no_saved_cards"), - reply_markup=get_yk_autopay_choice_keyboard( - months, - price_rub, - current_lang, - i18n, - has_saved_cards=False, - ), - ) - except Exception: - pass - try: - await callback.answer() - except Exception: - pass - return - - cards: List[Tuple[str, str]] = [] - for method in saved_methods: - title = _format_saved_payment_method_title( - get_text, method.card_network, method.card_last4, method.is_default - ) - cards.append((str(method.method_id), title)) - - per_page = 5 - max_page = max(0, (len(cards) - 1) // per_page) - page = max(0, min(page, max_page)) - - try: - await callback.message.edit_text( - get_text("yookassa_autopay_choose_saved_card"), - reply_markup=get_yk_saved_cards_keyboard( - cards, - months, - price_rub, - current_lang, - i18n, - page=page, - ), - ) - except Exception as e_edit: - logging.warning(f"Failed to display saved card list: {e_edit}") - try: - await callback.message.answer( - get_text("yookassa_autopay_choose_saved_card"), - reply_markup=get_yk_saved_cards_keyboard( - cards, - months, - price_rub, - current_lang, - i18n, - page=page, - ), - ) - except Exception: - pass - try: - await callback.answer() - except Exception: - pass - - -@router.callback_query(F.data.startswith("pay_yk_use_saved:")) -async def pay_yk_use_saved_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, yookassa_service: YooKassaService, 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 - - autopay_enabled = bool(getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False)) - if not autopay_enabled: - try: - await callback.answer(get_text("error_try_again"), show_alert=True) - except Exception: - pass - return - - if not yookassa_service or not yookassa_service.configured: - logging.error("YooKassa service unavailable for pay_yk_use_saved.") - 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) - except ValueError: - logging.error(f"Invalid pay_yk_use_saved data: {callback.data}") - try: - await callback.answer(get_text("error_try_again"), show_alert=True) - except Exception: - pass - return - - parts = data_payload.split(":") - if len(parts) < 3: - logging.error(f"pay_yk_use_saved payload missing components: {callback.data}") - try: - await callback.answer(get_text("error_try_again"), show_alert=True) - except Exception: - pass - return - - try: - months = int(parts[0]) - price_rub = float(parts[1]) - except (ValueError, IndexError): - logging.error(f"pay_yk_use_saved months/price parsing error: {callback.data}") - try: - await callback.answer(get_text("error_try_again"), show_alert=True) - except Exception: - pass - return - - method_identifier = parts[2] - user_id = callback.from_user.id - - try: - saved_methods = await user_billing_dal.list_user_payment_methods( - session, user_id, provider="yookassa" - ) - except Exception as e_list: - logging.exception(f"Failed to list saved payment methods for user {user_id}: {e_list}") - saved_methods = [] - - selected_method = None - for method in saved_methods: - if method_identifier.isdigit(): - if method.method_id == int(method_identifier): - selected_method = method - break - if method.provider_payment_method_id == method_identifier: - selected_method = method - break - - if not selected_method: - logging.warning(f"Selected payment method not found for user {user_id}: {method_identifier}") - try: - await callback.answer(get_text("error_try_again"), show_alert=True) - except Exception: - pass - return - - currency_code_for_yk = "RUB" - - await _initiate_yk_payment( - callback, - settings=settings, - session=session, - yookassa_service=yookassa_service, - i18n=i18n, - current_lang=current_lang, - get_text=get_text, - user_id=user_id, - months=months, - price_rub=price_rub, - currency_code_for_yk=currency_code_for_yk, - save_payment_method=False, - back_callback=f"pay_yk_saved_list:{months}:{price_rub}", - payment_method_id=selected_method.provider_payment_method_id, - selected_method_internal_id=selected_method.method_id, - ) - try: - await callback.answer() - except Exception: - 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 - - success, response_data = await freekassa_service.create_order( - payment_db_id=payment_record.payment_id, - user_id=payment_record.user_id, - months=months, - amount=price_rub, - currency=freekassa_service.default_currency, - payment_method_id=freekassa_service.payment_method_id, - ip_address=freekassa_service.server_ip, - extra_params={ - "us_method": freekassa_service.payment_method_id, - }, - ) - - if success: - location = response_data.get("location") - order_hash = response_data.get("orderHash") - order_id_api = response_data.get("orderId") - provider_identifier = order_hash or order_id_api - - 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: - order_identifier_display = str(order_id_api or provider_identifier or payment_record.payment_id) - order_info_text = get_text( - "free_kassa_order_info", - order_id=order_identifier_display, - date=datetime.now().strftime("%Y-%m-%d"), - ) - try: - await callback.message.edit_text( - f"{order_info_text}\n\n" + get_text(key="payment_link_message", months=months), - reply_markup=get_payment_url_keyboard( - location, - current_lang, - i18n, - back_callback=f"subscribe_period:{months}", - back_text_key="back_to_payment_methods_button", - ), - 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( - f"{order_info_text}\n\n" + get_text(key="payment_link_message", months=months), - reply_markup=get_payment_url_keyboard( - location, - current_lang, - i18n, - back_callback=f"subscribe_period:{months}", - back_text_key="back_to_payment_methods_button", - ), - 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_platega:")) -async def pay_platega_callback_handler( - callback: types.CallbackQuery, - settings: Settings, - i18n_data: dict, - platega_service: PlategaService, - 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 platega_service or not platega_service.configured: - logging.error("Platega 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_platega 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 = settings.DEFAULT_CURRENCY_SYMBOL or "RUB" - - payment_record_payload = { - "user_id": user_id, - "amount": price_rub, - "currency": currency_code, - "status": "pending_platega", - "description": payment_description, - "subscription_duration_months": months, - "provider": "platega", - } - - 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"Platega: 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 - - payload_meta = json.dumps( - { - "payment_db_id": payment_record.payment_id, - "user_id": user_id, - "months": months, - } - ) - - success, response_data = await platega_service.create_transaction( - payment_db_id=payment_record.payment_id, - user_id=user_id, - months=months, - amount=price_rub, - currency=currency_code, - description=payment_description, - payload=payload_meta, - ) - - if success: - transaction_id = response_data.get("transactionId") or response_data.get("id") - redirect_url = ( - response_data.get("redirect") - or response_data.get("url") - or response_data.get("paymentUrl") - ) - provider_status = response_data.get("status", payment_record.status) - - if transaction_id and redirect_url: - try: - await payment_dal.update_provider_payment_and_status( - session, - payment_record.payment_id, - str(transaction_id), - str(provider_status), - ) - await session.commit() - except Exception as e_status: - await session.rollback() - logging.error( - f"Platega: failed to store transaction id for payment {payment_record.payment_id}: {e_status}", - exc_info=True, - ) - - try: - await callback.message.edit_text( - get_text(key="payment_link_message", months=months), - reply_markup=get_payment_url_keyboard( - redirect_url, - current_lang, - i18n, - back_callback=f"subscribe_period:{months}", - back_text_key="back_to_payment_methods_button", - ), - disable_web_page_preview=False, - ) - except Exception as e_edit: - logging.warning(f"Platega: 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( - redirect_url, - current_lang, - i18n, - back_callback=f"subscribe_period:{months}", - back_text_key="back_to_payment_methods_button", - ), - disable_web_page_preview=False, - ) - except Exception: - pass - try: - await callback.answer() - except Exception: - pass - return - - logging.error( - "Platega: transaction created but missing transaction id or payment link for payment %s. 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"Platega: 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_severpay:")) -async def pay_severpay_callback_handler( - callback: types.CallbackQuery, - settings: Settings, - i18n_data: dict, - severpay_service: SeverPayService, - 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 severpay_service or not severpay_service.configured: - logging.error("SeverPay 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_severpay 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 = settings.DEFAULT_CURRENCY_SYMBOL or "RUB" - - payment_record_payload = { - "user_id": user_id, - "amount": price_rub, - "currency": currency_code, - "status": "pending_severpay", - "description": payment_description, - "subscription_duration_months": months, - "provider": "severpay", - } - - 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"SeverPay: 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 - - success, response_data = await severpay_service.create_payment( - payment_db_id=payment_record.payment_id, - user_id=user_id, - months=months, - amount=price_rub, - currency=currency_code, - description=payment_description, - ) - - if success: - payment_link = ( - response_data.get("url") - or response_data.get("payment_url") - or response_data.get("paymentUrl") - ) - provider_identifier = response_data.get("id") or response_data.get("uid") - - 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"SeverPay: failed to store provider payment id for payment {payment_record.payment_id}: {e_status}", - 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, - back_callback=f"subscribe_period:{months}", - back_text_key="back_to_payment_methods_button", - ), - disable_web_page_preview=False, - ) - except Exception as e_edit: - logging.warning(f"SeverPay: 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( - payment_link, - current_lang, - i18n, - back_callback=f"subscribe_period:{months}", - back_text_key="back_to_payment_methods_button", - ), - disable_web_page_preview=False, - ) - except Exception: - pass - try: - await callback.answer() - except Exception: - pass - return - - logging.error( - "SeverPay: payment created but missing payment link for payment %s. 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"SeverPay: 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, - settings: Settings, - i18n_data: dict, - session: AsyncSession, - cryptopay_service: CryptoPayService, -): - 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 cryptopay_service or not getattr(cryptopay_service, "configured", False): - try: - await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True) - except Exception: - pass - return - - try: - _, data_payload = callback.data.split(":", 1) - months_str, price_str = data_payload.split(":") - months = int(months_str) - price_amount = float(price_str) - except (ValueError, IndexError): - 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) - - invoice_url = await cryptopay_service.create_invoice( - session=session, - user_id=user_id, - months=months, - amount=price_amount, - description=payment_description, - ) - - if invoice_url: - try: - await callback.message.edit_text( - get_text(key="payment_link_message", months=months), - reply_markup=get_payment_url_keyboard( - invoice_url, - current_lang, - i18n, - back_callback=f"subscribe_period:{months}", - back_text_key="back_to_payment_methods_button", - ), - disable_web_page_preview=False, - ) - except Exception: - try: - await callback.message.answer( - get_text(key="payment_link_message", months=months), - reply_markup=get_payment_url_keyboard( - invoice_url, - current_lang, - i18n, - back_callback=f"subscribe_period:{months}", - back_text_key="back_to_payment_methods_button", - ), - disable_web_page_preview=False, - ) - except Exception: - pass - try: - await callback.answer() - except Exception: - pass - return - - try: - await callback.answer(get_text("error_payment_gateway"), show_alert=True) - except Exception: - pass - - -@router.callback_query(F.data.startswith("pay_stars:")) -async def pay_stars_callback_handler( - callback: types.CallbackQuery, - settings: Settings, - i18n_data: dict, - session: AsyncSession, - stars_service: StarsService, -): - 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 settings.STARS_ENABLED: - try: - await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True) - except Exception: - pass - return - - try: - _, data_payload = callback.data.split(":", 1) - months_str, stars_price_str = data_payload.split(":") - months = int(months_str) - stars_price = int(stars_price_str) - except (ValueError, IndexError): - 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) - - payment_db_id = await stars_service.create_invoice( - session=session, - user_id=user_id, - months=months, - stars_price=stars_price, - description=payment_description, - ) - - if payment_db_id: - try: - await callback.message.edit_text( - get_text("payment_invoice_sent_message", months=months), - reply_markup=InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton( - text=get_text("back_to_payment_methods_button"), - callback_data=f"subscribe_period:{months}", - )] - ]), - ) - except Exception as e_edit: - logging.warning(f"Stars payment: failed to show invoice info message ({e_edit})") - try: - await callback.answer() - except Exception: - pass - return - - try: - await callback.answer(get_text("error_payment_gateway"), show_alert=True) - except Exception: - pass - - -@router.pre_checkout_query() -async def handle_pre_checkout_query(query: types.PreCheckoutQuery): - try: - await query.answer(ok=True) - except Exception: - # Nothing else to do here; Telegram will show an error if not answered - pass - - -@router.message(F.successful_payment) -async def handle_successful_stars_payment( - message: types.Message, - settings: Settings, - i18n_data: dict, - session: AsyncSession, - stars_service: StarsService, -): - payload = (message.successful_payment.invoice_payload - if message and message.successful_payment else "") - try: - payment_db_id_str, months_str = (payload or "").split(":", 1) - payment_db_id = int(payment_db_id_str) - months = int(months_str) - except Exception: - return - - stars_amount = int(message.successful_payment.total_amount) if message.successful_payment else 0 - await stars_service.process_successful_payment( - session=session, - message=message, - payment_db_id=payment_db_id, - months=months, - stars_amount=stars_amount, - i18n_data=i18n_data, - ) +router.include_router(subscription_selection_router) +router.include_router(yookassa_router) +router.include_router(freekassa_router) +router.include_router(platega_router) +router.include_router(severpay_router) +router.include_router(crypto_router) +router.include_router(stars_router) + +__all__ = ["router"] diff --git a/bot/handlers/user/subscription/payments_crypto.py b/bot/handlers/user/subscription/payments_crypto.py new file mode 100644 index 0000000..267f4d4 --- /dev/null +++ b/bot/handlers/user/subscription/payments_crypto.py @@ -0,0 +1,100 @@ +from typing import Optional + +from aiogram import F, Router, types +from sqlalchemy.ext.asyncio import AsyncSession + +from bot.keyboards.inline.user_keyboards import get_payment_url_keyboard +from bot.middlewares.i18n import JsonI18n +from bot.services.crypto_pay_service import CryptoPayService +from config.settings import Settings + +router = Router(name="user_subscription_payments_crypto_router") + + +@router.callback_query(F.data.startswith("pay_crypto:")) +async def pay_crypto_callback_handler( + callback: types.CallbackQuery, + settings: Settings, + i18n_data: dict, + session: AsyncSession, + cryptopay_service: CryptoPayService, +): + 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 cryptopay_service or not getattr(cryptopay_service, "configured", False): + try: + await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True) + except Exception: + pass + return + + try: + _, data_payload = callback.data.split(":", 1) + months_str, price_str = data_payload.split(":") + months = int(months_str) + price_amount = float(price_str) + except (ValueError, IndexError): + 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) + + invoice_url = await cryptopay_service.create_invoice( + session=session, + user_id=user_id, + months=months, + amount=price_amount, + description=payment_description, + ) + + if invoice_url: + try: + await callback.message.edit_text( + get_text(key="payment_link_message", months=months), + reply_markup=get_payment_url_keyboard( + invoice_url, + current_lang, + i18n, + back_callback=f"subscribe_period:{months}", + back_text_key="back_to_payment_methods_button", + ), + disable_web_page_preview=False, + ) + except Exception: + try: + await callback.message.answer( + get_text(key="payment_link_message", months=months), + reply_markup=get_payment_url_keyboard( + invoice_url, + current_lang, + i18n, + back_callback=f"subscribe_period:{months}", + back_text_key="back_to_payment_methods_button", + ), + disable_web_page_preview=False, + ) + except Exception: + pass + try: + await callback.answer() + except Exception: + pass + return + + try: + await callback.answer(get_text("error_payment_gateway"), show_alert=True) + except Exception: + pass diff --git a/bot/handlers/user/subscription/payments_freekassa.py b/bot/handlers/user/subscription/payments_freekassa.py new file mode 100644 index 0000000..5266443 --- /dev/null +++ b/bot/handlers/user/subscription/payments_freekassa.py @@ -0,0 +1,200 @@ +import logging +from datetime import datetime +from typing import Optional + +from aiogram import F, Router, types +from sqlalchemy.ext.asyncio import AsyncSession + +from bot.keyboards.inline.user_keyboards import get_payment_url_keyboard +from bot.middlewares.i18n import JsonI18n +from bot.services.freekassa_service import FreeKassaService +from config.settings import Settings +from db.dal import payment_dal + +router = Router(name="user_subscription_payments_freekassa_router") + + +@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 + + success, response_data = await freekassa_service.create_order( + payment_db_id=payment_record.payment_id, + user_id=payment_record.user_id, + months=months, + amount=price_rub, + currency=freekassa_service.default_currency, + payment_method_id=freekassa_service.payment_method_id, + ip_address=freekassa_service.server_ip, + extra_params={ + "us_method": freekassa_service.payment_method_id, + }, + ) + + if success: + location = response_data.get("location") + order_hash = response_data.get("orderHash") + order_id_api = response_data.get("orderId") + provider_identifier = order_hash or order_id_api + + 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: + order_identifier_display = str(order_id_api or provider_identifier or payment_record.payment_id) + order_info_text = get_text( + "free_kassa_order_info", + order_id=order_identifier_display, + date=datetime.now().strftime("%Y-%m-%d"), + ) + try: + await callback.message.edit_text( + f"{order_info_text}\n\n" + get_text(key="payment_link_message", months=months), + reply_markup=get_payment_url_keyboard( + location, + current_lang, + i18n, + back_callback=f"subscribe_period:{months}", + back_text_key="back_to_payment_methods_button", + ), + 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( + f"{order_info_text}\n\n" + get_text(key="payment_link_message", months=months), + reply_markup=get_payment_url_keyboard( + location, + current_lang, + i18n, + back_callback=f"subscribe_period:{months}", + back_text_key="back_to_payment_methods_button", + ), + 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 diff --git a/bot/handlers/user/subscription/payments_platega.py b/bot/handlers/user/subscription/payments_platega.py new file mode 100644 index 0000000..1da2f29 --- /dev/null +++ b/bot/handlers/user/subscription/payments_platega.py @@ -0,0 +1,195 @@ +import json +import logging +from typing import Optional + +from aiogram import F, Router, types +from sqlalchemy.ext.asyncio import AsyncSession + +from bot.keyboards.inline.user_keyboards import get_payment_url_keyboard +from bot.middlewares.i18n import JsonI18n +from bot.services.platega_service import PlategaService +from config.settings import Settings +from db.dal import payment_dal + +router = Router(name="user_subscription_payments_platega_router") + + +@router.callback_query(F.data.startswith("pay_platega:")) +async def pay_platega_callback_handler( + callback: types.CallbackQuery, + settings: Settings, + i18n_data: dict, + platega_service: PlategaService, + 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 platega_service or not platega_service.configured: + logging.error("Platega 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_platega 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 = settings.DEFAULT_CURRENCY_SYMBOL or "RUB" + + payment_record_payload = { + "user_id": user_id, + "amount": price_rub, + "currency": currency_code, + "status": "pending_platega", + "description": payment_description, + "subscription_duration_months": months, + "provider": "platega", + } + + 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"Platega: 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 + + payload_meta = json.dumps( + { + "payment_db_id": payment_record.payment_id, + "user_id": user_id, + "months": months, + } + ) + + success, response_data = await platega_service.create_transaction( + payment_db_id=payment_record.payment_id, + user_id=user_id, + months=months, + amount=price_rub, + currency=currency_code, + description=payment_description, + payload=payload_meta, + ) + + if success: + transaction_id = response_data.get("transactionId") or response_data.get("id") + redirect_url = ( + response_data.get("redirect") + or response_data.get("url") + or response_data.get("paymentUrl") + ) + provider_status = response_data.get("status", payment_record.status) + + if transaction_id and redirect_url: + try: + await payment_dal.update_provider_payment_and_status( + session, + payment_record.payment_id, + str(transaction_id), + str(provider_status), + ) + await session.commit() + except Exception as e_status: + await session.rollback() + logging.error( + f"Platega: failed to store transaction id for payment {payment_record.payment_id}: {e_status}", + exc_info=True, + ) + + try: + await callback.message.edit_text( + get_text(key="payment_link_message", months=months), + reply_markup=get_payment_url_keyboard( + redirect_url, + current_lang, + i18n, + back_callback=f"subscribe_period:{months}", + back_text_key="back_to_payment_methods_button", + ), + disable_web_page_preview=False, + ) + except Exception as e_edit: + logging.warning(f"Platega: 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( + redirect_url, + current_lang, + i18n, + back_callback=f"subscribe_period:{months}", + back_text_key="back_to_payment_methods_button", + ), + disable_web_page_preview=False, + ) + except Exception: + pass + try: + await callback.answer() + except Exception: + pass + return + + logging.error( + "Platega: transaction created but missing transaction id or payment link for payment %s. 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"Platega: 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 diff --git a/bot/handlers/user/subscription/payments_severpay.py b/bot/handlers/user/subscription/payments_severpay.py new file mode 100644 index 0000000..47df521 --- /dev/null +++ b/bot/handlers/user/subscription/payments_severpay.py @@ -0,0 +1,185 @@ +import logging +from typing import Optional + +from aiogram import F, Router, types +from sqlalchemy.ext.asyncio import AsyncSession + +from bot.keyboards.inline.user_keyboards import get_payment_url_keyboard +from bot.middlewares.i18n import JsonI18n +from bot.services.severpay_service import SeverPayService +from config.settings import Settings +from db.dal import payment_dal + +router = Router(name="user_subscription_payments_severpay_router") + + +@router.callback_query(F.data.startswith("pay_severpay:")) +async def pay_severpay_callback_handler( + callback: types.CallbackQuery, + settings: Settings, + i18n_data: dict, + severpay_service: SeverPayService, + 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 severpay_service or not severpay_service.configured: + logging.error("SeverPay 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_severpay 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 = settings.DEFAULT_CURRENCY_SYMBOL or "RUB" + + payment_record_payload = { + "user_id": user_id, + "amount": price_rub, + "currency": currency_code, + "status": "pending_severpay", + "description": payment_description, + "subscription_duration_months": months, + "provider": "severpay", + } + + 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"SeverPay: 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 + + success, response_data = await severpay_service.create_payment( + payment_db_id=payment_record.payment_id, + user_id=user_id, + months=months, + amount=price_rub, + currency=currency_code, + description=payment_description, + ) + + if success: + payment_link = ( + response_data.get("url") + or response_data.get("payment_url") + or response_data.get("paymentUrl") + ) + provider_identifier = response_data.get("id") or response_data.get("uid") + + 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"SeverPay: failed to store provider payment id for payment {payment_record.payment_id}: {e_status}", + 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, + back_callback=f"subscribe_period:{months}", + back_text_key="back_to_payment_methods_button", + ), + disable_web_page_preview=False, + ) + except Exception as e_edit: + logging.warning(f"SeverPay: 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( + payment_link, + current_lang, + i18n, + back_callback=f"subscribe_period:{months}", + back_text_key="back_to_payment_methods_button", + ), + disable_web_page_preview=False, + ) + except Exception: + pass + try: + await callback.answer() + except Exception: + pass + return + + logging.error( + "SeverPay: payment created but missing payment link for payment %s. 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"SeverPay: 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 diff --git a/bot/handlers/user/subscription/payments_stars.py b/bot/handlers/user/subscription/payments_stars.py new file mode 100644 index 0000000..fa4ef47 --- /dev/null +++ b/bot/handlers/user/subscription/payments_stars.py @@ -0,0 +1,123 @@ +import logging +from typing import Optional + +from aiogram import F, Router, types +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup +from sqlalchemy.ext.asyncio import AsyncSession + +from bot.middlewares.i18n import JsonI18n +from bot.services.stars_service import StarsService +from config.settings import Settings + +router = Router(name="user_subscription_payments_stars_router") + + +@router.callback_query(F.data.startswith("pay_stars:")) +async def pay_stars_callback_handler( + callback: types.CallbackQuery, + settings: Settings, + i18n_data: dict, + session: AsyncSession, + stars_service: StarsService, +): + 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 settings.STARS_ENABLED: + try: + await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True) + except Exception: + pass + return + + try: + _, data_payload = callback.data.split(":", 1) + months_str, stars_price_str = data_payload.split(":") + months = int(months_str) + stars_price = int(stars_price_str) + except (ValueError, IndexError): + 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) + + payment_db_id = await stars_service.create_invoice( + session=session, + user_id=user_id, + months=months, + stars_price=stars_price, + description=payment_description, + ) + + if payment_db_id: + try: + await callback.message.edit_text( + get_text("payment_invoice_sent_message", months=months), + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton( + text=get_text("back_to_payment_methods_button"), + callback_data=f"subscribe_period:{months}", + )] + ]), + ) + except Exception as e_edit: + logging.warning(f"Stars payment: failed to show invoice info message ({e_edit})") + try: + await callback.answer() + except Exception: + pass + return + + try: + await callback.answer(get_text("error_payment_gateway"), show_alert=True) + except Exception: + pass + + +@router.pre_checkout_query() +async def handle_pre_checkout_query(query: types.PreCheckoutQuery): + try: + await query.answer(ok=True) + except Exception: + # Nothing else to do here; Telegram will show an error if not answered + pass + + +@router.message(F.successful_payment) +async def handle_successful_stars_payment( + message: types.Message, + settings: Settings, + i18n_data: dict, + session: AsyncSession, + stars_service: StarsService, +): + payload = (message.successful_payment.invoice_payload + if message and message.successful_payment else "") + try: + payment_db_id_str, months_str = (payload or "").split(":", 1) + payment_db_id = int(payment_db_id_str) + months = int(months_str) + except Exception: + return + + stars_amount = int(message.successful_payment.total_amount) if message.successful_payment else 0 + await stars_service.process_successful_payment( + session=session, + message=message, + payment_db_id=payment_db_id, + months=months, + stars_amount=stars_amount, + i18n_data=i18n_data, + ) diff --git a/bot/handlers/user/subscription/payments_subscription.py b/bot/handlers/user/subscription/payments_subscription.py new file mode 100644 index 0000000..7116d18 --- /dev/null +++ b/bot/handlers/user/subscription/payments_subscription.py @@ -0,0 +1,78 @@ +import logging +from typing import Optional + +from aiogram import F, Router, types +from sqlalchemy.ext.asyncio import AsyncSession + +from bot.keyboards.inline.user_keyboards import get_payment_method_keyboard +from bot.middlewares.i18n import JsonI18n +from config.settings import Settings + +router = Router(name="user_subscription_payments_selection_router") + + +@router.callback_query(F.data.startswith("subscribe_period:")) +async def select_subscription_period_callback_handler( + callback: types.CallbackQuery, + settings: Settings, + i18n_data: dict, + 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 + + try: + months = int(callback.data.split(":")[-1]) + except (ValueError, IndexError): + logging.error(f"Invalid subscription period in callback_data: {callback.data}") + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + price_rub = settings.subscription_options.get(months) + if price_rub is None: + logging.error( + f"Price not found for {months} months subscription period in settings.subscription_options." + ) + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + currency_symbol_val = settings.DEFAULT_CURRENCY_SYMBOL + text_content = get_text("choose_payment_method") + tribute_url = settings.tribute_payment_links.get(months) + stars_price = settings.stars_subscription_options.get(months) + reply_markup = get_payment_method_keyboard( + months, + price_rub, + tribute_url, + stars_price, + currency_symbol_val, + current_lang, + i18n, + settings, + ) + + try: + await callback.message.edit_text(text_content, reply_markup=reply_markup) + except Exception as e_edit: + logging.warning( + f"Edit message for payment method selection failed: {e_edit}. Sending new one." + ) + await callback.message.answer(text_content, reply_markup=reply_markup) + try: + await callback.answer() + except Exception: + pass diff --git a/bot/handlers/user/subscription/payments_yookassa.py b/bot/handlers/user/subscription/payments_yookassa.py new file mode 100644 index 0000000..e1ed517 --- /dev/null +++ b/bot/handlers/user/subscription/payments_yookassa.py @@ -0,0 +1,737 @@ +import logging +from typing import List, Optional, Tuple + +from aiogram import F, Router, types +from sqlalchemy.ext.asyncio import AsyncSession + +from bot.keyboards.inline.user_keyboards import ( + get_back_to_main_menu_markup, + get_payment_url_keyboard, + get_yk_autopay_choice_keyboard, + get_yk_saved_cards_keyboard, +) +from bot.middlewares.i18n import JsonI18n +from bot.services.yookassa_service import YooKassaService +from config.settings import Settings +from db.dal import payment_dal, user_billing_dal + +router = Router(name="user_subscription_payments_yookassa_router") + + +def _parse_months_and_price(payload: str) -> Optional[Tuple[int, float]]: + try: + months_str, price_str = payload.split(":") + return int(months_str), float(price_str) + except (ValueError, IndexError): + return None + + +def _format_saved_payment_method_title(get_text, network: Optional[str], last4: Optional[str], is_default: bool) -> str: + def _is_yoomoney_network(name: Optional[str]) -> bool: + s = (name or "").lower() + return "yoomoney" in s or "yoo money" in s or "yoo-money" in s + + def _extract_last4(text: str) -> Optional[str]: + digits = "".join(ch for ch in text if ch.isdigit()) + return digits[-4:] if len(digits) >= 4 else None + + if _is_yoomoney_network(network): + inferred_last4 = last4 or (_extract_last4(network or "") or "****") + title = get_text("payment_method_wallet_title", last4=inferred_last4) + elif last4: + network_name = network or get_text("payment_network_card") + title = get_text("payment_method_card_title", network=network_name, last4=last4) + else: + network_name = network or get_text("payment_network_generic") + title = get_text("payment_method_generic_title", network=network_name) + return f"⭐ {title}" if is_default else title + + +async def _initiate_yk_payment( + callback: types.CallbackQuery, + *, + settings: Settings, + session: AsyncSession, + yookassa_service: YooKassaService, + i18n: Optional[JsonI18n], + current_lang: str, + get_text, + user_id: int, + months: int, + price_rub: float, + currency_code_for_yk: str, + save_payment_method: bool, + back_callback: str, + payment_method_id: Optional[str] = None, + selected_method_internal_id: Optional[int] = None, +) -> bool: + """Create payment record and initiate YooKassa payment (new card or saved card).""" + if not callback.message: + return False + + payment_description = get_text("payment_description_subscription", months=months) + payment_record_data = { + "user_id": user_id, + "amount": price_rub, + "currency": currency_code_for_yk, + "status": "pending_yookassa", + "description": payment_description, + "subscription_duration_months": months, + } + + db_payment_record = None + try: + db_payment_record = await payment_dal.create_payment_record(session, payment_record_data) + await session.commit() + logging.info( + f"Payment record {db_payment_record.payment_id} created for user {user_id} with status 'pending_yookassa'." + ) + except Exception as e_db_payment: + await session.rollback() + logging.error( + f"Failed to create payment record in DB for user {user_id}: {e_db_payment}", + exc_info=True, + ) + try: + await callback.message.edit_text(get_text("error_creating_payment_record")) + except Exception: + pass + return False + + if not db_payment_record: + try: + await callback.message.edit_text(get_text("error_creating_payment_record")) + except Exception: + pass + return False + + yookassa_metadata = { + "user_id": str(user_id), + "subscription_months": str(months), + "payment_db_id": str(db_payment_record.payment_id), + } + if payment_method_id: + yookassa_metadata["used_saved_payment_method_id"] = payment_method_id + + receipt_email_for_yk = settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL + + payment_response_yk = await yookassa_service.create_payment( + amount=price_rub, + currency=currency_code_for_yk, + description=payment_description, + metadata=yookassa_metadata, + receipt_email=receipt_email_for_yk, + save_payment_method=save_payment_method, + payment_method_id=payment_method_id, + ) + + if payment_response_yk and payment_response_yk.get("confirmation_url"): + pm = payment_response_yk.get("payment_method") + try: + if pm and pm.get("id"): + pm_type = pm.get("type") + title = pm.get("title") + card = pm.get("card") or {} + account_number = pm.get("account_number") or pm.get("account") + if isinstance(card, dict) and (pm_type or "").lower() in {"bank_card", "bank-card", "card"}: + display_network = card.get("card_type") or title or "Card" + display_last4 = card.get("last4") + elif (pm_type or "").lower() in {"yoo_money", "yoomoney", "yoo-money", "wallet"}: + display_network = "YooMoney" + display_last4 = ( + account_number[-4:] + if isinstance(account_number, str) and len(account_number) >= 4 + else None + ) + else: + display_network = title or (pm_type.upper() if pm_type else "Payment method") + display_last4 = None + await user_billing_dal.upsert_yk_payment_method( + session, + user_id=user_id, + payment_method_id=pm["id"], + card_last4=display_last4, + card_network=display_network, + ) + try: + await user_billing_dal.upsert_user_payment_method( + session, + user_id=user_id, + provider_payment_method_id=pm["id"], + provider="yookassa", + card_last4=display_last4, + card_network=display_network, + set_default=save_payment_method, + ) + except Exception: + pass + await session.commit() + except Exception: + await session.rollback() + logging.exception("Failed to save YooKassa payment method preliminarily") + try: + await payment_dal.update_payment_status_by_db_id( + session, + payment_db_id=db_payment_record.payment_id, + new_status=payment_response_yk.get("status", "pending"), + yk_payment_id=payment_response_yk.get("id"), + ) + if selected_method_internal_id is not None: + try: + await user_billing_dal.set_user_default_payment_method( + session, user_id, selected_method_internal_id + ) + except Exception: + logging.exception("Failed to set default payment method after initiating payment") + await session.commit() + except Exception as e_db_update_ykid: + await session.rollback() + logging.error( + f"Failed to update payment record {db_payment_record.payment_id} with YK ID: {e_db_update_ykid}", + exc_info=True, + ) + try: + await callback.message.edit_text(get_text("error_payment_gateway_link_failed")) + except Exception: + pass + return False + + try: + await callback.message.edit_text( + get_text(key="payment_link_message", months=months), + reply_markup=get_payment_url_keyboard( + payment_response_yk["confirmation_url"], + current_lang, + i18n, + back_callback=back_callback, + back_text_key="back_to_payment_methods_button", + ), + disable_web_page_preview=False, + ) + except Exception as e_edit: + logging.warning( + f"Edit message for payment link failed: {e_edit}. Sending new one." + ) + try: + await callback.message.answer( + get_text(key="payment_link_message", months=months), + reply_markup=get_payment_url_keyboard( + payment_response_yk["confirmation_url"], + current_lang, + i18n, + back_callback=back_callback, + back_text_key="back_to_payment_methods_button", + ), + disable_web_page_preview=False, + ) + except Exception: + pass + return True + + if payment_response_yk and payment_method_id: + status_to_store = payment_response_yk.get("status", "pending") + try: + await payment_dal.update_payment_status_by_db_id( + session, + payment_db_id=db_payment_record.payment_id, + new_status=status_to_store, + yk_payment_id=payment_response_yk.get("id"), + ) + if selected_method_internal_id is not None: + try: + await user_billing_dal.set_user_default_payment_method( + session, user_id, selected_method_internal_id + ) + except Exception: + logging.exception("Failed to set default payment method after saved-card payment start") + await session.commit() + except Exception as e_db_update_saved: + await session.rollback() + logging.error( + f"Failed to update saved-card payment record {db_payment_record.payment_id}: {e_db_update_saved}", + exc_info=True, + ) + try: + await callback.message.edit_text(get_text("error_payment_gateway")) + except Exception: + pass + return False + + message_text = get_text("yookassa_autopay_charge_initiated") + try: + await callback.message.edit_text( + message_text, + reply_markup=get_back_to_main_menu_markup(current_lang, i18n), + ) + except Exception as e_edit: + logging.warning(f"Failed to notify about saved-card charge start: {e_edit}") + try: + await callback.message.answer( + message_text, + reply_markup=get_back_to_main_menu_markup(current_lang, i18n), + ) + except Exception: + pass + return True + + try: + await payment_dal.update_payment_status_by_db_id( + session, db_payment_record.payment_id, "failed_creation" + ) + await session.commit() + except Exception as e_db_fail_create: + await session.rollback() + logging.error( + f"Additionally failed to update payment record to 'failed_creation': {e_db_fail_create}", + exc_info=True, + ) + logging.error( + f"Failed to create payment in YooKassa for user {user_id}, payment_db_id {db_payment_record.payment_id}. Response: {payment_response_yk}" + ) + try: + await callback.message.edit_text(get_text("error_payment_gateway")) + except Exception: + pass + return False + + +@router.callback_query(F.data.startswith("pay_yk:")) +async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, yookassa_service: YooKassaService, 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 yookassa_service or not yookassa_service.configured: + logging.error("YooKassa service is not configured or unavailable.") + target_msg_edit = callback.message + await target_msg_edit.edit_text(get_text("payment_service_unavailable")) + try: + await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True) + except Exception: + pass + return + + try: + _, data_payload = callback.data.split(":", 1) + except ValueError: + logging.error(f"Invalid pay_yk data in callback: {callback.data}") + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + parsed = _parse_months_and_price(data_payload) + if not parsed: + logging.error(f"Invalid pay_yk payload structure: {callback.data}") + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + months, price_rub = parsed + user_id = callback.from_user.id + currency_code_for_yk = "RUB" + autopay_enabled = bool(getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False)) + autopay_require_binding = bool( + getattr(settings, 'YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING', True) + ) + saved_methods: List = [] + if autopay_enabled: + try: + saved_methods = await user_billing_dal.list_user_payment_methods( + session, user_id, provider="yookassa" + ) + except Exception as e_list: + logging.exception(f"Failed to load saved payment methods for user {user_id}: {e_list}") + saved_methods = [] + + if autopay_enabled and saved_methods: + try: + await callback.message.edit_text( + get_text("yookassa_autopay_flow_prompt"), + reply_markup=get_yk_autopay_choice_keyboard( + months, + price_rub, + current_lang, + i18n, + has_saved_cards=True, + ), + ) + except Exception as e_edit: + logging.warning(f"Failed to show autopay choice: {e_edit}. Sending new message.") + try: + await callback.message.answer( + get_text("yookassa_autopay_flow_prompt"), + reply_markup=get_yk_autopay_choice_keyboard( + months, + price_rub, + current_lang, + i18n, + has_saved_cards=True, + ), + ) + except Exception: + pass + try: + await callback.answer() + except Exception: + pass + return + + await _initiate_yk_payment( + callback, + settings=settings, + session=session, + yookassa_service=yookassa_service, + i18n=i18n, + current_lang=current_lang, + get_text=get_text, + user_id=user_id, + months=months, + price_rub=price_rub, + currency_code_for_yk=currency_code_for_yk, + save_payment_method=autopay_enabled and autopay_require_binding, + back_callback=f"subscribe_period:{months}", + ) + try: + await callback.answer() + except Exception: + pass + + +@router.callback_query(F.data.startswith("pay_yk_new:")) +async def pay_yk_new_card_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, yookassa_service: YooKassaService, 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 yookassa_service or not yookassa_service.configured: + logging.error("YooKassa service unavailable for pay_yk_new.") + 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) + except ValueError: + logging.error(f"Invalid pay_yk_new data in callback: {callback.data}") + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + parsed = _parse_months_and_price(data_payload) + if not parsed: + logging.error(f"Invalid pay_yk_new payload structure: {callback.data}") + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + months, price_rub = parsed + user_id = callback.from_user.id + currency_code_for_yk = "RUB" + autopay_enabled = bool(getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False)) + autopay_require_binding = bool( + getattr(settings, 'YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING', True) + ) + + await _initiate_yk_payment( + callback, + settings=settings, + session=session, + yookassa_service=yookassa_service, + i18n=i18n, + current_lang=current_lang, + get_text=get_text, + user_id=user_id, + months=months, + price_rub=price_rub, + currency_code_for_yk=currency_code_for_yk, + save_payment_method=autopay_enabled and autopay_require_binding, + back_callback=f"subscribe_period:{months}", + ) + try: + await callback.answer() + except Exception: + pass + + +@router.callback_query(F.data.startswith("pay_yk_saved_list:")) +async def pay_yk_saved_list_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, yookassa_service: YooKassaService, 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 + + autopay_enabled = bool(getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False)) + if not autopay_enabled: + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + try: + _, data_payload = callback.data.split(":", 1) + except ValueError: + logging.error(f"Invalid pay_yk_saved_list data: {callback.data}") + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + parts = data_payload.split(":") + if len(parts) < 2: + logging.error(f"pay_yk_saved_list payload missing components: {callback.data}") + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + try: + months = int(parts[0]) + price_rub = float(parts[1]) + page = int(parts[2]) if len(parts) > 2 else 0 + except (ValueError, IndexError): + logging.error(f"pay_yk_saved_list payload parsing error: {callback.data}") + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + user_id = callback.from_user.id + try: + saved_methods = await user_billing_dal.list_user_payment_methods( + session, user_id, provider="yookassa" + ) + except Exception as e_list: + logging.exception(f"Failed to list saved payment methods for user {user_id}: {e_list}") + saved_methods = [] + + if not saved_methods: + try: + await callback.message.edit_text( + get_text("yookassa_autopay_no_saved_cards"), + reply_markup=get_yk_autopay_choice_keyboard( + months, + price_rub, + current_lang, + i18n, + has_saved_cards=False, + ), + ) + except Exception as e_edit: + logging.warning(f"Failed to display no-saved-card notice: {e_edit}") + try: + await callback.message.answer( + get_text("yookassa_autopay_no_saved_cards"), + reply_markup=get_yk_autopay_choice_keyboard( + months, + price_rub, + current_lang, + i18n, + has_saved_cards=False, + ), + ) + except Exception: + pass + try: + await callback.answer() + except Exception: + pass + return + + cards: List[Tuple[str, str]] = [] + for method in saved_methods: + title = _format_saved_payment_method_title( + get_text, method.card_network, method.card_last4, method.is_default + ) + cards.append((str(method.method_id), title)) + + per_page = 5 + max_page = max(0, (len(cards) - 1) // per_page) + page = max(0, min(page, max_page)) + + try: + await callback.message.edit_text( + get_text("yookassa_autopay_choose_saved_card"), + reply_markup=get_yk_saved_cards_keyboard( + cards, + months, + price_rub, + current_lang, + i18n, + page=page, + ), + ) + except Exception as e_edit: + logging.warning(f"Failed to display saved card list: {e_edit}") + try: + await callback.message.answer( + get_text("yookassa_autopay_choose_saved_card"), + reply_markup=get_yk_saved_cards_keyboard( + cards, + months, + price_rub, + current_lang, + i18n, + page=page, + ), + ) + except Exception: + pass + try: + await callback.answer() + except Exception: + pass + + +@router.callback_query(F.data.startswith("pay_yk_use_saved:")) +async def pay_yk_use_saved_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, yookassa_service: YooKassaService, 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 + + autopay_enabled = bool(getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False)) + if not autopay_enabled: + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + if not yookassa_service or not yookassa_service.configured: + logging.error("YooKassa service unavailable for pay_yk_use_saved.") + 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) + except ValueError: + logging.error(f"Invalid pay_yk_use_saved data: {callback.data}") + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + parts = data_payload.split(":") + if len(parts) < 3: + logging.error(f"pay_yk_use_saved payload missing components: {callback.data}") + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + try: + months = int(parts[0]) + price_rub = float(parts[1]) + except (ValueError, IndexError): + logging.error(f"pay_yk_use_saved months/price parsing error: {callback.data}") + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + method_identifier = parts[2] + user_id = callback.from_user.id + + try: + saved_methods = await user_billing_dal.list_user_payment_methods( + session, user_id, provider="yookassa" + ) + except Exception as e_list: + logging.exception(f"Failed to list saved payment methods for user {user_id}: {e_list}") + saved_methods = [] + + selected_method = None + for method in saved_methods: + if method_identifier.isdigit(): + if method.method_id == int(method_identifier): + selected_method = method + break + if method.provider_payment_method_id == method_identifier: + selected_method = method + break + + if not selected_method: + logging.warning(f"Selected payment method not found for user {user_id}: {method_identifier}") + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + currency_code_for_yk = "RUB" + + await _initiate_yk_payment( + callback, + settings=settings, + session=session, + yookassa_service=yookassa_service, + i18n=i18n, + current_lang=current_lang, + get_text=get_text, + user_id=user_id, + months=months, + price_rub=price_rub, + currency_code_for_yk=currency_code_for_yk, + save_payment_method=False, + back_callback=f"pay_yk_saved_list:{months}:{price_rub}", + payment_method_id=selected_method.provider_payment_method_id, + selected_method_internal_id=selected_method.method_id, + ) + try: + await callback.answer() + except Exception: + pass diff --git a/bot/services/panel_api_service.py b/bot/services/panel_api_service.py index c8e649f..b9e75af 100644 --- a/bot/services/panel_api_service.py +++ b/bot/services/panel_api_service.py @@ -1,6 +1,7 @@ import aiohttp import logging import json +import re from typing import Optional, List, Dict, Any from datetime import datetime, timedelta, timezone import asyncio @@ -345,18 +346,19 @@ class PanelApiService: status: str = "ACTIVE", log_response: bool = True) -> Optional[Dict[str, Any]]: - if not (6 <= len(username_on_panel) <= 34 and - username_on_panel.replace('_', '').replace('-', '').isalnum()): - if not (username_on_panel.startswith("tg_") - and username_on_panel.split("tg_")[-1].isdigit()): - msg = f"Panel username '{username_on_panel}' does not meet panel requirements." - logging.error(msg) - return { - "error": True, - "status_code": 400, - "message": msg, - "errorCode": "VALIDATION_ERROR_USERNAME" - } + username_is_valid = ( + 3 <= len(username_on_panel) <= 36 + and re.match(r"^[A-Za-z0-9_-]+$", username_on_panel) is not None + ) + if not username_is_valid: + msg = f"Panel username '{username_on_panel}' does not meet panel requirements." + logging.error(msg) + return { + "error": True, + "status_code": 400, + "message": msg, + "errorCode": "VALIDATION_ERROR_USERNAME" + } now = datetime.now(timezone.utc) expire_at_dt = now + timedelta(days=default_expire_days) diff --git a/bot/services/subscription_service.py b/bot/services/subscription_service.py index b22b7b4..8b7b1c7 100644 --- a/bot/services/subscription_service.py +++ b/bot/services/subscription_service.py @@ -717,7 +717,8 @@ class SubscriptionService: update_payload_local = {} panel_status = panel_user_data.get("status", "UNKNOWN").upper() panel_expire_at_str = panel_user_data.get("expireAt") - panel_traffic_used = panel_user_data.get("usedTrafficBytes") + traffic_stats = panel_user_data.get("userTraffic") or {} + panel_traffic_used = traffic_stats.get("usedTrafficBytes") panel_traffic_limit = panel_user_data.get("trafficLimitBytes") panel_sub_uuid_from_panel = panel_user_data.get( "subscriptionUuid" @@ -781,7 +782,7 @@ class SubscriptionService: "status_from_panel": panel_user_data.get("status", "UNKNOWN").upper(), "config_link": panel_user_data.get("subscriptionUrl"), "traffic_limit_bytes": panel_user_data.get("trafficLimitBytes"), - "traffic_used_bytes": panel_user_data.get("usedTrafficBytes"), + "traffic_used_bytes": (panel_user_data.get("userTraffic") or {}).get("usedTrafficBytes"), "user_bot_username": db_user.username, "is_panel_data": True, "max_devices": hwid_limit, From 805bd4cf607186db5e4156c31bea6c2c02224c59 Mon Sep 17 00:00:00 2001 From: machka pasla Date: Tue, 9 Dec 2025 20:21:46 +0300 Subject: [PATCH 05/16] readme upd --- README.md | 68 +++++++++++++++++++++++++++---------------------------- 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index d9e937d..d97410e 100644 --- a/README.md +++ b/README.md @@ -77,39 +77,39 @@ | Переменная | Описание | | --- | --- | -| `WEBHOOK_BASE_URL`| **Обязательно.** Базовый URL для вебхуков, например `https://your.domain.com`. | -| `WEB_SERVER_HOST` | Хост для веб-сервера. | `0.0.0.0` | -| `WEB_SERVER_PORT` | Порт для веб-сервера. | `8080` | -| `PAYMENT_METHODS_ORDER` | (Опционально) Порядок отображения кнопок оплаты через запятую. Поддерживаемые ключи: `severpay`, `freekassa`, `platega`, `yookassa`, `tribute`, `stars`, `cryptopay`. Первый будет сверху. | -| `YOOKASSA_ENABLED` | Включить/выключить YooKassa (`true`/`false`). | -| `YOOKASSA_SHOP_ID` | ID вашего магазина в YooKassa. | -| `YOOKASSA_SECRET_KEY`| Секретный ключ магазина YooKassa. | -| `YOOKASSA_AUTOPAYMENTS_ENABLED` | Включить автопродление (сохранение карт, автосписания, управление способами оплаты). | -| `YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING` | Требовать обязательную привязку карты при оплате с автосписанием. Установите `false`, чтобы пользователю показывался чекбокс «Сохранить карту». | -| `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_PAYMENT_IP` | Внешний IP вашего сервера, который будет передаваться в запрос оплаты. | -| `FREEKASSA_PAYMENT_METHOD_ID` | ID метода оплаты через магазин FreeKassa. По умолчанию `44`. | -| `STARS_ENABLED` | Включить/выключить Telegram Stars (`true`/`false`). | -| `TRIBUTE_ENABLED`| Включить/выключить Tribute (`true`/`false`). | -| `PLATEGA_ENABLED`| Включить/выключить Platega (`true`/`false`). | -| `PLATEGA_MERCHANT_ID`| MerchantId из личного кабинета Platega. | -| `PLATEGA_SECRET`| API секрет для запросов Platega. | -| `PLATEGA_PAYMENT_METHOD`| ID способа оплаты (2 — SBP QR, 10 — РФ карты, 12 — международные карты, 13 — crypto). | -| `PLATEGA_RETURN_URL`| (Опционально) URL редиректа после успешной оплаты. По умолчанию ссылка на бота. | -| `PLATEGA_FAILED_URL`| (Опционально) URL редиректа при ошибке/отмене. По умолчанию как `PLATEGA_RETURN_URL`. | -| `SEVERPAY_ENABLED` | Включить/выключить SeverPay (`true`/`false`). | -| `SEVERPAY_MID` | MID магазина в SeverPay. | -| `SEVERPAY_TOKEN` | Секрет/токен для подписи запросов SeverPay. | -| `SEVERPAY_BASE_URL` | (Опционально) Базовый URL API SeverPay. По умолчанию `https://severpay.io/api/merchant`. | -| `SEVERPAY_RETURN_URL` | (Опционально) URL редиректа после оплаты (по умолчанию ссылка на бота). | -| `SEVERPAY_LIFETIME_MINUTES` | (Опционально) Время жизни платежной ссылки в минутах (30–4320). | -
+ | `WEBHOOK_BASE_URL`| **Обязательно.** Базовый URL для вебхуков, например `https://your.domain.com`. | + | `WEB_SERVER_HOST` | Хост для веб-сервера. | `0.0.0.0` | + | `WEB_SERVER_PORT` | Порт для веб-сервера. | `8080` | + | `PAYMENT_METHODS_ORDER` | (Опционально) Порядок отображения кнопок оплаты через запятую. Поддерживаемые ключи: `severpay`, `freekassa`, `platega`, `yookassa`, `tribute`, `stars`, `cryptopay`. Первый будет сверху. | + | `YOOKASSA_ENABLED` | Включить/выключить YooKassa (`true`/`false`). | + | `YOOKASSA_SHOP_ID` | ID вашего магазина в YooKassa. | + | `YOOKASSA_SECRET_KEY`| Секретный ключ магазина YooKassa. | + | `YOOKASSA_AUTOPAYMENTS_ENABLED` | Включить автопродление (сохранение карт, автосписания, управление способами оплаты). | + | `YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING` | Требовать обязательную привязку карты при оплате с автосписанием. Установите `false`, чтобы пользователю показывался чекбокс «Сохранить карту». | + | `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_PAYMENT_IP` | Внешний IP вашего сервера, который будет передаваться в запрос оплаты. | + | `FREEKASSA_PAYMENT_METHOD_ID` | ID метода оплаты через магазин FreeKassa. По умолчанию `44`. | + | `STARS_ENABLED` | Включить/выключить Telegram Stars (`true`/`false`). | + | `TRIBUTE_ENABLED`| Включить/выключить Tribute (`true`/`false`). | + | `PLATEGA_ENABLED`| Включить/выключить Platega (`true`/`false`). | + | `PLATEGA_MERCHANT_ID`| MerchantId из личного кабинета Platega. | + | `PLATEGA_SECRET`| API секрет для запросов Platega. | + | `PLATEGA_PAYMENT_METHOD`| ID способа оплаты (2 — SBP QR, 10 — РФ карты, 12 — международные карты, 13 — crypto). | + | `PLATEGA_RETURN_URL`| (Опционально) URL редиректа после успешной оплаты. По умолчанию ссылка на бота. | + | `PLATEGA_FAILED_URL`| (Опционально) URL редиректа при ошибке/отмене. По умолчанию как `PLATEGA_RETURN_URL`. | + | `SEVERPAY_ENABLED` | Включить/выключить SeverPay (`true`/`false`). | + | `SEVERPAY_MID` | MID магазина в SeverPay. | + | `SEVERPAY_TOKEN` | Секрет/токен для подписи запросов SeverPay. | + | `SEVERPAY_BASE_URL` | (Опционально) Базовый URL API SeverPay. По умолчанию `https://severpay.io/api/merchant`. | + | `SEVERPAY_RETURN_URL` | (Опционально) URL редиректа после оплаты (по умолчанию ссылка на бота). | + | `SEVERPAY_LIFETIME_MINUTES` | (Опционально) Время жизни платежной ссылки в минутах (30–4320). | +
Настройки подписок @@ -136,7 +136,7 @@ | `USER_HWID_DEVICE_LIMIT`| Лимит устройств (HWID) для новых пользователей (0 - безлимит). | > Раздел "Мои устройства" становится доступен пользователям только при включении `MY_DEVICES_SECTION_ENABLED`. Значение лимита устройств при создании записей в панели берётся из `USER_HWID_DEVICE_LIMIT`. - +
Настройки пробного периода From 7b650a74a65ba8d9b2a5b1e1b03cb925aa4a74ba Mon Sep 17 00:00:00 2001 From: machka pasla Date: Tue, 9 Dec 2025 20:48:16 +0300 Subject: [PATCH 06/16] severpay fix --- bot/services/severpay_service.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/services/severpay_service.py b/bot/services/severpay_service.py index a49aff7..a2b9b32 100644 --- a/bot/services/severpay_service.py +++ b/bot/services/severpay_service.py @@ -84,9 +84,9 @@ class SeverPayService: provided_sign = str(payload.get("sign") or "") if not provided_sign or not self.token: return False + # Webhook signatures are calculated on the original payload order (without sorting). data = {k: v for k, v in payload.items() if k != "sign"} - sorted_body = dict(sorted(data.items())) - expected_sign = self._sign_payload(sorted_body) + expected_sign = self._sign_payload(data) return hmac.compare_digest(provided_sign, expected_sign) async def create_payment( From 8eba23574b450a0868ff2f5a56cf851561248808 Mon Sep 17 00:00:00 2001 From: machka pasla Date: Tue, 9 Dec 2025 22:32:10 +0300 Subject: [PATCH 07/16] some yookassa changes --- bot/handlers/user/payment.py | 4 ++-- bot/handlers/user/subscription/core.py | 4 ++-- bot/handlers/user/subscription/payment_methods.py | 12 ++++++------ bot/handlers/user/subscription/payments_yookassa.py | 8 ++++---- bot/services/subscription_service.py | 7 ++----- bot/services/yookassa_service.py | 5 ++++- config/settings.py | 6 ++++++ 7 files changed, 26 insertions(+), 20 deletions(-) diff --git a/bot/handlers/user/payment.py b/bot/handlers/user/payment.py index 0dcfba8..204d4e8 100644 --- a/bot/handlers/user/payment.py +++ b/bot/handlers/user/payment.py @@ -139,7 +139,7 @@ async def process_successful_payment(session: AsyncSession, bot: Bot, # Try to capture and save payment method for future charges if available try: payment_method = payment_info_from_webhook.get("payment_method") - if getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False) and isinstance(payment_method, dict) and payment_method.get("saved", False): + if settings.yookassa_autopayments_active and isinstance(payment_method, dict) and payment_method.get("saved", False): pm_id = payment_method.get("id") pm_type = payment_method.get("type") title = payment_method.get("title") @@ -499,7 +499,7 @@ async def yookassa_webhook_route(request: web.Request): elif notification_object.event == YOOKASSA_EVENT_PAYMENT_WAITING_FOR_CAPTURE: # Bind-only flow: save method and cancel auth if metadata has bind_only metadata = payment_dict_for_processing.get("metadata", {}) or {} - if getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False) and metadata.get("bind_only") == "1": + if settings.yookassa_autopayments_active and metadata.get("bind_only") == "1": try: user_id_str = metadata.get("user_id") if user_id_str and user_id_str.isdigit(): diff --git a/bot/handlers/user/subscription/core.py b/bot/handlers/user/subscription/core.py index 2d6d9df..3cec154 100644 --- a/bot/handlers/user/subscription/core.py +++ b/bot/handlers/user/subscription/core.py @@ -225,7 +225,7 @@ async def my_subscription_command_handler( ]) # 2) Auto-renew toggle (if supported and not tribute) - if local_sub and local_sub.provider != "tribute" and getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False): + if local_sub and local_sub.provider != "tribute" and settings.yookassa_autopayments_active: toggle_text = ( get_text("autorenew_disable_button") if local_sub.auto_renew_enabled else get_text("autorenew_enable_button") ) @@ -237,7 +237,7 @@ async def my_subscription_command_handler( ]) # 3) Payment methods management (when autopayments enabled) - if getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False): + if settings.yookassa_autopayments_active: prepend_rows.append([ InlineKeyboardButton(text=get_text("payment_methods_manage_button"), callback_data="pm:manage") ]) diff --git a/bot/handlers/user/subscription/payment_methods.py b/bot/handlers/user/subscription/payment_methods.py index 5dec974..3962b55 100644 --- a/bot/handlers/user/subscription/payment_methods.py +++ b/bot/handlers/user/subscription/payment_methods.py @@ -22,7 +22,7 @@ router = Router(name="user_subscription_payment_methods_router") async def payment_methods_manage(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession): current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - if not getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False): + if not settings.yookassa_autopayments_active: try: _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key await callback.answer(_("error_service_unavailable"), show_alert=True) @@ -75,7 +75,7 @@ async def payment_methods_manage(callback: types.CallbackQuery, settings: Settin async def payment_method_bind(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession, yookassa_service: YooKassaService): current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - if not getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False): + if not settings.yookassa_autopayments_active: try: _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key await callback.answer(_("error_service_unavailable"), show_alert=True) @@ -109,7 +109,7 @@ async def payment_method_bind(callback: types.CallbackQuery, settings: Settings, async def payment_method_delete_confirm(callback: types.CallbackQuery, settings: Settings, i18n_data: dict): current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - if not getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False): + if not settings.yookassa_autopayments_active: try: _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key await callback.answer(_("error_service_unavailable"), show_alert=True) @@ -130,7 +130,7 @@ async def payment_method_delete_confirm(callback: types.CallbackQuery, settings: async def payment_method_delete(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession): current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - if not getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False): + if not settings.yookassa_autopayments_active: try: _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key await callback.answer(_("error_service_unavailable"), show_alert=True) @@ -204,7 +204,7 @@ async def payment_method_delete(callback: types.CallbackQuery, settings: Setting async def payment_method_view(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession): current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - if not getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False): + if not settings.yookassa_autopayments_active: try: _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key await callback.answer(_("error_service_unavailable"), show_alert=True) @@ -325,7 +325,7 @@ async def payment_method_view(callback: types.CallbackQuery, settings: Settings, async def payment_method_history(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession, yookassa_service: YooKassaService): current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") - if not getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False): + if not settings.yookassa_autopayments_active: try: _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key await callback.answer(_("error_service_unavailable"), show_alert=True) diff --git a/bot/handlers/user/subscription/payments_yookassa.py b/bot/handlers/user/subscription/payments_yookassa.py index e1ed517..c586143 100644 --- a/bot/handlers/user/subscription/payments_yookassa.py +++ b/bot/handlers/user/subscription/payments_yookassa.py @@ -340,7 +340,7 @@ async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Setti months, price_rub = parsed user_id = callback.from_user.id currency_code_for_yk = "RUB" - autopay_enabled = bool(getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False)) + autopay_enabled = bool(settings.yookassa_autopayments_active) autopay_require_binding = bool( getattr(settings, 'YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING', True) ) @@ -455,7 +455,7 @@ async def pay_yk_new_card_handler(callback: types.CallbackQuery, settings: Setti months, price_rub = parsed user_id = callback.from_user.id currency_code_for_yk = "RUB" - autopay_enabled = bool(getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False)) + autopay_enabled = bool(settings.yookassa_autopayments_active) autopay_require_binding = bool( getattr(settings, 'YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING', True) ) @@ -494,7 +494,7 @@ async def pay_yk_saved_list_handler(callback: types.CallbackQuery, settings: Set pass return - autopay_enabled = bool(getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False)) + autopay_enabled = bool(settings.yookassa_autopayments_active) if not autopay_enabled: try: await callback.answer(get_text("error_try_again"), show_alert=True) @@ -633,7 +633,7 @@ async def pay_yk_use_saved_handler(callback: types.CallbackQuery, settings: Sett pass return - autopay_enabled = bool(getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False)) + autopay_enabled = bool(settings.yookassa_autopayments_active) if not autopay_enabled: try: await callback.answer(get_text("error_try_again"), show_alert=True) diff --git a/bot/services/subscription_service.py b/bot/services/subscription_service.py index 8b7b1c7..8cd0cba 100644 --- a/bot/services/subscription_service.py +++ b/bot/services/subscription_service.py @@ -501,10 +501,7 @@ class SubscriptionService: ) auto_renew_should_enable = False - if ( - provider == "yookassa" - and getattr(self.settings, "YOOKASSA_AUTOPAYMENTS_ENABLED", False) - ): + if provider == "yookassa" and self.settings.yookassa_autopayments_active: auto_renew_should_enable = await user_billing_dal.user_has_saved_payment_method( session, user_id ) @@ -829,7 +826,7 @@ class SubscriptionService: if not sub.auto_renew_enabled: return True # If autopayments are disabled globally, skip charging attempts - if not getattr(self.settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False): + if not self.settings.yookassa_autopayments_active: return True if sub.provider == "tribute": # Tribute is paid externally; we do not auto-charge here diff --git a/bot/services/yookassa_service.py b/bot/services/yookassa_service.py index 6df92af..f837faf 100644 --- a/bot/services/yookassa_service.py +++ b/bot/services/yookassa_service.py @@ -21,7 +21,10 @@ class YooKassaService: self.settings = settings_obj - if not shop_id or not secret_key: + if self.settings and not self.settings.YOOKASSA_ENABLED: + logging.warning("YooKassa is disabled via YOOKASSA_ENABLED flag. Payment functionality will be DISABLED.") + self.configured = False + elif not shop_id or not secret_key: logging.warning( "YooKassa SHOP_ID or SECRET_KEY not configured in settings. " "Payment functionality will be DISABLED.") diff --git a/config/settings.py b/config/settings.py index 319fcbe..5700d3c 100644 --- a/config/settings.py +++ b/config/settings.py @@ -427,6 +427,12 @@ class Settings(BaseSettings): bonuses[12] = self.REFERRAL_BONUS_DAYS_REFEREE_12_MONTHS return bonuses + @computed_field + @property + def yookassa_autopayments_active(self) -> bool: + """Autopay features are available only when YooKassa itself is enabled.""" + return bool(self.YOOKASSA_ENABLED and self.YOOKASSA_AUTOPAYMENTS_ENABLED) + @computed_field @property def payment_methods_order(self) -> List[str]: From 061fdeb72b0a12f9d9f614a37759d27d0ebb2450 Mon Sep 17 00:00:00 2001 From: machka pasla Date: Thu, 11 Dec 2025 09:49:31 +0300 Subject: [PATCH 08/16] removed tribute --- README.md | 9 +- bot/app/factories/build_services.py | 11 - bot/app/web/web_server.py | 7 - bot/handlers/admin/payments.py | 1 - bot/handlers/user/subscription/core.py | 35 +- .../subscription/payments_subscription.py | 2 - bot/keyboards/inline/user_keyboards.py | 3 - bot/main_bot.py | 2 - bot/services/notification_service.py | 1 - bot/services/panel_webhook_service.py | 135 +------ bot/services/subscription_service.py | 6 +- bot/services/tribute_service.py | 335 ------------------ config/settings.py | 44 +-- db/dal/payment_dal.py | 27 +- locales/en.json | 6 - locales/ru.json | 6 - 16 files changed, 24 insertions(+), 606 deletions(-) delete mode 100644 bot/services/tribute_service.py diff --git a/README.md b/README.md index d97410e..5f10ac1 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ - **Пробная подписка:** Система пробных подписок для новых пользователей (активируется вручную по кнопке). - **Промокоды:** Возможность применять промокоды для получения скидок или бонусных дней. - **Реферальная программа:** Пользователи могут приглашать друзей и получать за это бонусные дни подписки. - - **Оплата:** Поддержка оплаты через YooKassa, FreeKassa (REST API), Platega, SeverPay, CryptoPay, Telegram Stars и Tribute. + - **Оплата:** Поддержка оплаты через YooKassa, FreeKassa (REST API), Platega, SeverPay, CryptoPay и Telegram Stars. ### Для администраторов: - **Защищенная админ-панель:** Доступ только для администраторов, указанных в `ADMIN_IDS`. @@ -80,7 +80,7 @@ | `WEBHOOK_BASE_URL`| **Обязательно.** Базовый URL для вебхуков, например `https://your.domain.com`. | | `WEB_SERVER_HOST` | Хост для веб-сервера. | `0.0.0.0` | | `WEB_SERVER_PORT` | Порт для веб-сервера. | `8080` | - | `PAYMENT_METHODS_ORDER` | (Опционально) Порядок отображения кнопок оплаты через запятую. Поддерживаемые ключи: `severpay`, `freekassa`, `platega`, `yookassa`, `tribute`, `stars`, `cryptopay`. Первый будет сверху. | + | `PAYMENT_METHODS_ORDER` | (Опционально) Порядок отображения кнопок оплаты через запятую. Поддерживаемые ключи: `severpay`, `freekassa`, `platega`, `yookassa`, `stars`, `cryptopay`. Первый будет сверху. | | `YOOKASSA_ENABLED` | Включить/выключить YooKassa (`true`/`false`). | | `YOOKASSA_SHOP_ID` | ID вашего магазина в YooKassa. | | `YOOKASSA_SECRET_KEY`| Секретный ключ магазина YooKassa. | @@ -96,7 +96,6 @@ | `FREEKASSA_PAYMENT_IP` | Внешний IP вашего сервера, который будет передаваться в запрос оплаты. | | `FREEKASSA_PAYMENT_METHOD_ID` | ID метода оплаты через магазин FreeKassa. По умолчанию `44`. | | `STARS_ENABLED` | Включить/выключить Telegram Stars (`true`/`false`). | - | `TRIBUTE_ENABLED`| Включить/выключить Tribute (`true`/`false`). | | `PLATEGA_ENABLED`| Включить/выключить Platega (`true`/`false`). | | `PLATEGA_MERCHANT_ID`| MerchantId из личного кабинета Platega. | | `PLATEGA_SECRET`| API секрет для запросов Platega. | @@ -118,7 +117,6 @@ - `1_MONTH_ENABLED`: `true` или `false` - `RUB_PRICE_1_MONTH`: Цена в рублях - `STARS_PRICE_1_MONTH`: Цена в Telegram Stars - - `TRIBUTE_LINK_1_MONTH`: Ссылка для оплаты через Tribute Аналогичные переменные есть для `3_MONTHS`, `6_MONTHS`, `12_MONTHS`.
@@ -155,7 +153,7 @@ Эта команда скачает образ и запустит сервис в фоновом режиме. 4. **Настройка вебхуков (Обязательно):** - Вебхуки являются **обязательным** компонентом для работы бота, так как они используются для получения уведомлений от платежных систем (YooKassa, FreeKassa, CryptoPay, Tribute) и панели Remnawave. + Вебхуки являются **обязательным** компонентом для работы бота, так как они используются для получения уведомлений от платежных систем (YooKassa, FreeKassa, CryptoPay, Platega, SeverPay) и панели Remnawave. Вам понадобится обратный прокси (например, Nginx) для обработки HTTPS-трафика и перенаправления запросов на контейнер с ботом. @@ -165,7 +163,6 @@ - `https://<ваш_домен>/webhook/platega` → `http://remnawave-tg-shop:/webhook/platega` - `https://<ваш_домен>/webhook/severpay` → `http://remnawave-tg-shop:/webhook/severpay` - `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` - **Для Telegram:** Бот автоматически установит вебхук, если в `.env` указан `WEBHOOK_BASE_URL`. Путь будет `https://<ваш_домен>/`. diff --git a/bot/app/factories/build_services.py b/bot/app/factories/build_services.py index 394bb0f..af034b0 100644 --- a/bot/app/factories/build_services.py +++ b/bot/app/factories/build_services.py @@ -9,7 +9,6 @@ from bot.services.subscription_service import SubscriptionService from bot.services.referral_service import ReferralService from bot.services.promo_code_service import PromoCodeService 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 @@ -47,15 +46,6 @@ def build_core_services( subscription_service=subscription_service, referral_service=referral_service, ) - tribute_service = TributeService( - bot, - settings, - i18n, - async_session_factory, - panel_service, - subscription_service, - referral_service, - ) platega_service = PlategaService( bot=bot, settings=settings, @@ -100,7 +90,6 @@ def build_core_services( "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, "platega_service": platega_service, diff --git a/bot/app/web/web_server.py b/bot/app/web/web_server.py index c9745e9..f3cfdbe 100644 --- a/bot/app/web/web_server.py +++ b/bot/app/web/web_server.py @@ -29,7 +29,6 @@ async def build_and_start_web_app( "stars_service", "freekassa_service", "cryptopay_service", - "tribute_service", "panel_webhook_service", "platega_service", "severpay_service", @@ -50,18 +49,12 @@ async def build_and_start_web_app( ) from bot.handlers.user.payment import yookassa_webhook_route - 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 from bot.services.platega_service import platega_webhook_route from bot.services.severpay_service import severpay_webhook_route - tribute_path = settings.tribute_webhook_path - if tribute_path.startswith("/"): - app.router.add_post(tribute_path, tribute_webhook_route) - logging.info(f"Tribute webhook route configured at: [POST] {tribute_path}") - cp_path = settings.cryptopay_webhook_path if cp_path.startswith("/"): app.router.add_post(cp_path, cryptopay_webhook_route) diff --git a/bot/handlers/admin/payments.py b/bot/handlers/admin/payments.py index 36fb4e7..ee0996f 100644 --- a/bot/handlers/admin/payments.py +++ b/bot/handlers/admin/payments.py @@ -60,7 +60,6 @@ def format_payment_text(payment: Payment, i18n: JsonI18n, lang: str) -> str: provider_text = { 'yookassa': 'YooKassa', - 'tribute': 'Tribute', 'telegram_stars': 'Telegram Stars', 'cryptopay': 'CryptoPay', 'freekassa': 'FreeKassa', diff --git a/bot/handlers/user/subscription/core.py b/bot/handlers/user/subscription/core.py index 3cec154..ef3e480 100644 --- a/bot/handlers/user/subscription/core.py +++ b/bot/handlers/user/subscription/core.py @@ -125,17 +125,6 @@ async def my_subscription_command_handler( end_date = active.get("end_date") days_left = (end_date.date() - datetime.now().date()).days if end_date else 0 - tribute_hint = "" - if active.get("status_from_panel", "").lower() == "active": - local_sub = await subscription_dal.get_active_subscription_by_user_id(session, event.from_user.id) - if local_sub: - if local_sub.provider == "tribute": - link = None - link = settings.tribute_payment_links.get(local_sub.duration_months or 1) if hasattr(settings, "tribute_payment_links") else None - tribute_hint = "\n\n" + ( - get_text("subscription_tribute_notice_with_link", link=link) if link else get_text("subscription_tribute_notice") - ) - text = get_text( "my_subscription_details", end_date=end_date.strftime("%Y-%m-%d") if end_date else "N/A", @@ -224,8 +213,8 @@ async def my_subscription_command_handler( ) ]) - # 2) Auto-renew toggle (if supported and not tribute) - if local_sub and local_sub.provider != "tribute" and settings.yookassa_autopayments_active: + # 2) Auto-renew toggle (YooKassa only) + if local_sub and local_sub.provider == "yookassa" and settings.yookassa_autopayments_active: toggle_text = ( get_text("autorenew_disable_button") if local_sub.auto_renew_enabled else get_text("autorenew_enable_button") ) @@ -254,17 +243,17 @@ async def my_subscription_command_handler( except Exception: pass try: - await event.message.edit_text(text + tribute_hint, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True) + await event.message.edit_text(text, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True) except Exception: await bot.send_message( chat_id=target.chat.id, - text=text + tribute_hint, + text=text, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True, ) else: - await target.answer(text + tribute_hint, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True) + await target.answer(text, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True) @router.callback_query(F.data == "main_action:my_devices") @@ -452,8 +441,8 @@ async def toggle_autorenew_handler( if not sub or sub.user_id != callback.from_user.id: await callback.answer(get_text("error_try_again"), show_alert=True) return - if sub.provider == "tribute": - await callback.answer(get_text("subscription_autorenew_not_supported_for_tribute"), show_alert=True) + if sub.provider != "yookassa": + await callback.answer(get_text("error_try_again"), show_alert=True) return if enable: has_saved_card = await user_billing_dal.user_has_saved_payment_method(session, callback.from_user.id) @@ -510,8 +499,8 @@ async def confirm_autorenew_handler( if not sub or sub.user_id != callback.from_user.id: await callback.answer(get_text("error_try_again"), show_alert=True) return - if sub.provider == "tribute": - await callback.answer(get_text("subscription_autorenew_not_supported_for_tribute"), show_alert=True) + if sub.provider != "yookassa": + await callback.answer(get_text("error_try_again"), show_alert=True) return if enable: has_saved_card = await user_billing_dal.user_has_saved_payment_method(session, callback.from_user.id) @@ -549,7 +538,7 @@ async def autorenew_cancel_from_webhook_button( i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key - # Disable auto-renew on the active subscription (non-tribute) + # Disable auto-renew on the active subscription from db.dal import subscription_dal sub = await subscription_dal.get_active_subscription_by_user_id(session, callback.from_user.id) if not sub: @@ -558,9 +547,9 @@ async def autorenew_cancel_from_webhook_button( except Exception: pass return - if sub.provider == "tribute": + if sub.provider != "yookassa": try: - await callback.answer(get_text("subscription_autorenew_not_supported_for_tribute"), show_alert=True) + await callback.answer(get_text("error_try_again"), show_alert=True) except Exception: pass return diff --git a/bot/handlers/user/subscription/payments_subscription.py b/bot/handlers/user/subscription/payments_subscription.py index 7116d18..0d7592e 100644 --- a/bot/handlers/user/subscription/payments_subscription.py +++ b/bot/handlers/user/subscription/payments_subscription.py @@ -52,12 +52,10 @@ async def select_subscription_period_callback_handler( currency_symbol_val = settings.DEFAULT_CURRENCY_SYMBOL text_content = get_text("choose_payment_method") - tribute_url = settings.tribute_payment_links.get(months) stars_price = settings.stars_subscription_options.get(months) reply_markup = get_payment_method_keyboard( months, price_rub, - tribute_url, stars_price, currency_symbol_val, current_lang, diff --git a/bot/keyboards/inline/user_keyboards.py b/bot/keyboards/inline/user_keyboards.py index 333464d..cbe51e1 100644 --- a/bot/keyboards/inline/user_keyboards.py +++ b/bot/keyboards/inline/user_keyboards.py @@ -112,7 +112,6 @@ def get_subscription_options_keyboard(subscription_options: Dict[ def get_payment_method_keyboard(months: int, price: float, - tribute_url: Optional[str], stars_price: Optional[int], currency_symbol_val: str, lang: str, i18n_instance, settings: Settings) -> InlineKeyboardMarkup: @@ -139,8 +138,6 @@ def get_payment_method_keyboard(months: int, price: float, text=_("pay_with_yookassa_button"), callback_data=f"pay_yk:{months}:{price}", ) - elif method == "tribute" and settings.TRIBUTE_ENABLED and tribute_url: - builder.button(text=_("pay_with_tribute_button"), url=tribute_url) elif method == "stars" and settings.STARS_ENABLED and stars_price is not None: builder.button( text=_("pay_with_stars_button"), diff --git a/bot/main_bot.py b/bot/main_bot.py index a91b55b..02be218 100644 --- a/bot/main_bot.py +++ b/bot/main_bot.py @@ -33,7 +33,6 @@ from bot.services.subscription_service import SubscriptionService from bot.services.referral_service import ReferralService from bot.services.promo_code_service import PromoCodeService from bot.services.stars_service import StarsService -from bot.services.tribute_service import TributeService, tribute_webhook_route from bot.services.crypto_pay_service import CryptoPayService, cryptopay_webhook_route from bot.handlers.user import payment as user_payment_webhook_module @@ -200,7 +199,6 @@ async def on_shutdown_configured(dispatcher: Dispatcher): "panel_service", "cryptopay_service", "freekassa_service", - "tribute_service", "panel_webhook_service", "yookassa_service", "promo_code_service", diff --git a/bot/services/notification_service.py b/bot/services/notification_service.py index a4ef9bf..99a6290 100644 --- a/bot/services/notification_service.py +++ b/bot/services/notification_service.py @@ -240,7 +240,6 @@ class NotificationService: "freekassa": "💳", "cryptopay": "₿", "stars": "⭐", - "tribute": "💎", "platega": "💳", "severpay": "💳", }.get(payment_provider.lower(), "💰") diff --git a/bot/services/panel_webhook_service.py b/bot/services/panel_webhook_service.py index 1910676..cee1320 100644 --- a/bot/services/panel_webhook_service.py +++ b/bot/services/panel_webhook_service.py @@ -12,7 +12,6 @@ from .panel_api_service import PanelApiService from bot.middlewares.i18n import JsonI18n from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup, get_autorenew_cancel_keyboard from db.dal import user_dal -from bot.utils.date_utils import add_months EVENT_MAP = { "user.expires_in_72_hours": (3, "subscription_72h_notification"), @@ -44,128 +43,6 @@ class PanelWebhookService: except Exception as e: logging.error(f"Failed to send notification to {user_id}: {e}") - async def _handle_expired_subscription(self, session, user_id: int, user_payload: dict, - lang: str, markup, first_name: str) -> bool: - """Handle expired subscription - auto-renew tribute users if no cancellation was received. - - Returns True if an auto-renewal was performed (and renewal message sent), False otherwise. - """ - from db.dal import subscription_dal, payment_dal - from datetime import datetime, timezone - - try: - auto_renewed = False - # Check if user has tribute subscriptions that weren't cancelled - user_subs = await subscription_dal.get_active_subscriptions_for_user(session, user_id) - - for sub in user_subs: - # Check if this subscription was marked as cancelled (from tribute cancellation webhook) - if sub.status_from_panel == 'CANCELLED': - logging.info(f"Subscription {sub.subscription_id} for user {user_id} was cancelled, skipping auto-renewal") - continue - - # Check if this user has tribute payments - last_tribute_duration = await payment_dal.get_last_tribute_payment_duration(session, user_id) - - if last_tribute_duration is not None: - # This user has tribute payments, auto-renew for the same duration - logging.info(f"Auto-renewing tribute subscription for user {user_id} for {last_tribute_duration} months") - - # Extend subscription by the last payment duration (calendar months) - new_end_date = add_months(datetime.now(timezone.utc), last_tribute_duration) - - # Update local DB subscription - await subscription_dal.update_subscription( - session, - sub.subscription_id, - { - 'end_date': new_end_date, - 'status_from_panel': 'ACTIVE', - 'is_active': True - } - ) - # Update panel expiry to ensure actual service access is extended - try: - panel_payload = { - "uuid": sub.panel_user_uuid, - "expireAt": new_end_date.isoformat(timespec='milliseconds').replace('+00:00', 'Z'), - "status": "ACTIVE", - } - panel_update_resp = await self.panel_service.update_user_details_on_panel( - sub.panel_user_uuid, - panel_payload, - log_response=True, - ) - if panel_update_resp: - logging.info( - f"Panel expiry updated for user {user_id} (panel_uuid {sub.panel_user_uuid}) to {new_end_date}" - ) - except Exception as e_panel: - logging.error( - f"Failed to update panel expiry for user {user_id} (panel_uuid {sub.panel_user_uuid}): {e_panel}") - - # Create a succeeded payment record in DB with the same amount/currency as last tribute payment - try: - last_payment = await payment_dal.get_last_tribute_payment(session, user_id) - if last_payment and last_payment.amount and last_payment.currency: - provider_payment_id = ( - f"tribute_auto_{user_id}_{sub.subscription_id}_" - f"{new_end_date.strftime('%Y%m%d')}" - ) - created_payment = await payment_dal.ensure_payment_with_provider_id( - session, - user_id=user_id, - amount=float(last_payment.amount), - currency=last_payment.currency, - months=last_tribute_duration, - description="Auto-renewal (panel webhook)", - provider="tribute", - provider_payment_id=provider_payment_id, - ) - if created_payment: - logging.info( - f"Auto-renew payment recorded (id={created_payment.payment_id}) for user {user_id} amount={created_payment.amount} {created_payment.currency} months={last_tribute_duration}" - ) - else: - logging.warning( - f"Could not create auto-renew payment for user {user_id}: previous tribute payment not found or missing amount/currency") - except Exception as e_pay: - logging.error( - f"Failed to create auto-renew payment record for user {user_id}: {e_pay}", - exc_info=True, - ) - - # Send auto-renewal notification - _ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k - auto_renewal_msg = _( - "tribute_auto_renewal", - default="🔄 Подписка автоматически продлена\n\n" - "Ваша подписка Tribute была автоматически продлена на {months} мес.\n" - "Новая дата окончания: {end_date}", - user_name=first_name, - months=last_tribute_duration, - end_date=new_end_date.strftime('%Y-%m-%d') - ) - - try: - await self.bot.send_message( - user_id, - auto_renewal_msg, - reply_markup=markup, - parse_mode="HTML" - ) - auto_renewed = True - except Exception as e: - logging.error(f"Failed to send auto-renewal notification to user {user_id}: {e}") - - await session.commit() - return auto_renewed - - except Exception as e: - logging.error(f"Error handling expired subscription for user {user_id}: {e}") - await session.rollback() - return False - async def handle_event(self, event_name: str, user_payload: dict): telegram_id = user_payload.get("telegramId") if not telegram_id: @@ -193,7 +70,7 @@ class PanelWebhookService: async with self.async_session_factory() as session: from db.dal import subscription_dal sub = await subscription_dal.get_active_subscription_by_user_id(session, user_id) - if sub and sub.auto_renew_enabled and sub.provider != 'tribute': + if sub and sub.auto_renew_enabled and sub.provider == 'yookassa': try: ok = await subscription_service.charge_subscription_renewal(session, sub) # If initiation succeeded, suppress the 24h reminder by returning early @@ -208,7 +85,7 @@ class PanelWebhookService: except Exception: logging.exception("Auto-renew trigger (24h) failed pre-check") if days_left <= self.settings.SUBSCRIPTION_NOTIFY_DAYS_BEFORE: - # For 48h event, if auto-renew is enabled and not tribute, show special notice with cancel button + # For 48h event, if auto-renew is enabled, show special notice with cancel button if days_left == 2: async with self.async_session_factory() as session: from db.dal import subscription_dal @@ -220,7 +97,7 @@ class PanelWebhookService: getattr(sub, 'auto_renew_enabled', None) if sub else None, getattr(sub, 'provider', None) if sub else None, ) - if sub and sub.auto_renew_enabled and sub.provider != 'tribute': + if sub and sub.auto_renew_enabled and sub.provider == 'yookassa': cancel_kb = get_autorenew_cancel_keyboard(lang, self.i18n) await self._send_message( user_id, @@ -239,11 +116,7 @@ class PanelWebhookService: end_date=user_payload.get("expireAt", "")[:10], ) elif event_name == "user.expired": - # Check if this is a tribute user that should be auto-renewed (regardless of notification settings) - auto_renewed = await self._handle_expired_subscription(session, user_id, user_payload, lang, markup, first_name) - - # If auto-renewed via Tribute, suppress expiration notification. Otherwise, send it if enabled. - if not auto_renewed and self.settings.SUBSCRIPTION_NOTIFY_ON_EXPIRE: + if self.settings.SUBSCRIPTION_NOTIFY_ON_EXPIRE: await self._send_message( user_id, lang, diff --git a/bot/services/subscription_service.py b/bot/services/subscription_service.py index 8cd0cba..f8a5303 100644 --- a/bot/services/subscription_service.py +++ b/bot/services/subscription_service.py @@ -517,7 +517,7 @@ class SubscriptionService: "status_from_panel": "ACTIVE", "traffic_limit_bytes": self.settings.user_traffic_limit_bytes, "provider": provider, - "skip_notifications": provider == "tribute" and self.settings.TRIBUTE_SKIP_NOTIFICATIONS, + "skip_notifications": False, "auto_renew_enabled": auto_renew_should_enable, } try: @@ -828,8 +828,8 @@ class SubscriptionService: # If autopayments are disabled globally, skip charging attempts if not self.settings.yookassa_autopayments_active: return True - if sub.provider == "tribute": - # Tribute is paid externally; we do not auto-charge here + if sub.provider != "yookassa": + logging.info("Auto-renew skipped: provider %s does not support auto-renew", sub.provider) return True from db.dal.user_billing_dal import get_user_default_payment_method diff --git a/bot/services/tribute_service.py b/bot/services/tribute_service.py deleted file mode 100644 index 0e48ce7..0000000 --- a/bot/services/tribute_service.py +++ /dev/null @@ -1,335 +0,0 @@ -import logging -import hmac -import hashlib -import json -from typing import Optional - -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.panel_api_service import PanelApiService -from bot.services.referral_service import ReferralService -from .notification_service import NotificationService -from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard -from db.dal import payment_dal, user_dal, subscription_dal -from bot.utils.text_sanitizer import sanitize_display_name, username_for_display - - -def convert_period_to_months(period: Optional[str]) -> int: - """Map Tribute subscription period strings to months.""" - if not period: - return 1 - - mapping = { - "monthly": 1, - "quarterly": 3, - "3-month": 3, - "3months": 3, - "3-months": 3, - "q": 3, - "halfyearly": 6, - "yearly": 12, - "annual": 12, - "y": 12, - } - return mapping.get(period.lower(), 1) - - -class TributeService: - def __init__( - self, - bot: Bot, - settings: Settings, - i18n: JsonI18n, - async_session_factory: sessionmaker, - panel_service: PanelApiService, - subscription_service: SubscriptionService, - referral_service: ReferralService, - ): - self.bot = bot - self.settings = settings - self.i18n = i18n - self.async_session_factory = async_session_factory - self.panel_service = panel_service - self.subscription_service = subscription_service - self.referral_service = referral_service - - async def handle_webhook(self, raw_body: bytes, signature_header: Optional[str]) -> web.Response: - settings = self.settings - bot = self.bot - i18n = self.i18n - async_session_factory = self.async_session_factory - subscription_service = self.subscription_service - referral_service = self.referral_service - - def ok(data: Optional[dict] = None) -> web.Response: - payload = {"status": "ok"} - if data: - payload.update(data) - return web.json_response(payload, status=200) - - def ignored(reason: str) -> web.Response: - return web.json_response({"status": "ignored", "reason": reason}, status=200) - - def bad_request(reason: str) -> web.Response: - return web.json_response({"status": "error", "reason": reason}, status=400) - - if settings.TRIBUTE_API_KEY: - if not signature_header: - return web.json_response({"status": "error", "reason": "no_signature"}, status=403) - expected_sig = hmac.new(settings.TRIBUTE_API_KEY.encode(), raw_body, - hashlib.sha256).hexdigest() - if not hmac.compare_digest(expected_sig, signature_header): - return web.json_response({"status": "error", "reason": "invalid_signature"}, status=403) - - try: - payload = json.loads(raw_body.decode()) - except Exception: - return bad_request("invalid_json") - - logging.info( - "Tribute webhook data: %s", - json.dumps(payload, ensure_ascii=False), - ) - - # Tribute webhook spec: only two events are sent - # name: new_subscription | cancelled_subscription - event_name = payload.get("name") - data = payload.get("payload", {}) - - # Mandatory routing fields - user_id = data.get("telegram_user_id") - if not user_id: - # Permanent format issue — acknowledge to avoid retries - return ignored("missing_telegram_user_id") - - period_val = data.get("period") - months = convert_period_to_months(period_val) - - # Tribute sends amount in minor units (kopecks/cents). Convert to major units before persisting. - amount_value = data.get("amount") or data.get("price") - currency = (data.get("currency") or settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper() - if amount_value is not None: - try: - amount_minor_units = float(amount_value) - except (TypeError, ValueError): - amount_minor_units = 0.0 - amount_float = round(amount_minor_units / 100.0, 2) - else: - amount_float = 0.0 - - async with async_session_factory() as session: - if event_name == "new_subscription": - # Use a unique, idempotent provider payment id per webhook event - # Prefer explicit event/payment identifiers if present; otherwise fall back to payload hash suffix - candidate_event_id = ( - str(data.get("event_id") or data.get("payment_id") or data.get("purchase_id") or data.get("invoice_id") or "") - ) - if candidate_event_id: - provider_payment_id = candidate_event_id - else: - # Combine subscription_id (if any) with a stable hash of the raw payload to ensure uniqueness per event - sub_id_part = str(data.get("subscription_id") or "sub") - payload_hash = hashlib.sha256(raw_body).hexdigest()[:16] - provider_payment_id = f"{sub_id_part}:{payload_hash}" - - # Idempotent ensure payment - payment_record = await payment_dal.ensure_payment_with_provider_id( - session, - user_id=int(user_id), - amount=amount_float, - currency=currency, - months=months, - description="Tribute subscription", - provider="tribute", - provider_payment_id=provider_payment_id, - ) - - activation_details = await subscription_service.activate_subscription( - session, - int(user_id), - months, - float(amount_float), - payment_record.payment_id, - provider="tribute", - ) - referral_bonus = await referral_service.apply_referral_bonuses_for_payment( - session, - int(user_id), - months, - current_payment_db_id=payment_record.payment_id, - skip_if_active_before_payment=False, - ) - await session.commit() - - db_user = await user_dal.get_user_by_id(session, int(user_id)) - lang = db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE - _ = lambda k, **kw: i18n.gettext(lang, k, **kw) - - applied_ref_days = referral_bonus.get('referee_bonus_applied_days') if referral_bonus else None - final_end = (referral_bonus.get('referee_new_end_date') - if referral_bonus else None) - if not final_end: - final_end = activation_details.get('end_date') - - if final_end: - config_link = activation_details.get("subscription_url") or _( - "config_link_not_available" - ) - - if applied_ref_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) - success_msg = _( - "payment_successful_with_referral_bonus_full", - months=months, - base_end_date=activation_details["end_date"].strftime('%Y-%m-%d'), - bonus_days=applied_ref_days, - final_end_date=final_end.strftime('%Y-%m-%d'), - inviter_name=inviter_name_display, - config_link=config_link, - ) - else: - success_msg = _( - "payment_successful_full", - months=months, - end_date=final_end.strftime('%Y-%m-%d'), - config_link=config_link, - ) - markup = get_connect_and_main_keyboard( - lang, - i18n, - settings, - config_link, - preserve_message=True, - ) - - try: - # Use user's DB language in success messages prepared above - await bot.send_message( - int(user_id), - success_msg, - reply_markup=markup, - parse_mode="HTML", - disable_web_page_preview=True, - ) - except Exception as e: - logging.error( - f"Failed to send Tribute payment success message to user {user_id}: {e}") - - # Send notification about payment - try: - notification_service = NotificationService(bot, settings, i18n) - user = await user_dal.get_user_by_id(session, int(user_id)) - await notification_service.notify_payment_received( - user_id=int(user_id), - amount=float(amount_float), - currency=currency, - months=months, - payment_provider="tribute", - username=user.username if user else None - ) - except Exception as e: - logging.error(f"Failed to send tribute payment notification: {e}") - elif event_name == "cancelled_subscription": - await self._handle_tribute_cancellation(session, int(user_id), bot, i18n) - - else: - await session.commit() - # Acknowledge to Tribute that webhook was received and processed/accepted - return ok({"event": event_name or "unknown"}) - - async def _handle_tribute_cancellation(self, session, user_id: int, bot: Bot, i18n: JsonI18n): - """Handle tribute subscription cancellation - set subscription to 1 day grace period""" - from datetime import datetime, timezone, timedelta - from db.dal import subscription_dal, user_dal - from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup - - try: - grace_days = 1 - grace_end = datetime.now(timezone.utc) + timedelta(days=grace_days) - - active_subscriptions = await subscription_dal.get_active_subscriptions_for_user(session, user_id) - - panel_users_updated: set[str] = set() - for sub in active_subscriptions: - updated_sub = await subscription_dal.update_subscription( - session, - sub.subscription_id, - { - "end_date": grace_end, - "status_from_panel": "CANCELLED", - "skip_notifications": True, - }, - ) - - panel_uuid = updated_sub.panel_user_uuid if updated_sub else None - if panel_uuid and panel_uuid not in panel_users_updated: - panel_users_updated.add(panel_uuid) - panel_payload = { - "expireAt": grace_end.isoformat(timespec="milliseconds").replace("+00:00", "Z"), - } - try: - await self.panel_service.update_user_details_on_panel( - panel_uuid, - panel_payload, - log_response=False, - ) - except Exception as panel_err: - logging.error( - f"Failed to update panel expiry for user {user_id} (panel_uuid {panel_uuid}) during Tribute cancellation: {panel_err}") - - await session.commit() - - # Send notification about cancellation if enabled - if not self.settings.TRIBUTE_SKIP_CANCELLATION_NOTIFICATIONS: - db_user = await user_dal.get_user_by_id(session, user_id) - lang = db_user.language_code if db_user and db_user.language_code else self.settings.DEFAULT_LANGUAGE - first_name = db_user.first_name or f"User {user_id}" if db_user else f"User {user_id}" - - _ = lambda k, **kw: i18n.gettext(lang, k, **kw) if i18n else k - markup = get_subscribe_only_markup(lang, i18n) - - cancellation_msg = _( - "tribute_subscription_cancelled", - default="🚨 Подписка отменена\n\n" - "Ваша подписка Tribute была отменена. У вас есть 24 часа для восстановления доступа, " - "после чего подписка будет заблокирована.\n\n" - "Для продления подписки нажмите кнопку ниже.", - user_name=first_name - ) - - try: - await bot.send_message( - int(user_id), - cancellation_msg, - reply_markup=markup, - parse_mode="HTML" - ) - except Exception as e: - logging.error(f"Failed to send tribute cancellation notification to user {user_id}: {e}") - - logging.info(f"Tribute subscription cancelled for user {user_id}, grace period set to 1 day") - - except Exception as e: - logging.error(f"Error handling tribute cancellation for user {user_id}: {e}") - await session.rollback() - - -async def tribute_webhook_route(request: web.Request): - """AIOHTTP route handler for Tribute webhook calls.""" - tribute_service: TributeService = request.app['tribute_service'] - raw_body = await request.read() - signature_header = request.headers.get('trbt-signature') - return await tribute_service.handle_webhook(raw_body, signature_header) diff --git a/config/settings.py b/config/settings.py index 5700d3c..783dca4 100644 --- a/config/settings.py +++ b/config/settings.py @@ -84,10 +84,9 @@ class Settings(BaseSettings): YOOKASSA_ENABLED: bool = Field(default=True) STARS_ENABLED: bool = Field(default=True) - TRIBUTE_ENABLED: bool = Field(default=True) PAYMENT_METHODS_ORDER: Optional[str] = Field( default=None, - description="Comma-separated list of payment methods to show (e.g., severpay,freekassa,yookassa,platega,stars,cryptopay,tribute)", + description="Comma-separated list of payment methods to show (e.g., severpay,freekassa,yookassa,platega,stars,cryptopay)", ) MONTH_1_ENABLED: bool = Field(default=True, alias="1_MONTH_ENABLED") @@ -104,15 +103,6 @@ class Settings(BaseSettings): STARS_PRICE_3_MONTHS: Optional[int] = Field(default=None) STARS_PRICE_6_MONTHS: Optional[int] = Field(default=None) STARS_PRICE_12_MONTHS: Optional[int] = Field(default=None) - - - TRIBUTE_LINK_1_MONTH: Optional[str] = Field(default=None) - TRIBUTE_LINK_3_MONTHS: Optional[str] = Field(default=None) - TRIBUTE_LINK_6_MONTHS: Optional[str] = Field(default=None) - TRIBUTE_LINK_12_MONTHS: Optional[str] = Field(default=None) - TRIBUTE_API_KEY: Optional[str] = Field(default=None) - TRIBUTE_SKIP_NOTIFICATIONS: bool = Field(default=True, description="Skip renewal notifications for Tribute payments") - TRIBUTE_SKIP_CANCELLATION_NOTIFICATIONS: bool = Field(default=False, description="Skip cancellation notifications for Tribute payments") PANEL_WEBHOOK_SECRET: Optional[str] = Field(default=None) SUBSCRIPTION_NOTIFICATIONS_ENABLED: bool = Field(default=True) @@ -265,21 +255,6 @@ class Settings(BaseSettings): return f"{base.rstrip('/')}{self.yookassa_webhook_path}" return None - @computed_field - @property - def tribute_webhook_path(self) -> str: - return "/webhook/tribute" - - @computed_field - @property - def tribute_full_webhook_url(self) -> Optional[str]: - base = self.WEBHOOK_BASE_URL - if base: - return f"{base.rstrip('/')}{self.tribute_webhook_path}" - return None - - @computed_field - @property def panel_webhook_path(self) -> str: return "/webhook/panel" @@ -385,22 +360,6 @@ class Settings(BaseSettings): options[12] = self.STARS_PRICE_12_MONTHS return options - @computed_field - @property - def tribute_payment_links(self) -> Dict[int, str]: - links: Dict[int, str] = {} - if self.TRIBUTE_ENABLED and self.MONTH_1_ENABLED and self.TRIBUTE_LINK_1_MONTH: - links[1] = self.TRIBUTE_LINK_1_MONTH - if self.TRIBUTE_ENABLED and self.MONTH_3_ENABLED and self.TRIBUTE_LINK_3_MONTHS: - links[3] = self.TRIBUTE_LINK_3_MONTHS - if self.TRIBUTE_ENABLED and self.MONTH_6_ENABLED and self.TRIBUTE_LINK_6_MONTHS: - links[6] = self.TRIBUTE_LINK_6_MONTHS - if self.TRIBUTE_ENABLED and self.MONTH_12_ENABLED and self.TRIBUTE_LINK_12_MONTHS: - links[12] = self.TRIBUTE_LINK_12_MONTHS - return links - - @computed_field - @property def referral_bonus_inviter(self) -> Dict[int, int]: bonuses: Dict[int, int] = {} if self.REFERRAL_BONUS_DAYS_INVITER_1_MONTH is not None: @@ -444,7 +403,6 @@ class Settings(BaseSettings): "platega", "severpay", "yookassa", - "tribute", "stars", "cryptopay", ] diff --git a/db/dal/payment_dal.py b/db/dal/payment_dal.py index cfd63de..87150ec 100644 --- a/db/dal/payment_dal.py +++ b/db/dal/payment_dal.py @@ -239,31 +239,6 @@ async def get_financial_statistics(session: AsyncSession) -> Dict[str, Any]: } -async def get_last_tribute_payment_duration(session: AsyncSession, user_id: int) -> Optional[int]: - """Get duration in months from the last successful tribute payment for a user.""" - stmt = select(Payment.subscription_duration_months).where( - and_( - Payment.user_id == user_id, - Payment.provider == 'tribute', - Payment.status == 'succeeded' - ) - ).order_by(Payment.created_at.desc()).limit(1) - - result = await session.execute(stmt) - return result.scalar_one_or_none() - - -async def get_last_tribute_payment( - session: AsyncSession, user_id: int) -> Optional[Payment]: - """Return the most recent succeeded Tribute payment for the user.""" - stmt = (select(Payment).where( - and_(Payment.user_id == user_id, Payment.provider == 'tribute', - Payment.status == 'succeeded')).order_by( - Payment.created_at.desc()).limit(1)) - result = await session.execute(stmt) - return result.scalar_one_or_none() - - async def get_user_total_paid(session: AsyncSession, user_id: int) -> float: """Get total amount paid by a specific user (sum of all succeeded payments).""" stmt = select(func.sum(Payment.amount)).where( @@ -295,4 +270,4 @@ async def get_referral_revenue(session: AsyncSession, referrer_id: int) -> float ) result = await session.execute(stmt) total = result.scalar() - return float(total or 0) \ No newline at end of file + return float(total or 0) diff --git a/locales/en.json b/locales/en.json index fe93a59..1176664 100644 --- a/locales/en.json +++ b/locales/en.json @@ -41,7 +41,6 @@ "pay_with_severpay_button": "💳 SeverPay", "back_to_payment_methods_button": "⬅️ Back", "pay_with_cryptopay_button": "💎 CryptoBot", - "pay_with_tribute_button": "❤️ Tribute", "pay_with_stars_button": "🌟 Telegram Stars", "connect_button": "🔗 Connect", "cancel_button": "❌ Cancel", @@ -240,8 +239,6 @@ "autorenew_48h_charge_tomorrow_notice": "🔔 Reminder\n\nTomorrow an automatic charge will occur to renew your subscription. If you don't want auto-renew, disable it using the button below.", "autorenew_confirm_enable": "🔄 Enable auto-renew? An automatic charge will be attempted before your subscription ends.", "autorenew_confirm_disable": "🛑 Disable auto-renew? No further automatic charges will occur.", - "tribute_subscription_cancelled": "🚨 Subscription Cancelled\n\nYour Tribute subscription has been cancelled. You have 24 hours to restore access, after which the subscription will be blocked.\n\nTo renew your subscription, press the button below.", - "tribute_auto_renewal": "🔄 Subscription Auto-Renewed\n\nYour Tribute subscription has been automatically renewed for {months} months.\nNew expiration date: {end_date}", "yookassa_auto_renewal": "🔄 Subscription Auto-Renewed\n\nYour subscription was automatically renewed for {months} month(s).\nNew expiration date: {end_date}", "admin_user_management_prompt": "👤 User Management\n\nEnter user ID or @username to search:", "admin_user_subscription_info": "Subscription Information:", @@ -442,9 +439,6 @@ "payment_method_tx_history_title": "📜 Transactions history", "payment_method_no_history": "No transactions history.", "subscription_purchase_title": "Subscription purchase for {months} mo.", - "subscription_tribute_notice": "Paid via Tribute. Renew using your Tribute link.", - "subscription_tribute_notice_with_link": "Paid via Tribute. Renew: {link}", - "subscription_autorenew_not_supported_for_tribute": "Auto-renew is handled by Tribute. Manage renewal in the Tribute app/link.", "autorenew_enable_requires_card": "Link a payment card in Payment Methods before enabling auto-renew.", "subscription_not_active": "You don't have an active subscription.", "error_service_unavailable": "Service unavailable. Please try again later.", diff --git a/locales/ru.json b/locales/ru.json index b8bd4b7..b3f669a 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -41,7 +41,6 @@ "pay_with_severpay_button": "💳 SeverPay", "back_to_payment_methods_button": "⬅️ Назад", "pay_with_cryptopay_button": "💎 CryptoBot", - "pay_with_tribute_button": "❤️ Tribute", "pay_with_stars_button": "🌟 Звезды Telegram", "connect_button": "🔗 Подключиться", @@ -169,7 +168,6 @@ "autorenew_48h_charge_tomorrow_notice": "🔔 Напоминание\n\nЗавтра будет автоматическое списание за продление подписки. Если вы не хотите автопродление — отключите его кнопкой ниже.", "autorenew_confirm_enable": "🔄 Включить автопродление? Перед окончанием подписки будет выполняться автосписание.", "autorenew_confirm_disable": "🛑 Отключить автопродление? Автосписаний больше не будет.", - "tribute_subscription_cancelled": "🚨 Подписка отменена\n\nВаша подписка Tribute была отменена. У вас есть 24 часа для восстановления доступа, после чего подписка будет заблокирована.\n\nДля продления подписки нажмите кнопку ниже.", "yookassa_auto_renewal": "🔄 Подписка автоматически продлена\n\nВаша подписка была автоматически продлена на {months} мес.\nНовая дата окончания: {end_date}", "admin_promo_set_validity_days": "⏰ Установить срок (дни)", "admin_back_to_panel": "⬅️ В панель", @@ -242,7 +240,6 @@ "admin_broadcast_invalid_html": "❌ Некорректный HTML в сообщении. Пожалуйста, отправьте корректный HTML (поддерживаются теги Telegram) или уберите теги.", "error_displaying_logs_too_long": "Ошибка: логи слишком длинные для отображения одним сообщением. Попробуйте найти логи по конкретному пользователю.", "error_displaying_statistics": "Ошибка отображения статистики.", - "tribute_auto_renewal": "🔄 Подписка автоматически продлена\n\nВаша подписка Tribute была автоматически продлена на {months} мес.\nНовая дата окончания: {end_date}", "admin_user_management_prompt": "👤 Управление пользователями\n\nВведите ID пользователя или @username для поиска:", "admin_user_subscription_info": "Информация о подписке:", "admin_user_reset_trial_button": "🔄 Сбросить триал", @@ -442,9 +439,6 @@ "payment_method_tx_history_title": "📜 История операций", "payment_method_no_history": "История операций отсутствует.", "subscription_purchase_title": "Покупка подписки на {months} мес.", - "subscription_tribute_notice": "Оплачено через Tribute. Продление делайте по ссылке Tribute.", - "subscription_tribute_notice_with_link": "Оплачено через Tribute. Продлить: {link}", - "subscription_autorenew_not_supported_for_tribute": "Автопродление управляется Tribute. Управляйте продлением в приложении/ссылке Tribute.", "autorenew_enable_requires_card": "Прежде чем включать автоплатёж, привяжите карту в разделе «Способы оплаты».", "subscription_not_active": "У вас нет активной подписки.", "error_service_unavailable": "Сервис недоступен. Попробуйте позже.", From 22171eb66f74f4ec60781916bb37e4fc96d62ee2 Mon Sep 17 00:00:00 2001 From: machka pasla Date: Thu, 11 Dec 2025 12:47:32 +0300 Subject: [PATCH 09/16] added gb packets selling --- .env.example | 4 + bot/handlers/admin/payments.py | 25 +++- bot/handlers/user/payment.py | 72 ++++++--- bot/handlers/user/referral.py | 34 +++-- bot/handlers/user/subscription/core.py | 69 +++++++-- .../user/subscription/payment_methods.py | 10 +- .../user/subscription/payments_crypto.py | 31 +++- .../user/subscription/payments_freekassa.py | 32 ++-- .../user/subscription/payments_platega.py | 33 ++-- .../user/subscription/payments_severpay.py | 32 ++-- .../user/subscription/payments_stars.py | 31 ++-- .../subscription/payments_subscription.py | 15 +- .../user/subscription/payments_yookassa.py | 102 ++++++++----- bot/keyboards/inline/user_keyboards.py | 70 ++++++--- bot/services/crypto_pay_service.py | 45 ++++-- bot/services/freekassa_service.py | 34 +++-- bot/services/notification_service.py | 56 ++++--- bot/services/platega_service.py | 35 +++-- bot/services/severpay_service.py | 35 +++-- bot/services/stars_service.py | 43 ++++-- bot/services/subscription_service.py | 141 +++++++++++++++++- config/settings.py | 65 ++++++++ locales/en.json | 15 ++ locales/ru.json | 15 ++ 24 files changed, 798 insertions(+), 246 deletions(-) diff --git a/.env.example b/.env.example index 8afef7d..d91fe46 100644 --- a/.env.example +++ b/.env.example @@ -104,6 +104,10 @@ RUB_PRICE_12_MONTHS=900 STARS_PRICE_12_MONTHS=0 TRIBUTE_LINK_12_MONTHS= +# Traffic Packages (enables traffic sale mode when set) +TRAFFIC_PACKAGES=10:199,50:799 # Format: ":", comma-separated +STARS_TRAFFIC_PACKAGES=10:2500 # Optional: traffic packages priced in Stars + # Subscription Notifications SUBSCRIPTION_NOTIFICATIONS_ENABLED=True # Enable subscription SUBSCRIPTION_NOTIFY_ON_EXPIRE=True # Notify on subscription diff --git a/bot/handlers/admin/payments.py b/bot/handlers/admin/payments.py index ee0996f..9f29d8c 100644 --- a/bot/handlers/admin/payments.py +++ b/bot/handlers/admin/payments.py @@ -34,7 +34,7 @@ async def get_payments_with_pagination(session: AsyncSession, page: int = 0, return payments, total_count -def format_payment_text(payment: Payment, i18n: JsonI18n, lang: str) -> str: +def format_payment_text(payment: Payment, i18n: JsonI18n, lang: str, settings: Settings) -> str: """Format single payment info as text.""" _ = lambda key, **kwargs: i18n.gettext(lang, key, **kwargs) @@ -66,12 +66,21 @@ def format_payment_text(payment: Payment, i18n: JsonI18n, lang: str) -> str: 'severpay': 'SeverPay', 'platega': 'Platega', }.get(payment.provider, payment.provider or 'Unknown') + + traffic_mode = getattr(settings, "traffic_sale_mode", False) + if traffic_mode: + traffic_val = payment.subscription_duration_months or 0 + traffic_display = str(int(traffic_val)) if float(traffic_val).is_integer() else f"{traffic_val:g}" + period_line = _("admin_payment_traffic_label", default="🗂 Трафик: {traffic_gb} GB", traffic_gb=traffic_display) + else: + period_line = _("admin_payment_months_label", default="📅 Период: {months} мес.", months=payment.subscription_duration_months or 0) return ( f"{status_emoji} {payment.amount} {payment.currency}\n" f"👤 {user_info}\n" f"💳 {provider_text}\n" f"📅 {payment_date}\n" + f"{period_line}\n" f"📋 {payment.status}\n" f"📝 {payment.description or 'N/A'}" ) @@ -109,7 +118,7 @@ async def view_payments_handler(callback: types.CallbackQuery, i18n_data: dict, total_pages=total_pages) + "\n") for i, payment in enumerate(payments, 1): - text_parts.append(f"{page * page_size + i}. {format_payment_text(payment, i18n, current_lang)}") + text_parts.append(f"{page * page_size + i}. {format_payment_text(payment, i18n, current_lang, settings)}") text_parts.append("") # Empty line between payments # Build keyboard with pagination and export @@ -202,13 +211,21 @@ async def export_payments_csv_handler(callback: types.CallbackQuery, i18n_data: _("admin_csv_provider", default="Provider"), _("admin_csv_status", default="Status"), _("admin_csv_description", default="Description"), - _("admin_csv_months", default="Months"), + _("admin_csv_units", default="Months/GB"), _("admin_csv_created_at", default="Created At"), _("admin_csv_provider_payment_id", default="Provider Payment ID") ]) + + traffic_mode = getattr(settings, "traffic_sale_mode", False) # Write payment data for payment in all_payments: + units_val = payment.subscription_duration_months or "" + if traffic_mode and units_val not in ("", None): + try: + units_val = str(int(units_val)) if float(units_val).is_integer() else f"{units_val:g}" + except Exception: + units_val = payment.subscription_duration_months or "" writer.writerow([ payment.payment_id, payment.user_id, @@ -219,7 +236,7 @@ async def export_payments_csv_handler(callback: types.CallbackQuery, i18n_data: payment.provider or "", payment.status, payment.description or "", - payment.subscription_duration_months or "", + units_val, payment.created_at.strftime('%Y-%m-%d %H:%M:%S') if payment.created_at else "", payment.provider_payment_id or "" ]) diff --git a/bot/handlers/user/payment.py b/bot/handlers/user/payment.py index 204d4e8..64b8b26 100644 --- a/bot/handlers/user/payment.py +++ b/bot/handlers/user/payment.py @@ -40,6 +40,8 @@ async def process_successful_payment(session: AsyncSession, bot: Bot, metadata = payment_info_from_webhook.get("metadata", {}) user_id_str = metadata.get("user_id") subscription_months_str = metadata.get("subscription_months") + traffic_gb_str = metadata.get("traffic_gb") + sale_mode = metadata.get("sale_mode") or ("traffic" if settings.traffic_sale_mode else "subscription") promo_code_id_str = metadata.get("promo_code_id") payment_db_id_str = metadata.get("payment_db_id") auto_renew_subscription_id_str = metadata.get( @@ -47,8 +49,11 @@ async def process_successful_payment(session: AsyncSession, bot: Bot, # For auto-renew payments, payment_db_id may be absent. In that case, # we will create/ensure a payment record idempotently using provider payment id. - if (not user_id_str or not subscription_months_str - or (not payment_db_id_str and not auto_renew_subscription_id_str)): + if ( + not user_id_str + or (not subscription_months_str and not traffic_gb_str) + or (not payment_db_id_str and not auto_renew_subscription_id_str) + ): logging.error( f"Missing crucial metadata for payment: {payment_info_from_webhook.get('id')}, metadata: {metadata}" ) @@ -57,15 +62,17 @@ async def process_successful_payment(session: AsyncSession, bot: Bot, db_user = None try: user_id = int(user_id_str) - subscription_months = int(subscription_months_str) + subscription_months = float(subscription_months_str or 0) + traffic_amount_gb = float(traffic_gb_str) if traffic_gb_str else subscription_months payment_db_id = int( payment_db_id_str) if payment_db_id_str and payment_db_id_str.isdigit() else None - is_auto_renew = bool(auto_renew_subscription_id_str and not payment_db_id) + is_auto_renew = bool(auto_renew_subscription_id_str and not payment_db_id and sale_mode != "traffic") promo_code_id = int( promo_code_id_str ) if promo_code_id_str and promo_code_id_str.isdigit() else None amount_data = payment_info_from_webhook.get("amount", {}) + months_for_record = int(subscription_months) if sale_mode != "traffic" else 0 payment_value = float(amount_data.get("value", 0.0)) # If this is an auto-renewal (no payment_db_id in metadata), ensure a payment record exists @@ -79,9 +86,9 @@ async def process_successful_payment(session: AsyncSession, bot: Bot, user_id=user_id, amount=payment_value, currency=amount_data.get("currency", settings.DEFAULT_CURRENCY_SYMBOL), - months=subscription_months, + months=months_for_record or 1, description=payment_info_from_webhook.get( - "description") or f"Auto-renewal for {subscription_months} months", + "description") or f"Auto-renewal for {months_for_record or subscription_months} months", provider="yookassa", provider_payment_id=yk_payment_id_from_hook, ) @@ -196,14 +203,18 @@ async def process_successful_payment(session: AsyncSession, bot: Bot, raise Exception( f"DB Error: Could not update payment record {payment_db_id}") + months_for_activation = int(subscription_months) if sale_mode != "traffic" else 0 activation_details = await subscription_service.activate_subscription( session, user_id, - subscription_months, + months_for_activation, payment_value, payment_db_id, promo_code_id_from_payment=promo_code_id, - provider="yookassa") + provider="yookassa", + sale_mode=sale_mode, + traffic_gb=traffic_amount_gb if sale_mode == "traffic" else None, + ) if not activation_details or not activation_details.get('end_date'): logging.error( @@ -217,13 +228,15 @@ async def process_successful_payment(session: AsyncSession, bot: Bot, applied_promo_bonus_days = activation_details.get( "applied_promo_bonus_days", 0) - referral_bonus_info = await referral_service.apply_referral_bonuses_for_payment( - session, - user_id, - subscription_months, - current_payment_db_id=payment_db_id, - skip_if_active_before_payment=False, - ) + referral_bonus_info = None + if sale_mode != "traffic": + referral_bonus_info = await referral_service.apply_referral_bonuses_for_payment( + session, + user_id, + months_for_activation or int(subscription_months) or 1, + current_payment_db_id=payment_db_id, + skip_if_active_before_payment=False, + ) applied_referee_bonus_days_from_referral: Optional[int] = None if referral_bonus_info and referral_bonus_info.get( "referee_new_end_date"): @@ -236,14 +249,28 @@ async def process_successful_payment(session: AsyncSession, bot: Bot, user_lang = db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE _ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs) + traffic_label = ( + str(int(traffic_amount_gb)) if float(traffic_amount_gb).is_integer() else f"{traffic_amount_gb:g}" + ) # For auto-renew charges, avoid re-sending config link; send concise message - if is_auto_renew and final_end_date_for_user: + if sale_mode != "traffic" and is_auto_renew and final_end_date_for_user: details_message = _( "yookassa_auto_renewal", - months=subscription_months, + months=int(subscription_months), end_date=final_end_date_for_user.strftime('%Y-%m-%d'), ) details_markup = None + elif sale_mode == "traffic": + config_link = activation_details.get("subscription_url") or _("config_link_not_available") + details_message = _( + "payment_successful_traffic_full", + traffic_gb=traffic_label, + end_date=final_end_date_for_user.strftime('%Y-%m-%d') if final_end_date_for_user else "—", + config_link=config_link, + ) + details_markup = get_connect_and_main_keyboard( + user_lang, i18n, settings, config_link, preserve_message=True + ) else: config_link = activation_details.get("subscription_url") or _( "config_link_not_available" @@ -263,7 +290,7 @@ async def process_successful_payment(session: AsyncSession, bot: Bot, details_message = _( "payment_successful_with_referral_bonus_full", - months=subscription_months, + months=int(subscription_months), base_end_date=base_subscription_end_date.strftime('%Y-%m-%d'), bonus_days=applied_referee_bonus_days_from_referral, final_end_date=final_end_date_for_user.strftime('%Y-%m-%d'), @@ -273,7 +300,7 @@ async def process_successful_payment(session: AsyncSession, bot: Bot, elif applied_promo_bonus_days > 0 and final_end_date_for_user: details_message = _( "payment_successful_with_promo_full", - months=subscription_months, + months=int(subscription_months), bonus_days=applied_promo_bonus_days, end_date=final_end_date_for_user.strftime('%Y-%m-%d'), config_link=config_link, @@ -281,7 +308,7 @@ async def process_successful_payment(session: AsyncSession, bot: Bot, elif final_end_date_for_user: details_message = _( "payment_successful_full", - months=subscription_months, + months=int(subscription_months), end_date=final_end_date_for_user.strftime('%Y-%m-%d'), config_link=config_link, ) @@ -315,9 +342,10 @@ async def process_successful_payment(session: AsyncSession, bot: Bot, user_id=user_id, amount=payment_value, currency=settings.DEFAULT_CURRENCY_SYMBOL, - months=subscription_months, + months=int(subscription_months) if sale_mode != "traffic" else 0, payment_provider="yookassa", # This is specifically for YooKassa webhook - username=user.username if user else None + username=user.username if user else None, + traffic_gb=traffic_amount_gb if sale_mode == "traffic" else None, ) except Exception as e: logging.error(f"Failed to send payment notification: {e}") diff --git a/bot/handlers/user/referral.py b/bot/handlers/user/referral.py index 06f3150..7b2207d 100644 --- a/bot/handlers/user/referral.py +++ b/bot/handlers/user/referral.py @@ -74,24 +74,26 @@ async def referral_command_handler(event: Union[types.Message, return bonus_info_parts = [] - if settings.subscription_options: + if getattr(settings, "traffic_sale_mode", False): + bonus_details_str = _("referral_not_available_for_traffic") + else: + if settings.subscription_options: + for months_period_key, _price in sorted( + settings.subscription_options.items()): - for months_period_key, _price in sorted( - settings.subscription_options.items()): + inv_bonus = settings.referral_bonus_inviter.get(months_period_key) + ref_bonus = settings.referral_bonus_referee.get(months_period_key) + if inv_bonus is not None or ref_bonus is not None: + bonus_info_parts.append( + _("referral_bonus_per_period", + months=months_period_key, + inviter_bonus_days=inv_bonus + if inv_bonus is not None else _("no_bonus_placeholder"), + referee_bonus_days=ref_bonus + if ref_bonus is not None else _("no_bonus_placeholder"))) - inv_bonus = settings.referral_bonus_inviter.get(months_period_key) - ref_bonus = settings.referral_bonus_referee.get(months_period_key) - if inv_bonus is not None or ref_bonus is not None: - bonus_info_parts.append( - _("referral_bonus_per_period", - months=months_period_key, - inviter_bonus_days=inv_bonus - if inv_bonus is not None else _("no_bonus_placeholder"), - referee_bonus_days=ref_bonus - if ref_bonus is not None else _("no_bonus_placeholder"))) - - bonus_details_str = "\n".join(bonus_info_parts) if bonus_info_parts else _( - "referral_no_bonuses_configured") + bonus_details_str = "\n".join(bonus_info_parts) if bonus_info_parts else _( + "referral_no_bonuses_configured") # Get referral statistics referral_stats = await referral_service.get_referral_stats(session, inviter_user_id) diff --git a/bot/handlers/user/subscription/core.py b/bot/handlers/user/subscription/core.py index ef3e480..108bf25 100644 --- a/bot/handlers/user/subscription/core.py +++ b/bot/handlers/user/subscription/core.py @@ -40,11 +40,15 @@ async def display_subscription_options(event: Union[types.Message, types.Callbac return currency_symbol_val = settings.DEFAULT_CURRENCY_SYMBOL - text_content = get_text("select_subscription_period") if settings.subscription_options else get_text("no_subscription_options_available") + traffic_mode = bool(getattr(settings, "traffic_sale_mode", False)) + options = settings.traffic_packages if traffic_mode else settings.subscription_options + text_content = ( + get_text("select_traffic_package") if traffic_mode else get_text("select_subscription_period") + ) if options else get_text("no_subscription_options_available") reply_markup = ( - get_subscription_options_keyboard(settings.subscription_options, currency_symbol_val, current_lang, i18n) - if settings.subscription_options + get_subscription_options_keyboard(options, currency_symbol_val, current_lang, i18n, traffic_mode=traffic_mode) + if options else get_back_to_main_menu_markup(current_lang, i18n) ) @@ -125,17 +129,50 @@ async def my_subscription_command_handler( end_date = active.get("end_date") days_left = (end_date.date() - datetime.now().date()).days if end_date else 0 - text = get_text( - "my_subscription_details", - end_date=end_date.strftime("%Y-%m-%d") if end_date else "N/A", - days_left=max(0, days_left), - status=active.get("status_from_panel", get_text("status_active")).capitalize(), - config_link=active.get("config_link") or get_text("config_link_not_available"), - traffic_limit=(f"{active['traffic_limit_bytes'] / 2**30:.2f} GB" if active.get("traffic_limit_bytes") else get_text("traffic_unlimited")), - traffic_used=( - f"{active['traffic_used_bytes'] / 2**30:.2f} GB" if active.get("traffic_used_bytes") is not None else get_text("traffic_na") - ), - ) + traffic_mode = bool(getattr(settings, "traffic_sale_mode", False)) + def _fmt_gb(val: Optional[float]) -> str: + if val is None: + return get_text("traffic_na") + try: + if isinstance(val, (int, float)): + val_gb = float(val) / (2**30) + return f"{val_gb:.2f} GB" + except Exception: + pass + return str(val) + + if traffic_mode: + limit_display = _fmt_gb(active.get("traffic_limit_bytes")) + used_display = _fmt_gb(active.get("traffic_used_bytes")) + remaining_display = get_text("traffic_na") + try: + limit_val = active.get("traffic_limit_bytes") or 0 + used_val = active.get("traffic_used_bytes") or 0 + remaining_val = max(0, float(limit_val) - float(used_val)) + remaining_display = _fmt_gb(remaining_val) + except Exception: + pass + text = get_text( + "my_traffic_details", + status=active.get("status_from_panel", get_text("status_active")).capitalize(), + end_date=end_date.strftime("%Y-%m-%d") if end_date else get_text("traffic_no_expiry"), + traffic_limit=limit_display, + traffic_used=used_display, + traffic_left=remaining_display, + config_link=active.get("config_link") or get_text("config_link_not_available"), + ) + else: + text = get_text( + "my_subscription_details", + end_date=end_date.strftime("%Y-%m-%d") if end_date else "N/A", + days_left=max(0, days_left), + status=active.get("status_from_panel", get_text("status_active")).capitalize(), + config_link=active.get("config_link") or get_text("config_link_not_available"), + traffic_limit=(f"{active['traffic_limit_bytes'] / 2**30:.2f} GB" if active.get("traffic_limit_bytes") else get_text("traffic_unlimited")), + traffic_used=( + f"{active['traffic_used_bytes'] / 2**30:.2f} GB" if active.get("traffic_used_bytes") is not None else get_text("traffic_na") + ), + ) base_markup = get_back_to_main_menu_markup(current_lang, i18n) kb = base_markup.inline_keyboard @@ -214,7 +251,7 @@ async def my_subscription_command_handler( ]) # 2) Auto-renew toggle (YooKassa only) - if local_sub and local_sub.provider == "yookassa" and settings.yookassa_autopayments_active: + if not traffic_mode and local_sub and local_sub.provider == "yookassa" and settings.yookassa_autopayments_active: toggle_text = ( get_text("autorenew_disable_button") if local_sub.auto_renew_enabled else get_text("autorenew_enable_button") ) @@ -226,7 +263,7 @@ async def my_subscription_command_handler( ]) # 3) Payment methods management (when autopayments enabled) - if settings.yookassa_autopayments_active: + if not traffic_mode and settings.yookassa_autopayments_active: prepend_rows.append([ InlineKeyboardButton(text=get_text("payment_methods_manage_button"), callback_data="pm:manage") ]) diff --git a/bot/handlers/user/subscription/payment_methods.py b/bot/handlers/user/subscription/payment_methods.py index 3962b55..947d592 100644 --- a/bot/handlers/user/subscription/payment_methods.py +++ b/bot/handlers/user/subscription/payment_methods.py @@ -390,8 +390,15 @@ async def payment_method_history(callback: types.CallbackQuery, settings: Settin await callback.message.edit_text(_("payment_method_no_history"), reply_markup=back_markup) return + traffic_mode = getattr(settings, "traffic_sale_mode", False) + def _format_item(p: Payment) -> str: - title = p.description or _("subscription_purchase_title", months=p.subscription_duration_months or 1) + if traffic_mode: + units_val = p.subscription_duration_months or 0 + units_display = str(int(units_val)) if float(units_val).is_integer() else f"{units_val:g}" + title = p.description or _("traffic_purchase_title", traffic_gb=units_display) + else: + title = p.description or _("subscription_purchase_title", months=p.subscription_duration_months or 1) date_str = p.created_at.strftime('%Y-%m-%d') if p.created_at else "N/A" return f"{date_str} — {title} — {p.amount:.2f} {p.currency}" @@ -455,4 +462,3 @@ async def payment_methods_list(callback: types.CallbackQuery, settings: Settings except Exception: pass - diff --git a/bot/handlers/user/subscription/payments_crypto.py b/bot/handlers/user/subscription/payments_crypto.py index 267f4d4..ed03c8a 100644 --- a/bot/handlers/user/subscription/payments_crypto.py +++ b/bot/handlers/user/subscription/payments_crypto.py @@ -39,9 +39,10 @@ async def pay_crypto_callback_handler( try: _, data_payload = callback.data.split(":", 1) - months_str, price_str = data_payload.split(":") - months = int(months_str) - price_amount = float(price_str) + parts = data_payload.split(":") + months = float(parts[0]) + price_amount = float(parts[1]) + sale_mode = parts[2] if len(parts) > 2 else "subscription" except (ValueError, IndexError): try: await callback.answer(get_text("error_try_again"), show_alert=True) @@ -50,7 +51,12 @@ async def pay_crypto_callback_handler( return user_id = callback.from_user.id - payment_description = get_text("payment_description_subscription", months=months) + human_value = str(int(months)) if float(months).is_integer() else f"{months:g}" + payment_description = ( + get_text("payment_description_traffic", traffic_gb=human_value) + if sale_mode == "traffic" + else get_text("payment_description_subscription", months=int(months)) + ) invoice_url = await cryptopay_service.create_invoice( session=session, @@ -58,17 +64,22 @@ async def pay_crypto_callback_handler( months=months, amount=price_amount, description=payment_description, + sale_mode=sale_mode, ) if invoice_url: try: await callback.message.edit_text( - get_text(key="payment_link_message", months=months), + get_text( + key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message", + months=int(months), + traffic_gb=human_value, + ), reply_markup=get_payment_url_keyboard( invoice_url, current_lang, i18n, - back_callback=f"subscribe_period:{months}", + back_callback=f"subscribe_period:{human_value}", back_text_key="back_to_payment_methods_button", ), disable_web_page_preview=False, @@ -76,12 +87,16 @@ async def pay_crypto_callback_handler( except Exception: try: await callback.message.answer( - get_text(key="payment_link_message", months=months), + get_text( + key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message", + months=int(months), + traffic_gb=human_value, + ), reply_markup=get_payment_url_keyboard( invoice_url, current_lang, i18n, - back_callback=f"subscribe_period:{months}", + back_callback=f"subscribe_period:{human_value}", back_text_key="back_to_payment_methods_button", ), disable_web_page_preview=False, diff --git a/bot/handlers/user/subscription/payments_freekassa.py b/bot/handlers/user/subscription/payments_freekassa.py index 5266443..1671c98 100644 --- a/bot/handlers/user/subscription/payments_freekassa.py +++ b/bot/handlers/user/subscription/payments_freekassa.py @@ -47,9 +47,10 @@ async def pay_fk_callback_handler( try: _, data_payload = callback.data.split(":", 1) - months_str, price_str = data_payload.split(":") - months = int(months_str) - price_rub = float(price_str) + parts = data_payload.split(":") + months = float(parts[0]) + price_rub = float(parts[1]) + sale_mode = parts[2] if len(parts) > 2 else "subscription" except (ValueError, IndexError): logging.error(f"Invalid pay_fk data in callback: {callback.data}") try: @@ -59,7 +60,12 @@ async def pay_fk_callback_handler( return user_id = callback.from_user.id - payment_description = get_text("payment_description_subscription", months=months) + human_value = str(int(months)) if float(months).is_integer() else f"{months:g}" + payment_description = ( + get_text("payment_description_traffic", traffic_gb=human_value) + if sale_mode == "traffic" + else get_text("payment_description_subscription", months=int(months)) + ) currency_code = getattr(freekassa_service, "default_currency", None) or settings.DEFAULT_CURRENCY_SYMBOL or "RUB" payment_record_payload = { @@ -68,7 +74,7 @@ async def pay_fk_callback_handler( "currency": currency_code, "status": "pending_freekassa", "description": payment_description, - "subscription_duration_months": months, + "subscription_duration_months": int(months), "provider": "freekassa", } @@ -135,12 +141,16 @@ async def pay_fk_callback_handler( ) try: await callback.message.edit_text( - f"{order_info_text}\n\n" + get_text(key="payment_link_message", months=months), + f"{order_info_text}\n\n" + get_text( + key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message", + months=int(months), + traffic_gb=human_value, + ), reply_markup=get_payment_url_keyboard( location, current_lang, i18n, - back_callback=f"subscribe_period:{months}", + back_callback=f"subscribe_period:{human_value}", back_text_key="back_to_payment_methods_button", ), disable_web_page_preview=False, @@ -149,12 +159,16 @@ async def pay_fk_callback_handler( logging.warning(f"FreeKassa: failed to display payment link ({e_edit}), sending new message.") try: await callback.message.answer( - f"{order_info_text}\n\n" + get_text(key="payment_link_message", months=months), + f"{order_info_text}\n\n" + get_text( + key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message", + months=int(months), + traffic_gb=human_value, + ), reply_markup=get_payment_url_keyboard( location, current_lang, i18n, - back_callback=f"subscribe_period:{months}", + back_callback=f"subscribe_period:{human_value}", back_text_key="back_to_payment_methods_button", ), disable_web_page_preview=False, diff --git a/bot/handlers/user/subscription/payments_platega.py b/bot/handlers/user/subscription/payments_platega.py index 1da2f29..b83e418 100644 --- a/bot/handlers/user/subscription/payments_platega.py +++ b/bot/handlers/user/subscription/payments_platega.py @@ -47,9 +47,10 @@ async def pay_platega_callback_handler( try: _, data_payload = callback.data.split(":", 1) - months_str, price_str = data_payload.split(":") - months = int(months_str) - price_rub = float(price_str) + parts = data_payload.split(":") + months = float(parts[0]) + price_rub = float(parts[1]) + sale_mode = parts[2] if len(parts) > 2 else "subscription" except (ValueError, IndexError): logging.error(f"Invalid pay_platega data in callback: {callback.data}") try: @@ -59,7 +60,12 @@ async def pay_platega_callback_handler( return user_id = callback.from_user.id - payment_description = get_text("payment_description_subscription", months=months) + human_value = str(int(months)) if float(months).is_integer() else f"{months:g}" + payment_description = ( + get_text("payment_description_traffic", traffic_gb=human_value) + if sale_mode == "traffic" + else get_text("payment_description_subscription", months=int(months)) + ) currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB" payment_record_payload = { @@ -68,7 +74,7 @@ async def pay_platega_callback_handler( "currency": currency_code, "status": "pending_platega", "description": payment_description, - "subscription_duration_months": months, + "subscription_duration_months": int(months), "provider": "platega", } @@ -96,6 +102,7 @@ async def pay_platega_callback_handler( "payment_db_id": payment_record.payment_id, "user_id": user_id, "months": months, + "sale_mode": sale_mode, } ) @@ -136,12 +143,16 @@ async def pay_platega_callback_handler( try: await callback.message.edit_text( - get_text(key="payment_link_message", months=months), + get_text( + key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message", + months=int(months), + traffic_gb=human_value, + ), reply_markup=get_payment_url_keyboard( redirect_url, current_lang, i18n, - back_callback=f"subscribe_period:{months}", + back_callback=f"subscribe_period:{human_value}", back_text_key="back_to_payment_methods_button", ), disable_web_page_preview=False, @@ -150,12 +161,16 @@ async def pay_platega_callback_handler( logging.warning(f"Platega: failed to display payment link ({e_edit}), sending new message.") try: await callback.message.answer( - get_text(key="payment_link_message", months=months), + get_text( + key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message", + months=int(months), + traffic_gb=human_value, + ), reply_markup=get_payment_url_keyboard( redirect_url, current_lang, i18n, - back_callback=f"subscribe_period:{months}", + back_callback=f"subscribe_period:{human_value}", back_text_key="back_to_payment_methods_button", ), disable_web_page_preview=False, diff --git a/bot/handlers/user/subscription/payments_severpay.py b/bot/handlers/user/subscription/payments_severpay.py index 47df521..2a3d100 100644 --- a/bot/handlers/user/subscription/payments_severpay.py +++ b/bot/handlers/user/subscription/payments_severpay.py @@ -46,9 +46,10 @@ async def pay_severpay_callback_handler( try: _, data_payload = callback.data.split(":", 1) - months_str, price_str = data_payload.split(":") - months = int(months_str) - price_rub = float(price_str) + parts = data_payload.split(":") + months = float(parts[0]) + price_rub = float(parts[1]) + sale_mode = parts[2] if len(parts) > 2 else "subscription" except (ValueError, IndexError): logging.error(f"Invalid pay_severpay data in callback: {callback.data}") try: @@ -58,7 +59,12 @@ async def pay_severpay_callback_handler( return user_id = callback.from_user.id - payment_description = get_text("payment_description_subscription", months=months) + human_value = str(int(months)) if float(months).is_integer() else f"{months:g}" + payment_description = ( + get_text("payment_description_traffic", traffic_gb=human_value) + if sale_mode == "traffic" + else get_text("payment_description_subscription", months=int(months)) + ) currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB" payment_record_payload = { @@ -67,7 +73,7 @@ async def pay_severpay_callback_handler( "currency": currency_code, "status": "pending_severpay", "description": payment_description, - "subscription_duration_months": months, + "subscription_duration_months": int(months), "provider": "severpay", } @@ -126,12 +132,16 @@ async def pay_severpay_callback_handler( if payment_link: try: await callback.message.edit_text( - get_text(key="payment_link_message", months=months), + get_text( + key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message", + months=int(months), + traffic_gb=human_value, + ), reply_markup=get_payment_url_keyboard( payment_link, current_lang, i18n, - back_callback=f"subscribe_period:{months}", + back_callback=f"subscribe_period:{human_value}", back_text_key="back_to_payment_methods_button", ), disable_web_page_preview=False, @@ -140,12 +150,16 @@ async def pay_severpay_callback_handler( logging.warning(f"SeverPay: failed to display payment link ({e_edit}), sending new message.") try: await callback.message.answer( - get_text(key="payment_link_message", months=months), + get_text( + key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message", + months=int(months), + traffic_gb=human_value, + ), reply_markup=get_payment_url_keyboard( payment_link, current_lang, i18n, - back_callback=f"subscribe_period:{months}", + back_callback=f"subscribe_period:{human_value}", back_text_key="back_to_payment_methods_button", ), disable_web_page_preview=False, diff --git a/bot/handlers/user/subscription/payments_stars.py b/bot/handlers/user/subscription/payments_stars.py index fa4ef47..8ee31cb 100644 --- a/bot/handlers/user/subscription/payments_stars.py +++ b/bot/handlers/user/subscription/payments_stars.py @@ -40,9 +40,10 @@ async def pay_stars_callback_handler( try: _, data_payload = callback.data.split(":", 1) - months_str, stars_price_str = data_payload.split(":") - months = int(months_str) - stars_price = int(stars_price_str) + parts = data_payload.split(":") + months = float(parts[0]) + stars_price = int(float(parts[1])) + sale_mode = parts[2] if len(parts) > 2 else "subscription" except (ValueError, IndexError): try: await callback.answer(get_text("error_try_again"), show_alert=True) @@ -51,7 +52,12 @@ async def pay_stars_callback_handler( return user_id = callback.from_user.id - payment_description = get_text("payment_description_subscription", months=months) + human_value = str(int(months)) if float(months).is_integer() else f"{months:g}" + payment_description = ( + get_text("payment_description_traffic", traffic_gb=human_value) + if sale_mode == "traffic" + else get_text("payment_description_subscription", months=int(months)) + ) payment_db_id = await stars_service.create_invoice( session=session, @@ -59,16 +65,21 @@ async def pay_stars_callback_handler( months=months, stars_price=stars_price, description=payment_description, + sale_mode=sale_mode, ) if payment_db_id: try: await callback.message.edit_text( - get_text("payment_invoice_sent_message", months=months), + get_text( + "payment_invoice_sent_message_traffic" if sale_mode == "traffic" else "payment_invoice_sent_message", + months=int(months), + traffic_gb=human_value, + ), reply_markup=InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton( text=get_text("back_to_payment_methods_button"), - callback_data=f"subscribe_period:{months}", + callback_data=f"subscribe_period:{human_value}", )] ]), ) @@ -106,9 +117,10 @@ async def handle_successful_stars_payment( payload = (message.successful_payment.invoice_payload if message and message.successful_payment else "") try: - payment_db_id_str, months_str = (payload or "").split(":", 1) - payment_db_id = int(payment_db_id_str) - months = int(months_str) + parts = (payload or "").split(":") + payment_db_id = int(parts[0]) + months = float(parts[1]) if len(parts) > 1 else 0 + sale_mode = parts[2] if len(parts) > 2 else "subscription" except Exception: return @@ -120,4 +132,5 @@ async def handle_successful_stars_payment( months=months, stars_amount=stars_amount, i18n_data=i18n_data, + sale_mode=sale_mode, ) diff --git a/bot/handlers/user/subscription/payments_subscription.py b/bot/handlers/user/subscription/payments_subscription.py index 0d7592e..e6145e1 100644 --- a/bot/handlers/user/subscription/payments_subscription.py +++ b/bot/handlers/user/subscription/payments_subscription.py @@ -29,8 +29,9 @@ async def select_subscription_period_callback_handler( pass return + traffic_mode = bool(getattr(settings, "traffic_sale_mode", False)) try: - months = int(callback.data.split(":")[-1]) + months = float(callback.data.split(":")[-1]) except (ValueError, IndexError): logging.error(f"Invalid subscription period in callback_data: {callback.data}") try: @@ -39,10 +40,13 @@ async def select_subscription_period_callback_handler( pass return - price_rub = settings.subscription_options.get(months) + price_source = settings.traffic_packages if traffic_mode else settings.subscription_options + stars_price_source = settings.stars_traffic_packages if traffic_mode else settings.stars_subscription_options + + price_rub = price_source.get(months) if price_rub is None: logging.error( - f"Price not found for {months} months subscription period in settings.subscription_options." + f"Price not found for option {months} using {'traffic_packages' if traffic_mode else 'subscription_options'}." ) try: await callback.answer(get_text("error_try_again"), show_alert=True) @@ -51,8 +55,8 @@ async def select_subscription_period_callback_handler( return currency_symbol_val = settings.DEFAULT_CURRENCY_SYMBOL - text_content = get_text("choose_payment_method") - stars_price = settings.stars_subscription_options.get(months) + text_content = get_text("choose_payment_method_traffic") if traffic_mode else get_text("choose_payment_method") + stars_price = stars_price_source.get(months) reply_markup = get_payment_method_keyboard( months, price_rub, @@ -61,6 +65,7 @@ async def select_subscription_period_callback_handler( current_lang, i18n, settings, + sale_mode="traffic" if traffic_mode else "subscription", ) try: diff --git a/bot/handlers/user/subscription/payments_yookassa.py b/bot/handlers/user/subscription/payments_yookassa.py index c586143..6566551 100644 --- a/bot/handlers/user/subscription/payments_yookassa.py +++ b/bot/handlers/user/subscription/payments_yookassa.py @@ -18,10 +18,17 @@ from db.dal import payment_dal, user_billing_dal router = Router(name="user_subscription_payments_yookassa_router") -def _parse_months_and_price(payload: str) -> Optional[Tuple[int, float]]: +def _format_value(val: float) -> str: + return str(int(val)) if float(val).is_integer() else f"{val:g}" + + +def _parse_offer_payload(payload: str) -> Optional[Tuple[float, float, str]]: try: - months_str, price_str = payload.split(":") - return int(months_str), float(price_str) + parts = payload.split(":") + value = float(parts[0]) + price = float(parts[1]) + sale_mode = parts[2] if len(parts) > 2 else "subscription" + return value, price, sale_mode except (ValueError, IndexError): return None @@ -64,19 +71,24 @@ async def _initiate_yk_payment( back_callback: str, payment_method_id: Optional[str] = None, selected_method_internal_id: Optional[int] = None, + sale_mode: str = "subscription", ) -> bool: """Create payment record and initiate YooKassa payment (new card or saved card).""" if not callback.message: return False - payment_description = get_text("payment_description_subscription", months=months) + payment_description = ( + get_text("payment_description_traffic", traffic_gb=_format_value(months)) + if sale_mode == "traffic" + else get_text("payment_description_subscription", months=int(months)) + ) payment_record_data = { "user_id": user_id, "amount": price_rub, "currency": currency_code_for_yk, "status": "pending_yookassa", "description": payment_description, - "subscription_duration_months": months, + "subscription_duration_months": int(months), } db_payment_record = None @@ -109,7 +121,10 @@ async def _initiate_yk_payment( "user_id": str(user_id), "subscription_months": str(months), "payment_db_id": str(db_payment_record.payment_id), + "sale_mode": sale_mode, } + if sale_mode == "traffic": + yookassa_metadata["traffic_gb"] = str(months) if payment_method_id: yookassa_metadata["used_saved_payment_method_id"] = payment_method_id @@ -198,7 +213,11 @@ async def _initiate_yk_payment( try: await callback.message.edit_text( - get_text(key="payment_link_message", months=months), + get_text( + key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message", + months=int(months), + traffic_gb=_format_value(months), + ), reply_markup=get_payment_url_keyboard( payment_response_yk["confirmation_url"], current_lang, @@ -214,7 +233,11 @@ async def _initiate_yk_payment( ) try: await callback.message.answer( - get_text(key="payment_link_message", months=months), + get_text( + key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message", + months=int(months), + traffic_gb=_format_value(months), + ), reply_markup=get_payment_url_keyboard( payment_response_yk["confirmation_url"], current_lang, @@ -328,7 +351,7 @@ async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Setti pass return - parsed = _parse_months_and_price(data_payload) + parsed = _parse_offer_payload(data_payload) if not parsed: logging.error(f"Invalid pay_yk payload structure: {callback.data}") try: @@ -337,10 +360,10 @@ async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Setti pass return - months, price_rub = parsed + months, price_rub, sale_mode = parsed user_id = callback.from_user.id currency_code_for_yk = "RUB" - autopay_enabled = bool(settings.yookassa_autopayments_active) + autopay_enabled = bool(settings.yookassa_autopayments_active and sale_mode != "traffic" and not settings.traffic_sale_mode) autopay_require_binding = bool( getattr(settings, 'YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING', True) ) @@ -364,6 +387,7 @@ async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Setti current_lang, i18n, has_saved_cards=True, + sale_mode=sale_mode, ), ) except Exception as e_edit: @@ -377,6 +401,7 @@ async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Setti current_lang, i18n, has_saved_cards=True, + sale_mode=sale_mode, ), ) except Exception: @@ -400,7 +425,8 @@ async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Setti price_rub=price_rub, currency_code_for_yk=currency_code_for_yk, save_payment_method=autopay_enabled and autopay_require_binding, - back_callback=f"subscribe_period:{months}", + back_callback=f"subscribe_period:{_format_value(months)}", + sale_mode=sale_mode, ) try: await callback.answer() @@ -443,7 +469,7 @@ async def pay_yk_new_card_handler(callback: types.CallbackQuery, settings: Setti pass return - parsed = _parse_months_and_price(data_payload) + parsed = _parse_offer_payload(data_payload) if not parsed: logging.error(f"Invalid pay_yk_new payload structure: {callback.data}") try: @@ -452,10 +478,10 @@ async def pay_yk_new_card_handler(callback: types.CallbackQuery, settings: Setti pass return - months, price_rub = parsed + months, price_rub, sale_mode = parsed user_id = callback.from_user.id currency_code_for_yk = "RUB" - autopay_enabled = bool(settings.yookassa_autopayments_active) + autopay_enabled = bool(settings.yookassa_autopayments_active and sale_mode != "traffic" and not settings.traffic_sale_mode) autopay_require_binding = bool( getattr(settings, 'YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING', True) ) @@ -473,7 +499,8 @@ async def pay_yk_new_card_handler(callback: types.CallbackQuery, settings: Setti price_rub=price_rub, currency_code_for_yk=currency_code_for_yk, save_payment_method=autopay_enabled and autopay_require_binding, - back_callback=f"subscribe_period:{months}", + back_callback=f"subscribe_period:{_format_value(months)}", + sale_mode=sale_mode, ) try: await callback.answer() @@ -494,14 +521,6 @@ async def pay_yk_saved_list_handler(callback: types.CallbackQuery, settings: Set pass return - autopay_enabled = bool(settings.yookassa_autopayments_active) - if not autopay_enabled: - try: - await callback.answer(get_text("error_try_again"), show_alert=True) - except Exception: - pass - return - try: _, data_payload = callback.data.split(":", 1) except ValueError: @@ -522,9 +541,10 @@ async def pay_yk_saved_list_handler(callback: types.CallbackQuery, settings: Set return try: - months = int(parts[0]) + months = float(parts[0]) price_rub = float(parts[1]) page = int(parts[2]) if len(parts) > 2 else 0 + sale_mode = parts[3] if len(parts) > 3 else "subscription" except (ValueError, IndexError): logging.error(f"pay_yk_saved_list payload parsing error: {callback.data}") try: @@ -533,6 +553,14 @@ async def pay_yk_saved_list_handler(callback: types.CallbackQuery, settings: Set pass return + autopay_enabled = bool(settings.yookassa_autopayments_active and sale_mode != "traffic" and not settings.traffic_sale_mode) + if not autopay_enabled: + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + user_id = callback.from_user.id try: saved_methods = await user_billing_dal.list_user_payment_methods( @@ -552,6 +580,7 @@ async def pay_yk_saved_list_handler(callback: types.CallbackQuery, settings: Set current_lang, i18n, has_saved_cards=False, + sale_mode=sale_mode, ), ) except Exception as e_edit: @@ -565,6 +594,7 @@ async def pay_yk_saved_list_handler(callback: types.CallbackQuery, settings: Set current_lang, i18n, has_saved_cards=False, + sale_mode=sale_mode, ), ) except Exception: @@ -596,6 +626,7 @@ async def pay_yk_saved_list_handler(callback: types.CallbackQuery, settings: Set current_lang, i18n, page=page, + sale_mode=sale_mode, ), ) except Exception as e_edit: @@ -610,6 +641,7 @@ async def pay_yk_saved_list_handler(callback: types.CallbackQuery, settings: Set current_lang, i18n, page=page, + sale_mode=sale_mode, ), ) except Exception: @@ -633,14 +665,6 @@ async def pay_yk_use_saved_handler(callback: types.CallbackQuery, settings: Sett pass return - autopay_enabled = bool(settings.yookassa_autopayments_active) - if not autopay_enabled: - try: - await callback.answer(get_text("error_try_again"), show_alert=True) - except Exception: - pass - return - if not yookassa_service or not yookassa_service.configured: logging.error("YooKassa service unavailable for pay_yk_use_saved.") try: @@ -673,8 +697,9 @@ async def pay_yk_use_saved_handler(callback: types.CallbackQuery, settings: Sett return try: - months = int(parts[0]) + months = float(parts[0]) price_rub = float(parts[1]) + sale_mode = parts[3] if len(parts) > 3 else "subscription" except (ValueError, IndexError): logging.error(f"pay_yk_use_saved months/price parsing error: {callback.data}") try: @@ -683,6 +708,14 @@ async def pay_yk_use_saved_handler(callback: types.CallbackQuery, settings: Sett pass return + autopay_enabled = bool(settings.yookassa_autopayments_active and sale_mode != "traffic" and not settings.traffic_sale_mode) + if not autopay_enabled: + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + method_identifier = parts[2] user_id = callback.from_user.id @@ -727,9 +760,10 @@ async def pay_yk_use_saved_handler(callback: types.CallbackQuery, settings: Sett price_rub=price_rub, currency_code_for_yk=currency_code_for_yk, save_payment_method=False, - back_callback=f"pay_yk_saved_list:{months}:{price_rub}", + back_callback=f"pay_yk_saved_list:{_format_value(months)}:{price_rub}:{sale_mode}", payment_method_id=selected_method.provider_payment_method_id, selected_method_internal_id=selected_method.method_id, + sale_mode=sale_mode, ) try: await callback.answer() diff --git a/bot/keyboards/inline/user_keyboards.py b/bot/keyboards/inline/user_keyboards.py index cbe51e1..954e3f5 100644 --- a/bot/keyboards/inline/user_keyboards.py +++ b/bot/keyboards/inline/user_keyboards.py @@ -91,19 +91,31 @@ def get_trial_confirmation_keyboard(lang: str, def get_subscription_options_keyboard(subscription_options: Dict[ - int, Optional[int]], currency_symbol_val: str, lang: str, - i18n_instance) -> InlineKeyboardMarkup: + float, Optional[float]], currency_symbol_val: str, lang: str, + i18n_instance, traffic_mode: bool = False) -> InlineKeyboardMarkup: _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) builder = InlineKeyboardBuilder() + def _format_gb(val: float) -> str: + return str(int(val)) if float(val).is_integer() else f"{val:g}" if subscription_options: for months, price in subscription_options.items(): if price is not None: - button_text = _("subscribe_for_months_button", - months=months, - price=price, - currency_symbol=currency_symbol_val) + if traffic_mode: + button_text = _( + "buy_traffic_package_button", + traffic_gb=_format_gb(months), + price=price, + currency_symbol=currency_symbol_val, + ) + callback_data = f"subscribe_period:{_format_gb(months)}" + else: + button_text = _("subscribe_for_months_button", + months=months, + price=price, + currency_symbol=currency_symbol_val) + callback_data = f"subscribe_period:{months}" builder.button(text=button_text, - callback_data=f"subscribe_period:{months}") + callback_data=callback_data) builder.adjust(1) builder.row( InlineKeyboardButton(text=_(key="back_to_main_menu_button"), @@ -114,39 +126,43 @@ def get_subscription_options_keyboard(subscription_options: Dict[ def get_payment_method_keyboard(months: int, price: float, stars_price: Optional[int], currency_symbol_val: str, lang: str, - i18n_instance, settings: Settings) -> InlineKeyboardMarkup: + i18n_instance, settings: Settings, sale_mode: str = "subscription") -> InlineKeyboardMarkup: _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) builder = InlineKeyboardBuilder() + def _format_value(val: float) -> str: + return str(int(val)) if float(val).is_integer() else f"{val:g}" + value_str = _format_value(months) + mode_suffix = f":{sale_mode}" for method in settings.payment_methods_order: if method == "severpay" and getattr(settings, "SEVERPAY_ENABLED", False): builder.button( text=_("pay_with_severpay_button"), - callback_data=f"pay_severpay:{months}:{price}", + callback_data=f"pay_severpay:{value_str}:{price}{mode_suffix}", ) elif method == "freekassa" and settings.FREEKASSA_ENABLED: builder.button( text=_("pay_with_sbp_button"), - callback_data=f"pay_fk:{months}:{price}", + callback_data=f"pay_fk:{value_str}:{price}{mode_suffix}", ) elif method == "platega" and settings.PLATEGA_ENABLED: builder.button( text=_("pay_with_platega_button"), - callback_data=f"pay_platega:{months}:{price}", + callback_data=f"pay_platega:{value_str}:{price}{mode_suffix}", ) elif method == "yookassa" and settings.YOOKASSA_ENABLED: builder.button( text=_("pay_with_yookassa_button"), - callback_data=f"pay_yk:{months}:{price}", + callback_data=f"pay_yk:{value_str}:{price}{mode_suffix}", ) elif method == "stars" and settings.STARS_ENABLED and stars_price is not None: builder.button( text=_("pay_with_stars_button"), - callback_data=f"pay_stars:{months}:{stars_price}", + callback_data=f"pay_stars:{value_str}:{stars_price}{mode_suffix}", ) elif method == "cryptopay" and settings.CRYPTOPAY_ENABLED: builder.button( text=_("pay_with_cryptopay_button"), - callback_data=f"pay_crypto:{months}:{price}", + callback_data=f"pay_crypto:{value_str}:{price}{mode_suffix}", ) builder.button(text=_(key="cancel_button"), callback_data="main_action:subscribe") @@ -178,28 +194,33 @@ def get_yk_autopay_choice_keyboard( lang: str, i18n_instance, has_saved_cards: bool = True, + sale_mode: str = "subscription", ) -> InlineKeyboardMarkup: """Keyboard for choosing between saved card charge or new card payment when auto-renew is enabled.""" _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) builder = InlineKeyboardBuilder() price_str = str(price) + def _format_value(val: float) -> str: + return str(int(val)) if float(val).is_integer() else f"{val:g}" + value_str = _format_value(months) + suffix = f":{sale_mode}" if has_saved_cards: builder.row( InlineKeyboardButton( text=_(key="yookassa_autopay_pay_saved_card_button"), - callback_data=f"pay_yk_saved_list:{months}:{price_str}", + callback_data=f"pay_yk_saved_list:{value_str}:{price_str}{suffix}", ) ) builder.row( InlineKeyboardButton( text=_(key="yookassa_autopay_pay_new_card_button"), - callback_data=f"pay_yk_new:{months}:{price_str}", + callback_data=f"pay_yk_new:{value_str}:{price_str}{suffix}", ) ) builder.row( InlineKeyboardButton( text=_(key="back_to_payment_methods_button"), - callback_data=f"subscribe_period:{months}", + callback_data=f"subscribe_period:{value_str}", ) ) return builder.as_markup() @@ -212,6 +233,7 @@ def get_yk_saved_cards_keyboard( lang: str, i18n_instance, page: int = 0, + sale_mode: str = "subscription", ) -> InlineKeyboardMarkup: """Paginated keyboard for selecting a saved YooKassa card.""" _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) @@ -221,12 +243,16 @@ def get_yk_saved_cards_keyboard( start = page * per_page end = min(total, start + per_page) price_str = str(price) + def _format_value(val: float) -> str: + return str(int(val)) if float(val).is_integer() else f"{val:g}" + value_str = _format_value(months) + suffix = f":{sale_mode}" for method_id, title in cards[start:end]: builder.row( InlineKeyboardButton( text=title, - callback_data=f"pay_yk_use_saved:{months}:{price_str}:{method_id}", + callback_data=f"pay_yk_use_saved:{value_str}:{price_str}:{method_id}{suffix}", ) ) @@ -235,14 +261,14 @@ def get_yk_saved_cards_keyboard( nav_buttons.append( InlineKeyboardButton( text="⬅️", - callback_data=f"pay_yk_saved_list:{months}:{price_str}:{page-1}", + callback_data=f"pay_yk_saved_list:{value_str}:{price_str}:{page-1}{suffix}", ) ) if end < total: nav_buttons.append( InlineKeyboardButton( text="➡️", - callback_data=f"pay_yk_saved_list:{months}:{price_str}:{page+1}", + callback_data=f"pay_yk_saved_list:{value_str}:{price_str}:{page+1}{suffix}", ) ) if nav_buttons: @@ -251,13 +277,13 @@ def get_yk_saved_cards_keyboard( builder.row( InlineKeyboardButton( text=_(key="yookassa_autopay_pay_new_card_button"), - callback_data=f"pay_yk_new:{months}:{price_str}", + callback_data=f"pay_yk_new:{value_str}:{price_str}{suffix}", ) ) builder.row( InlineKeyboardButton( text=_(key="back_to_autopay_method_choice_button"), - callback_data=f"pay_yk:{months}:{price_str}", + callback_data=f"pay_yk:{value_str}:{price_str}{suffix}", ) ) return builder.as_markup() diff --git a/bot/services/crypto_pay_service.py b/bot/services/crypto_pay_service.py index b846438..4afa298 100644 --- a/bot/services/crypto_pay_service.py +++ b/bot/services/crypto_pay_service.py @@ -63,6 +63,7 @@ class CryptoPayService: months: int, amount: float, description: str, + sale_mode: str = "subscription", ) -> Optional[str]: if not self.configured or not self.client: logging.error("CryptoPayService not configured") @@ -78,7 +79,7 @@ class CryptoPayService: "currency": self.settings.CRYPTOPAY_ASSET, "status": "pending_cryptopay", "description": description, - "subscription_duration_months": months, + "subscription_duration_months": int(months), "provider": "cryptopay", }, ) @@ -94,6 +95,8 @@ class CryptoPayService: "user_id": str(user_id), "subscription_months": str(months), "payment_db_id": str(payment_record.payment_id), + "sale_mode": sale_mode, + "traffic_gb": str(months) if sale_mode == "traffic" else None, }) try: invoice = await self.client.create_invoice( @@ -132,8 +135,10 @@ class CryptoPayService: try: meta = json.loads(invoice.payload) user_id = int(meta["user_id"]) - months = int(meta["subscription_months"]) + months = float(meta.get("subscription_months") or 0) payment_db_id = int(meta["payment_db_id"]) + sale_mode = meta.get("sale_mode") or ("traffic" if self.settings.traffic_sale_mode else "subscription") + traffic_gb = float(meta.get("traffic_gb")) if meta.get("traffic_gb") else months except Exception as e: logging.error(f"Failed to parse CryptoPay payload: {e}") return @@ -156,18 +161,22 @@ class CryptoPayService: activation = await subscription_service.activate_subscription( session, user_id, - months, + int(months) if sale_mode != "traffic" else 0, float(invoice.amount), payment_db_id, provider="cryptopay", + sale_mode=sale_mode, + traffic_gb=traffic_gb if sale_mode == "traffic" else None, ) - referral_bonus = await referral_service.apply_referral_bonuses_for_payment( - session, - user_id, - months, - current_payment_db_id=payment_db_id, - skip_if_active_before_payment=False, - ) + referral_bonus = None + if sale_mode != "traffic": + referral_bonus = await referral_service.apply_referral_bonuses_for_payment( + session, + user_id, + int(months) or 1, + current_payment_db_id=payment_db_id, + skip_if_active_before_payment=False, + ) await session.commit() except Exception as e: await session.rollback() @@ -186,7 +195,12 @@ class CryptoPayService: final_end = referral_bonus["referee_new_end_date"] applied_days = referral_bonus.get("referee_bonus_applied_days", 0) - if applied_days: + if sale_mode == "traffic": + text = _("payment_successful_traffic_full", + traffic_gb=str(int(traffic_gb)) if float(traffic_gb).is_integer() else f"{traffic_gb:g}", + end_date=final_end.strftime('%Y-%m-%d') if final_end else "—", + config_link=config_link) + elif 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) @@ -197,7 +211,7 @@ class CryptoPayService: elif inviter.username: inviter_name_display = username_for_display(inviter.username, with_at=False) text = _("payment_successful_with_referral_bonus_full", - months=months, + months=int(months), base_end_date=activation["end_date"].strftime('%Y-%m-%d'), bonus_days=applied_days, final_end_date=final_end.strftime('%Y-%m-%d'), @@ -205,8 +219,8 @@ class CryptoPayService: config_link=config_link) else: text = _("payment_successful_full", - months=months, - end_date=final_end.strftime('%Y-%m-%d'), + months=int(months), + end_date=final_end.strftime('%Y-%m-%d') if final_end else "—", config_link=config_link) markup = get_connect_and_main_keyboard( @@ -231,7 +245,8 @@ class CryptoPayService: user_id=user_id, amount=float(invoice.amount), currency=invoice.asset or settings.DEFAULT_CURRENCY_SYMBOL, - months=months, + months=int(months) if sale_mode != "traffic" else 0, + traffic_gb=traffic_gb if sale_mode == "traffic" else None, payment_provider="crypto_pay", username=user.username if user else None ) diff --git a/bot/services/freekassa_service.py b/bot/services/freekassa_service.py index 23f266d..ab1457a 100644 --- a/bot/services/freekassa_service.py +++ b/bot/services/freekassa_service.py @@ -284,23 +284,28 @@ class FreeKassaService: ) months = payment.subscription_duration_months or 1 + sale_mode = "traffic" if self.settings.traffic_sale_mode else "subscription" activation = await self.subscription_service.activate_subscription( session, payment.user_id, - months, + int(months) if sale_mode != "traffic" else 0, float(payment.amount), payment.payment_id, provider="freekassa", + sale_mode=sale_mode, + traffic_gb=months if sale_mode == "traffic" else None, ) - 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, - ) + referral_bonus = None + if sale_mode != "traffic": + referral_bonus = await self.referral_service.apply_referral_bonuses_for_payment( + session, + payment.user_id, + int(months), + current_payment_db_id=payment.payment_id, + skip_if_active_before_payment=False, + ) await session.commit() except Exception as e: @@ -315,6 +320,7 @@ class FreeKassaService: config_link = None final_end = None months = payment.subscription_duration_months or 1 + sale_mode = "traffic" if self.settings.traffic_sale_mode else "subscription" if activation: config_link = activation.get("subscription_url") final_end = activation.get("end_date") @@ -334,7 +340,14 @@ class FreeKassaService: else: end_date_str = _("config_link_not_available") - if applied_days: + traffic_label = str(int(months)) if float(months).is_integer() else f"{months:g}" + + if sale_mode == "traffic": + text = _("payment_successful_traffic_full", + traffic_gb=traffic_label, + end_date=end_date_str if final_end else "", + config_link=config_link) + elif 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) @@ -392,7 +405,8 @@ class FreeKassaService: user_id=payment.user_id, amount=float(payment.amount), currency=self.default_currency, - months=months, + months=int(months) if sale_mode != "traffic" else 0, + traffic_gb=months if sale_mode == "traffic" else None, payment_provider="freekassa", username=db_user.username if db_user else None, ) diff --git a/bot/services/notification_service.py b/bot/services/notification_service.py index 99a6290..53b4ec5 100644 --- a/bot/services/notification_service.py +++ b/bot/services/notification_service.py @@ -222,7 +222,8 @@ class NotificationService: async def notify_payment_received(self, user_id: int, amount: float, currency: str, months: int, payment_provider: str, - username: Optional[str] = None): + username: Optional[str] = None, + traffic_gb: Optional[float] = None): """Send notification about successful payment""" if not self.settings.LOG_PAYMENTS: return @@ -243,23 +244,42 @@ class NotificationService: "platega": "💳", "severpay": "💳", }.get(payment_provider.lower(), "💰") - - message = _( - "log_payment_received", - default="{provider_emoji} Получен платеж\n\n" - "👤 Пользователь: {user_display}\n" - "💰 Сумма: {amount} {currency}\n" - "📅 Период: {months} мес.\n" - "🏦 Провайдер: {payment_provider}\n" - "🕐 Время: {timestamp}", - provider_emoji=provider_emoji, - user_display=user_display, - amount=amount, - currency=currency, - months=months, - payment_provider=payment_provider, - timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S") - ) + + if traffic_gb is not None: + traffic_label = str(int(traffic_gb)) if float(traffic_gb).is_integer() else f"{traffic_gb:g}" + message = _( + "log_payment_received_traffic", + default="{provider_emoji} Получен платеж\n\n" + "👤 Пользователь: {user_display}\n" + "💰 Сумма: {amount} {currency}\n" + "🗂 Трафик: {traffic_gb} GB\n" + "🏦 Провайдер: {payment_provider}\n" + "🕐 Время: {timestamp}", + provider_emoji=provider_emoji, + user_display=user_display, + amount=amount, + currency=currency, + traffic_gb=traffic_label, + payment_provider=payment_provider, + timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S") + ) + else: + message = _( + "log_payment_received", + default="{provider_emoji} Получен платеж\n\n" + "👤 Пользователь: {user_display}\n" + "💰 Сумма: {amount} {currency}\n" + "📅 Период: {months} мес.\n" + "🏦 Провайдер: {payment_provider}\n" + "🕐 Время: {timestamp}", + provider_emoji=provider_emoji, + user_display=user_display, + amount=amount, + currency=currency, + months=months, + payment_provider=payment_provider, + timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S") + ) # Send to log channel profile_keyboard = self._build_profile_keyboard(_, user_id) diff --git a/bot/services/platega_service.py b/bot/services/platega_service.py index c449034..3acc13b 100644 --- a/bot/services/platega_service.py +++ b/bot/services/platega_service.py @@ -157,6 +157,7 @@ class PlategaService: return web.Response(text="ok") payment_months = payment.subscription_duration_months or 1 + sale_mode = "traffic" if self.settings.traffic_sale_mode else "subscription" if status == "CONFIRMED": if amount_raw is not None: @@ -184,19 +185,23 @@ class PlategaService: activation = await self.subscription_service.activate_subscription( session, payment.user_id, - payment_months, + int(payment_months) if sale_mode != "traffic" else 0, float(payment.amount), payment.payment_id, provider="platega", + sale_mode=sale_mode, + traffic_gb=payment_months if sale_mode == "traffic" else None, ) - referral_bonus = await self.referral_service.apply_referral_bonuses_for_payment( - session, - payment.user_id, - payment_months, - current_payment_db_id=payment.payment_id, - skip_if_active_before_payment=False, - ) + referral_bonus = None + if sale_mode != "traffic": + referral_bonus = await self.referral_service.apply_referral_bonuses_for_payment( + session, + payment.user_id, + int(payment_months), + current_payment_db_id=payment.payment_id, + skip_if_active_before_payment=False, + ) await session.commit() except Exception as exc: @@ -221,7 +226,16 @@ class PlategaService: final_end = referral_bonus["referee_new_end_date"] applied_days = referral_bonus.get("referee_bonus_applied_days", 0) - if applied_days: + traffic_label = str(int(payment_months)) if float(payment_months).is_integer() else f"{payment_months:g}" + + if sale_mode == "traffic": + text = _( + "payment_successful_traffic_full", + traffic_gb=traffic_label, + end_date=final_end.strftime("%Y-%m-%d") if final_end else "", + config_link=config_link, + ) + elif 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) @@ -281,7 +295,8 @@ class PlategaService: user_id=payment.user_id, amount=float(payment.amount), currency=currency, - months=payment_months, + months=int(payment_months) if sale_mode != "traffic" else 0, + traffic_gb=payment_months if sale_mode == "traffic" else None, payment_provider="platega", username=db_user.username if db_user else None, ) diff --git a/bot/services/severpay_service.py b/bot/services/severpay_service.py index a2b9b32..379305f 100644 --- a/bot/services/severpay_service.py +++ b/bot/services/severpay_service.py @@ -190,6 +190,7 @@ class SeverPayService: return web.json_response({"status": False, "msg": "payment_not_found"}, status=404) payment_months = payment.subscription_duration_months or 1 + sale_mode = "traffic" if self.settings.traffic_sale_mode else "subscription" if status == "success": try: await payment_dal.update_provider_payment_and_status( @@ -202,19 +203,23 @@ class SeverPayService: activation = await self.subscription_service.activate_subscription( session, payment.user_id, - payment_months, + int(payment_months) if sale_mode != "traffic" else 0, float(payment.amount), payment.payment_id, provider="severpay", + sale_mode=sale_mode, + traffic_gb=payment_months if sale_mode == "traffic" else None, ) - referral_bonus = await self.referral_service.apply_referral_bonuses_for_payment( - session, - payment.user_id, - payment_months, - current_payment_db_id=payment.payment_id, - skip_if_active_before_payment=False, - ) + referral_bonus = None + if sale_mode != "traffic": + referral_bonus = await self.referral_service.apply_referral_bonuses_for_payment( + session, + payment.user_id, + int(payment_months), + current_payment_db_id=payment.payment_id, + skip_if_active_before_payment=False, + ) await session.commit() except Exception as exc: @@ -239,7 +244,16 @@ class SeverPayService: final_end = referral_bonus["referee_new_end_date"] applied_days = referral_bonus.get("referee_bonus_applied_days", 0) - if applied_days: + traffic_label = str(int(payment_months)) if float(payment_months).is_integer() else f"{payment_months:g}" + + if sale_mode == "traffic": + text = _( + "payment_successful_traffic_full", + traffic_gb=traffic_label, + end_date=final_end.strftime("%Y-%m-%d") if final_end else "", + config_link=config_link, + ) + elif 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) @@ -299,7 +313,8 @@ class SeverPayService: user_id=payment.user_id, amount=float(payment.amount), currency=payment.currency, - months=payment_months, + months=int(payment_months) if sale_mode != "traffic" else 0, + traffic_gb=payment_months if sale_mode == "traffic" else None, payment_provider="severpay", username=db_user.username if db_user else None, ) diff --git a/bot/services/stars_service.py b/bot/services/stars_service.py index e278796..4fc9745 100644 --- a/bot/services/stars_service.py +++ b/bot/services/stars_service.py @@ -26,14 +26,14 @@ class StarsService: self.referral_service = referral_service async def create_invoice(self, session: AsyncSession, user_id: int, months: int, - stars_price: int, description: str) -> Optional[int]: + stars_price: int, description: str, sale_mode: str = "subscription") -> Optional[int]: payment_record_data = { "user_id": user_id, "amount": float(stars_price), "currency": "XTR", "status": "pending_stars", "description": description, - "subscription_duration_months": months, + "subscription_duration_months": int(months), "provider": "telegram_stars", } try: @@ -46,7 +46,7 @@ class StarsService: exc_info=True) return None - payload = f"{db_payment_record.payment_id}:{months}" + payload = f"{db_payment_record.payment_id}:{months}:{sale_mode}" prices = [LabeledPrice(label=description, amount=stars_price)] try: await self.bot.send_invoice( @@ -69,7 +69,8 @@ class StarsService: payment_db_id: int, months: int, stars_amount: int, - i18n_data: dict) -> None: + i18n_data: dict, + sale_mode: str = "subscription") -> None: try: await payment_dal.update_provider_payment_and_status( session, payment_db_id, @@ -86,23 +87,27 @@ class StarsService: activation_details = await self.subscription_service.activate_subscription( session, message.from_user.id, - months, + int(months) if sale_mode != "traffic" else 0, float(stars_amount), payment_db_id, provider="telegram_stars", + sale_mode=sale_mode, + traffic_gb=months if sale_mode == "traffic" else None, ) if not activation_details or not activation_details.get("end_date"): logging.error( f"Failed to activate subscription after stars payment for user {message.from_user.id}") return - referral_bonus = await self.referral_service.apply_referral_bonuses_for_payment( - session, - message.from_user.id, - months, - current_payment_db_id=payment_db_id, - skip_if_active_before_payment=False, - ) + referral_bonus = None + if sale_mode != "traffic": + referral_bonus = await self.referral_service.apply_referral_bonuses_for_payment( + session, + message.from_user.id, + int(months) or 1, + current_payment_db_id=payment_db_id, + skip_if_active_before_payment=False, + ) await session.commit() applied_days = referral_bonus.get("referee_bonus_applied_days") if referral_bonus else None @@ -120,7 +125,14 @@ class StarsService: "config_link_not_available" ) - if applied_days: + if sale_mode == "traffic": + success_msg = _( + "payment_successful_traffic_full", + traffic_gb=str(int(months)) if float(months).is_integer() else f"{months:g}", + end_date=final_end.strftime('%Y-%m-%d'), + config_link=config_link, + ) + elif applied_days: inviter_name_display = _("friend_placeholder") db_user = await user_dal.get_user_by_id(session, message.from_user.id) if db_user and db_user.referred_by_id: @@ -170,9 +182,10 @@ class StarsService: user_id=message.from_user.id, amount=float(stars_amount), currency="XTR", - months=months, + months=int(months) if sale_mode != "traffic" else 0, payment_provider="stars", - username=user.username if user else None + username=user.username if user else None, + traffic_gb=months if sale_mode == "traffic" else None, ) except Exception as e: logging.error(f"Failed to send stars payment notification: {e}") diff --git a/bot/services/subscription_service.py b/bot/services/subscription_service.py index f8a5303..fe9adcd 100644 --- a/bot/services/subscription_service.py +++ b/bot/services/subscription_service.py @@ -419,6 +419,119 @@ class SubscriptionService: "subscription_url": final_subscription_url, } + async def _activate_traffic_package( + self, + session: AsyncSession, + user_id: int, + traffic_gb: float, + payment_amount: float, + payment_db_id: int, + provider: str = "yookassa", + ) -> Optional[Dict[str, Any]]: + """Activate or extend a traffic-based package instead of a time-based subscription.""" + db_user = await user_dal.get_user_by_id(session, user_id) + if not db_user: + logging.error("User %s not found for traffic package activation", user_id) + return None + + panel_user_uuid, panel_sub_link_id, panel_short_uuid, _ = ( + await self._get_or_create_panel_user_link_details(session, user_id, db_user) + ) + + if not panel_user_uuid or not panel_sub_link_id: + logging.error("Failed to ensure panel linkage for user %s during traffic activation", user_id) + return None + + panel_user_data = await self.panel_service.get_user_by_uuid(panel_user_uuid) or {} + traffic_info = panel_user_data.get("userTraffic") or {} + current_limit = panel_user_data.get("trafficLimitBytes") + current_used = traffic_info.get("usedTrafficBytes") + + active_sub = await subscription_dal.get_active_subscription_by_user_id( + session, user_id, panel_user_uuid + ) + if current_limit is None and active_sub: + current_limit = active_sub.traffic_limit_bytes + if current_used is None and active_sub: + current_used = active_sub.traffic_used_bytes + + purchase_bytes = int(float(traffic_gb) * (1024**3)) + new_limit = (current_limit or 0) + purchase_bytes + + start_date = datetime.now(timezone.utc) + # Set a far-future expiry to satisfy panel requirements; keep the latest known expiry if it's further. + far_future = datetime(2099, 1, 1, tzinfo=timezone.utc) + final_end_date = far_future + if active_sub and active_sub.end_date and active_sub.end_date > final_end_date: + final_end_date = active_sub.end_date + + await subscription_dal.deactivate_other_active_subscriptions( + session, panel_user_uuid, panel_sub_link_id + ) + + sub_payload = { + "user_id": user_id, + "panel_user_uuid": panel_user_uuid, + "panel_subscription_uuid": panel_sub_link_id, + "start_date": start_date, + "end_date": final_end_date, + "duration_months": 0, + "is_active": True, + "status_from_panel": "ACTIVE", + "traffic_limit_bytes": new_limit, + "traffic_used_bytes": current_used, + "provider": provider, + "skip_notifications": True, + "auto_renew_enabled": False, + } + + try: + new_or_updated_sub = await subscription_dal.upsert_subscription(session, sub_payload) + except Exception as exc: + logging.error("Failed to upsert traffic subscription for user %s: %s", user_id, exc, exc_info=True) + return None + + panel_update_payload = self._build_panel_update_payload( + panel_user_uuid=panel_user_uuid, + expire_at=final_end_date, + status="ACTIVE", + traffic_limit_bytes=new_limit, + traffic_limit_strategy="NO_RESET", + ) + + panel_update_payload["description"] = "\n".join( + [ + (db_user.username or "") if db_user else "", + (db_user.first_name or "") if db_user else "", + (db_user.last_name or "") if db_user else "", + ] + ) + + updated_panel_user = await self.panel_service.update_user_details_on_panel( + panel_user_uuid, panel_update_payload + ) + if not updated_panel_user or updated_panel_user.get("error"): + logging.warning( + "Panel user details update FAILED for traffic package user %s. Response: %s", + panel_user_uuid, + updated_panel_user, + ) + return None + + final_subscription_url = updated_panel_user.get("subscriptionUrl") + final_panel_short_uuid = updated_panel_user.get("shortUuid", panel_short_uuid) + + return { + "subscription_id": new_or_updated_sub.subscription_id, + "end_date": final_end_date, + "is_active": True, + "panel_user_uuid": panel_user_uuid, + "panel_short_uuid": final_panel_short_uuid, + "subscription_url": final_subscription_url, + "applied_promo_bonus_days": 0, + "traffic_limit_bytes": new_limit, + } + async def activate_subscription( self, session: AsyncSession, @@ -428,8 +541,21 @@ class SubscriptionService: payment_db_id: int, promo_code_id_from_payment: Optional[int] = None, provider: str = "yookassa", + sale_mode: str = "subscription", + traffic_gb: Optional[float] = None, ) -> Optional[Dict[str, Any]]: + if sale_mode == "traffic" or getattr(self.settings, "traffic_sale_mode", False): + target_gb = traffic_gb if traffic_gb is not None else float(months) + return await self._activate_traffic_package( + session=session, + user_id=user_id, + traffic_gb=target_gb, + payment_amount=payment_amount, + payment_db_id=payment_db_id, + provider=provider, + ) + db_user = await user_dal.get_user_by_id(session, user_id) if not db_user: logging.error( @@ -447,6 +573,11 @@ class SubscriptionService: ) return None + try: + months_int = int(months) + except Exception: + months_int = 1 + current_active_sub = await subscription_dal.get_active_subscription_by_user_id( session, user_id, panel_user_uuid ) @@ -459,7 +590,7 @@ class SubscriptionService: start_date = current_active_sub.end_date # base duration by months - end_after_months = add_months(start_date, months) + end_after_months = add_months(start_date, months_int) duration_days_total = (end_after_months - start_date).days applied_promo_bonus_days = 0 @@ -512,7 +643,7 @@ class SubscriptionService: "panel_subscription_uuid": panel_sub_link_id, "start_date": start_date, "end_date": final_end_date, - "duration_months": months, + "duration_months": months_int, "is_active": True, "status_from_panel": "ACTIVE", "traffic_limit_bytes": self.settings.user_traffic_limit_bytes, @@ -823,6 +954,9 @@ class SubscriptionService: sub: Subscription, ) -> bool: """Attempt to charge user using saved payment method. Return True on initiated/handled, False on failure.""" + if getattr(self.settings, "traffic_sale_mode", False): + logging.info("Auto-renew skipped: traffic sale mode enabled") + return True if not sub.auto_renew_enabled: return True # If autopayments are disabled globally, skip charging attempts @@ -902,6 +1036,7 @@ class SubscriptionService: status: Optional[str] = None, traffic_limit_bytes: Optional[int] = None, include_uuid: bool = True, + traffic_limit_strategy: Optional[str] = None, ) -> Dict[str, Any]: payload: Dict[str, Any] = {} if include_uuid and panel_user_uuid: @@ -912,7 +1047,7 @@ class SubscriptionService: payload["status"] = status if traffic_limit_bytes is not None: payload["trafficLimitBytes"] = traffic_limit_bytes - payload["trafficLimitStrategy"] = self.settings.USER_TRAFFIC_STRATEGY + payload["trafficLimitStrategy"] = traffic_limit_strategy or self.settings.USER_TRAFFIC_STRATEGY if self.settings.parsed_user_squad_uuids: payload["activeInternalSquads"] = self.settings.parsed_user_squad_uuids if self.settings.parsed_user_external_squad_uuid: diff --git a/config/settings.py b/config/settings.py index 783dca4..535efb9 100644 --- a/config/settings.py +++ b/config/settings.py @@ -105,6 +105,15 @@ class Settings(BaseSettings): STARS_PRICE_12_MONTHS: Optional[int] = Field(default=None) PANEL_WEBHOOK_SECRET: Optional[str] = Field(default=None) + TRAFFIC_PACKAGES: Optional[str] = Field( + default=None, + description="Comma-separated list of traffic packages in the format ':', e.g. '10:199,50:799'", + ) + STARS_TRAFFIC_PACKAGES: Optional[str] = Field( + default=None, + description="Comma-separated list of traffic packages priced in Stars, e.g. '5:500,20:1500'", + ) + SUBSCRIPTION_NOTIFICATIONS_ENABLED: bool = Field(default=True) SUBSCRIPTION_NOTIFY_ON_EXPIRE: bool = Field(default=True) SUBSCRIPTION_NOTIFY_AFTER_EXPIRE: bool = Field(default=True) @@ -360,6 +369,62 @@ class Settings(BaseSettings): options[12] = self.STARS_PRICE_12_MONTHS return options + @computed_field + @property + def traffic_packages(self) -> Dict[float, float]: + """ + Mapping of traffic size in GB to price in the default currency. + """ + packages: Dict[float, float] = {} + raw = (self.TRAFFIC_PACKAGES or "").strip() + if not raw: + return packages + for part in raw.split(","): + chunk = part.strip() + if not chunk or ":" not in chunk: + continue + size_str, price_str = chunk.split(":", 1) + try: + size_gb = float(size_str.strip()) + price_val = float(price_str.strip()) + if size_gb > 0 and price_val >= 0: + packages[size_gb] = price_val + except ValueError: + logging.warning("Invalid TRAFFIC_PACKAGES entry skipped: %s", chunk) + continue + return packages + + @computed_field + @property + def stars_traffic_packages(self) -> Dict[float, int]: + """ + Mapping of traffic size in GB to price in Telegram Stars. + """ + packages: Dict[float, int] = {} + raw = (self.STARS_TRAFFIC_PACKAGES or "").strip() + if not raw: + return packages + for part in raw.split(","): + chunk = part.strip() + if not chunk or ":" not in chunk: + continue + size_str, price_str = chunk.split(":", 1) + try: + size_gb = float(size_str.strip()) + price_val = int(float(price_str.strip())) + if size_gb > 0 and price_val >= 0: + packages[size_gb] = price_val + except ValueError: + logging.warning("Invalid STARS_TRAFFIC_PACKAGES entry skipped: %s", chunk) + continue + return packages + + @computed_field + @property + def traffic_sale_mode(self) -> bool: + """When true, the bot sells traffic packages instead of time-based subscriptions.""" + return bool(self.traffic_packages) + def referral_bonus_inviter(self) -> Dict[int, int]: bonuses: Dict[int, int] = {} if self.REFERRAL_BONUS_DAYS_INVITER_1_MONTH is not None: diff --git a/locales/en.json b/locales/en.json index 1176664..7732f34 100644 --- a/locales/en.json +++ b/locales/en.json @@ -25,8 +25,11 @@ "error_displaying_menu": "Error displaying menu.", "main_menu_unknown_action": "Unknown action.", "select_subscription_period": "Select subscription period:", + "select_traffic_package": "Select a traffic package:", "subscribe_for_months_button": "{months} mo. - {price} {currency_symbol}", + "buy_traffic_package_button": "{traffic_gb} GB - {price} {currency_symbol}", "choose_payment_method": "Choose payment method:", + "choose_payment_method_traffic": "Choose how to pay for the traffic package:", "pay_button": "💳 Pay", "pay_with_yookassa_button": "💳 YooKassa", "yookassa_autopay_flow_prompt": "Auto-renew is enabled. Choose how you'd like to pay:", @@ -56,11 +59,15 @@ "my_devices_feature_disabled": "The My Devices section is currently unavailable.", "payment_description_subscription": "Subscription payment for {months} mo.", + "payment_description_traffic": "Traffic package {traffic_gb} GB", "payment_link_message": "To pay for {months} mo. subscription, click the button below:", + "payment_link_message_traffic": "To pay for a {traffic_gb} GB package, tap the button below:", "free_kassa_order_info": "Order #{order_id} from {date}", "payment_invoice_sent_message": "Telegram has sent the invoice above. Complete the payment or pick another method below.", + "payment_invoice_sent_message_traffic": "Invoice for {traffic_gb} GB sent above. Complete the payment or pick another method below.", "payment_successful_error_details": "✅ Payment succeeded, but an error occurred displaying details. Your subscription is active. Contact support if anything is wrong.", "payment_successful_full": "✅ Payment successful!\nYour {months}-month subscription is active until {end_date}.\n\nConnection key:\n{config_link}\n\nTo connect, open the link and follow the instructions 👇", + "payment_successful_traffic_full": "✅ Payment successful!\nYour {traffic_gb} GB package is active.\nValidity: {end_date}\n\nConnection key:\n{config_link}\n\nTo connect, open the link and follow the instructions 👇", "payment_successful_with_referral_bonus_full": "✅ Payment successful!\nYour {months}-month subscription (base end date: {base_end_date}) has been extended by {bonus_days} bonus days for referral from {inviter_name} and is now active until {final_end_date}.\n\nConnection key:\n{config_link}\n\nTo connect, open the link and follow the instructions 👇", "payment_failed": "❌ Payment failed or was cancelled. Please try again or contact support.", "config_link_not_available": "not available, contact support", @@ -80,6 +87,7 @@ "no_button": "No", "referral_program_info_new": "🎁 Referral Program\n\n📊 Your stats:\n👥 Friends invited: {invited_count}\n💳 Purchased subscription: {purchased_count}\n\n🔗 Your link:\n{referral_link}\n\n💰 Invitation bonuses:\n{bonus_details}\n\n📢 Share the link with friends and get bonuses!", "referral_bonus_per_period": "\n\n🎁 For a friend's {months}-month subscription:\n ➢ You: {inviter_bonus_days} days\n ➢ Friend: {referee_bonus_days} days", + "referral_not_available_for_traffic": "Referral bonuses are not available for traffic packages.", "referral_share_message_button": "📩 Message for friend", "referral_friend_message": "🚀 Hey! Try this VPN - it's fast, reliable and affordable!\n\n🎁 Use my link to get bonus days with your subscription!\n\n{referral_link}", "friend_placeholder": "friend", @@ -131,6 +139,7 @@ "admin_csv_provider": "Provider", "admin_csv_status": "Status", "admin_csv_description": "Description", + "admin_csv_units": "Months/GB", "admin_csv_months": "Months", "admin_csv_created_at": "Created At", "admin_csv_provider_payment_id": "Provider Payment ID", @@ -297,6 +306,7 @@ "log_open_referrer_profile_button": "👤 Referrer profile", "log_new_user_registration": "👤 New User\n\n🆔 ID: {user_id}\n👤 Name: {user_display}{referral_text}\n📅 Time: {timestamp}", "log_payment_received": "{provider_emoji} Payment Received\n\n👤 User: {user_display}\n💰 Amount: {amount} {currency}\n📅 Period: {months} mo.\n🏦 Provider: {payment_provider}\n🕐 Time: {timestamp}", + "log_payment_received_traffic": "{provider_emoji} Payment Received\n\n👤 User: {user_display}\n💰 Amount: {amount} {currency}\n🗂 Traffic: {traffic_gb} GB\n🏦 Provider: {payment_provider}\n🕐 Time: {timestamp}", "log_promo_activation": "🎁 Promo Code Activated\n\n👤 User: {user_display}\n🏷 Code: {promo_code}\n🎯 Bonus: +{bonus_days}d\n🕐 Time: {timestamp}", "log_trial_activation": "🆓 Trial Activated\n\n👤 User: {user_display}\n⏰ Valid until: {end_date}\n🕐 Time: {timestamp}", "log_panel_sync": "{status_emoji} Panel Synchronization\n\n📊 Status: {status}\n👥 Users processed: {users_processed}\n📋 Subscriptions synced: {subs_synced}\n🕐 Time: {timestamp}\n\n📝 Details:\n{details}", @@ -416,7 +426,11 @@ "admin_sync_no_telegram_id": "\n⚠️ Records without telegramId: {count}", "admin_sync_not_found_in_db": "\n❌ Not found in DB: {count}", "admin_payments_pagination_info": "📊 Showing {shown} of {total} payments (page {current_page}/{total_pages})", + "admin_payment_traffic_label": "🗂 Traffic: {traffic_gb} GB", + "admin_payment_months_label": "📅 Period: {months} mo.", "my_subscription_details": "🔐 My Subscription\n\n⏰ Status: {status}\n📅 Active until: {end_date}\n📆 Days left: {days_left}\n\n🔗 Configuration link:\n{config_link}\n\n📊 Traffic:\nLimit: {traffic_limit}\nUsed: {traffic_used}", + "my_traffic_details": "🔐 My Traffic\n\n⏰ Status: {status}\n📅 Valid until: {end_date}\n\n🔗 Configuration link:\n{config_link}\n\n📊 Traffic:\nLimit: {traffic_limit}\nUsed: {traffic_used}\nLeft: {traffic_left}", + "traffic_no_expiry": "no limit", "autorenew_enable_button": "🔄 Enable auto-renew", "autorenew_disable_button": "🛑 Disable auto-renew", "subscription_autorenew_updated": "Auto-renew settings updated.", @@ -439,6 +453,7 @@ "payment_method_tx_history_title": "📜 Transactions history", "payment_method_no_history": "No transactions history.", "subscription_purchase_title": "Subscription purchase for {months} mo.", + "traffic_purchase_title": "Traffic purchase {traffic_gb} GB", "autorenew_enable_requires_card": "Link a payment card in Payment Methods before enabling auto-renew.", "subscription_not_active": "You don't have an active subscription.", "error_service_unavailable": "Service unavailable. Please try again later.", diff --git a/locales/ru.json b/locales/ru.json index b3f669a..42b3e2b 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -25,8 +25,11 @@ "error_displaying_menu": "Ошибка отображения меню.", "main_menu_unknown_action": "Неизвестное действие.", "select_subscription_period": "Выберите срок подписки:", + "select_traffic_package": "Выберите пакет трафика:", "subscribe_for_months_button": "{months} мес. - {price} {currency_symbol}", + "buy_traffic_package_button": "{traffic_gb} ГБ - {price} {currency_symbol}", "choose_payment_method": "Выберите способ оплаты:", + "choose_payment_method_traffic": "Выберите способ оплаты пакета трафика:", "pay_button": "💳 Оплатить", "pay_with_yookassa_button": "💳 ЮKassa", "yookassa_autopay_flow_prompt": "Автопродление включено. Выберите, как оплатить подписку:", @@ -56,11 +59,15 @@ "cancel_button": "❌ Отмена", "payment_description_subscription": "Оплата подписки на {months} мес.", + "payment_description_traffic": "Пакет трафика {traffic_gb} ГБ", "payment_link_message": "Для оплаты подписки на {months} мес., нажмите кнопку ниже:", + "payment_link_message_traffic": "Для оплаты пакета {traffic_gb} ГБ нажмите кнопку ниже:", "free_kassa_order_info": "Заказ №{order_id} от {date}", "payment_invoice_sent_message": "Счёт Telegram Stars отправлен выше. Нажмите «Оплатить» или вернитесь к выбору способа ниже.", + "payment_invoice_sent_message_traffic": "Счет на пакет {traffic_gb} ГБ отправлен выше. Нажмите «Оплатить» или вернитесь к выбору способа ниже.", "payment_successful_error_details": "✅ Оплата прошла успешно, но возникла ошибка при отображении деталей. Ваша подписка активна. Свяжитесь с поддержкой, если что-то не так.", "payment_successful_full": "✅ Оплата прошла успешно!\nВаша подписка на {months} мес. активна до {end_date}.\n\nКлюч подключения:\n{config_link}\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇", + "payment_successful_traffic_full": "✅ Оплата прошла успешно!\nВаш пакет {traffic_gb} ГБ активирован.\nДата действия: {end_date}\n\nКлюч подключения:\n{config_link}\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇", "payment_successful_with_referral_bonus_full": "✅ Оплата прошла успешно!\nВаша подписка на {months} мес. (базовая дата окончания: {base_end_date}) продлена на {bonus_days} бонусных дней за приглашение от {inviter_name} и теперь активна до {final_end_date}.\n\nКлюч подключения:\n{config_link}\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇", "payment_failed": "❌ Оплата не удалась или была отменена. Пожалуйста, попробуйте еще раз или свяжитесь с поддержкой.", "config_link_not_available": "недоступна, обратитесь в поддержку", @@ -80,6 +87,7 @@ "no_button": "Нет", "referral_program_info_new": "🎁 Реферальная программа\n\n📊 Твоя статистика:\n👥 Приглашено друзей: {invited_count}\n💳 Купили подписку: {purchased_count}\n\n🔗 Твоя ссылка:\n{referral_link}\n\n💰 Бонусы за приглашения:\n{bonus_details}\n\n📢 Поделись ссылкой с друзьями и получай бонусы!", "referral_bonus_per_period": "\n\n🎁 За {months}-мес. подписку друга:\n ➢ Вы: {inviter_bonus_days} дн.\n ➢ Друг: {referee_bonus_days} дн.", + "referral_not_available_for_traffic": "Для пакетов трафика реферальные бонусы не начисляются.", "referral_share_message_button": "📩 Сообщение для друга", "referral_friend_message": "🚀 Привет! Попробуй этот VPN - быстрый, надёжный и доступный!\n\n🎁 По моей ссылке тебе дадут бонусные дни к подписке!\n\n{referral_link}", "friend_placeholder": "друг", @@ -131,6 +139,7 @@ "admin_csv_provider": "Платежная система", "admin_csv_status": "Статус", "admin_csv_description": "Описание", + "admin_csv_units": "Месяцы/ГБ", "admin_csv_months": "Месяцев", "admin_csv_created_at": "Дата создания", "admin_csv_provider_payment_id": "ID платежа в системе", @@ -297,6 +306,7 @@ "log_open_referrer_profile_button": "👤 Профиль пригласившего", "log_new_user_registration": "👤 Новый пользователь\n\n🆔 ID: {user_id}\n👤 Имя: {user_display}{referral_text}\n📅 Время: {timestamp}", "log_payment_received": "{provider_emoji} Получен платеж\n\n👤 Пользователь: {user_display}\n💰 Сумма: {amount} {currency}\n📅 Период: {months} мес.\n🏦 Провайдер: {payment_provider}\n🕐 Время: {timestamp}", + "log_payment_received_traffic": "{provider_emoji} Получен платеж\n\n👤 Пользователь: {user_display}\n💰 Сумма: {amount} {currency}\n🗂 Трафик: {traffic_gb} ГБ\n🏦 Провайдер: {payment_provider}\n🕐 Время: {timestamp}", "log_promo_activation": "🎁 Активирован промокод\n\n👤 Пользователь: {user_display}\n🏷 Код: {promo_code}\n🎯 Бонус: +{bonus_days} дн.\n🕐 Время: {timestamp}", "log_trial_activation": "🆓 Активирован триал\n\n👤 Пользователь: {user_display}\n⏰ Действует до: {end_date}\n🕐 Время: {timestamp}", "log_panel_sync": "{status_emoji} Синхронизация с панелью\n\n📊 Статус: {status}\n👥 Обработано пользователей: {users_processed}\n📋 Синхронизировано подписок: {subs_synced}\n🕐 Время: {timestamp}\n\n📝 Детали:\n{details}", @@ -416,7 +426,11 @@ "admin_sync_no_telegram_id": "\n⚠️ Записей без telegramId: {count}", "admin_sync_not_found_in_db": "\n❌ Не найдено в БД: {count}", "admin_payments_pagination_info": "📊 Показано {shown} из {total} платежей (стр. {current_page}/{total_pages})", + "admin_payment_traffic_label": "🗂 Трафик: {traffic_gb} ГБ", + "admin_payment_months_label": "📅 Период: {months} мес.", "my_subscription_details": "🔐 Моя подписка\n\n⏰ Статус: {status}\n📅 Действует до: {end_date}\n📆 Осталось дней: {days_left}\n\n🔗 Ссылка на конфигурацию:\n{config_link}\n\n📊 Трафик:\nЛимит: {traffic_limit}\nИспользовано: {traffic_used}", + "my_traffic_details": "🔐 Мой трафик\n\n⏰ Статус: {status}\n📅 Действует до: {end_date}\n\n🔗 Ссылка на конфигурацию:\n{config_link}\n\n📊 Трафик:\nЛимит: {traffic_limit}\nИспользовано: {traffic_used}\nОсталось: {traffic_left}", + "traffic_no_expiry": "без ограничения", "autorenew_enable_button": "🔄 Включить автопродление", "autorenew_disable_button": "🛑 Отключить автопродление", "subscription_autorenew_updated": "Настройки автопродления обновлены.", @@ -439,6 +453,7 @@ "payment_method_tx_history_title": "📜 История операций", "payment_method_no_history": "История операций отсутствует.", "subscription_purchase_title": "Покупка подписки на {months} мес.", + "traffic_purchase_title": "Покупка {traffic_gb} ГБ", "autorenew_enable_requires_card": "Прежде чем включать автоплатёж, привяжите карту в разделе «Способы оплаты».", "subscription_not_active": "У вас нет активной подписки.", "error_service_unavailable": "Сервис недоступен. Попробуйте позже.", From f00a72ad15bc4663d025d896f8d48f342bafbe61 Mon Sep 17 00:00:00 2001 From: machka pasla Date: Thu, 11 Dec 2025 12:59:39 +0300 Subject: [PATCH 10/16] yookassa fix --- bot/services/yookassa_service.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/bot/services/yookassa_service.py b/bot/services/yookassa_service.py index f837faf..e3f25d9 100644 --- a/bot/services/yookassa_service.py +++ b/bot/services/yookassa_service.py @@ -114,10 +114,12 @@ class YooKassaService: capture = False amount = max(amount, 1.00) builder.set_capture(capture) - builder.set_confirmation({ - "type": ConfirmationType.REDIRECT, - "return_url": self.return_url - }) + if not payment_method_id: + # Saved payment_method_id charges must omit confirmation per YooKassa API + builder.set_confirmation({ + "type": ConfirmationType.REDIRECT, + "return_url": self.return_url + }) builder.set_description(description) builder.set_metadata(metadata) if save_payment_method: From c96ebea077697e0eb6f504c54ffbdda3e02f0d98 Mon Sep 17 00:00:00 2001 From: machka pasla Date: Thu, 11 Dec 2025 13:51:54 +0300 Subject: [PATCH 11/16] upd --- bot/handlers/admin/ads.py | 4 +- bot/handlers/admin/broadcast.py | 10 - bot/handlers/admin/logs_admin.py | 28 ++- bot/handlers/admin/payments.py | 43 +++-- bot/handlers/admin/promo/bulk.py | 53 ++---- bot/handlers/admin/promo/create.py | 50 ++--- bot/handlers/admin/statistics.py | 56 +++--- bot/handlers/admin/user_management.py | 177 ++++++------------ bot/handlers/inline_mode.py | 55 +----- bot/handlers/user/subscription/core.py | 34 ++-- .../user/subscription/payment_methods.py | 20 +- .../subscription/payments_subscription.py | 56 ++++-- bot/keyboards/inline/admin_keyboards.py | 36 ++-- bot/services/notification_service.py | 39 ---- config/settings.py | 2 +- locales/ru.json | 2 +- 16 files changed, 258 insertions(+), 407 deletions(-) diff --git a/bot/handlers/admin/ads.py b/bot/handlers/admin/ads.py index 33bd539..ddd6027 100644 --- a/bot/handlers/admin/ads.py +++ b/bot/handlers/admin/ads.py @@ -178,7 +178,7 @@ async def ads_delete_cancel(callback: types.CallbackQuery, settings: Settings, i camp = await ad_dal.get_campaign_by_id(session, camp_id) if not camp: - await callback.answer(_("admin_ads_not_found", default="Кампания не найдена."), show_alert=True) + await callback.answer(_("admin_ads_not_found"), show_alert=True) return try: stats = await ad_dal.get_campaign_stats(session, camp_id) @@ -224,7 +224,7 @@ async def ads_delete_confirm(callback: types.CallbackQuery, settings: Settings, existed = await ad_dal.delete_campaign(session, camp_id) if not existed: - await callback.answer(_("admin_ads_not_found", default="Кампания не найдена."), show_alert=True) + await callback.answer(_("admin_ads_not_found"), show_alert=True) return await session.commit() diff --git a/bot/handlers/admin/broadcast.py b/bot/handlers/admin/broadcast.py index be425c9..221886b 100644 --- a/bot/handlers/admin/broadcast.py +++ b/bot/handlers/admin/broadcast.py @@ -122,7 +122,6 @@ async def process_broadcast_message_handler( await message.answer( _( "admin_broadcast_invalid_html", - default="❌ Некорректный HTML в сообщении. Пожалуйста, отправьте корректный HTML (поддерживаются теги Telegram) или уберите теги.\nОшибка: {error}", error=str(e), ) ) @@ -347,15 +346,6 @@ async def confirm_broadcast_callback_handler( total_failed = failed_count + dynamic_failed return _( "broadcast_queue_result", - default=( - "🚀 Рассылка поставлена в очередь!\n" - "📤 В очередь добавлено: {sent_count}\n" - "❌ Ошибок: {failed_count}\n\n" - "📊 Статус очередей:\n" - "👥 Очередь пользователей: {user_queue_size} сообщений\n" - "📢 Очередь групп: {group_queue_size} сообщений\n\n" - "ℹ️ Сообщения будут отправлены автоматически с соблюдением лимитов Telegram." - ), sent_count=sent_count, failed_count=total_failed, user_queue_size=stats["user_queue_size"], diff --git a/bot/handlers/admin/logs_admin.py b/bot/handlers/admin/logs_admin.py index f12034b..7fa37cb 100644 --- a/bot/handlers/admin/logs_admin.py +++ b/bot/handlers/admin/logs_admin.py @@ -338,8 +338,7 @@ async def export_logs_csv_handler(callback: types.CallbackQuery, _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) await callback.answer(_( - "admin_logs_csv_export_started", - default="🔄 Начинаю экспорт логов в CSV..." + "admin_logs_csv_export_started" )) try: @@ -349,8 +348,7 @@ async def export_logs_csv_handler(callback: types.CallbackQuery, if not logs_models: await callback.message.answer(_( - "admin_logs_csv_no_data", - default="❌ Нет данных для экспорта" + "admin_logs_csv_no_data" )) return @@ -360,16 +358,16 @@ async def export_logs_csv_handler(callback: types.CallbackQuery, # Write header headers = [ - _("admin_csv_header_log_id", default="Log ID"), - _("admin_csv_header_timestamp", default="Timestamp"), - _("admin_csv_header_user_id", default="User ID"), - _("admin_csv_header_telegram_username", default="Telegram Username"), - _("admin_csv_header_telegram_first_name", default="Telegram First Name"), - _("admin_csv_header_event_type", default="Event Type"), - _("admin_csv_header_content", default="Content"), - _("admin_csv_header_is_admin_event", default="Is Admin Event"), - _("admin_csv_header_target_user_id", default="Target User ID"), - _("admin_csv_header_raw_update_preview", default="Raw Update Preview") + _("admin_csv_header_log_id"), + _("admin_csv_header_timestamp"), + _("admin_csv_header_user_id"), + _("admin_csv_header_telegram_username"), + _("admin_csv_header_telegram_first_name"), + _("admin_csv_header_event_type"), + _("admin_csv_header_content"), + _("admin_csv_header_is_admin_event"), + _("admin_csv_header_target_user_id"), + _("admin_csv_header_raw_update_preview") ] csv_writer.writerow(headers) @@ -414,7 +412,6 @@ async def export_logs_csv_handler(callback: types.CallbackQuery, csv_file, caption=_( "admin_logs_csv_export_success", - default="✅ Экспорт логов завершен!\n\n📊 Записей: {count}\n📅 Дата экспорта: {date}", count=len(logs_models), date=now.strftime('%Y-%m-%d %H:%M:%S') ) @@ -424,6 +421,5 @@ async def export_logs_csv_handler(callback: types.CallbackQuery, logging.error(f"Error exporting logs to CSV: {e}", exc_info=True) await callback.message.answer(_( "admin_logs_csv_export_failed", - default="❌ Ошибка при экспорте логов: {error}", error=str(e) )) diff --git a/bot/handlers/admin/payments.py b/bot/handlers/admin/payments.py index 9f29d8c..f7ba8eb 100644 --- a/bot/handlers/admin/payments.py +++ b/bot/handlers/admin/payments.py @@ -71,9 +71,9 @@ def format_payment_text(payment: Payment, i18n: JsonI18n, lang: str, settings: S if traffic_mode: traffic_val = payment.subscription_duration_months or 0 traffic_display = str(int(traffic_val)) if float(traffic_val).is_integer() else f"{traffic_val:g}" - period_line = _("admin_payment_traffic_label", default="🗂 Трафик: {traffic_gb} GB", traffic_gb=traffic_display) + period_line = _("admin_payment_traffic_label", traffic_gb=traffic_display) else: - period_line = _("admin_payment_months_label", default="📅 Период: {months} мес.", months=payment.subscription_duration_months or 0) + period_line = _("admin_payment_months_label", months=payment.subscription_duration_months or 0) return ( f"{status_emoji} {payment.amount} {payment.currency}\n" @@ -102,7 +102,7 @@ async def view_payments_handler(callback: types.CallbackQuery, i18n_data: dict, if not payments and page == 0: await callback.message.edit_text( - _("admin_no_payments_found", default="Платежи не найдены."), + _("admin_no_payments_found"), reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), parse_mode="HTML" ) @@ -110,7 +110,7 @@ async def view_payments_handler(callback: types.CallbackQuery, i18n_data: dict, return # Format payments text - text_parts = [_("admin_payments_header", default="💰 Все платежи")] + text_parts = [_("admin_payments_header")] text_parts.append(_("admin_payments_pagination_info", shown=len(payments), total=total_count, @@ -140,11 +140,11 @@ async def view_payments_handler(callback: types.CallbackQuery, i18n_data: dict, # Export and refresh buttons builder.row( InlineKeyboardButton( - text=_("admin_export_payments_csv", default="📊 Экспорт CSV"), + text=_("admin_export_payments_csv"), callback_data="payments_export_csv" ), InlineKeyboardButton( - text=_("admin_refresh_payments", default="🔄 Обновить"), + text=_("admin_refresh_payments"), callback_data=f"payments_page:{page}" ) ) @@ -191,7 +191,7 @@ async def export_payments_csv_handler(callback: types.CallbackQuery, i18n_data: if not all_payments: await callback.answer( - _("admin_no_payments_to_export", default="Нет платежей для экспорта."), + _("admin_no_payments_to_export"), show_alert=True ) return @@ -202,18 +202,18 @@ async def export_payments_csv_handler(callback: types.CallbackQuery, i18n_data: # Write header writer.writerow([ - _("admin_csv_payment_id", default="ID"), - _("admin_csv_user_id", default="User ID"), - _("admin_csv_username", default="Username"), - _("admin_csv_first_name", default="First Name"), - _("admin_csv_amount", default="Amount"), - _("admin_csv_currency", default="Currency"), - _("admin_csv_provider", default="Provider"), - _("admin_csv_status", default="Status"), - _("admin_csv_description", default="Description"), - _("admin_csv_units", default="Months/GB"), - _("admin_csv_created_at", default="Created At"), - _("admin_csv_provider_payment_id", default="Provider Payment ID") + _("admin_csv_payment_id"), + _("admin_csv_user_id"), + _("admin_csv_username"), + _("admin_csv_first_name"), + _("admin_csv_amount"), + _("admin_csv_currency"), + _("admin_csv_provider"), + _("admin_csv_status"), + _("admin_csv_description"), + _("admin_csv_units"), + _("admin_csv_created_at"), + _("admin_csv_provider_payment_id") ]) traffic_mode = getattr(settings, "traffic_sale_mode", False) @@ -255,13 +255,12 @@ async def export_payments_csv_handler(callback: types.CallbackQuery, i18n_data: await callback.message.reply_document( document=file, - caption=_("admin_payments_export_success", - default="📊 Payments export completed!\nTotal records: {count}", + caption=_("admin_payments_export_success", count=len(all_payments)) ) await callback.answer( - _("admin_export_sent", default="File sent!"), + _("admin_export_sent"), show_alert=False ) diff --git a/bot/handlers/admin/promo/bulk.py b/bot/handlers/admin/promo/bulk.py index 1b6b302..7013275 100644 --- a/bot/handlers/admin/promo/bulk.py +++ b/bot/handlers/admin/promo/bulk.py @@ -34,8 +34,7 @@ async def create_bulk_promo_prompt_handler(callback: types.CallbackQuery, # Step 1: Ask for quantity prompt_text = _( - "admin_bulk_promo_step1_quantity", - default="🎟 Массовое создание промокодов\n\nШаг 1 из 4: Количество\n\nВведите количество промокодов для создания (1-100):" + "admin_bulk_promo_step1_quantity" ) try: @@ -77,8 +76,7 @@ async def process_bulk_promo_quantity_handler(message: types.Message, quantity = int(message.text.strip()) if not (1 <= quantity <= 100): await message.answer(_( - "admin_bulk_promo_invalid_quantity", - default="❌ Количество промокодов должно быть от 1 до 100" + "admin_bulk_promo_invalid_quantity" )) return @@ -87,7 +85,6 @@ async def process_bulk_promo_quantity_handler(message: types.Message, # Step 2: Ask for bonus days prompt_text = _( "admin_bulk_promo_step2_bonus_days", - default="🎟 Массовое создание промокодов\n\nШаг 2 из 4: Бонусные дни\n\nКоличество: {quantity}\n\nВведите количество бонусных дней для каждого промокода (1-365):", quantity=quantity ) @@ -100,8 +97,7 @@ async def process_bulk_promo_quantity_handler(message: types.Message, except ValueError: await message.answer(_( - "admin_promo_invalid_number", - default="❌ Введите корректное число" + "admin_promo_invalid_number" )) except Exception as e: logging.error(f"Error processing bulk promo quantity: {e}") @@ -125,8 +121,7 @@ async def process_bulk_promo_bonus_days_handler(message: types.Message, bonus_days = int(message.text.strip()) if not (1 <= bonus_days <= 365): await message.answer(_( - "admin_promo_invalid_bonus_days", - default="❌ Количество бонусных дней должно быть от 1 до 365" + "admin_promo_invalid_bonus_days" )) return @@ -136,7 +131,6 @@ async def process_bulk_promo_bonus_days_handler(message: types.Message, data = await state.get_data() prompt_text = _( "admin_bulk_promo_step3_max_activations", - default="🎟 Массовое создание промокодов\n\nШаг 3 из 4: Лимит активаций\n\nКоличество: {quantity}\nБонусные дни: {bonus_days}\n\nВведите максимальное количество активаций для каждого промокода (1-10000):", quantity=data.get("quantity"), bonus_days=bonus_days ) @@ -150,8 +144,7 @@ async def process_bulk_promo_bonus_days_handler(message: types.Message, except ValueError: await message.answer(_( - "admin_promo_invalid_number", - default="❌ Введите корректное число" + "admin_promo_invalid_number" )) except Exception as e: logging.error(f"Error processing bulk promo bonus days: {e}") @@ -175,8 +168,7 @@ async def process_bulk_promo_max_activations_handler(message: types.Message, max_activations = int(message.text.strip()) if not (1 <= max_activations <= 10000): await message.answer(_( - "admin_promo_invalid_max_activations", - default="❌ Максимальное количество активаций должно быть от 1 до 10000" + "admin_promo_invalid_max_activations" )) return @@ -186,7 +178,6 @@ async def process_bulk_promo_max_activations_handler(message: types.Message, data = await state.get_data() prompt_text = _( "admin_bulk_promo_step4_validity", - default="🎟 Массовое создание промокодов\n\nШаг 4 из 4: Срок действия\n\nКоличество: {quantity}\nБонусные дни: {bonus_days}\nМакс. активаций: {max_activations}\n\nВыберите срок действия промокодов:", quantity=data.get("quantity"), bonus_days=data.get("bonus_days"), max_activations=max_activations @@ -196,19 +187,19 @@ async def process_bulk_promo_max_activations_handler(message: types.Message, builder = InlineKeyboardBuilder() builder.row( InlineKeyboardButton( - text=_("admin_promo_unlimited_validity", default="🔄 Без ограничений"), + text=_("admin_promo_unlimited_validity"), callback_data="bulk_promo_unlimited_validity" ) ) builder.row( InlineKeyboardButton( - text=_("admin_promo_set_validity_days", default="📅 Указать дни"), + text=_("admin_promo_set_validity_days"), callback_data="bulk_promo_set_validity" ) ) builder.row( InlineKeyboardButton( - text=_("admin_back_to_panel", default="🔙 В админ панель"), + text=_("admin_back_to_panel"), callback_data="admin_action:main" ) ) @@ -222,8 +213,7 @@ async def process_bulk_promo_max_activations_handler(message: types.Message, except ValueError: await message.answer(_( - "admin_promo_invalid_number", - default="❌ Введите корректное число" + "admin_promo_invalid_number" )) except Exception as e: logging.error(f"Error processing bulk promo max activations: {e}") @@ -257,7 +247,6 @@ async def process_bulk_promo_set_validity(callback: types.CallbackQuery, data = await state.get_data() prompt_text = _( "admin_bulk_promo_enter_validity_days", - default="🎟 Массовое создание промокодов\n\nШаг 4 из 4: Срок действия\n\nКоличество: {quantity}\nБонусные дни: {bonus_days}\nМакс. активаций: {max_activations}\n\nВведите количество дней действия промокодов (1-365):", quantity=data.get("quantity"), bonus_days=data.get("bonus_days"), max_activations=data.get("max_activations") @@ -296,8 +285,7 @@ async def process_bulk_promo_validity_days_handler(message: types.Message, validity_days = int(message.text.strip()) if not (1 <= validity_days <= 365): await message.answer(_( - "admin_promo_invalid_validity_days", - default="❌ Срок действия должен быть от 1 до 365 дней" + "admin_promo_invalid_validity_days" )) return @@ -306,8 +294,7 @@ async def process_bulk_promo_validity_days_handler(message: types.Message, except ValueError: await message.answer(_( - "admin_promo_invalid_number", - default="❌ Введите корректное число" + "admin_promo_invalid_number" )) except Exception as e: logging.error(f"Error processing bulk promo validity days: {e}") @@ -333,7 +320,6 @@ async def create_bulk_promo_codes_final(callback_or_message, # Show progress message progress_text = _( "admin_bulk_promo_creating", - default="🔄 Создание {quantity} промокодов...", quantity=quantity ) @@ -395,12 +381,10 @@ async def create_bulk_promo_codes_final(callback_or_message, # Success message success_lines = [ _( - "admin_bulk_promo_created_title", - default="✅ Массовое создание завершено!\n" + "admin_bulk_promo_created_title" ), _( "admin_bulk_promo_created_stats", - default="📊 Создано: {created} из {total}", created=len(created_codes), total=quantity ) @@ -409,14 +393,11 @@ async def create_bulk_promo_codes_final(callback_or_message, if data.get("validity_days"): validity_text = f"{data['validity_days']} дней" else: - validity_text = _("admin_promo_unlimited", default="Без ограничений") + validity_text = _("admin_promo_unlimited") success_lines.append( _( "admin_bulk_promo_settings", - default="🎁 Бонусные дни: {bonus_days}\n" - "📊 Макс. активаций: {max_activations}\n" - "⏰ Срок действия: {validity}", bonus_days=data["bonus_days"], max_activations=data["max_activations"], validity=validity_text @@ -521,7 +502,7 @@ async def create_bulk_promo_codes_final(callback_or_message, except Exception as e: logging.error(f"Error creating bulk promo codes: {e}") - error_text = _("error_occurred_try_again", default="❌ Произошла ошибка. Попробуйте снова.") + error_text = _("error_occurred_try_again") if hasattr(callback_or_message, 'message'): # CallbackQuery await callback_or_message.message.answer(error_text) @@ -564,5 +545,5 @@ async def cancel_bulk_promo_creation_state_to_menu(callback: types.CallbackQuery reply_markup=get_admin_panel_keyboard(i18n, current_lang, settings) ) - await callback.answer(_("admin_bulk_promo_creation_cancelled", default="Массовое создание промокодов отменено")) - await state.clear() \ No newline at end of file + await callback.answer(_("admin_bulk_promo_creation_cancelled")) + await state.clear() diff --git a/bot/handlers/admin/promo/create.py b/bot/handlers/admin/promo/create.py index 6044913..e26cea6 100644 --- a/bot/handlers/admin/promo/create.py +++ b/bot/handlers/admin/promo/create.py @@ -30,8 +30,7 @@ async def create_promo_prompt_handler(callback: types.CallbackQuery, # Step 1: Ask for promo code prompt_text = _( - "admin_promo_step1_code", - default="🎟 Создание промокода\n\nШаг 1 из 4: Код промокода\n\nВведите код промокода (3-30 символов, только буквы и цифры):" + "admin_promo_step1_code" ) try: @@ -68,8 +67,7 @@ async def process_promo_code_handler(message: types.Message, code_str = message.text.strip().upper() if not (3 <= len(code_str) <= 30 and code_str.isalnum()): await message.answer(_( - "admin_promo_invalid_code_format", - default="❌ Код промокода должен содержать 3-30 символов (только буквы и цифры)" + "admin_promo_invalid_code_format" )) return @@ -77,8 +75,7 @@ async def process_promo_code_handler(message: types.Message, existing_promo = await promo_code_dal.get_promo_code_by_code(session, code_str) if existing_promo: await message.answer(_( - "admin_promo_code_already_exists", - default="❌ Промокод с таким кодом уже существует" + "admin_promo_code_already_exists" )) return @@ -87,7 +84,6 @@ async def process_promo_code_handler(message: types.Message, # Step 2: Ask for bonus days prompt_text = _( "admin_promo_step2_bonus_days", - default="🎟 Создание промокода\n\nШаг 2 из 4: Бонусные дни\n\nКод: {code}\n\nВведите количество бонусных дней (1-365):", code=code_str ) @@ -120,8 +116,7 @@ async def process_promo_bonus_days_handler(message: types.Message, bonus_days = int(message.text.strip()) if not (1 <= bonus_days <= 365): await message.answer(_( - "admin_promo_invalid_bonus_days", - default="❌ Количество бонусных дней должно быть от 1 до 365" + "admin_promo_invalid_bonus_days" )) return @@ -131,7 +126,6 @@ async def process_promo_bonus_days_handler(message: types.Message, data = await state.get_data() prompt_text = _( "admin_promo_step3_max_activations", - default="🎟 Создание промокода\n\nШаг 3 из 4: Лимит активаций\n\nКод: {code}\nБонусные дни: {bonus_days}\n\nВведите максимальное количество активаций (1-10000):", code=data.get("promo_code"), bonus_days=bonus_days ) @@ -145,8 +139,7 @@ async def process_promo_bonus_days_handler(message: types.Message, except ValueError: await message.answer(_( - "admin_promo_invalid_number", - default="❌ Введите корректное число" + "admin_promo_invalid_number" )) except Exception as e: logging.error(f"Error processing promo bonus days: {e}") @@ -170,8 +163,7 @@ async def process_promo_max_activations_handler(message: types.Message, max_activations = int(message.text.strip()) if not (1 <= max_activations <= 10000): await message.answer(_( - "admin_promo_invalid_max_activations", - default="❌ Максимальное количество активаций должно быть от 1 до 10000" + "admin_promo_invalid_max_activations" )) return @@ -181,7 +173,6 @@ async def process_promo_max_activations_handler(message: types.Message, data = await state.get_data() prompt_text = _( "admin_promo_step4_validity", - default="🎟 Создание промокода\n\nШаг 4 из 4: Срок действия\n\nКод: {code}\nБонусные дни: {bonus_days}\nМакс. активаций: {max_activations}\n\nВыберите срок действия промокода:", code=data.get("promo_code"), bonus_days=data.get("bonus_days"), max_activations=max_activations @@ -191,19 +182,19 @@ async def process_promo_max_activations_handler(message: types.Message, builder = InlineKeyboardBuilder() builder.row( InlineKeyboardButton( - text=_("admin_promo_unlimited_validity", default="🔄 Без ограничений"), + text=_("admin_promo_unlimited_validity"), callback_data="promo_unlimited_validity" ) ) builder.row( InlineKeyboardButton( - text=_("admin_promo_set_validity_days", default="📅 Указать дни"), + text=_("admin_promo_set_validity_days"), callback_data="promo_set_validity" ) ) builder.row( InlineKeyboardButton( - text=_("admin_back_to_panel", default="🔙 В админ панель"), + text=_("admin_back_to_panel"), callback_data="admin_action:main" ) ) @@ -217,8 +208,7 @@ async def process_promo_max_activations_handler(message: types.Message, except ValueError: await message.answer(_( - "admin_promo_invalid_number", - default="❌ Введите корректное число" + "admin_promo_invalid_number" )) except Exception as e: logging.error(f"Error processing promo max activations: {e}") @@ -252,7 +242,6 @@ async def process_promo_set_validity(callback: types.CallbackQuery, data = await state.get_data() prompt_text = _( "admin_promo_enter_validity_days", - default="🎟 Создание промокода\n\nШаг 4 из 4: Срок действия\n\nКод: {code}\nБонусные дни: {bonus_days}\nМакс. активаций: {max_activations}\n\nВведите количество дней действия промокода (1-365):", code=data.get("promo_code"), bonus_days=data.get("bonus_days"), max_activations=data.get("max_activations") @@ -291,8 +280,7 @@ async def process_promo_validity_days_handler(message: types.Message, validity_days = int(message.text.strip()) if not (1 <= validity_days <= 365): await message.answer(_( - "admin_promo_invalid_validity_days", - default="❌ Срок действия должен быть от 1 до 365 дней" + "admin_promo_invalid_validity_days" )) return @@ -301,8 +289,7 @@ async def process_promo_validity_days_handler(message: types.Message, except ValueError: await message.answer(_( - "admin_promo_invalid_number", - default="❌ Введите корректное число" + "admin_promo_invalid_number" )) except Exception as e: logging.error(f"Error processing promo validity days: {e}") @@ -349,14 +336,9 @@ async def create_promo_code_final(callback_or_message, logging.info(f"Promo code '{data['promo_code']}' created with ID {created_promo.promo_code_id}") # Success message - valid_until_str = _("admin_promo_unlimited", default="Без ограничений") if not data.get("validity_days") else f"{data['validity_days']} дней" + valid_until_str = _("admin_promo_unlimited") if not data.get("validity_days") else f"{data['validity_days']} дней" success_text = _( "admin_promo_created_success", - default="✅ Промокод успешно создан!\n\n" - "🎟 Код: {code}\n" - "🎁 Бонусные дни: {bonus_days}\n" - "📊 Макс. активаций: {max_activations}\n" - "⏰ Срок действия: {valid_until_str}", code=data["promo_code"], bonus_days=data["bonus_days"], max_activations=data["max_activations"], @@ -388,7 +370,7 @@ async def create_promo_code_final(callback_or_message, except Exception as e: logging.error(f"Error creating promo code: {e}") - error_text = _("error_occurred_try_again", default="❌ Произошла ошибка. Попробуйте снова.") + error_text = _("error_occurred_try_again") if hasattr(callback_or_message, 'message'): # CallbackQuery await callback_or_message.message.answer(error_text) @@ -432,5 +414,5 @@ async def cancel_promo_creation_state_to_menu(callback: types.CallbackQuery, reply_markup=get_admin_panel_keyboard(i18n, current_lang, settings) ) - await callback.answer(_("admin_promo_creation_cancelled", default="Создание промокода отменено")) - await state.clear() \ No newline at end of file + await callback.answer(_("admin_promo_creation_cancelled")) + await state.clear() diff --git a/bot/handlers/admin/statistics.py b/bot/handlers/admin/statistics.py index ac58d30..f136b9c 100644 --- a/bot/handlers/admin/statistics.py +++ b/bot/handlers/admin/statistics.py @@ -34,30 +34,30 @@ async def show_statistics_handler(callback: types.CallbackQuery, user_stats = await user_dal.get_enhanced_user_statistics(session) stats_text_parts.append( - f"\n👥 {_('admin_enhanced_users_stats_header', default='Пользователи')}" + f"\n👥 {_('admin_enhanced_users_stats_header')}" ) stats_text_parts.append( - f"📊 {_('admin_user_stats_total_label', default='Всего')}: {user_stats['total_users']}" + f"📊 {_('admin_user_stats_total_label')}: {user_stats['total_users']}" ) # Removed: Active today moved to panel stats stats_text_parts.append( - f"💳 {_('admin_user_stats_paid_subs_label', default='С платной подпиской')}: {user_stats['paid_subscriptions']}" + f"💳 {_('admin_user_stats_paid_subs_label')}: {user_stats['paid_subscriptions']}" ) stats_text_parts.append( - f"🆓 {_('admin_user_stats_trial_label', default='На пробном периоде')}: {user_stats['trial_users']}" + f"🆓 {_('admin_user_stats_trial_label')}: {user_stats['trial_users']}" ) stats_text_parts.append( - f"😴 {_('admin_user_stats_inactive_label', default='Неактивных')}: {user_stats['inactive_users']}" + f"😴 {_('admin_user_stats_inactive_label')}: {user_stats['inactive_users']}" ) stats_text_parts.append( - f"🚫 {_('admin_user_stats_banned_label', default='Заблокированных')}: {user_stats['banned_users']}" + f"🚫 {_('admin_user_stats_banned_label')}: {user_stats['banned_users']}" ) stats_text_parts.append( - f"🎁 {_('admin_user_stats_referral_label', default='Привлечено по реферальной программе')}: {user_stats['referral_users']}" + f"🎁 {_('admin_user_stats_referral_label')}: {user_stats['referral_users']}" ) # Panel Statistics - moved above financial - stats_text_parts.append(f"\n🖥 {_('admin_panel_stats_header', default='Статистика панели')}") + stats_text_parts.append(f"\n🖥 {_('admin_panel_stats_header')}") try: async with PanelApiService(settings) as panel_service: @@ -80,12 +80,12 @@ async def show_statistics_handler(callback: types.CallbackQuery, total_users = users.get('totalUsers', 0) online_now = online_stats.get('onlineNow', 0) - stats_text_parts.append(f"🟢 {_('admin_panel_online_label', default='Онлайн')}: {online_now}") - stats_text_parts.append(f"📊 {_('admin_panel_active_label', default='Активных')}: {active_users}") - stats_text_parts.append(f"🔴 {_('admin_panel_disabled_label', default='Отключенных')}: {disabled_users}") - stats_text_parts.append(f"⏰ {_('admin_panel_expired_label', default='Истекшие')}: {expired_users}") - stats_text_parts.append(f"⚠️ {_('admin_panel_limited_label', default='Ограниченные')}: {limited_users}") - stats_text_parts.append(f"👥 {_('admin_panel_total_users_label', default='Всего пользователей')}: {total_users}") + stats_text_parts.append(f"🟢 {_('admin_panel_online_label')}: {online_now}") + stats_text_parts.append(f"📊 {_('admin_panel_active_label')}: {active_users}") + stats_text_parts.append(f"🔴 {_('admin_panel_disabled_label')}: {disabled_users}") + stats_text_parts.append(f"⏰ {_('admin_panel_expired_label')}: {expired_users}") + stats_text_parts.append(f"⚠️ {_('admin_panel_limited_label')}: {limited_users}") + stats_text_parts.append(f"👥 {_('admin_panel_total_users_label')}: {total_users}") # System resources memory = system_stats.get('memory', {}) @@ -93,9 +93,9 @@ async def show_statistics_handler(callback: types.CallbackQuery, memory_total = memory.get('total', 1) memory_used = memory.get('used', 0) memory_usage = (memory_used / memory_total) * 100 if memory_total > 0 else 0 - stats_text_parts.append(f"💾 {_('admin_panel_memory_usage_label', default='Использование RAM')}: {memory_usage:.1f}%") + stats_text_parts.append(f"💾 {_('admin_panel_memory_usage_label')}: {memory_usage:.1f}%") else: - stats_text_parts.append(f"⚠️ {_('admin_panel_system_stats_error', default='Ошибка получения системной статистики')}") + stats_text_parts.append(f"⚠️ {_('admin_panel_system_stats_error')}") # Bandwidth stats if bandwidth_stats: @@ -107,13 +107,13 @@ async def show_statistics_handler(callback: types.CallbackQuery, if week_traffic: week_total = week_traffic.get('current', '0 B') - stats_text_parts.append(f"📊 {_('admin_panel_traffic_week_label', default='Трафик за неделю')}: {week_total}") + stats_text_parts.append(f"📊 {_('admin_panel_traffic_week_label')}: {week_total}") if month_traffic: month_total = month_traffic.get('current', '0 B') - stats_text_parts.append(f"📊 {_('admin_panel_traffic_month_label', default='Трафик за месяц')}: {month_total}") + stats_text_parts.append(f"📊 {_('admin_panel_traffic_month_label')}: {month_total}") else: - stats_text_parts.append(f"⚠️ {_('admin_panel_bandwidth_stats_error', default='Ошибка получения статистики трафика')}") + stats_text_parts.append(f"⚠️ {_('admin_panel_bandwidth_stats_error')}") # Nodes stats if nodes_stats and 'lastSevenDays' in nodes_stats: @@ -124,35 +124,35 @@ async def show_statistics_handler(callback: types.CallbackQuery, unique_nodes.add(node_data.get('nodeName', '')) total_nodes_count = len(unique_nodes) # Assume all nodes are active since we don't have status info - stats_text_parts.append(f"🔗 {_('admin_panel_nodes_label', default='Активных нод')}: {total_nodes_count}/{total_nodes_count}") + stats_text_parts.append(f"🔗 {_('admin_panel_nodes_label')}: {total_nodes_count}/{total_nodes_count}") else: # Use nodes total from system stats as fallback nodes_info = system_stats.get('nodes', {}) if system_stats else {} total_online = nodes_info.get('totalOnline', 0) - stats_text_parts.append(f"🔗 {_('admin_panel_nodes_label', default='Активных нод')}: {total_online}") + stats_text_parts.append(f"🔗 {_('admin_panel_nodes_label')}: {total_online}") except Exception as e: logging.error(f"Failed to fetch panel statistics: {e}", exc_info=True) - stats_text_parts.append(f"❌ {_('admin_panel_stats_fetch_error', default='Ошибка получения данных с панели')}") - stats_text_parts.append(f"⚠️ {_('admin_panel_stats_error_details', default='Детали')}: {str(e)}") + stats_text_parts.append(f"❌ {_('admin_panel_stats_fetch_error')}") + stats_text_parts.append(f"⚠️ {_('admin_panel_stats_error_details')}: {str(e)}") # Financial statistics financial_stats = await payment_dal.get_financial_statistics(session) stats_text_parts.append( - f"\n💰 {_('admin_financial_stats_header', default='Финансовая статистика')}" + f"\n💰 {_('admin_financial_stats_header')}" ) stats_text_parts.append( - f"📅 {_('admin_financial_today_label', default='За сегодня')}: {financial_stats['today_revenue']:.2f} RUB ({financial_stats['today_payments_count']} {_('admin_financial_payments_label', default='платежей')})" + f"📅 {_('admin_financial_today_label')}: {financial_stats['today_revenue']:.2f} RUB ({financial_stats['today_payments_count']} {_('admin_financial_payments_label')})" ) stats_text_parts.append( - f"📅 {_('admin_financial_week_label', default='За неделю')}: {financial_stats['week_revenue']:.2f} RUB" + f"📅 {_('admin_financial_week_label')}: {financial_stats['week_revenue']:.2f} RUB" ) stats_text_parts.append( - f"📅 {_('admin_financial_month_label', default='За месяц')}: {financial_stats['month_revenue']:.2f} RUB" + f"📅 {_('admin_financial_month_label')}: {financial_stats['month_revenue']:.2f} RUB" ) stats_text_parts.append( - f"🏆 {_('admin_financial_all_time_label', default='За все время')}: {financial_stats['all_time_revenue']:.2f} RUB" + f"🏆 {_('admin_financial_all_time_label')}: {financial_stats['all_time_revenue']:.2f} RUB" ) last_payments_models: List[ diff --git a/bot/handlers/admin/user_management.py b/bot/handlers/admin/user_management.py index 07f092d..4ecf6e1 100644 --- a/bot/handlers/admin/user_management.py +++ b/bot/handlers/admin/user_management.py @@ -56,7 +56,6 @@ async def users_list_handler(callback: types.CallbackQuery, # Format message header_text = _( "admin_users_list_header", - default="👥 Список пользователей\n\nСтраница {current}/{total} ({total_users} пользователей)", current=page + 1, total=total_pages, total_users=total_users @@ -88,8 +87,7 @@ async def user_search_prompt_handler(callback: types.CallbackQuery, _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) prompt_text = _( - "admin_user_management_prompt", - default="👤 Управление пользователями\n\nВведите ID пользователя или @username для поиска:" + "admin_user_management_prompt" ) try: @@ -116,56 +114,54 @@ def get_user_card_keyboard(user_id: int, i18n_instance, lang: str, # Row 1: Trial and Subscription actions builder.button( - text=_(key="admin_user_reset_trial_button", default="🔄 Сбросить триал"), + text=_(key="admin_user_reset_trial_button"), callback_data=f"user_action:reset_trial:{user_id}" ) builder.button( - text=_(key="admin_user_add_subscription_button", default="➕ Добавить дни"), + text=_(key="admin_user_add_subscription_button"), callback_data=f"user_action:add_subscription:{user_id}" ) # Row 2: Block/Unblock and Message builder.button( - text=_(key="admin_user_toggle_ban_button", default="🚫 Заблокировать/Разблокировать"), + text=_(key="admin_user_toggle_ban_button"), callback_data=f"user_action:toggle_ban:{user_id}" ) builder.button( - text=_(key="admin_user_send_message_button", default="✉️ Отправить сообщение"), + text=_(key="admin_user_send_message_button"), callback_data=f"user_action:send_message:{user_id}" ) # Row 3: View actions builder.button( - text=_(key="admin_user_view_logs_button", default="📜 Действия пользователя"), + text=_(key="admin_user_view_logs_button"), callback_data=f"user_action:view_logs:{user_id}" ) builder.button( - text=_(key="admin_user_refresh_button", default="🔄 Обновить"), + text=_(key="admin_user_refresh_button"), callback_data=f"user_action:refresh:{user_id}" ) # Row 4: Quick links builder.button( - text=_(key="user_card_open_profile_button", - default="👤 Открыть профиль"), + text=_(key="user_card_open_profile_button"), url=f"tg://user?id={user_id}" ) if referrer_id: builder.button( - text=_(key="user_card_open_referrer_profile_button", - default="👤 Открыть профиль пригласившего"), + text=_(key="user_card_open_referrer_profile_button"), url=f"tg://user?id={referrer_id}" ) # Row 5: Destructive action builder.button( - text=_(key="admin_user_delete_button", default="❌ Удалить пользователя"), + text=_(key="admin_user_delete_button"), callback_data=f"user_action:delete_user:{user_id}" ) # Row 6: Navigation builder.button( - text=_(key="admin_user_search_new_button", default="🔍 Найти другого"), + text=_(key="admin_user_search_new_button"), callback_data="admin_action:users_management" ) builder.button( @@ -215,10 +211,10 @@ async def format_user_card(user: User, session: AsyncSession, # Basic user info card_parts = [] - card_parts.append(f"👤 {_('admin_user_card_title', default='Карточка пользователя')}\n") + card_parts.append(f"👤 {_('admin_user_card_title')}\n") # User details - na_value = _("admin_user_na_value", default="N/A") + na_value = _("admin_user_na_value") safe_first_name = sanitize_display_name(user.first_name) if user.first_name else None user_name = safe_first_name or na_value if user.username: @@ -231,23 +227,23 @@ async def format_user_card(user: User, session: AsyncSession, username_display = na_value registration_date = user.registration_date.strftime('%Y-%m-%d %H:%M') if user.registration_date else na_value - card_parts.append(f"{_('admin_user_id_label', default='🆔 ID:')} {hcode(str(user.user_id))}") - card_parts.append(f"{_('admin_user_name_label', default='👤 Имя:')} {hcode(user_name)}") - card_parts.append(f"{_('admin_user_username_label', default='📱 Username:')} {hcode(username_display)}") - card_parts.append(f"{_('admin_user_language_label', default='🌍 Язык:')} {hcode(user.language_code or na_value)}") - card_parts.append(f"{_('admin_user_registration_label', default='📅 Регистрация:')} {hcode(registration_date)}") + card_parts.append(f"{_('admin_user_id_label')} {hcode(str(user.user_id))}") + card_parts.append(f"{_('admin_user_name_label')} {hcode(user_name)}") + card_parts.append(f"{_('admin_user_username_label')} {hcode(username_display)}") + card_parts.append(f"{_('admin_user_language_label')} {hcode(user.language_code or na_value)}") + card_parts.append(f"{_('admin_user_registration_label')} {hcode(registration_date)}") # Ban status - ban_status = _("admin_user_status_banned", default="🚫 Заблокирован") if user.is_banned else _("admin_user_status_active", default="✅ Активен") - card_parts.append(f"{_('admin_user_status_label', default='🛡 Статус:')} {ban_status}") + ban_status = _("admin_user_status_banned") if user.is_banned else _("admin_user_status_active") + card_parts.append(f"{_('admin_user_status_label')} {ban_status}") # Referral info if user.referred_by_id: - card_parts.append(f"{_('admin_user_referral_label', default='🎁 Привлечен по реферальной программе от:')} {hcode(str(user.referred_by_id))}") + card_parts.append(f"{_('admin_user_referral_label')} {hcode(str(user.referred_by_id))}") # Panel info if user.panel_user_uuid: - card_parts.append(f"{_('admin_user_panel_uuid_label', default='🔗 Panel UUID:')} {hcode(user.panel_user_uuid[:8] + '...' if len(user.panel_user_uuid) > 8 else user.panel_user_uuid)}") + card_parts.append(f"{_('admin_user_panel_uuid_label')} {hcode(user.panel_user_uuid[:8] + '...' if len(user.panel_user_uuid) > 8 else user.panel_user_uuid)}") card_parts.append("") # Empty line @@ -255,38 +251,38 @@ async def format_user_card(user: User, session: AsyncSession, try: subscription_details = await subscription_service.get_active_subscription_details(session, user.user_id) if subscription_details: - card_parts.append(f"💳 {_('admin_user_subscription_info', default='Информация о подписке:')}") + card_parts.append(f"💳 {_('admin_user_subscription_info')}") end_date = subscription_details.get('end_date') if end_date: end_date_str = end_date.strftime('%Y-%m-%d %H:%M') if isinstance(end_date, datetime) else str(end_date) - card_parts.append(f"{_('admin_user_subscription_active_until', default='⏰ Действует до:')} {hcode(end_date_str)}") + card_parts.append(f"{_('admin_user_subscription_active_until')} {hcode(end_date_str)}") status = subscription_details.get('status_from_panel', 'UNKNOWN') - card_parts.append(f"{_('admin_user_panel_status_label', default='📊 Статус на панели:')} {hcode(status)}") + card_parts.append(f"{_('admin_user_panel_status_label')} {hcode(status)}") traffic_limit = subscription_details.get('traffic_limit_bytes') traffic_used = subscription_details.get('traffic_used_bytes') if traffic_limit and traffic_used is not None: traffic_limit_gb = traffic_limit / (1024**3) traffic_used_gb = traffic_used / (1024**3) - card_parts.append(f"{_('admin_user_traffic_label', default='📊 Трафик:')} {hcode(f'{traffic_used_gb:.2f}GB / {traffic_limit_gb:.2f}GB')}") + card_parts.append(f"{_('admin_user_traffic_label')} {hcode(f'{traffic_used_gb:.2f}GB / {traffic_limit_gb:.2f}GB')}") else: - card_parts.append(f"{_('admin_user_subscription_label', default='💼 Подписка:')} {hcode(_('admin_user_subscription_none', default='Нет активной подписки'))}") + card_parts.append(f"{_('admin_user_subscription_label')} {hcode(_('admin_user_subscription_none'))}") except Exception as e: logging.error(f"Error getting subscription details for user {user.user_id}: {e}") - card_parts.append(f"{_('admin_user_subscription_label', default='💼 Подписка:')} {hcode(_('admin_user_subscription_error', default='Ошибка загрузки'))}") + card_parts.append(f"{_('admin_user_subscription_label')} {hcode(_('admin_user_subscription_error'))}") # Statistics try: # Count user logs logs_count = await message_log_dal.count_user_message_logs(session, user.user_id) - card_parts.append(f"{_('admin_user_actions_count_label', default='📜 Всего действий:')} {hcode(str(logs_count))}") + card_parts.append(f"{_('admin_user_actions_count_label')} {hcode(str(logs_count))}") # Check if user had any subscriptions had_subscriptions = await subscription_service.has_had_any_subscription(session, user.user_id) - trial_status = _("admin_user_trial_used", default="Использовал") if had_subscriptions else _("admin_user_trial_not_used", default="Не использовал") - card_parts.append(f"{_('admin_user_trial_label', default='🏡 Триал:')} {hcode(trial_status)}") + trial_status = _("admin_user_trial_used") if had_subscriptions else _("admin_user_trial_not_used") + card_parts.append(f"{_('admin_user_trial_label')} {hcode(trial_status)}") # Financial analytics (admin-only) try: @@ -294,11 +290,11 @@ async def format_user_card(user: User, session: AsyncSession, # Total amount paid by this user total_paid = await payment_dal.get_user_total_paid(session, user.user_id) - card_parts.append(f"{_('admin_user_total_paid_label', default='💰 Всего оплачено:')} {hcode(f'{total_paid:.2f} RUB')}") + card_parts.append(f"{_('admin_user_total_paid_label')} {hcode(f'{total_paid:.2f} RUB')}") # Total revenue from referrals referral_revenue = await payment_dal.get_referral_revenue(session, user.user_id) - card_parts.append(f"{_('admin_user_referral_revenue_label', default='💸 Доход по рефералам:')} {hcode(f'{referral_revenue:.2f} RUB')}") + card_parts.append(f"{_('admin_user_referral_revenue_label')} {hcode(f'{referral_revenue:.2f} RUB')}") except Exception as e_fin: logging.error(f"Failed to build financial analytics for admin card {user.user_id}: {e_fin}") @@ -308,8 +304,8 @@ async def format_user_card(user: User, session: AsyncSession, stats = await referral_service.get_referral_stats(session, user.user_id) invited_count = stats.get('invited_count', 0) purchased_count = stats.get('purchased_count', 0) - card_parts.append(f"{_('admin_user_invited_friends_label', default='👥 Приглашено друзей:')} {hcode(str(invited_count))}") - card_parts.append(f"{_('admin_user_ref_purchased_label', default='💳 Купили подписку:')} {hcode(str(purchased_count))}") + card_parts.append(f"{_('admin_user_invited_friends_label')} {hcode(str(invited_count))}") + card_parts.append(f"{_('admin_user_ref_purchased_label')} {hcode(str(purchased_count))}") except Exception as e_rs: logging.error(f"Failed to build referral stats for admin card {user.user_id}: {e_rs}") @@ -349,7 +345,6 @@ async def process_user_search_handler(message: types.Message, state: FSMContext, if not user_model: await message.answer(_( "admin_user_not_found", - default="❌ Пользователь не найден: {input}", input=hcode(input_text) )) return @@ -379,8 +374,7 @@ async def process_user_search_handler(message: types.Message, state: FSMContext, except Exception as e: logging.error(f"Error displaying user card for {user_model.user_id}: {e}") await message.answer(_( - "admin_user_card_error", - default="❌ Ошибка отображения карточки пользователя" + "admin_user_card_error" )) @@ -410,8 +404,7 @@ async def user_action_handler(callback: types.CallbackQuery, state: FSMContext, user = await user_dal.get_user_by_id(session, user_id) if not user: await callback.answer(_( - "admin_user_not_found_action", - default="Пользователь не найден" + "admin_user_not_found_action" ), show_alert=True) return @@ -447,8 +440,7 @@ async def handle_reset_trial(callback: types.CallbackQuery, user: User, await session.commit() await callback.answer(_( - "admin_user_trial_reset_success", - default="✅ Триал сброшен! Пользователь может активировать триал заново." + "admin_user_trial_reset_success" ), show_alert=True) # Refresh user card @@ -458,8 +450,7 @@ async def handle_reset_trial(callback: types.CallbackQuery, user: User, logging.error(f"Error resetting trial for user {user.user_id}: {e}") await session.rollback() await callback.answer(_( - "admin_user_trial_reset_error", - default="❌ Ошибка сброса триала" + "admin_user_trial_reset_error" ), show_alert=True) @@ -473,7 +464,6 @@ async def handle_add_subscription_prompt(callback: types.CallbackQuery, state: F prompt_text = _( "admin_user_add_subscription_prompt", - default="➕ Добавление дней подписки для пользователя {user_id}\n\nВведите количество дней для добавления:", user_id=user.user_id ) @@ -504,10 +494,9 @@ async def handle_toggle_ban(callback: types.CallbackQuery, user: User, await session.commit() - status_text = _("admin_user_ban_action_banned", default="заблокирован") if new_ban_status else _("admin_user_ban_action_unbanned", default="разблокирован") + status_text = _("admin_user_ban_action_banned") if new_ban_status else _("admin_user_ban_action_unbanned") await callback.answer(_( "admin_user_ban_toggle_success", - default="✅ Пользователь {status}", status=status_text ), show_alert=True) @@ -524,8 +513,7 @@ async def handle_toggle_ban(callback: types.CallbackQuery, user: User, logging.error(f"Error toggling ban for user {user.user_id}: {e}") await session.rollback() await callback.answer(_( - "admin_user_ban_toggle_error", - default="❌ Ошибка изменения статуса блокировки" + "admin_user_ban_toggle_error" ), show_alert=True) @@ -539,7 +527,6 @@ async def handle_send_message_prompt(callback: types.CallbackQuery, state: FSMCo prompt_text = _( "admin_user_send_message_prompt", - default="✉️ Отправка сообщения пользователю {user_id}\n\nВведите текст сообщения:", user_id=user.user_id ) @@ -563,13 +550,12 @@ async def handle_view_user_logs(callback: types.CallbackQuery, user: User, if not logs: await callback.answer(_( - "admin_user_no_logs", - default="📜 У пользователя нет действий" + "admin_user_no_logs" ), show_alert=True) return logs_text_parts = [ - f"{_('admin_user_recent_actions_title', default='📜 Последние действия пользователя {user_id}:', user_id=user.user_id)}\n" + f"{_('admin_user_recent_actions_title', user_id=user.user_id)}\n" ] for log in logs: @@ -587,11 +573,11 @@ async def handle_view_user_logs(callback: types.CallbackQuery, user: User, # Create inline keyboard for full logs builder = InlineKeyboardBuilder() builder.button( - text=_(key="admin_user_view_all_logs_button", default="📋 Все действия"), + text=_(key="admin_user_view_all_logs_button"), callback_data=f"admin_logs:view_user:{user.user_id}:0" ) builder.button( - text=_(key="admin_user_back_to_card_button", default="🔙 К карточке"), + text=_(key="admin_user_back_to_card_button"), callback_data=f"user_action:refresh:{user.user_id}" ) builder.adjust(1) @@ -614,8 +600,7 @@ async def handle_view_user_logs(callback: types.CallbackQuery, user: User, except Exception as e: logging.error(f"Error viewing logs for user {user.user_id}: {e}") await callback.answer(_( - "admin_user_logs_error", - default="❌ Ошибка загрузки действий пользователя" + "admin_user_logs_error" ), show_alert=True) @@ -682,7 +667,6 @@ async def handle_delete_user_prompt(callback: types.CallbackQuery, state: FSMCon await callback.answer( _( "admin_user_delete_not_allowed", - default="❌ У вас нет прав для удаления пользователей.", ), show_alert=True, ) @@ -696,11 +680,6 @@ async def handle_delete_user_prompt(callback: types.CallbackQuery, state: FSMCon prompt_text = _( "admin_user_delete_confirmation_prompt", - default=( - "⚠️ Вы хотите полностью удалить пользователя {user_id}.\n\n" - "Отправьте точный Telegram ID этого пользователя, чтобы подтвердить удаление.\n" - "Любой другой ответ отменит операцию." - ), user_id=hcode(str(user.user_id)), ) @@ -771,7 +750,6 @@ async def process_delete_user_confirmation_handler(message: types.Message, await message.answer( _( "admin_user_delete_not_allowed", - default="❌ У вас нет прав для удаления пользователей.", ) ) await state.clear() @@ -783,7 +761,6 @@ async def process_delete_user_confirmation_handler(message: types.Message, await message.answer( _( "admin_user_delete_state_missing", - default="⚠️ Нет активной операции удаления. Начните заново.", ) ) await state.clear() @@ -794,7 +771,6 @@ async def process_delete_user_confirmation_handler(message: types.Message, await message.answer( _( "admin_user_delete_cancelled", - default="Операция удаления отменена по запросу.", ) ) await state.clear() @@ -804,7 +780,6 @@ async def process_delete_user_confirmation_handler(message: types.Message, await message.answer( _( "admin_user_delete_mismatch", - default="⚠️ ID не совпадает. Удаление отменено.", ) ) await state.clear() @@ -815,7 +790,6 @@ async def process_delete_user_confirmation_handler(message: types.Message, await message.answer( _( "admin_user_delete_already_removed", - default="ℹ️ Пользователь уже удален.", ) ) await state.clear() @@ -830,10 +804,6 @@ async def process_delete_user_confirmation_handler(message: types.Message, await message.answer( _( "admin_user_delete_panel_error", - default=( - "❌ Не удалось удалить пользователя на панели. " - "Операция прервана." - ), ) ) await session.rollback() @@ -847,7 +817,6 @@ async def process_delete_user_confirmation_handler(message: types.Message, await message.answer( _( "admin_user_delete_already_removed", - default="ℹ️ Пользователь уже удален.", ) ) await state.clear() @@ -859,7 +828,6 @@ async def process_delete_user_confirmation_handler(message: types.Message, await message.answer( _( "admin_user_delete_success", - default="✅ Пользователь {user_id} удален из бота и панели.", user_id=hcode(str(target_user_id)), ), parse_mode="HTML", @@ -870,7 +838,6 @@ async def process_delete_user_confirmation_handler(message: types.Message, await message.answer( _( "admin_user_delete_error", - default="❌ Не удалось завершить удаление пользователя. Попробуйте позже.", ) ) finally: @@ -903,8 +870,7 @@ async def process_subscription_days_handler(message: types.Message, state: FSMCo raise ValueError("Invalid days count") except ValueError: await message.answer(_( - "admin_user_invalid_days", - default="❌ Неверное количество дней. Введите число от 1 до 3650." + "admin_user_invalid_days" )) return @@ -918,7 +884,6 @@ async def process_subscription_days_handler(message: types.Message, state: FSMCo await session.commit() await message.answer(_( "admin_user_subscription_added_success", - default="✅ Успешно добавлено {days} дней подписки пользователю {user_id}", days=days_to_add, user_id=target_user_id )) @@ -945,16 +910,14 @@ async def process_subscription_days_handler(message: types.Message, state: FSMCo else: await session.rollback() await message.answer(_( - "admin_user_subscription_added_error", - default="❌ Ошибка добавления дней подписки" + "admin_user_subscription_added_error" )) except Exception as e: logging.error(f"Error adding subscription days for user {target_user_id}: {e}") await session.rollback() await message.answer(_( - "admin_user_subscription_added_error", - default="❌ Ошибка добавления дней подписки" + "admin_user_subscription_added_error" )) await state.clear() @@ -983,8 +946,7 @@ async def process_direct_message_handler(message: types.Message, state: FSMConte text = (message.text or message.caption or "").strip() if len(text) > 4000: await message.answer(_( - "admin_user_message_too_long", - default="❌ Сообщение слишком длинное (максимум 4000 символов)" + "admin_user_message_too_long" )) return @@ -998,16 +960,14 @@ async def process_direct_message_handler(message: types.Message, state: FSMConte # Prepare admin signature and get content admin_signature = _( - "admin_direct_message_signature", - default="\n\n---\n💬 Сообщение от администратора" + "admin_direct_message_signature" ) content = get_message_content(message) if not content.text and not content.file_id: await message.answer(_( - "admin_direct_empty_message", - default="❌ Пустое сообщение. Отправьте текст или медиа." + "admin_direct_empty_message" )) return @@ -1026,7 +986,6 @@ async def process_direct_message_handler(message: types.Message, state: FSMConte except TelegramBadRequest as e: await message.answer(_( "admin_broadcast_invalid_html", - default="❌ Некорректный HTML в сообщении. Пожалуйста, отправьте корректный HTML (поддерживаются теги Telegram) или уберите теги.\nОшибка: {error}", error=str(e), )) return @@ -1034,7 +993,6 @@ async def process_direct_message_handler(message: types.Message, state: FSMConte # Confirm to admin await message.answer(_( "admin_user_message_sent_success", - default="✅ Сообщение отправлено пользователю {user_id}", user_id=target_user_id )) @@ -1062,8 +1020,7 @@ async def process_direct_message_handler(message: types.Message, state: FSMConte except Exception as e: logging.error(f"Error sending direct message to user {target_user_id}: {e}") await message.answer(_( - "admin_user_message_sent_error", - default="❌ Ошибка отправки сообщения" + "admin_user_message_sent_error" )) await state.clear() @@ -1081,8 +1038,7 @@ async def ban_user_prompt_handler(callback: types.CallbackQuery, _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) prompt_text = _( - "admin_ban_user_prompt", - default="🚫 Блокировка пользователя\n\nВведите ID пользователя или @username для блокировки:" + "admin_ban_user_prompt" ) try: @@ -1113,8 +1069,7 @@ async def unban_user_prompt_handler(callback: types.CallbackQuery, _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) prompt_text = _( - "admin_unban_user_prompt", - default="✅ Разблокировка пользователя\n\nВведите ID пользователя или @username для разблокировки:" + "admin_unban_user_prompt" ) try: @@ -1150,8 +1105,7 @@ async def view_banned_users_handler(callback: types.CallbackQuery, if not banned_users: message_text = _( - "admin_banned_users_empty", - default="📋 Заблокированные пользователи\n\nСписок пуст" + "admin_banned_users_empty" ) else: user_list = [] @@ -1163,7 +1117,6 @@ async def view_banned_users_handler(callback: types.CallbackQuery, message_text = _( "admin_banned_users_list", - default="📋 Заблокированные пользователи ({count}):\n\n{users}", count=len(banned_users), users="\n".join(user_list) ) @@ -1208,7 +1161,6 @@ async def process_ban_user_handler(message: types.Message, state: FSMContext, if not user_model: await message.answer(_( "admin_user_not_found", - default="❌ Пользователь не найден: {input}", input=hcode(input_text) )) return @@ -1217,8 +1169,7 @@ async def process_ban_user_handler(message: types.Message, state: FSMContext, # Check if user is already banned if user_model.is_banned: await message.answer(_( - "admin_user_already_banned", - default="⚠️ Пользователь уже заблокирован" + "admin_user_already_banned" )) await state.clear() return @@ -1234,7 +1185,6 @@ async def process_ban_user_handler(message: types.Message, state: FSMContext, await message.answer(_( "admin_user_ban_success", - default="✅ Пользователь {input} заблокирован", input=hcode(input_text) )) @@ -1242,8 +1192,7 @@ async def process_ban_user_handler(message: types.Message, state: FSMContext, logging.error(f"Error banning user {user_model.user_id}: {e}") await session.rollback() await message.answer(_( - "admin_user_ban_error", - default="❌ Ошибка блокировки пользователя" + "admin_user_ban_error" )) await state.clear() @@ -1279,7 +1228,6 @@ async def process_unban_user_handler(message: types.Message, state: FSMContext, if not user_model: await message.answer(_( "admin_user_not_found", - default="❌ Пользователь не найден: {input}", input=hcode(input_text) )) return @@ -1288,8 +1236,7 @@ async def process_unban_user_handler(message: types.Message, state: FSMContext, # Check if user is not banned if not user_model.is_banned: await message.answer(_( - "admin_user_not_banned", - default="⚠️ Пользователь не заблокирован" + "admin_user_not_banned" )) await state.clear() return @@ -1305,7 +1252,6 @@ async def process_unban_user_handler(message: types.Message, state: FSMContext, await message.answer(_( "admin_user_unban_success", - default="✅ Пользователь {input} разблокирован", input=hcode(input_text) )) @@ -1313,8 +1259,7 @@ async def process_unban_user_handler(message: types.Message, state: FSMContext, logging.error(f"Error unbanning user {user_model.user_id}: {e}") await session.rollback() await message.answer(_( - "admin_user_unban_error", - default="❌ Ошибка разблокировки пользователя" + "admin_user_unban_error" )) await state.clear() @@ -1357,7 +1302,7 @@ async def user_card_from_list_handler(callback: types.CallbackQuery, user.referred_by_id ) keyboard.button( - text=_("admin_user_back_to_list_button", default="⬅️ К списку"), + text=_("admin_user_back_to_list_button"), callback_data=f"admin_action:users_list:{page}" ) quick_links_width = 2 if user.referred_by_id else 1 diff --git a/bot/handlers/inline_mode.py b/bot/handlers/inline_mode.py index 145fbfb..feb1897 100644 --- a/bot/handlers/inline_mode.py +++ b/bot/handlers/inline_mode.py @@ -103,20 +103,16 @@ async def create_referral_result( # Create message content (use same text as friend message) message_text = _( "referral_friend_message", - default="🚀 Привет! Попробуй этот VPN - быстрый, надёжный и доступный!\n\n" - "🎁 По моей ссылке тебе дадут бонусные дни к подписке!\n\n{referral_link}", referral_link=referral_link ) return InlineQueryResultArticle( id="referral_link", title=_( - "inline_referral_title", - default="🎁 Пригласить друга" + "inline_referral_title" ), description=_( - "inline_referral_description", - default="Поделиться реферальной ссылкой для получения бонусов" + "inline_referral_description" ), input_message_content=InputTextMessageContent( message_text=message_text, @@ -167,13 +163,6 @@ async def create_user_stats_result(session: AsyncSession, i18n_instance, lang: s stats_text = _( "inline_user_stats_message", - default="📊 Статистика Бота\n👥 Пользователи\n\n" - "📊 Всего: {total}\n" - "💳 С платной подпиской: {paid}\n" - "🆓 На пробном периоде: {trial}\n" - "😴 Неактивных: {inactive}\n" - "🚫 Заблокированных: {banned}\n" - "🎁 Привлечено по реферальной программе: {referral}", total=user_stats['total_users'], active_today=user_stats['active_today'], paid=user_stats['paid_subscriptions'], @@ -186,12 +175,10 @@ async def create_user_stats_result(session: AsyncSession, i18n_instance, lang: s return InlineQueryResultArticle( id="admin_user_stats", title=_( - "inline_admin_user_stats_title", - default="📊 Статистика пользователей" + "inline_admin_user_stats_title" ), description=_( "inline_user_stats_description", - default="Всего: {total}, Платных: {active}", total=user_stats['total_users'], active=user_stats['paid_subscriptions'] ), @@ -217,12 +204,6 @@ async def create_financial_stats_result(session: AsyncSession, i18n_instance, la stats_text = _( "inline_financial_stats_message", - default="💰 Финансовая статистика\n\n" - "📅 За сегодня: {today:.2f} RUB\n" - " ({today_count} платежей)\n" - "📅 За неделю: {week:.2f} RUB\n" - "📅 За месяц: {month:.2f} RUB\n" - "🏆 За все время: {all_time:.2f} RUB", today=financial_stats['today_revenue'], today_count=financial_stats['today_payments_count'], week=financial_stats['week_revenue'], @@ -233,12 +214,10 @@ async def create_financial_stats_result(session: AsyncSession, i18n_instance, la return InlineQueryResultArticle( id="admin_financial_stats", title=_( - "inline_admin_financial_stats_title", - default="💰 Финансовая статистика" + "inline_admin_financial_stats_title" ), description=_( "inline_financial_description", - default="Сегодня: {today} RUB", today=f"{financial_stats['today_revenue']:.2f}" ), input_message_content=InputTextMessageContent( @@ -311,17 +290,6 @@ async def create_system_stats_result(session: AsyncSession, i18n_instance, lang: stats_text = _( "inline_system_stats_message", - default="🖥 Статистика панели\n\n" - "🟢 Онлайн: {online}\n" - "📊 Активных: {active}\n" - "🔴 Отключенных: {disabled}\n" - "⏰ Истекшие: {expired}\n" - "⚠️ Ограниченные: {limited}\n" - "👥 Всего пользователей: {total}\n" - "💾 Использование RAM: {memory:.1f}%\n" - "📊 Трафик за неделю: {week_traffic}\n" - "📊 Трафик за месяц: {month_traffic}\n" - "🔗 Активных нод: {active_nodes}/{total_nodes}", online=online_now, active=active_users, disabled=disabled_users, @@ -335,17 +303,15 @@ async def create_system_stats_result(session: AsyncSession, i18n_instance, lang: total_nodes=total_nodes ) else: - stats_text = _("inline_panel_stats_error", default="❌ Ошибка получения данных с панели") + stats_text = _("inline_panel_stats_error") return InlineQueryResultArticle( id="admin_system_stats", title=_( - "inline_admin_system_stats_title", - default="🖥 Системная статистика" + "inline_admin_system_stats_title" ), description=_( "inline_system_description", - default="🟢 Онлайн: {online}, 📊 Активных: {active}", online=online_now, active=active_users ), @@ -359,15 +325,14 @@ async def create_system_stats_result(session: AsyncSession, i18n_instance, lang: except Exception as e: logging.error(f"Error creating system stats result: {e}") # Fallback error message - error_text = _("inline_panel_stats_error", default="❌ Ошибка получения данных с панели") + error_text = _("inline_panel_stats_error") return InlineQueryResultArticle( id="admin_system_stats", title=_( - "inline_admin_system_stats_title", - default="🖥 Системная статистика" + "inline_admin_system_stats_title" ), - description=_("inline_system_error", default="Ошибка получения данных"), + description=_("inline_system_error"), input_message_content=InputTextMessageContent( message_text=error_text, parse_mode="HTML" @@ -375,5 +340,3 @@ async def create_system_stats_result(session: AsyncSession, i18n_instance, lang: thumbnail_url=settings.INLINE_SYSTEM_STATS_THUMBNAIL_URL ) return None - - diff --git a/bot/handlers/user/subscription/core.py b/bot/handlers/user/subscription/core.py index 108bf25..d40ece5 100644 --- a/bot/handlers/user/subscription/core.py +++ b/bot/handlers/user/subscription/core.py @@ -40,17 +40,29 @@ async def display_subscription_options(event: Union[types.Message, types.Callbac return currency_symbol_val = settings.DEFAULT_CURRENCY_SYMBOL - traffic_mode = bool(getattr(settings, "traffic_sale_mode", False)) - options = settings.traffic_packages if traffic_mode else settings.subscription_options - text_content = ( - get_text("select_traffic_package") if traffic_mode else get_text("select_subscription_period") - ) if options else get_text("no_subscription_options_available") + traffic_packages = getattr(settings, "traffic_packages", {}) or {} + stars_traffic_packages = getattr(settings, "stars_traffic_packages", {}) or {} + traffic_mode = bool(getattr(settings, "traffic_sale_mode", False) or stars_traffic_packages) - reply_markup = ( - get_subscription_options_keyboard(options, currency_symbol_val, current_lang, i18n, traffic_mode=traffic_mode) - if options - else get_back_to_main_menu_markup(current_lang, i18n) - ) + if traffic_mode: + if traffic_packages: + options = traffic_packages + elif stars_traffic_packages: + options = stars_traffic_packages + currency_symbol_val = "⭐" + else: + options = {} + else: + options = settings.subscription_options + + if options: + text_content = get_text("select_traffic_package") if traffic_mode else get_text("select_subscription_period") + reply_markup = get_subscription_options_keyboard( + options, currency_symbol_val, current_lang, i18n, traffic_mode=traffic_mode + ) + else: + text_content = get_text("no_subscription_options_available") + reply_markup = get_back_to_main_menu_markup(current_lang, i18n) target_message_obj = event.message if isinstance(event, types.CallbackQuery) else event if not target_message_obj: @@ -108,7 +120,7 @@ async def my_subscription_command_handler( text = get_text("subscription_not_active") buy_button = InlineKeyboardButton( - text=get_text("menu_subscribe_inline", default="Купить"), callback_data="main_action:subscribe" + text=get_text("menu_subscribe_inline"), callback_data="main_action:subscribe" ) back_markup = get_back_to_main_menu_markup(current_lang, i18n) diff --git a/bot/handlers/user/subscription/payment_methods.py b/bot/handlers/user/subscription/payment_methods.py index 947d592..efea859 100644 --- a/bot/handlers/user/subscription/payment_methods.py +++ b/bot/handlers/user/subscription/payment_methods.py @@ -51,9 +51,9 @@ async def payment_methods_manage(callback: types.CallbackQuery, settings: Settin return get_text("payment_method_wallet_title", last4=l4) return get_text("payment_method_wallet_title", last4="****") if last4: - network_name = network or get_text("payment_network_card", default="Card") + network_name = network or get_text("payment_network_card") return get_text("payment_method_card_title", network=network_name, last4=last4) - network_name = network or get_text("payment_network_generic", default="Payment method") + network_name = network or get_text("payment_network_generic") return get_text("payment_method_generic_title", network=network_name) for m in methods: @@ -177,9 +177,9 @@ async def payment_method_delete(callback: types.CallbackQuery, settings: Setting return _("payment_method_wallet_title", last4=l4) return _("payment_method_wallet_title", last4="****") if last4: - network_name = network or _("payment_network_card", default="Card") + network_name = network or _("payment_network_card") return _("payment_method_card_title", network=network_name, last4=last4) - network_name = network or _("payment_network_generic", default="Payment method") + network_name = network or _("payment_network_generic") return _("payment_method_generic_title", network=network_name) title = _format_pm_title(m.card_network, m.card_last4) cards.append((str(m.method_id), title if not m.is_default else f"⭐ {title}")) @@ -239,9 +239,9 @@ async def payment_method_view(callback: types.CallbackQuery, settings: Settings, return _("payment_method_wallet_title", last4=l4) return _("payment_method_wallet_title", last4="****") if last4: - network_name = network or _("payment_network_card", default="Card") + network_name = network or _("payment_network_card") return _("payment_method_card_title", network=network_name, last4=last4) - network_name = network or _("payment_network_generic", default="Payment method") + network_name = network or _("payment_network_generic") return _("payment_method_generic_title", network=network_name) title = _format_pm_title(sel.card_network, sel.card_last4) @@ -307,9 +307,9 @@ async def payment_method_view(callback: types.CallbackQuery, settings: Settings, return _("payment_method_wallet_title", last4=l4) return _("payment_method_wallet_title", last4="****") if last4: - network_name = network or _("payment_network_card", default="Card") + network_name = network or _("payment_network_card") return _("payment_method_card_title", network=network_name, last4=last4) - network_name = network or _("payment_network_generic", default="Payment method") + network_name = network or _("payment_network_generic") return _("payment_method_generic_title", network=network_name) title = _format_pm_title(billing.card_network, billing.card_last4) @@ -440,9 +440,9 @@ async def payment_methods_list(callback: types.CallbackQuery, settings: Settings return get_text("payment_method_wallet_title", last4=l4) return get_text("payment_method_wallet_title", last4="****") if last4: - network_name = network or get_text("payment_network_card", default="Card") + network_name = network or get_text("payment_network_card") return get_text("payment_method_card_title", network=network_name, last4=last4) - network_name = network or get_text("payment_network_generic", default="Payment method") + network_name = network or get_text("payment_network_generic") return get_text("payment_method_generic_title", network=network_name) title = _format_pm_title(m.card_network, m.card_last4) cards.append((str(m.method_id), title if not m.is_default else f"⭐ {title}")) diff --git a/bot/handlers/user/subscription/payments_subscription.py b/bot/handlers/user/subscription/payments_subscription.py index e6145e1..7a63e31 100644 --- a/bot/handlers/user/subscription/payments_subscription.py +++ b/bot/handlers/user/subscription/payments_subscription.py @@ -29,7 +29,9 @@ async def select_subscription_period_callback_handler( pass return - traffic_mode = bool(getattr(settings, "traffic_sale_mode", False)) + traffic_packages = getattr(settings, "traffic_packages", {}) or {} + stars_traffic_packages = getattr(settings, "stars_traffic_packages", {}) or {} + traffic_mode = bool(getattr(settings, "traffic_sale_mode", False) or stars_traffic_packages) try: months = float(callback.data.split(":")[-1]) except (ValueError, IndexError): @@ -40,23 +42,47 @@ async def select_subscription_period_callback_handler( pass return - price_source = settings.traffic_packages if traffic_mode else settings.subscription_options - stars_price_source = settings.stars_traffic_packages if traffic_mode else settings.stars_subscription_options + price_source = traffic_packages if traffic_mode else settings.subscription_options + stars_price_source = stars_traffic_packages if traffic_mode else settings.stars_subscription_options price_rub = price_source.get(months) - if price_rub is None: - logging.error( - f"Price not found for option {months} using {'traffic_packages' if traffic_mode else 'subscription_options'}." - ) - try: - await callback.answer(get_text("error_try_again"), show_alert=True) - except Exception: - pass - return - - currency_symbol_val = settings.DEFAULT_CURRENCY_SYMBOL - text_content = get_text("choose_payment_method_traffic") if traffic_mode else get_text("choose_payment_method") stars_price = stars_price_source.get(months) + currency_symbol_val = settings.DEFAULT_CURRENCY_SYMBOL + + if price_rub is None: + if traffic_mode and not price_source and stars_price is not None: + currency_methods_enabled = any( + [ + settings.FREEKASSA_ENABLED, + settings.PLATEGA_ENABLED, + settings.SEVERPAY_ENABLED, + settings.YOOKASSA_ENABLED, + settings.CRYPTOPAY_ENABLED, + ] + ) + if currency_methods_enabled: + logging.error( + "Currency price missing for traffic option %s while fiat providers are enabled.", + months, + ) + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + price_rub = 0.0 + currency_symbol_val = "⭐" + else: + logging.error( + f"Price not found for option {months} using {'traffic_packages' if traffic_mode else 'subscription_options'}." + ) + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + text_content = get_text("choose_payment_method_traffic") if traffic_mode else get_text("choose_payment_method") reply_markup = get_payment_method_keyboard( months, price_rub, diff --git a/bot/keyboards/inline/admin_keyboards.py b/bot/keyboards/inline/admin_keyboards.py index c27a3bf..1b38a66 100644 --- a/bot/keyboards/inline/admin_keyboards.py +++ b/bot/keyboards/inline/admin_keyboards.py @@ -26,7 +26,7 @@ def get_admin_panel_keyboard(i18n_instance, lang: str, callback_data="admin_section:promo_marketing") # Реклама - builder.button(text=_(key="admin_ads_section", default="📈 Реклама"), + builder.button(text=_(key="admin_ads_section"), callback_data="admin_action:ads") # Системные функции @@ -43,7 +43,7 @@ def get_stats_monitoring_keyboard(i18n_instance, lang: str) -> InlineKeyboardMar builder.button(text=_(key="admin_stats_button"), callback_data="admin_action:stats") - builder.button(text=_(key="admin_view_payments_button", default="💰 Платежи"), + builder.button(text=_(key="admin_view_payments_button"), callback_data="admin_action:view_payments") builder.button(text=_(key="admin_view_logs_menu_button"), callback_data="admin_action:view_logs_menu") @@ -125,7 +125,7 @@ def get_system_functions_keyboard(i18n_instance, lang: str) -> InlineKeyboardMar def get_ads_menu_keyboard(i18n_instance, lang: str) -> InlineKeyboardMarkup: _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) builder = InlineKeyboardBuilder() - builder.button(text=_(key="admin_ads_create_button", default="➕ Создать кампанию"), + builder.button(text=_(key="admin_ads_create_button"), callback_data="admin_action:ads_create") builder.button(text=_(key="back_to_admin_panel_button"), callback_data="admin_action:main") @@ -156,7 +156,7 @@ def get_ads_list_keyboard( if current_page > 0: row.append( InlineKeyboardButton( - text="⬅️ " + _("prev_page_button", default="Prev"), + text="⬅️ " + _("prev_page_button"), callback_data=f"admin_ads:page:{current_page - 1}", ) ) @@ -169,14 +169,14 @@ def get_ads_list_keyboard( if current_page < total_pages - 1: row.append( InlineKeyboardButton( - text=_("next_page_button", default="Next") + " ➡️", + text=_("next_page_button") + " ➡️", callback_data=f"admin_ads:page:{current_page + 1}", ) ) if row: builder.row(*row) - builder.button(text=_(key="admin_ads_create_button", default="➕ Создать кампанию"), + builder.button(text=_(key="admin_ads_create_button"), callback_data="admin_action:ads_create") builder.button(text=_(key="back_to_admin_panel_button"), callback_data="admin_action:main") @@ -188,9 +188,9 @@ def get_ad_card_keyboard(i18n_instance, lang: str, campaign_id: int, back_page: _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) builder = InlineKeyboardBuilder() # Dangerous action: Delete campaign - builder.button(text=_(key="admin_ads_delete_button", default="🗑 Удалить кампанию"), + builder.button(text=_(key="admin_ads_delete_button"), callback_data=f"admin_ads:delete:{campaign_id}:{back_page}") - builder.button(text=_(key="back_to_ads_list_button", default="⬅️ К списку"), + builder.button(text=_(key="back_to_ads_list_button"), callback_data=f"admin_ads:page:{back_page}") builder.button(text=_(key="back_to_admin_panel_button"), callback_data="admin_action:main") @@ -227,12 +227,12 @@ def get_logs_pagination_keyboard( if current_page > 0: row_buttons.append( InlineKeyboardButton( - text="⬅️ " + _("prev_page_button", default="Prev"), + text="⬅️ " + _("prev_page_button"), callback_data=f"{base_callback_data}:{current_page - 1}")) if current_page < total_pages - 1: row_buttons.append( InlineKeyboardButton( - text=_("next_page_button", default="Next") + " ➡️", + text=_("next_page_button") + " ➡️", callback_data=f"{base_callback_data}:{current_page + 1}")) if row_buttons: builder.row(*row_buttons) @@ -386,8 +386,7 @@ def get_user_card_keyboard(user_id: int, callback_data=f"admin_ban_confirm:{user_id}:{banned_list_page}") builder.button( text=_( - key="user_card_open_profile_button", - default="👤 Open profile" + key="user_card_open_profile_button" ), url=f"tg://user?id={user_id}" ) @@ -418,16 +417,13 @@ def get_broadcast_confirmation_keyboard(lang: str, # Row: target selection (all / active / inactive) target_all_label = _( - key="broadcast_target_all_button", - default="👥 Все" + key="broadcast_target_all_button" ) target_active_label = _( - key="broadcast_target_active_button", - default="✅ Активные" + key="broadcast_target_active_button" ) target_inactive_label = _( - key="broadcast_target_inactive_button", - default="⌛ Неактивные" + key="broadcast_target_inactive_button" ) # Highlight current selection with a prefix @@ -449,9 +445,9 @@ def get_broadcast_confirmation_keyboard(lang: str, builder.adjust(3) # Row: confirmation - builder.button(text=_(key="confirm_broadcast_send_button", default="🚀 Отправить"), + builder.button(text=_(key="confirm_broadcast_send_button"), callback_data="broadcast_final_action:send") - builder.button(text=_(key="cancel_broadcast_button", default="❌ Отмена"), + builder.button(text=_(key="cancel_broadcast_button"), callback_data="broadcast_final_action:cancel") builder.adjust(2) return builder.as_markup() diff --git a/bot/services/notification_service.py b/bot/services/notification_service.py index 53b4ec5..8c79903 100644 --- a/bot/services/notification_service.py +++ b/bot/services/notification_service.py @@ -52,7 +52,6 @@ class NotificationService: InlineKeyboardButton( text=translate( "log_open_profile_link", - default="👤 Открыть профиль", ), url=f"tg://user?id={user_id}", ) @@ -64,7 +63,6 @@ class NotificationService: InlineKeyboardButton( text=translate( "log_open_referrer_profile_button", - default="👤 Открыть профиль пригласившего", ), url=f"tg://user?id={referrer_id}", ) @@ -200,16 +198,11 @@ class NotificationService: referrer_link = hd.link(str(referred_by_id), f"tg://user?id={referred_by_id}") referral_text = _( "log_referral_suffix", - default=" (реферал от {referrer_link})", referrer_link=referrer_link, ) message = _( "log_new_user_registration", - default="👤 Новый пользователь\n\n" - "🆔 ID: {user_id}\n" - "👤 Имя: {user_display}{referral_text}\n" - "📅 Время: {timestamp}", user_id=user_id, user_display=user_display, referral_text=referral_text, @@ -249,12 +242,6 @@ class NotificationService: traffic_label = str(int(traffic_gb)) if float(traffic_gb).is_integer() else f"{traffic_gb:g}" message = _( "log_payment_received_traffic", - default="{provider_emoji} Получен платеж\n\n" - "👤 Пользователь: {user_display}\n" - "💰 Сумма: {amount} {currency}\n" - "🗂 Трафик: {traffic_gb} GB\n" - "🏦 Провайдер: {payment_provider}\n" - "🕐 Время: {timestamp}", provider_emoji=provider_emoji, user_display=user_display, amount=amount, @@ -266,12 +253,6 @@ class NotificationService: else: message = _( "log_payment_received", - default="{provider_emoji} Получен платеж\n\n" - "👤 Пользователь: {user_display}\n" - "💰 Сумма: {amount} {currency}\n" - "📅 Период: {months} мес.\n" - "🏦 Провайдер: {payment_provider}\n" - "🕐 Время: {timestamp}", provider_emoji=provider_emoji, user_display=user_display, amount=amount, @@ -301,11 +282,6 @@ class NotificationService: message = _( "log_promo_activation", - default="🎁 Активирован промокод\n\n" - "👤 Пользователь: {user_display}\n" - "🏷 Код: {promo_code}\n" - "🎯 Бонус: +{bonus_days} дн.\n" - "🕐 Время: {timestamp}", user_display=user_display, promo_code=promo_code, bonus_days=bonus_days, @@ -332,10 +308,6 @@ class NotificationService: message = _( "log_trial_activation", - default="🆓 Активирован триал\n\n" - "👤 Пользователь: {user_display}\n" - "⏰ Действует до: {end_date}\n" - "🕐 Время: {timestamp}", user_display=user_display, end_date=end_date.strftime("%Y-%m-%d %H:%M"), timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S") @@ -364,12 +336,6 @@ class NotificationService: message = _( "log_panel_sync", - default="{status_emoji} Синхронизация с панелью\n\n" - "📊 Статус: {status}\n" - "👥 Обработано пользователей: {users_processed}\n" - "📋 Синхронизировано подписок: {subs_synced}\n" - "🕐 Время: {timestamp}\n\n" - "📝 Детали:\n{details}", status_emoji=status_emoji, status=status, users_processed=users_processed, @@ -400,11 +366,6 @@ class NotificationService: message = _( "log_suspicious_promo", - default="⚠️ Подозрительная попытка ввода промокода\n\n" - "👤 Пользователь: {user_display}\n" - "🆔 ID: {user_id}\n" - "📝 Ввод:
{suspicious_input}
\n" - "🕐 Время: {timestamp}", user_display=hd.quote(user_display), user_id=user_id, suspicious_input=hd.quote(suspicious_input), diff --git a/config/settings.py b/config/settings.py index 535efb9..ce33b1b 100644 --- a/config/settings.py +++ b/config/settings.py @@ -423,7 +423,7 @@ class Settings(BaseSettings): @property def traffic_sale_mode(self) -> bool: """When true, the bot sells traffic packages instead of time-based subscriptions.""" - return bool(self.traffic_packages) + return bool(self.traffic_packages or self.stars_traffic_packages) def referral_bonus_inviter(self) -> Dict[int, int]: bonuses: Dict[int, int] = {} diff --git a/locales/ru.json b/locales/ru.json index 42b3e2b..de5f44d 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -9,7 +9,7 @@ "menu_activate_trial_button": "🆓 Пробный период", "menu_subscribe_inline": "🚀 Купить", "menu_my_subscription_inline": "🔐 Моя подписка", - "no_subscription_options_available": "Выдача подписки не натроена администратором бота", + "no_subscription_options_available": "Выдача подписки не настроена администратором бота", "menu_referral_inline": "🎁 Рефералы", "referral_no_bonuses_configured": "Извините, реферальная программа в данный момент отключена", "menu_apply_promo_button": "🎟 Промокод", From d0e34126e18f1bf4ff36ddfca532eb4e91c8c923 Mon Sep 17 00:00:00 2001 From: machka pasla Date: Thu, 11 Dec 2025 16:13:22 +0300 Subject: [PATCH 12/16] upd --- config/settings.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/config/settings.py b/config/settings.py index ce33b1b..613b091 100644 --- a/config/settings.py +++ b/config/settings.py @@ -264,6 +264,8 @@ class Settings(BaseSettings): return f"{base.rstrip('/')}{self.yookassa_webhook_path}" return None + @computed_field + @property def panel_webhook_path(self) -> str: return "/webhook/panel" From a1d3db0e16a19f0405417450a20905e21e36910d Mon Sep 17 00:00:00 2001 From: machka pasla Date: Thu, 11 Dec 2025 22:17:43 +0300 Subject: [PATCH 13/16] added happ crypt4 links --- .env.example | 4 ++ bot/handlers/user/payment.py | 32 +++++++---- bot/handlers/user/promo_user.py | 13 +++-- bot/handlers/user/start.py | 15 ++++-- bot/handlers/user/subscription/core.py | 9 ++-- bot/handlers/user/trial_handler.py | 27 +++++++--- bot/keyboards/inline/user_keyboards.py | 6 ++- bot/services/crypto_pay_service.py | 18 +++++-- bot/services/freekassa_service.py | 21 ++++---- bot/services/platega_service.py | 20 +++---- bot/services/severpay_service.py | 20 +++---- bot/services/stars_service.py | 20 ++++--- bot/services/subscription_service.py | 6 ++- bot/utils/config_link.py | 75 ++++++++++++++++++++++++++ config/settings.py | 4 ++ happ-crypt4.pem | 14 +++++ requirements.txt | 1 + 17 files changed, 231 insertions(+), 74 deletions(-) create mode 100644 bot/utils/config_link.py create mode 100644 happ-crypt4.pem diff --git a/.env.example b/.env.example index d91fe46..850a61c 100644 --- a/.env.example +++ b/.env.example @@ -150,6 +150,10 @@ TRIAL_DURATION_DAYS=5 # TRIAL_TRAFFIC_LIMIT_GB=0 # Traffic limit for the trial period (0 = unlimited) TRIAL_TRAFFIC_STRATEGY="NO_RESET" # Traffic reset strategy for the trial period (NO_RESET, WEEK, MONTH) +# Connection link handling (happ crypt4) +CRYPT4_ENABLED=False # Enable happ crypt4 encryption for subscription URLs +CRYPT4_REDIRECT_URL= # Base redirect to wrap the connect button, e.g. https://redir.example.com?url= + # Web Server Settings (for handling webhooks) WEB_SERVER_HOST="0.0.0.0" WEB_SERVER_PORT=8080 diff --git a/bot/handlers/user/payment.py b/bot/handlers/user/payment.py index 64b8b26..20ca199 100644 --- a/bot/handlers/user/payment.py +++ b/bot/handlers/user/payment.py @@ -23,6 +23,7 @@ from config.settings import Settings from bot.services.notification_service import NotificationService from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard from bot.utils.text_sanitizer import sanitize_display_name, username_for_display +from bot.utils.config_link import prepare_config_links payment_processing_lock = asyncio.Lock() @@ -252,6 +253,10 @@ async def process_successful_payment(session: AsyncSession, bot: Bot, traffic_label = ( str(int(traffic_amount_gb)) if float(traffic_amount_gb).is_integer() else f"{traffic_amount_gb:g}" ) + config_link_display, connect_button_url = prepare_config_links( + settings, activation_details.get("subscription_url") if activation_details else None + ) + config_link_text = config_link_display or _("config_link_not_available") # For auto-renew charges, avoid re-sending config link; send concise message if sale_mode != "traffic" and is_auto_renew and final_end_date_for_user: details_message = _( @@ -261,21 +266,21 @@ async def process_successful_payment(session: AsyncSession, bot: Bot, ) details_markup = None elif sale_mode == "traffic": - config_link = activation_details.get("subscription_url") or _("config_link_not_available") details_message = _( "payment_successful_traffic_full", traffic_gb=traffic_label, end_date=final_end_date_for_user.strftime('%Y-%m-%d') if final_end_date_for_user else "—", - config_link=config_link, + config_link=config_link_text, ) details_markup = get_connect_and_main_keyboard( - user_lang, i18n, settings, config_link, preserve_message=True + user_lang, + i18n, + settings, + config_link_display, + connect_button_url=connect_button_url, + preserve_message=True, ) else: - config_link = activation_details.get("subscription_url") or _( - "config_link_not_available" - ) - if applied_referee_bonus_days_from_referral and final_end_date_for_user: inviter_name_display = _("friend_placeholder") if db_user and db_user.referred_by_id: @@ -295,7 +300,7 @@ async def process_successful_payment(session: AsyncSession, bot: Bot, bonus_days=applied_referee_bonus_days_from_referral, final_end_date=final_end_date_for_user.strftime('%Y-%m-%d'), inviter_name=inviter_name_display, - config_link=config_link, + config_link=config_link_text, ) elif applied_promo_bonus_days > 0 and final_end_date_for_user: details_message = _( @@ -303,14 +308,14 @@ async def process_successful_payment(session: AsyncSession, bot: Bot, months=int(subscription_months), bonus_days=applied_promo_bonus_days, end_date=final_end_date_for_user.strftime('%Y-%m-%d'), - config_link=config_link, + config_link=config_link_text, ) elif final_end_date_for_user: details_message = _( "payment_successful_full", months=int(subscription_months), end_date=final_end_date_for_user.strftime('%Y-%m-%d'), - config_link=config_link, + config_link=config_link_text, ) else: logging.error( @@ -319,7 +324,12 @@ async def process_successful_payment(session: AsyncSession, bot: Bot, details_message = _("payment_successful_error_details") details_markup = get_connect_and_main_keyboard( - user_lang, i18n, settings, config_link, preserve_message=True + user_lang, + i18n, + settings, + config_link_display, + connect_button_url=connect_button_url, + preserve_message=True, ) try: await bot.send_message( diff --git a/bot/handlers/user/promo_user.py b/bot/handlers/user/promo_user.py index 18fa120..2fc02df 100644 --- a/bot/handlers/user/promo_user.py +++ b/bot/handlers/user/promo_user.py @@ -134,16 +134,21 @@ async def process_promo_code_input(message: types.Message, state: FSMContext, new_end_date = result if isinstance(result, datetime) else None active = await subscription_service.get_active_subscription_details(session, user.id) - config_link = active.get("config_link") if active else None - config_link = config_link or _("config_link_not_available") + config_link_display = active.get("config_link") if active else None + connect_button_url = active.get("connect_button_url") if active else None + config_link_text = config_link_display or _("config_link_not_available") response_to_user_text = _( "promo_code_applied_success_full", end_date=(new_end_date.strftime("%d.%m.%Y %H:%M:%S") if new_end_date else "N/A"), - config_link=config_link, + config_link=config_link_text, ) reply_markup = get_connect_and_main_keyboard( - current_lang, i18n, settings, config_link + current_lang, + i18n, + settings, + config_link_display, + connect_button_url=connect_button_url, ) else: await session.rollback() diff --git a/bot/handlers/user/start.py b/bot/handlers/user/start.py index 1eb7551..cd2d980 100644 --- a/bot/handlers/user/start.py +++ b/bot/handlers/user/start.py @@ -478,21 +478,28 @@ async def start_command_handler(message: types.Message, # Get updated subscription details active = await subscription_service.get_active_subscription_details(session, user_id) - config_link = active.get("config_link") if active else None - config_link = config_link or _("config_link_not_available") + config_link_display = active.get("config_link") if active else None + connect_button_url = active.get("connect_button_url") if active else None + config_link_text = config_link_display or _("config_link_not_available") new_end_date = result if isinstance(result, datetime) else None promo_success_text = _( "promo_code_applied_success_full", end_date=(new_end_date.strftime("%d.%m.%Y %H:%M:%S") if new_end_date else "N/A"), - config_link=config_link, + config_link=config_link_text, ) from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard await message.answer( promo_success_text, - reply_markup=get_connect_and_main_keyboard(current_lang, i18n, settings, config_link), + reply_markup=get_connect_and_main_keyboard( + current_lang, + i18n, + settings, + config_link_display, + connect_button_url=connect_button_url, + ), parse_mode="HTML" ) diff --git a/bot/handlers/user/subscription/core.py b/bot/handlers/user/subscription/core.py index d40ece5..ef81790 100644 --- a/bot/handlers/user/subscription/core.py +++ b/bot/handlers/user/subscription/core.py @@ -142,6 +142,9 @@ async def my_subscription_command_handler( end_date = active.get("end_date") days_left = (end_date.date() - datetime.now().date()).days if end_date else 0 traffic_mode = bool(getattr(settings, "traffic_sale_mode", False)) + config_link_display = active.get("config_link") + connect_button_url = active.get("connect_button_url") + config_link_value = config_link_display or get_text("config_link_not_available") def _fmt_gb(val: Optional[float]) -> str: if val is None: return get_text("traffic_na") @@ -171,7 +174,7 @@ async def my_subscription_command_handler( traffic_limit=limit_display, traffic_used=used_display, traffic_left=remaining_display, - config_link=active.get("config_link") or get_text("config_link_not_available"), + config_link=config_link_value, ) else: text = get_text( @@ -179,7 +182,7 @@ async def my_subscription_command_handler( end_date=end_date.strftime("%Y-%m-%d") if end_date else "N/A", days_left=max(0, days_left), status=active.get("status_from_panel", get_text("status_active")).capitalize(), - config_link=active.get("config_link") or get_text("config_link_not_available"), + config_link=config_link_value, traffic_limit=(f"{active['traffic_limit_bytes'] / 2**30:.2f} GB" if active.get("traffic_limit_bytes") else get_text("traffic_unlimited")), traffic_used=( f"{active['traffic_used_bytes'] / 2**30:.2f} GB" if active.get("traffic_used_bytes") is not None else get_text("traffic_na") @@ -202,7 +205,7 @@ async def my_subscription_command_handler( ) ]) else: - cfg_link_val = (active or {}).get("config_link") + cfg_link_val = connect_button_url or config_link_display if cfg_link_val: prepend_rows.append([ InlineKeyboardButton( diff --git a/bot/handlers/user/trial_handler.py b/bot/handlers/user/trial_handler.py index c3ad911..ab9d2a3 100644 --- a/bot/handlers/user/trial_handler.py +++ b/bot/handlers/user/trial_handler.py @@ -13,6 +13,7 @@ from bot.keyboards.inline.user_keyboards import ( get_main_menu_inline_keyboard, get_connect_and_main_keyboard, ) +from bot.utils.config_link import prepare_config_links from bot.middlewares.i18n import JsonI18n from .start import send_main_menu @@ -76,7 +77,9 @@ async def request_trial_confirmation_handler( final_message_text_in_chat = "" show_trial_button_after_action = False + config_link_display_for_trial = None config_link_for_trial = None + connect_button_url_for_trial = None if activation_result and activation_result.get("activated"): try: @@ -85,9 +88,10 @@ async def request_trial_confirmation_handler( pass end_date_obj = activation_result.get("end_date") - config_link_for_trial = activation_result.get("subscription_url") or _( - "config_link_not_available" + config_link_display_for_trial, connect_button_url_for_trial = prepare_config_links( + settings, activation_result.get("subscription_url") ) + config_link_for_trial = config_link_display_for_trial or _("config_link_not_available") traffic_gb_val = activation_result.get( "traffic_gb", settings.TRIAL_TRAFFIC_LIMIT_GB @@ -142,7 +146,11 @@ async def request_trial_confirmation_handler( reply_markup = ( get_connect_and_main_keyboard( - current_lang, i18n, settings, config_link_for_trial + current_lang, + i18n, + settings, + config_link_display_for_trial, + connect_button_url=connect_button_url_for_trial, ) if activation_result and activation_result.get("activated") else get_main_menu_inline_keyboard( @@ -221,7 +229,9 @@ async def confirm_activate_trial_handler( final_message_text_in_chat = "" show_trial_button_after_action = False + config_link_display_for_trial = None config_link_for_trial = None + connect_button_url_for_trial = None if activation_result and activation_result.get("activated"): try: @@ -230,9 +240,10 @@ async def confirm_activate_trial_handler( pass end_date_obj = activation_result.get("end_date") - config_link_for_trial = activation_result.get("subscription_url") or _( - "config_link_not_available" + config_link_display_for_trial, connect_button_url_for_trial = prepare_config_links( + settings, activation_result.get("subscription_url") ) + config_link_for_trial = config_link_display_for_trial or _("config_link_not_available") traffic_gb_val = activation_result.get( "traffic_gb", settings.TRIAL_TRAFFIC_LIMIT_GB @@ -275,7 +286,11 @@ async def confirm_activate_trial_handler( reply_markup = ( get_connect_and_main_keyboard( - current_lang, i18n, settings, config_link_for_trial + current_lang, + i18n, + settings, + config_link_display_for_trial, + connect_button_url=connect_button_url_for_trial, ) if activation_result and activation_result.get("activated") else get_main_menu_inline_keyboard( diff --git a/bot/keyboards/inline/user_keyboards.py b/bot/keyboards/inline/user_keyboards.py index 954e3f5..5f9020e 100644 --- a/bot/keyboards/inline/user_keyboards.py +++ b/bot/keyboards/inline/user_keyboards.py @@ -375,10 +375,12 @@ def get_connect_and_main_keyboard( i18n_instance, settings: Settings, config_link: Optional[str], + connect_button_url: Optional[str] = None, preserve_message: bool = False) -> InlineKeyboardMarkup: """Keyboard with a connect button and a back to main menu button.""" _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) builder = InlineKeyboardBuilder() + button_target = connect_button_url or config_link if settings.SUBSCRIPTION_MINI_APP_URL: builder.row( @@ -387,9 +389,9 @@ def get_connect_and_main_keyboard( web_app=WebAppInfo(url=settings.SUBSCRIPTION_MINI_APP_URL), ) ) - elif config_link: + elif button_target: builder.row( - InlineKeyboardButton(text=_("connect_button"), url=config_link) + InlineKeyboardButton(text=_("connect_button"), url=button_target) ) else: builder.row( diff --git a/bot/services/crypto_pay_service.py b/bot/services/crypto_pay_service.py index 4afa298..57dbc86 100644 --- a/bot/services/crypto_pay_service.py +++ b/bot/services/crypto_pay_service.py @@ -17,6 +17,7 @@ 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 +from bot.utils.config_link import prepare_config_links class CryptoPayService: @@ -188,7 +189,9 @@ class CryptoPayService: lang = db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE _ = lambda k, **kw: i18n.gettext(lang, k, **kw) - config_link = activation.get("subscription_url") or _("config_link_not_available") + raw_config_link = activation.get("subscription_url") if activation else None + display_link, button_link = prepare_config_links(settings, raw_config_link) + config_link_text = display_link or _("config_link_not_available") final_end = activation.get("end_date") applied_days = 0 if referral_bonus and referral_bonus.get("referee_new_end_date"): @@ -199,7 +202,7 @@ class CryptoPayService: text = _("payment_successful_traffic_full", traffic_gb=str(int(traffic_gb)) if float(traffic_gb).is_integer() else f"{traffic_gb:g}", end_date=final_end.strftime('%Y-%m-%d') if final_end else "—", - config_link=config_link) + config_link=config_link_text) elif applied_days: inviter_name_display = _("friend_placeholder") if db_user and db_user.referred_by_id: @@ -216,15 +219,20 @@ class CryptoPayService: bonus_days=applied_days, final_end_date=final_end.strftime('%Y-%m-%d'), inviter_name=inviter_name_display, - config_link=config_link) + config_link=config_link_text) else: text = _("payment_successful_full", months=int(months), end_date=final_end.strftime('%Y-%m-%d') if final_end else "—", - config_link=config_link) + config_link=config_link_text) markup = get_connect_and_main_keyboard( - lang, i18n, settings, config_link, preserve_message=True + lang, + i18n, + settings, + display_link, + connect_button_url=button_link, + preserve_message=True, ) try: await bot.send_message( diff --git a/bot/services/freekassa_service.py b/bot/services/freekassa_service.py index ab1457a..f899dca 100644 --- a/bot/services/freekassa_service.py +++ b/bot/services/freekassa_service.py @@ -20,6 +20,7 @@ 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 +from bot.utils.config_link import prepare_config_links class FreeKassaService: @@ -317,13 +318,12 @@ class FreeKassaService: 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 + raw_config_link = activation.get("subscription_url") if activation else None + config_link_display, connect_button_url = prepare_config_links(self.settings, raw_config_link) + config_link_text = config_link_display or _("config_link_not_available") + final_end = activation.get("end_date") if activation else None months = payment.subscription_duration_months or 1 sale_mode = "traffic" if self.settings.traffic_sale_mode else "subscription" - 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"): @@ -333,8 +333,6 @@ class FreeKassaService: 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: @@ -346,7 +344,7 @@ class FreeKassaService: text = _("payment_successful_traffic_full", traffic_gb=traffic_label, end_date=end_date_str if final_end else "", - config_link=config_link) + config_link=config_link_text) elif applied_days: inviter_name_display = _("friend_placeholder") if db_user and db_user.referred_by_id: @@ -364,14 +362,14 @@ class FreeKassaService: bonus_days=applied_days, final_end_date=end_date_str, inviter_name=inviter_name_display, - config_link=config_link, + config_link=config_link_text, ) else: text = _( "payment_successful_full", months=months, end_date=end_date_str, - config_link=config_link, + config_link=config_link_text, ) if provider_payment_id: order_info_text = _( @@ -385,7 +383,8 @@ class FreeKassaService: lang, self.i18n, self.settings, - config_link, + config_link_display, + connect_button_url=connect_button_url, preserve_message=True, ) try: diff --git a/bot/services/platega_service.py b/bot/services/platega_service.py index 3acc13b..b6ea1be 100644 --- a/bot/services/platega_service.py +++ b/bot/services/platega_service.py @@ -15,6 +15,7 @@ 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 +from bot.utils.config_link import prepare_config_links class PlategaService: @@ -213,11 +214,9 @@ class PlategaService: 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 = ( - activation.get("subscription_url") - if activation - else None - ) or _("config_link_not_available") + raw_config_link = activation.get("subscription_url") if activation else None + config_link_display, connect_button_url = prepare_config_links(self.settings, raw_config_link) + config_link_text = config_link_display or _("config_link_not_available") final_end = activation.get("end_date") if activation else None applied_days = 0 applied_promo_days = activation.get("applied_promo_bonus_days", 0) if activation else 0 @@ -233,7 +232,7 @@ class PlategaService: "payment_successful_traffic_full", traffic_gb=traffic_label, end_date=final_end.strftime("%Y-%m-%d") if final_end else "", - config_link=config_link, + config_link=config_link_text, ) elif applied_days: inviter_name_display = _("friend_placeholder") @@ -253,7 +252,7 @@ class PlategaService: bonus_days=applied_days, final_end_date=final_end.strftime("%Y-%m-%d") if final_end else "", inviter_name=inviter_name_display, - config_link=config_link, + config_link=config_link_text, ) elif applied_promo_days and final_end: text = _( @@ -261,21 +260,22 @@ class PlategaService: months=payment_months, bonus_days=applied_promo_days, end_date=final_end.strftime("%Y-%m-%d"), - config_link=config_link, + config_link=config_link_text, ) else: text = _( "payment_successful_full", months=payment_months, end_date=final_end.strftime("%Y-%m-%d") if final_end else "", - config_link=config_link, + config_link=config_link_text, ) markup = get_connect_and_main_keyboard( lang, self.i18n, self.settings, - config_link, + config_link_display, + connect_button_url=connect_button_url, preserve_message=True, ) try: diff --git a/bot/services/severpay_service.py b/bot/services/severpay_service.py index 379305f..f87822e 100644 --- a/bot/services/severpay_service.py +++ b/bot/services/severpay_service.py @@ -18,6 +18,7 @@ from bot.services.notification_service import NotificationService from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard from db.dal import payment_dal, user_dal from bot.utils.text_sanitizer import sanitize_display_name, username_for_display +from bot.utils.config_link import prepare_config_links class SeverPayService: @@ -231,11 +232,9 @@ class SeverPayService: 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 = ( - activation.get("subscription_url") - if activation - else None - ) or _("config_link_not_available") + raw_config_link = activation.get("subscription_url") if activation else None + config_link_display, connect_button_url = prepare_config_links(self.settings, raw_config_link) + config_link_text = config_link_display or _("config_link_not_available") final_end = activation.get("end_date") if activation else None applied_days = 0 applied_promo_days = activation.get("applied_promo_bonus_days", 0) if activation else 0 @@ -251,7 +250,7 @@ class SeverPayService: "payment_successful_traffic_full", traffic_gb=traffic_label, end_date=final_end.strftime("%Y-%m-%d") if final_end else "", - config_link=config_link, + config_link=config_link_text, ) elif applied_days: inviter_name_display = _("friend_placeholder") @@ -271,7 +270,7 @@ class SeverPayService: bonus_days=applied_days, final_end_date=final_end.strftime("%Y-%m-%d") if final_end else "", inviter_name=inviter_name_display, - config_link=config_link, + config_link=config_link_text, ) elif applied_promo_days and final_end: text = _( @@ -279,21 +278,22 @@ class SeverPayService: months=payment_months, bonus_days=applied_promo_days, end_date=final_end.strftime("%Y-%m-%d"), - config_link=config_link, + config_link=config_link_text, ) else: text = _( "payment_successful_full", months=payment_months, end_date=final_end.strftime("%Y-%m-%d") if final_end else "", - config_link=config_link, + config_link=config_link_text, ) markup = get_connect_and_main_keyboard( lang, self.i18n, self.settings, - config_link, + config_link_display, + connect_button_url=connect_button_url, preserve_message=True, ) try: diff --git a/bot/services/stars_service.py b/bot/services/stars_service.py index 4fc9745..efeb151 100644 --- a/bot/services/stars_service.py +++ b/bot/services/stars_service.py @@ -13,6 +13,7 @@ from bot.middlewares.i18n import JsonI18n from .notification_service import NotificationService from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard from bot.utils.text_sanitizer import sanitize_display_name, username_for_display +from bot.utils.config_link import prepare_config_links class StarsService: @@ -121,16 +122,16 @@ class StarsService: i18n: JsonI18n = i18n_data.get("i18n_instance") _ = lambda k, **kw: i18n.gettext(current_lang, k, **kw) if i18n else k - config_link = activation_details.get("subscription_url") or _( - "config_link_not_available" - ) + raw_config_link = activation_details.get("subscription_url") if activation_details else None + config_link_display, connect_button_url = prepare_config_links(self.settings, raw_config_link) + config_link_text = config_link_display or _("config_link_not_available") if sale_mode == "traffic": success_msg = _( "payment_successful_traffic_full", traffic_gb=str(int(months)) if float(months).is_integer() else f"{months:g}", end_date=final_end.strftime('%Y-%m-%d'), - config_link=config_link, + config_link=config_link_text, ) elif applied_days: inviter_name_display = _("friend_placeholder") @@ -150,17 +151,22 @@ class StarsService: bonus_days=applied_days, final_end_date=final_end.strftime('%Y-%m-%d'), inviter_name=inviter_name_display, - config_link=config_link, + config_link=config_link_text, ) else: success_msg = _( "payment_successful_full", months=months, end_date=final_end.strftime('%Y-%m-%d'), - config_link=config_link, + config_link=config_link_text, ) markup = get_connect_and_main_keyboard( - current_lang, i18n, self.settings, config_link, preserve_message=True + current_lang, + i18n, + self.settings, + config_link_display, + connect_button_url=connect_button_url, + preserve_message=True, ) try: await self.bot.send_message( diff --git a/bot/services/subscription_service.py b/bot/services/subscription_service.py index fe9adcd..1806476 100644 --- a/bot/services/subscription_service.py +++ b/bot/services/subscription_service.py @@ -7,6 +7,7 @@ from bot.middlewares.i18n import JsonI18n from db.dal import user_dal, subscription_dal, promo_code_dal, payment_dal, user_billing_dal from bot.utils.date_utils import add_months +from bot.utils.config_link import prepare_config_links from db.models import User, Subscription from config.settings import Settings @@ -900,6 +901,8 @@ class SubscriptionService: if panel_user_data.get("expireAt") else None ) + config_link_raw = panel_user_data.get("subscriptionUrl") + display_link, connect_button_url = prepare_config_links(self.settings, config_link_raw) hwid_limit = panel_user_data.get("hwidDeviceLimit") if hwid_limit is None: hwid_limit = self.settings.USER_HWID_DEVICE_LIMIT @@ -908,7 +911,8 @@ class SubscriptionService: "user_id": panel_user_data.get("uuid"), "end_date": panel_end_date, "status_from_panel": panel_user_data.get("status", "UNKNOWN").upper(), - "config_link": panel_user_data.get("subscriptionUrl"), + "config_link": display_link, + "connect_button_url": connect_button_url, "traffic_limit_bytes": panel_user_data.get("trafficLimitBytes"), "traffic_used_bytes": (panel_user_data.get("userTraffic") or {}).get("usedTrafficBytes"), "user_bot_username": db_user.username, diff --git a/bot/utils/config_link.py b/bot/utils/config_link.py new file mode 100644 index 0000000..ca47871 --- /dev/null +++ b/bot/utils/config_link.py @@ -0,0 +1,75 @@ +import base64 +import logging +from functools import lru_cache +from pathlib import Path +from typing import Optional, Tuple + +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import padding +from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey + +from config.settings import Settings + +CRYPT4_PREFIX = "happ://crypt4/" + + +@lru_cache(maxsize=1) +def _load_crypt4_public_key() -> Optional[RSAPublicKey]: + """Load and cache the happ crypt4 public key from the project root.""" + pem_path = Path(__file__).resolve().parent.parent.parent / "happ-crypt4.pem" + try: + pem_bytes = pem_path.read_bytes() + key = serialization.load_pem_public_key(pem_bytes) + return key if isinstance(key, RSAPublicKey) else None + except FileNotFoundError: + logging.error("Crypt4 public key file not found at %s", pem_path) + except Exception as exc: + logging.error("Failed to load crypt4 public key: %s", exc, exc_info=True) + return None + + +def _encrypt_raw_link(raw_link: str) -> Optional[str]: + """Encrypt the raw subscription URL with RSA PKCS#1 v1.5 and return base64 payload.""" + public_key = _load_crypt4_public_key() + if not public_key: + return None + + try: + encrypted = public_key.encrypt(raw_link.encode("utf-8"), padding.PKCS1v15()) + return base64.urlsafe_b64encode(encrypted).decode("utf-8") + except Exception as exc: + logging.error("Failed to encrypt config link with crypt4: %s", exc, exc_info=True) + return None + + +def prepare_config_links(settings: Settings, raw_link: Optional[str]) -> Tuple[Optional[str], Optional[str]]: + """ + Build the user-facing connection key and the URL for the connect button. + + Returns (display_link, button_link). When CRYPT4 is enabled the display link + is encrypted and prefixed with happ://crypt4/, and the button link is wrapped + with CRYPT4_REDIRECT_URL if provided. + """ + if not raw_link: + return None, None + + cleaned = raw_link.strip() + if not cleaned: + return None, None + + display_link = cleaned + button_link = cleaned + + if settings.CRYPT4_ENABLED: + encrypted_payload = _encrypt_raw_link(cleaned) + if encrypted_payload: + display_link = f"{CRYPT4_PREFIX}{encrypted_payload}" + button_link = display_link + else: + logging.error("CRYPT4_ENABLED is set but encryption failed; using raw link as fallback.") + + redirect_base = (settings.CRYPT4_REDIRECT_URL or "").strip() + if redirect_base and settings.CRYPT4_ENABLED and display_link: + button_link = f"{redirect_base}{display_link}" + + return display_link, button_link diff --git a/config/settings.py b/config/settings.py index 613b091..5c084f7 100644 --- a/config/settings.py +++ b/config/settings.py @@ -164,6 +164,9 @@ class Settings(BaseSettings): TRIAL_DURATION_DAYS: int = Field(default=3) TRIAL_TRAFFIC_LIMIT_GB: Optional[float] = Field(default=5.0) + CRYPT4_ENABLED: bool = Field(default=False, description="Enable happ crypt4 encryption for subscription URLs") + CRYPT4_REDIRECT_URL: Optional[str] = Field(default=None, description="Base redirect URL used for the connect button when crypt4 is enabled") + WEB_SERVER_HOST: str = Field(default="0.0.0.0") WEB_SERVER_PORT: int = Field(default=8080) LOGS_PAGE_SIZE: int = Field(default=10) @@ -499,6 +502,7 @@ class Settings(BaseSettings): 'PLATEGA_RETURN_URL', 'PLATEGA_FAILED_URL', 'SEVERPAY_RETURN_URL', + 'CRYPT4_REDIRECT_URL', mode='before', ) @classmethod diff --git a/happ-crypt4.pem b/happ-crypt4.pem new file mode 100644 index 0000000..08aa083 --- /dev/null +++ b/happ-crypt4.pem @@ -0,0 +1,14 @@ +-----BEGIN PUBLIC KEY----- +MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA3UZ0M3L4K+WjM3vkbQnz +ozHg/cRbEXvQ6i4A8RVN4OM3rK9kU01FdjyoIgywve8OEKsFnVwERZAQZ1Trv60B +hmaM76QQEE+EUlIOL9EpwKWGtTL5lYC1sT9XJMNP3/CI0gP5wwQI88cY/xedpOEB +W72EmOOShHUm/b/3m+HPmqwc4ugKj5zWV5SyiT829aFA5DxSjmIIFBAms7DafmSq +LFTYIQL5cShDY2u+/sqyAw9yZIOoqW2TFIgIHhLPWek/ocDU7zyOrlu1E0SmcQQb +LFqHq02fsnH6IcqTv3N5Adb/CkZDDQ6HvQVBmqbKZKf7ZdXkqsc/Zw27xhG7OfXC +tUmWsiL7zA+KoTd3avyOh93Q9ju4UQsHthL3Gs4vECYOCS9dsXXSHEY/1ngU/hjO +WFF8QEE/rYV6nA4PTyUvo5RsctSQL/9DJX7XNh3zngvif8LsCN2MPvx6X+zLouBX +zgBkQ9DFfZAGLWf9TR7KVjZC/3NsuUCDoAOcpmN8pENBbeB0puiKMMWSvll36+2M +YR1Xs0MgT8Y9TwhE2+TnnTJOhzmHi/BxiUlY/w2E0s4ax9GHAmX0wyF4zeV7kDkc +vHuEdc0d7vDmdw0oqCqWj0Xwq86HfORu6tm1A8uRATjb4SzjTKclKuoElVAVa5Jo +oh/uZMozC65SmDw+N5p6Su8CAwEAAQ== +-----END PUBLIC KEY----- diff --git a/requirements.txt b/requirements.txt index 4425ce9..c44cf19 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,3 +9,4 @@ sqlalchemy[asyncio]==2.0.29 asyncpg==0.29.0 alembic==1.13.1 aiocryptopay==0.4.8 +cryptography==42.0.8 From 52bc9f71ccbaf6c7266d4caeca6d2d0406a053ec Mon Sep 17 00:00:00 2001 From: machka pasla Date: Thu, 11 Dec 2025 22:28:51 +0300 Subject: [PATCH 14/16] bug fix --- bot/utils/config_link.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/utils/config_link.py b/bot/utils/config_link.py index ca47871..992e42c 100644 --- a/bot/utils/config_link.py +++ b/bot/utils/config_link.py @@ -36,7 +36,7 @@ def _encrypt_raw_link(raw_link: str) -> Optional[str]: try: encrypted = public_key.encrypt(raw_link.encode("utf-8"), padding.PKCS1v15()) - return base64.urlsafe_b64encode(encrypted).decode("utf-8") + return base64.b64encode(encrypted).decode("utf-8") except Exception as exc: logging.error("Failed to encrypt config link with crypt4: %s", exc, exc_info=True) return None From c8fd79e4ba7158689f544190340226b3343e4f72 Mon Sep 17 00:00:00 2001 From: machka pasla Date: Sat, 20 Dec 2025 09:41:10 +0300 Subject: [PATCH 15/16] shorten hwid --- bot/handlers/user/subscription/core.py | 69 ++++++++++++++++++++++---- 1 file changed, 60 insertions(+), 9 deletions(-) diff --git a/bot/handlers/user/subscription/core.py b/bot/handlers/user/subscription/core.py index ef81790..60555ee 100644 --- a/bot/handlers/user/subscription/core.py +++ b/bot/handlers/user/subscription/core.py @@ -1,3 +1,4 @@ +import hashlib import logging from aiogram import Router, F, types, Bot from aiogram.filters import Command @@ -22,6 +23,22 @@ from db.models import Subscription router = Router(name="user_subscription_core_router") +def _shorten_hwid_for_display(hwid: Optional[str], max_length: int = 24) -> str: + """Trim HWID for button text to keep within Telegram limits.""" + if not hwid: + return "-" + hwid_str = str(hwid) + if len(hwid_str) <= max_length: + return hwid_str + return f"{hwid_str[:8]}...{hwid_str[-6:]}" + + +def _hwid_callback_token(hwid: Optional[str]) -> str: + """Stable short token for callback_data; avoids 64b limit with raw HWID.""" + hwid_str = str(hwid or "") + return hashlib.sha256(hwid_str.encode()).hexdigest()[:32] + + async def display_subscription_options(event: Union[types.Message, types.CallbackQuery], i18n_data: dict, settings: Settings, session: AsyncSession): current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") @@ -362,6 +379,12 @@ async def my_devices_command_handler( await target.answer(get_text("no_devices_found")) return + devices_list_raw = [] + if isinstance(devices, dict): + devices_list_raw = devices.get("devices") or [] + elif isinstance(devices, list): + devices_list_raw = devices + max_devices_value = active.get("max_devices") max_devices_display = get_text("devices_unlimited_label") if max_devices_value not in (None, 0): @@ -372,19 +395,22 @@ async def my_devices_command_handler( except (TypeError, ValueError): max_devices_display = str(max_devices_value) - if not devices or not devices.get('devices') or len(devices.get('devices')) == 0: + if not devices_list_raw: text = get_text("no_devices_details_found_message", max_devices=max_devices_display) else: devices_list = [] - current_devices = len(devices.get('devices') or []) - for index, device in enumerate(devices.get('devices') or [], start=1): + current_devices = len(devices_list_raw) + for index, device in enumerate(devices_list_raw, start=1): device_model = device.get('deviceModel') or None platform = device.get('platform') or None user_agent = device.get('userAgent') or None os_version = device.get('osVersion') or None created_at = device.get('createdAt') hwid = device.get('hwid') - created_at_str = datetime.fromisoformat(created_at).strftime("%d.%m.%Y %H:%M") + try: + created_at_str = datetime.fromisoformat(created_at).strftime("%d.%m.%Y %H:%M") if created_at else "-" + except Exception: + created_at_str = str(created_at) device_details = get_text("device_details", index=index, device_model=device_model, platform=platform, os_version=os_version, created_at_str=created_at_str, user_agent=user_agent, hwid=hwid) devices_list.append(device_details) @@ -395,11 +421,14 @@ async def my_devices_command_handler( kb = base_markup.inline_keyboard devices_kb = [] - for index, device in enumerate(devices.get('devices') or [], start=1): + for index, device in enumerate(devices_list_raw, start=1): hwid = device.get('hwid') - device_button_text = get_text("disconnect_device_button", hwid=hwid, index=index) + if not hwid: + continue + device_button_text = get_text("disconnect_device_button", hwid=_shorten_hwid_for_display(hwid), index=index) + hwid_token = _hwid_callback_token(hwid) - devices_kb.append([InlineKeyboardButton(text=device_button_text, callback_data=f"disconnect_device:{hwid}")]) + devices_kb.append([InlineKeyboardButton(text=device_button_text, callback_data=f"disconnect_device:{hwid_token}")]) kb = devices_kb + kb markup = InlineKeyboardMarkup(inline_keyboard=kb) @@ -438,7 +467,7 @@ async def disconnect_device_handler( return try: - _, hwid = callback.data.split(":", 1) + _, hwid_token = callback.data.split(":", 1) except Exception: try: await callback.answer(get_text("error_try_again"), show_alert=True) @@ -447,10 +476,32 @@ async def disconnect_device_handler( return active = await subscription_service.get_active_subscription_details(session, callback.from_user.id) - if not active: + if not active or not active.get("user_id"): await callback.answer(get_text("subscription_not_active"), show_alert=True) return + devices = await panel_service.get_user_devices(active.get("user_id")) + if not devices: + await callback.answer(get_text("no_devices_found"), show_alert=True) + return + + devices_list_raw = [] + if isinstance(devices, dict): + devices_list_raw = devices.get("devices") or [] + elif isinstance(devices, list): + devices_list_raw = devices + + hwid = None + for device in devices_list_raw: + hwid_candidate = device.get("hwid") + if hwid_candidate and _hwid_callback_token(hwid_candidate) == hwid_token: + hwid = hwid_candidate + break + + if not hwid: + await callback.answer(get_text("error_try_again"), show_alert=True) + return + success = await panel_service.disconnect_device(active.get("user_id"), hwid) if not success: await callback.answer(get_text("error_try_again"), show_alert=True) From adb11056216991da1d1232b857b939ccd8911171 Mon Sep 17 00:00:00 2001 From: machka pasla Date: Sat, 20 Dec 2025 14:40:47 +0300 Subject: [PATCH 16/16] bug fix --- config/settings.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/config/settings.py b/config/settings.py index 5c084f7..4e029ea 100644 --- a/config/settings.py +++ b/config/settings.py @@ -430,6 +430,8 @@ class Settings(BaseSettings): """When true, the bot sells traffic packages instead of time-based subscriptions.""" return bool(self.traffic_packages or self.stars_traffic_packages) + @computed_field + @property def referral_bonus_inviter(self) -> Dict[int, int]: bonuses: Dict[int, int] = {} if self.REFERRAL_BONUS_DAYS_INVITER_1_MONTH is not None: