diff --git a/bot/services/panel_webhook_service.py b/bot/services/panel_webhook_service.py index ca5a426..818c04e 100644 --- a/bot/services/panel_webhook_service.py +++ b/bot/services/panel_webhook_service.py @@ -41,6 +41,70 @@ 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): + """Handle expired subscription - auto-renew tribute users if no cancellation was received""" + from db.dal import subscription_dal, payment_dal + from datetime import datetime, timezone, timedelta + + try: + # 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 + new_end_date = datetime.now(timezone.utc) + timedelta(days=last_tribute_duration * 30) + + await subscription_dal.update_subscription( + session, + sub.subscription_id, + { + 'end_date': new_end_date, + 'status_from_panel': 'ACTIVE', + 'is_active': 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" + ) + except Exception as e: + logging.error(f"Failed to send auto-renewal notification to user {user_id}: {e}") + + await session.commit() + + except Exception as e: + logging.error(f"Error handling expired subscription for user {user_id}: {e}") + await session.rollback() + async def handle_event(self, event_name: str, user_payload: dict): telegram_id = user_payload.get("telegramId") if not telegram_id: @@ -70,6 +134,9 @@ class PanelWebhookService: end_date=user_payload.get("expireAt", "")[:10], ) elif event_name == "user.expired" and self.settings.SUBSCRIPTION_NOTIFY_ON_EXPIRE: + # Check if this is a tribute user that should be auto-renewed + await self._handle_expired_subscription(session, user_id, user_payload, lang, markup, first_name) + await self._send_message( user_id, lang, diff --git a/bot/services/tribute_service.py b/bot/services/tribute_service.py index f310f2d..6e2029a 100644 --- a/bot/services/tribute_service.py +++ b/bot/services/tribute_service.py @@ -201,10 +201,75 @@ class TributeService: ) except Exception as e: logging.error(f"Failed to send tribute payment notification: {e}") + + elif event_name == 'subscription_cancelled': + # Handle tribute subscription cancellation + await self._handle_tribute_cancellation(session, user_id, bot, i18n) + else: await session.commit() return web.Response(status=200, text="ok") + 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: + # Set all user's subscriptions to expire in 1 day (grace period) + grace_end_date = datetime.now(timezone.utc) + timedelta(days=1) + + # Get all active subscriptions for the user + user_subs = await subscription_dal.get_active_subscriptions_for_user(session, user_id) + + for sub in user_subs: + await subscription_dal.update_subscription( + session, + sub.subscription_id, + { + 'end_date': grace_end_date, + 'status_from_panel': 'CANCELLED', + 'skip_notifications': True # Skip future notifications for cancelled subs + } + ) + + 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( + 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.""" diff --git a/db/dal/payment_dal.py b/db/dal/payment_dal.py index 6f87e82..394e43a 100644 --- a/db/dal/payment_dal.py +++ b/db/dal/payment_dal.py @@ -170,3 +170,17 @@ async def get_financial_statistics(session: AsyncSession) -> Dict[str, Any]: "all_time_revenue": float(all_amount), "today_payments_count": today_payments_count } + + +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() diff --git a/db/dal/subscription_dal.py b/db/dal/subscription_dal.py index f67a2eb..91ede4a 100644 --- a/db/dal/subscription_dal.py +++ b/db/dal/subscription_dal.py @@ -31,6 +31,16 @@ async def get_subscription_by_panel_subscription_uuid( return result.scalar_one_or_none() +async def get_active_subscriptions_for_user(session: AsyncSession, user_id: int) -> List[Subscription]: + """Get all active subscriptions for a user.""" + stmt = select(Subscription).where( + Subscription.user_id == user_id, + Subscription.is_active == True + ).order_by(Subscription.end_date.desc()) + result = await session.execute(stmt) + return result.scalars().all() + + async def update_subscription( session: AsyncSession, subscription_id: int, update_data: Dict[str, Any]) -> Optional[Subscription]: diff --git a/locales/en.json b/locales/en.json index bbbf702..6b8405c 100644 --- a/locales/en.json +++ b/locales/en.json @@ -232,6 +232,8 @@ "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.", + "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}", "admin_new_trial_notification": "\ud83c\udf21 User {user_id} activated a free trial until {end_date}.", diff --git a/locales/ru.json b/locales/ru.json index 623f981..9f50ed9 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -232,6 +232,8 @@ "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ΠŸΡ€ΠΎΠ΄Π»ΠΈΡ‚Π΅ Π΅Ρ‘ ΠΏΠΎ ΠΊΠ½ΠΎΠΏΠΊΠ΅ Π½ΠΈΠΆΠ΅.", + "tribute_subscription_cancelled": "🚨 Подписка ΠΎΡ‚ΠΌΠ΅Π½Π΅Π½Π°\n\nΠ’Π°ΡˆΠ° подписка Tribute Π±Ρ‹Π»Π° ΠΎΡ‚ΠΌΠ΅Π½Π΅Π½Π°. Π£ вас Π΅ΡΡ‚ΡŒ 24 часа для восстановлСния доступа, послС Ρ‡Π΅Π³ΠΎ подписка Π±ΡƒΠ΄Π΅Ρ‚ Π·Π°Π±Π»ΠΎΠΊΠΈΡ€ΠΎΠ²Π°Π½Π°.\n\nДля продлСния подписки Π½Π°ΠΆΠΌΠΈΡ‚Π΅ ΠΊΠ½ΠΎΠΏΠΊΡƒ Π½ΠΈΠΆΠ΅.", + "tribute_auto_renewal": "πŸ”„ Подписка автоматичСски ΠΏΡ€ΠΎΠ΄Π»Π΅Π½Π°\n\nΠ’Π°ΡˆΠ° подписка Tribute Π±Ρ‹Π»Π° автоматичСски ΠΏΡ€ΠΎΠ΄Π»Π΅Π½Π° Π½Π° {months} мСс.\nНовая Π΄Π°Ρ‚Π° окончания: {end_date}", "admin_new_trial_notification": "\ud83c\udf21 ΠŸΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»ΡŒ {user_id} Π°ΠΊΡ‚ΠΈΠ²ΠΈΡ€ΠΎΠ²Π°Π» ΠΏΡ€ΠΎΠ±Π½Ρ‹ΠΉ ΠΏΠ΅Ρ€ΠΈΠΎΠ΄ Π΄ΠΎ {end_date}.",