From 2b592e8abe385279e5930b9380bfed2b135661c8 Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Fri, 5 Sep 2025 22:02:52 +0300 Subject: [PATCH] Implement auto-renew confirmation and cancellation features in subscription module - Added confirmation popups for enabling and disabling auto-renewal, enhancing user interaction and clarity. - Introduced inline keyboard options for auto-renewal management, allowing users to confirm their choices easily. - Updated notification messages to inform users about upcoming automatic charges and provide cancellation options. - Enhanced localization files to include new strings for auto-renewal features in both English and Russian. --- bot/handlers/user/subscription/core.py | 51 ++++++++++++++++++++++++++ bot/keyboards/inline/user_keyboards.py | 22 +++++++++++ bot/services/panel_webhook_service.py | 17 ++++++++- locales/en.json | 7 +++- locales/ru.json | 7 +++- 5 files changed, 99 insertions(+), 5 deletions(-) diff --git a/bot/handlers/user/subscription/core.py b/bot/handlers/user/subscription/core.py index cb84d1b..ad499a2 100644 --- a/bot/handlers/user/subscription/core.py +++ b/bot/handlers/user/subscription/core.py @@ -11,6 +11,7 @@ from config.settings import Settings from bot.keyboards.inline.user_keyboards import ( get_subscription_options_keyboard, get_back_to_main_menu_markup, + get_autorenew_confirm_keyboard, ) from bot.services.subscription_service import SubscriptionService from bot.services.panel_api_service import PanelApiService @@ -240,6 +241,56 @@ async def toggle_autorenew_handler( await callback.answer(get_text("subscription_autorenew_not_supported_for_tribute"), show_alert=True) return + # Show confirmation popup and inline buttons + confirm_text = get_text("autorenew_confirm_enable") if enable else get_text("autorenew_confirm_disable") + kb = get_autorenew_confirm_keyboard(enable, sub.subscription_id, current_lang, i18n) + try: + await callback.message.edit_text(confirm_text, reply_markup=kb) + except Exception: + try: + await callback.message.answer(confirm_text, reply_markup=kb) + except Exception: + pass + try: + await callback.answer() + except Exception: + pass + return + + +@router.callback_query(F.data.startswith("autorenew:confirm:")) +async def confirm_autorenew_handler( + callback: types.CallbackQuery, + settings: Settings, + i18n_data: dict, + session: AsyncSession, + subscription_service: SubscriptionService, + panel_service: PanelApiService, + bot: Bot, +): + 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 + + try: + _, _, sub_id_str, enable_str = callback.data.split(":", 3) + sub_id = int(sub_id_str) + enable = bool(int(enable_str)) + except Exception: + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + sub = await session.get(Subscription, sub_id) + 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) + return + await subscription_dal.update_subscription(session, sub.subscription_id, {"auto_renew_enabled": enable}) await session.commit() try: diff --git a/bot/keyboards/inline/user_keyboards.py b/bot/keyboards/inline/user_keyboards.py index 6fd2dae..456eae4 100644 --- a/bot/keyboards/inline/user_keyboards.py +++ b/bot/keyboards/inline/user_keyboards.py @@ -320,3 +320,25 @@ def get_back_to_payment_method_details_keyboard(pm_id: str, lang: str, i18n_inst # Back one step: return to specific payment method details builder.row(InlineKeyboardButton(text=_(key="back_to_main_menu_button"), callback_data=f"pm:view:{pm_id}")) return builder.as_markup() + + +def get_autorenew_cancel_keyboard(lang: str, i18n_instance) -> InlineKeyboardMarkup: + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + builder = InlineKeyboardBuilder() + builder.row( + InlineKeyboardButton(text=_(key="autorenew_disable_button"), callback_data="autorenew:cancel") + ) + builder.row( + InlineKeyboardButton(text=_(key="menu_my_subscription_inline"), callback_data="main_action:my_subscription") + ) + return builder.as_markup() + + +def get_autorenew_confirm_keyboard(enable: bool, sub_id: int, lang: str, i18n_instance) -> InlineKeyboardMarkup: + _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) + builder = InlineKeyboardBuilder() + builder.row( + InlineKeyboardButton(text=_(key="yes_button"), callback_data=f"autorenew:confirm:{sub_id}:{1 if enable else 0}"), + InlineKeyboardButton(text=_(key="no_button"), callback_data="main_action:my_subscription"), + ) + return builder.as_markup() diff --git a/bot/services/panel_webhook_service.py b/bot/services/panel_webhook_service.py index c2ec807..56b6fda 100644 --- a/bot/services/panel_webhook_service.py +++ b/bot/services/panel_webhook_service.py @@ -10,7 +10,7 @@ from typing import Optional from config.settings import Settings from .panel_api_service import PanelApiService from bot.middlewares.i18n import JsonI18n -from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup +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 @@ -208,6 +208,21 @@ 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 + if days_left == 2: + 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': + cancel_kb = get_autorenew_cancel_keyboard(lang, self.i18n) + await self._send_message( + user_id, + lang, + "autorenew_48h_charge_tomorrow_notice", + reply_markup=cancel_kb, + user_name=first_name, + ) + return await self._send_message( user_id, lang, diff --git a/locales/en.json b/locales/en.json index 63234a7..d4a73e8 100644 --- a/locales/en.json +++ b/locales/en.json @@ -209,6 +209,9 @@ "subscription_24h_notification": "👋 Hi, {user_name}!\n\n⏳ Your VPN subscription expires in 1 day — {end_date}.\n\nPlease renew it using the button below.", "subscription_expired_notification": "👋 Hi, {user_name}!\n\n⛔ Your VPN subscription expired on {end_date}.\n\nPlease renew it using the button below.", "subscription_expired_yesterday_notification": "👋 Hi, {user_name}!\n\n⏳ Your VPN subscription expired yesterday ({end_date}).\n\nPlease renew it using the button below.", + "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}", @@ -372,8 +375,8 @@ "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})", "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}", - "autorenew_enable_button": "Enable auto-renew", - "autorenew_disable_button": "Disable auto-renew", + "autorenew_enable_button": "🔄 Enable auto-renew", + "autorenew_disable_button": "🛑 Disable auto-renew", "subscription_autorenew_updated": "Auto-renew settings updated.", "payment_methods_manage_button": "💳 Payment Methods", "payment_methods_title": "💳 Payment Methods", diff --git a/locales/ru.json b/locales/ru.json index ea8ffaa..d28f4b8 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -139,6 +139,9 @@ "subscription_24h_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекает через 1 день — {end_date}.\n\nПродлите её по кнопке ниже.", "subscription_expired_notification": "👋 Привет, {user_name}!\n\n⛔ Срок вашей подписки на VPN истек ({end_date}).\n\nПродлите её по кнопке ниже.", "subscription_expired_yesterday_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекла сутки назад ({end_date}).\n\nПродлите её по кнопке ниже.", + "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": "⏰ Установить срок (дни)", @@ -371,8 +374,8 @@ "admin_sync_not_found_in_db": "\n❌ Не найдено в БД: {count}", "admin_payments_pagination_info": "📊 Показано {shown} из {total} платежей (стр. {current_page}/{total_pages})", "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}", - "autorenew_enable_button": "Включить автопродление", - "autorenew_disable_button": "Выключить автопродление", + "autorenew_enable_button": "🔄 Включить автопродление", + "autorenew_disable_button": "🛑 Отключить автопродление", "subscription_autorenew_updated": "Настройки автопродления обновлены.", "payment_methods_manage_button": "💳 Способы оплаты", "payment_methods_title": "💳 Способы оплаты",