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}.",