diff --git a/app/cabinet/routes/subscription.py b/app/cabinet/routes/subscription.py
index c9fe20f2..3cc55da3 100644
--- a/app/cabinet/routes/subscription.py
+++ b/app/cabinet/routes/subscription.py
@@ -19,6 +19,7 @@ from .subscription_modules import (
devices_router,
purchase_router,
renewal_router,
+ revoke_router,
servers_router,
status_router,
tariff_switch_router,
@@ -50,3 +51,4 @@ router.include_router(servers_router)
router.include_router(autopay_router)
router.include_router(daily_router)
router.include_router(tariff_switch_router)
+router.include_router(revoke_router)
diff --git a/app/cabinet/routes/subscription_modules/__init__.py b/app/cabinet/routes/subscription_modules/__init__.py
index 0fa0b711..9e28e917 100644
--- a/app/cabinet/routes/subscription_modules/__init__.py
+++ b/app/cabinet/routes/subscription_modules/__init__.py
@@ -10,6 +10,7 @@ from .devices import router as devices_router
from .multi_tariff import router as multi_tariff_router
from .purchase import router as purchase_router
from .renewal import router as renewal_router
+from .revoke import router as revoke_router
from .servers import router as servers_router
from .status import router as status_router
from .tariff_switch import router as tariff_switch_router
@@ -23,6 +24,7 @@ __all__ = [
'multi_tariff_router',
'purchase_router',
'renewal_router',
+ 'revoke_router',
'servers_router',
'status_router',
'tariff_switch_router',
diff --git a/app/cabinet/routes/subscription_modules/revoke.py b/app/cabinet/routes/subscription_modules/revoke.py
new file mode 100644
index 00000000..e3e812eb
--- /dev/null
+++ b/app/cabinet/routes/subscription_modules/revoke.py
@@ -0,0 +1,92 @@
+"""Cabinet API endpoint for subscription reissue.
+
+POST /subscription/revoke
+"""
+
+from __future__ import annotations
+
+from datetime import UTC, datetime
+
+import structlog
+from fastapi import APIRouter, Depends, HTTPException, Query, status
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.config import settings
+from app.database.models import User
+from app.services.subscription_service import SubscriptionService
+
+from ...dependencies import get_cabinet_db, get_current_cabinet_user
+from .helpers import resolve_subscription
+
+
+logger = structlog.get_logger(__name__)
+
+router = APIRouter()
+
+
+@router.post('/revoke')
+async def revoke_subscription(
+ subscription_id: int | None = Query(None, description='Subscription ID for multi-tariff'),
+ user: User = Depends(get_current_cabinet_user),
+ db: AsyncSession = Depends(get_cabinet_db),
+) -> dict:
+ """Revoke and reissue subscription (generate new connection link)."""
+ if not settings.is_subscription_revoke_enabled():
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail='Subscription reissue is not available',
+ )
+
+ # Reload user from current session
+ from app.database.crud.user import get_user_by_id
+
+ fresh_user = await get_user_by_id(db, user.id)
+ if not fresh_user:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='User not found')
+
+ subscription = await resolve_subscription(db, fresh_user, subscription_id)
+ if not subscription:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Subscription not found')
+
+ if not subscription.is_active:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail='Only active subscriptions can be reissued',
+ )
+
+ # Check cooldown
+ if subscription.last_revoke_at:
+ elapsed = (datetime.now(UTC) - subscription.last_revoke_at).total_seconds()
+ cooldown = settings.SUBSCRIPTION_REVOKE_COOLDOWN_SECONDS
+ if elapsed < cooldown:
+ remaining = int(cooldown - elapsed)
+ raise HTTPException(
+ status_code=status.HTTP_429_TOO_MANY_REQUESTS,
+ detail=f'Cooldown active. Try again in {remaining} seconds.',
+ headers={'Retry-After': str(remaining)},
+ )
+
+ # Execute revoke
+ sub_service = SubscriptionService()
+ new_url = await sub_service.revoke_subscription(db, subscription)
+
+ if not new_url:
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail='Failed to reissue subscription',
+ )
+
+ # Update cooldown timestamp
+ subscription.last_revoke_at = datetime.now(UTC)
+ await db.commit()
+
+ logger.info(
+ 'Subscription revoked via cabinet API',
+ user_id=user.id,
+ subscription_id=subscription.id,
+ )
+
+ return {
+ 'success': True,
+ 'cooldown_seconds': settings.SUBSCRIPTION_REVOKE_COOLDOWN_SECONDS,
+ }
diff --git a/app/config.py b/app/config.py
index 9cd6fa5b..92a6ae94 100644
--- a/app/config.py
+++ b/app/config.py
@@ -286,6 +286,10 @@ class Settings(BaseSettings):
DISPOSABLE_EMAIL_CHECK_ENABLED: bool = True
+ # Настройки перевыпуска подписки (revoke + regenerate link)
+ SUBSCRIPTION_REVOKE_ENABLED: bool = True
+ SUBSCRIPTION_REVOKE_COOLDOWN_SECONDS: int = 900 # 15 minutes
+
# Настройки простой покупки
SIMPLE_SUBSCRIPTION_ENABLED: bool = False
SIMPLE_SUBSCRIPTION_PERIOD_DAYS: int = 30
@@ -1803,6 +1807,10 @@ class Settings(BaseSettings):
def get_disabled_mode_device_limit(self) -> int | None:
return self.get_devices_selection_disabled_amount()
+ def is_subscription_revoke_enabled(self) -> bool:
+ """Проверяет, включен ли перевыпуск подписки."""
+ return self.SUBSCRIPTION_REVOKE_ENABLED
+
def is_multi_tariff_enabled(self) -> bool:
"""Проверяет, включен ли мультитарифный режим."""
return self.MULTI_TARIFF_ENABLED and self.SALES_MODE == 'tariffs'
diff --git a/app/database/models.py b/app/database/models.py
index fa6fc78b..93c3de12 100644
--- a/app/database/models.py
+++ b/app/database/models.py
@@ -1808,6 +1808,7 @@ class Subscription(Base):
updated_at = Column(AwareDateTime(), default=func.now(), onupdate=func.now())
last_webhook_update_at = Column(AwareDateTime(), nullable=True)
+ last_revoke_at = Column(AwareDateTime(), nullable=True)
remnawave_short_uuid = Column(String(255), nullable=True)
remnawave_uuid = Column(String(255), nullable=True)
diff --git a/app/handlers/subscription/my_subscriptions.py b/app/handlers/subscription/my_subscriptions.py
index 1500b0c4..e8b0ac4b 100644
--- a/app/handlers/subscription/my_subscriptions.py
+++ b/app/handlers/subscription/my_subscriptions.py
@@ -132,6 +132,16 @@ def _build_subscription_detail_keyboard(sub_id: int, sub=None) -> types.InlineKe
if is_inactive:
buttons.append([types.InlineKeyboardButton(text='🗑 Удалить подписку', callback_data=f'sub_del:{sub_id}')])
+ if not is_inactive and settings.is_subscription_revoke_enabled():
+ buttons.append(
+ [
+ types.InlineKeyboardButton(
+ text='🔄 Перевыпустить',
+ callback_data=f'sr:{sub_id}',
+ )
+ ]
+ )
+
buttons.append([types.InlineKeyboardButton(text='◀️ К списку подписок', callback_data='my_subscriptions')])
return types.InlineKeyboardMarkup(inline_keyboard=buttons)
diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py
index ff98ad64..46c1aebd 100644
--- a/app/handlers/subscription/purchase.py
+++ b/app/handlers/subscription/purchase.py
@@ -4141,6 +4141,17 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(handle_change_devices_menu, F.data.startswith('change_devices_menu:'))
dp.callback_query.register(handle_device_management_menu, F.data.startswith('device_management:'))
+ # Subscription revoke (reissue)
+ from app.handlers.subscription.revoke import (
+ confirm_subscription_revoke,
+ start_multi_revoke,
+ start_subscription_revoke,
+ )
+
+ dp.callback_query.register(start_subscription_revoke, F.data == 'subscription_revoke')
+ dp.callback_query.register(confirm_subscription_revoke, F.data == 'subscription_revoke_confirm')
+ dp.callback_query.register(start_multi_revoke, F.data.startswith('sr:'))
+
dp.callback_query.register(show_trial_offer, F.data == 'menu_trial')
dp.callback_query.register(activate_trial, F.data == 'trial_activate')
diff --git a/app/handlers/subscription/revoke.py b/app/handlers/subscription/revoke.py
new file mode 100644
index 00000000..00f75afc
--- /dev/null
+++ b/app/handlers/subscription/revoke.py
@@ -0,0 +1,350 @@
+"""Handler for subscription reissue (revoke + regenerate link)."""
+
+from __future__ import annotations
+
+from datetime import UTC, datetime
+
+import structlog
+from aiogram import types
+from aiogram.fsm.context import FSMContext
+from aiogram.types import InaccessibleMessage, InlineKeyboardButton, InlineKeyboardMarkup
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.config import settings
+from app.database.crud.subscription import get_subscription_by_id_for_user
+from app.database.models import Subscription, User
+from app.localization.texts import get_texts
+from app.services.subscription_service import SubscriptionService
+from app.utils.decorators import error_handler
+
+
+logger = structlog.get_logger(__name__)
+
+
+def _check_revoke_cooldown(subscription: Subscription) -> int | None:
+ """Returns remaining seconds if on cooldown, None if ready."""
+ if not subscription.last_revoke_at:
+ return None
+ elapsed = (datetime.now(UTC) - subscription.last_revoke_at).total_seconds()
+ cooldown = settings.SUBSCRIPTION_REVOKE_COOLDOWN_SECONDS
+ if elapsed < cooldown:
+ return int(cooldown - elapsed)
+ return None
+
+
+def _build_revoke_confirm_keyboard(
+ language: str,
+ multi_tariff: bool = False,
+) -> InlineKeyboardMarkup:
+ """Build confirmation keyboard for revoke action."""
+ texts = get_texts(language)
+ back_callback = 'my_subscriptions' if multi_tariff else 'subscription_settings'
+ return InlineKeyboardMarkup(
+ inline_keyboard=[
+ [
+ InlineKeyboardButton(
+ text=texts.t('SUBSCRIPTION_REVOKE_CONFIRM_BTN', '✅ Подтвердить'),
+ callback_data='subscription_revoke_confirm',
+ ),
+ ],
+ [
+ InlineKeyboardButton(
+ text=texts.BACK,
+ callback_data=back_callback,
+ ),
+ ],
+ ]
+ )
+
+
+def _build_revoke_success_keyboard(
+ language: str,
+ multi_tariff: bool = False,
+) -> InlineKeyboardMarkup:
+ """Build success keyboard with connect and back buttons."""
+ texts = get_texts(language)
+ back_callback = 'my_subscriptions' if multi_tariff else 'menu_subscription'
+ return InlineKeyboardMarkup(
+ inline_keyboard=[
+ [
+ InlineKeyboardButton(
+ text=texts.t('SUBSCRIPTION_REVOKE_CONNECT_BTN', '🔗 Подключиться'),
+ callback_data='subscription_connect',
+ ),
+ ],
+ [
+ InlineKeyboardButton(
+ text=texts.BACK,
+ callback_data=back_callback,
+ ),
+ ],
+ ]
+ )
+
+
+# ---------------------------------------------------------------------------
+# Classic mode (single subscription)
+# ---------------------------------------------------------------------------
+
+
+@error_handler
+async def start_subscription_revoke(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession,
+ state: FSMContext | None = None,
+) -> None:
+ """Show revoke confirmation for classic single-subscription mode."""
+ if isinstance(callback.message, InaccessibleMessage):
+ await callback.answer()
+ return
+
+ texts = get_texts(db_user.language)
+
+ if not settings.is_subscription_revoke_enabled():
+ await callback.answer(
+ texts.t('SUBSCRIPTION_REVOKE_DISABLED', 'Перевыпуск подписки недоступен'),
+ show_alert=True,
+ )
+ return
+
+ subscription = db_user.subscription
+ if not subscription or not subscription.is_active:
+ await callback.answer(
+ texts.t('SUBSCRIPTION_NOT_FOUND', 'Подписка не найдена'),
+ show_alert=True,
+ )
+ return
+
+ # Check cooldown
+ remaining = _check_revoke_cooldown(subscription)
+ if remaining is not None:
+ minutes = remaining // 60
+ seconds = remaining % 60
+ await callback.answer(
+ texts.t(
+ 'SUBSCRIPTION_REVOKE_COOLDOWN',
+ '⏱ Перевыпуск будет доступен через {minutes} мин. {seconds} сек.',
+ ).format(minutes=minutes, seconds=seconds),
+ show_alert=True,
+ )
+ return
+
+ await callback.answer()
+
+ await callback.message.edit_text(
+ texts.t(
+ 'SUBSCRIPTION_REVOKE_WARNING',
+ (
+ '⚠️ Перевыпуск подписки\n\n'
+ 'Это действие:\n'
+ '• Сгенерирует новую ссылку подключения\n'
+ '• Сбросит все подключённые устройства\n'
+ '• Старая ссылка перестанет работать\n\n'
+ 'Продолжить?'
+ ),
+ ),
+ reply_markup=_build_revoke_confirm_keyboard(db_user.language, multi_tariff=False),
+ parse_mode='HTML',
+ )
+
+
+@error_handler
+async def confirm_subscription_revoke(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession,
+ state: FSMContext | None = None,
+) -> None:
+ """Execute revoke for classic or multi-tariff mode (uses FSM state for multi)."""
+ if isinstance(callback.message, InaccessibleMessage):
+ await callback.answer()
+ return
+
+ texts = get_texts(db_user.language)
+
+ if not settings.is_subscription_revoke_enabled():
+ await callback.answer(
+ texts.t('SUBSCRIPTION_REVOKE_DISABLED', 'Перевыпуск подписки недоступен'),
+ show_alert=True,
+ )
+ return
+
+ # Determine subscription: multi-tariff via FSM state or classic via db_user
+ is_multi = False
+ subscription: Subscription | None = None
+
+ if state:
+ data = await state.get_data()
+ revoke_sub_id = data.get('revoke_sub_id')
+ if revoke_sub_id is not None:
+ is_multi = True
+ subscription = await get_subscription_by_id_for_user(db, revoke_sub_id, db_user.id)
+ if not subscription:
+ await callback.answer(
+ texts.t('SUBSCRIPTION_NOT_FOUND', 'Подписка не найдена'),
+ show_alert=True,
+ )
+ return
+
+ if subscription is None:
+ subscription = db_user.subscription
+
+ if not subscription or not subscription.is_active:
+ await callback.answer(
+ texts.t('SUBSCRIPTION_NOT_FOUND', 'Подписка не найдена'),
+ show_alert=True,
+ )
+ return
+
+ # TOCTOU protection: re-check cooldown
+ remaining = _check_revoke_cooldown(subscription)
+ if remaining is not None:
+ minutes = remaining // 60
+ seconds = remaining % 60
+ await callback.answer(
+ texts.t(
+ 'SUBSCRIPTION_REVOKE_COOLDOWN',
+ '⏱ Перевыпуск будет доступен через {minutes} мин. {seconds} сек.',
+ ).format(minutes=minutes, seconds=seconds),
+ show_alert=True,
+ )
+ return
+
+ # Answer callback BEFORE heavy operation
+ await callback.answer()
+
+ # Execute revoke
+ sub_service = SubscriptionService()
+ new_url = await sub_service.revoke_subscription(db, subscription)
+
+ if not new_url:
+ await callback.message.edit_text(
+ texts.t('SUBSCRIPTION_REVOKE_ERROR', '❌ Ошибка при перевыпуске подписки. Попробуйте позже.'),
+ reply_markup=InlineKeyboardMarkup(
+ inline_keyboard=[
+ [InlineKeyboardButton(text=texts.BACK, callback_data='menu_subscription')],
+ ]
+ ),
+ parse_mode='HTML',
+ )
+ return
+
+ # Update cooldown timestamp
+ subscription.last_revoke_at = datetime.now(UTC)
+ await db.commit()
+
+ logger.info(
+ 'Subscription revoked successfully',
+ user_id=db_user.id,
+ subscription_id=subscription.id,
+ is_multi=is_multi,
+ )
+
+ # Clean up FSM state
+ if state and is_multi:
+ await state.update_data(revoke_sub_id=None)
+
+ await callback.message.edit_text(
+ texts.t(
+ 'SUBSCRIPTION_REVOKE_SUCCESS',
+ (
+ '✅ Подписка перевыпущена!\n\n'
+ 'Новая ссылка подключения готова. '
+ 'Старая ссылка больше не действительна.\n\n'
+ 'Все устройства были отключены.'
+ ),
+ ),
+ reply_markup=_build_revoke_success_keyboard(db_user.language, multi_tariff=is_multi),
+ parse_mode='HTML',
+ )
+
+
+# ---------------------------------------------------------------------------
+# Multi-tariff mode
+# ---------------------------------------------------------------------------
+
+
+@error_handler
+async def start_multi_revoke(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession,
+ state: FSMContext,
+) -> None:
+ """Show revoke confirmation for multi-tariff mode (callback_data = 'sr:{sub_id}')."""
+ if isinstance(callback.message, InaccessibleMessage):
+ await callback.answer()
+ return
+
+ texts = get_texts(db_user.language)
+
+ if not settings.is_subscription_revoke_enabled():
+ await callback.answer(
+ texts.t('SUBSCRIPTION_REVOKE_DISABLED', 'Перевыпуск подписки недоступен'),
+ show_alert=True,
+ )
+ return
+
+ # Extract sub_id from callback_data
+ parts = (callback.data or '').split(':')
+ if len(parts) < 2:
+ await callback.answer('Неверный формат', show_alert=True)
+ return
+
+ try:
+ sub_id = int(parts[1])
+ except (ValueError, TypeError):
+ await callback.answer('Неверный формат', show_alert=True)
+ return
+
+ # Validate ownership (IDOR protection)
+ subscription = await get_subscription_by_id_for_user(db, sub_id, db_user.id)
+ if not subscription:
+ await callback.answer(
+ texts.t('SUBSCRIPTION_NOT_FOUND', 'Подписка не найдена'),
+ show_alert=True,
+ )
+ return
+
+ if not subscription.is_active:
+ await callback.answer(
+ texts.t('SUBSCRIPTION_NOT_FOUND', 'Подписка не найдена'),
+ show_alert=True,
+ )
+ return
+
+ # Check cooldown
+ remaining = _check_revoke_cooldown(subscription)
+ if remaining is not None:
+ minutes = remaining // 60
+ seconds = remaining % 60
+ await callback.answer(
+ texts.t(
+ 'SUBSCRIPTION_REVOKE_COOLDOWN',
+ '⏱ Перевыпуск будет доступен через {minutes} мин. {seconds} сек.',
+ ).format(minutes=minutes, seconds=seconds),
+ show_alert=True,
+ )
+ return
+
+ # Store sub_id in FSM state for the confirmation handler
+ await state.update_data(revoke_sub_id=sub_id)
+
+ await callback.answer()
+
+ await callback.message.edit_text(
+ texts.t(
+ 'SUBSCRIPTION_REVOKE_WARNING',
+ (
+ '⚠️ Перевыпуск подписки\n\n'
+ 'Это действие:\n'
+ '• Сгенерирует новую ссылку подключения\n'
+ '• Сбросит все подключённые устройства\n'
+ '• Старая ссылка перестанет работать\n\n'
+ 'Продолжить?'
+ ),
+ ),
+ reply_markup=_build_revoke_confirm_keyboard(db_user.language, multi_tariff=True),
+ parse_mode='HTML',
+ )
diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py
index 98986af7..1b9d24af 100644
--- a/app/keyboards/inline.py
+++ b/app/keyboards/inline.py
@@ -3119,6 +3119,16 @@ def get_updated_subscription_settings_keyboard(
]
)
+ if settings.is_subscription_revoke_enabled():
+ keyboard.append(
+ [
+ InlineKeyboardButton(
+ text=texts.t('SUBSCRIPTION_REVOKE_BTN', '🔄 Перевыпустить подписку'),
+ callback_data='subscription_revoke',
+ )
+ ]
+ )
+
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_subscription')])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json
index c61740f7..ac6bf068 100644
--- a/app/localization/locales/en.json
+++ b/app/localization/locales/en.json
@@ -1758,5 +1758,15 @@
"WEBHOOK_TORRENT_DETECTED": "🚫 Torrent detected\n\nTorrent traffic was detected on your connection{tariff_label}. Using torrents may result in subscription restrictions.",
"WEBHOOK_CLOSE_BUTTON": "✖️ Close",
"TRAFFIC_WARNING_ALERT": "⚠️ Traffic Warning\n\nUsed: {used:.1f} / {limit} GB ({percent:.0f}%)\n\nYour traffic limit is almost reached.",
- "LOW_BALANCE_ALERT": "⚠️ Low Balance\n\nYour balance: {balance} ₽\nNotification threshold: {threshold} ₽\n\nTop up your balance to ensure automatic subscription renewal."
+ "LOW_BALANCE_ALERT": "⚠️ Low Balance\n\nYour balance: {balance} ₽\nNotification threshold: {threshold} ₽\n\nTop up your balance to ensure automatic subscription renewal.",
+
+ "SUBSCRIPTION_REVOKE_BTN": "🔄 Reissue Subscription",
+ "SUBSCRIPTION_REVOKE_TITLE": "⚠️ Reissue Subscription",
+ "SUBSCRIPTION_REVOKE_WARNING": "⚠️ Reissue Subscription\n\nThis action will:\n• Generate a new connection link\n• Disconnect all devices\n• The old link will stop working\n\nContinue?",
+ "SUBSCRIPTION_REVOKE_CONFIRM_BTN": "✅ Confirm",
+ "SUBSCRIPTION_REVOKE_SUCCESS": "✅ Subscription reissued!\n\nYour new connection link is ready. The old link is no longer valid.\n\nAll devices have been disconnected.",
+ "SUBSCRIPTION_REVOKE_COOLDOWN": "⏱ Reissue will be available in {minutes} min {seconds} sec.",
+ "SUBSCRIPTION_REVOKE_DISABLED": "Subscription reissue is not available",
+ "SUBSCRIPTION_REVOKE_ERROR": "❌ Error reissuing subscription. Please try again later.",
+ "SUBSCRIPTION_REVOKE_CONNECT_BTN": "🔗 Connect"
}
\ No newline at end of file
diff --git a/app/localization/locales/fa.json b/app/localization/locales/fa.json
index cce8be55..67fd6eab 100644
--- a/app/localization/locales/fa.json
+++ b/app/localization/locales/fa.json
@@ -1779,5 +1779,15 @@
"WEBHOOK_TORRENT_DETECTED": "🚫 تورنت شناسایی شد\n\nترافیک تورنت در اتصال{tariff_label} شما شناسایی شد. استفاده از تورنت ممکن است منجر به محدودیت اشتراک شود.",
"WEBHOOK_CLOSE_BUTTON": "✖️ بستن",
"TRAFFIC_WARNING_ALERT": "⚠️ هشدار ترافیک\n\nاستفاده شده: {used:.1f} / {limit} گیگابایت ({percent:.0f}%)\n\nحد ترافیک شما تقریباً تمام شده است.",
- "LOW_BALANCE_ALERT": "⚠️ موجودی کم\n\nموجودی شما: {balance} ₽\nآستانه اطلاعرسانی: {threshold} ₽\n\nموجودی خود را شارژ کنید تا تمدید خودکار اشتراک با موفقیت انجام شود."
+ "LOW_BALANCE_ALERT": "⚠️ موجودی کم\n\nموجودی شما: {balance} ₽\nآستانه اطلاعرسانی: {threshold} ₽\n\nموجودی خود را شارژ کنید تا تمدید خودکار اشتراک با موفقیت انجام شود.",
+
+ "SUBSCRIPTION_REVOKE_BTN": "🔄 صدور مجدد اشتراک",
+ "SUBSCRIPTION_REVOKE_TITLE": "⚠️ صدور مجدد اشتراک",
+ "SUBSCRIPTION_REVOKE_WARNING": "⚠️ صدور مجدد اشتراک\n\nاین عمل:\n• لینک اتصال جدیدی تولید میکند\n• تمام دستگاههای متصل را قطع میکند\n• لینک قدیمی دیگر کار نخواهد کرد\n\nادامه میدهید؟",
+ "SUBSCRIPTION_REVOKE_CONFIRM_BTN": "✅ تأیید",
+ "SUBSCRIPTION_REVOKE_SUCCESS": "✅ اشتراک مجدداً صادر شد!\n\nلینک اتصال جدید آماده است. لینک قدیمی دیگر معتبر نیست.\n\nتمام دستگاهها قطع شدند.",
+ "SUBSCRIPTION_REVOKE_COOLDOWN": "⏱ صدور مجدد {minutes} دقیقه و {seconds} ثانیه دیگر در دسترس خواهد بود.",
+ "SUBSCRIPTION_REVOKE_DISABLED": "صدور مجدد اشتراک در دسترس نیست",
+ "SUBSCRIPTION_REVOKE_ERROR": "❌ خطا در صدور مجدد اشتراک. لطفاً بعداً دوباره امتحان کنید.",
+ "SUBSCRIPTION_REVOKE_CONNECT_BTN": "🔗 اتصال"
}
\ No newline at end of file
diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json
index c75fecfe..61c7c540 100644
--- a/app/localization/locales/ru.json
+++ b/app/localization/locales/ru.json
@@ -1779,5 +1779,15 @@
"WEBHOOK_TORRENT_DETECTED": "🚫 Обнаружен торрент\n\nВ вашем подключении{tariff_label} обнаружен торрент-трафик. Использование торрентов может привести к ограничению подписки.",
"WEBHOOK_CLOSE_BUTTON": "✖️ Закрыть",
"TRAFFIC_WARNING_ALERT": "⚠️ Предупреждение о трафике\n\nИспользовано: {used:.1f} / {limit} ГБ ({percent:.0f}%)\n\nВаш лимит трафика почти исчерпан.",
- "LOW_BALANCE_ALERT": "⚠️ Низкий баланс\n\nВаш баланс: {balance} ₽\nПорог уведомления: {threshold} ₽\n\nПополните баланс, чтобы автопродление подписки прошло успешно."
+ "LOW_BALANCE_ALERT": "⚠️ Низкий баланс\n\nВаш баланс: {balance} ₽\nПорог уведомления: {threshold} ₽\n\nПополните баланс, чтобы автопродление подписки прошло успешно.",
+
+ "SUBSCRIPTION_REVOKE_BTN": "🔄 Перевыпустить подписку",
+ "SUBSCRIPTION_REVOKE_TITLE": "⚠️ Перевыпуск подписки",
+ "SUBSCRIPTION_REVOKE_WARNING": "⚠️ Перевыпуск подписки\n\nЭто действие:\n• Сгенерирует новую ссылку подключения\n• Сбросит все подключённые устройства\n• Старая ссылка перестанет работать\n\nПродолжить?",
+ "SUBSCRIPTION_REVOKE_CONFIRM_BTN": "✅ Подтвердить",
+ "SUBSCRIPTION_REVOKE_SUCCESS": "✅ Подписка перевыпущена!\n\nНовая ссылка подключения готова. Старая ссылка больше не действительна.\n\nВсе устройства были отключены.",
+ "SUBSCRIPTION_REVOKE_COOLDOWN": "⏱ Перевыпуск будет доступен через {minutes} мин. {seconds} сек.",
+ "SUBSCRIPTION_REVOKE_DISABLED": "Перевыпуск подписки недоступен",
+ "SUBSCRIPTION_REVOKE_ERROR": "❌ Ошибка при перевыпуске подписки. Попробуйте позже.",
+ "SUBSCRIPTION_REVOKE_CONNECT_BTN": "🔗 Подключиться"
}
\ No newline at end of file
diff --git a/app/localization/locales/ua.json b/app/localization/locales/ua.json
index 663ddacb..8183c7ae 100644
--- a/app/localization/locales/ua.json
+++ b/app/localization/locales/ua.json
@@ -1650,5 +1650,15 @@
"WEBHOOK_TORRENT_DETECTED": "🚫 Виявлено торент\n\nУ вашому підключенні{tariff_label} виявлено торент-трафік. Використання торентів може призвести до обмеження підписки.",
"WEBHOOK_CLOSE_BUTTON": "✖️ Закрити",
"TRAFFIC_WARNING_ALERT": "⚠️ Попередження про трафік\n\nВикористано: {used:.1f} / {limit} ГБ ({percent:.0f}%)\n\nВаш ліміт трафіку майже вичерпаний.",
- "LOW_BALANCE_ALERT": "⚠️ Низький баланс\n\nВаш баланс: {balance} ₽\nПоріг сповіщення: {threshold} ₽\n\nПоповніть баланс, щоб автопродовження підписки пройшло успішно."
+ "LOW_BALANCE_ALERT": "⚠️ Низький баланс\n\nВаш баланс: {balance} ₽\nПоріг сповіщення: {threshold} ₽\n\nПоповніть баланс, щоб автопродовження підписки пройшло успішно.",
+
+ "SUBSCRIPTION_REVOKE_BTN": "🔄 Перевипустити підписку",
+ "SUBSCRIPTION_REVOKE_TITLE": "⚠️ Перевипуск підписки",
+ "SUBSCRIPTION_REVOKE_WARNING": "⚠️ Перевипуск підписки\n\nЦя дія:\n• Згенерує нове посилання підключення\n• Скине всі підключені пристрої\n• Старе посилання перестане працювати\n\nПродовжити?",
+ "SUBSCRIPTION_REVOKE_CONFIRM_BTN": "✅ Підтвердити",
+ "SUBSCRIPTION_REVOKE_SUCCESS": "✅ Підписку перевипущено!\n\nНове посилання підключення готове. Старе посилання більше не дійсне.\n\nВсі пристрої були відключені.",
+ "SUBSCRIPTION_REVOKE_COOLDOWN": "⏱ Перевипуск буде доступний через {minutes} хв. {seconds} сек.",
+ "SUBSCRIPTION_REVOKE_DISABLED": "Перевипуск підписки недоступний",
+ "SUBSCRIPTION_REVOKE_ERROR": "❌ Помилка при перевипуску підписки. Спробуйте пізніше.",
+ "SUBSCRIPTION_REVOKE_CONNECT_BTN": "🔗 Підключитися"
}
\ No newline at end of file
diff --git a/app/localization/locales/zh.json b/app/localization/locales/zh.json
index 77de6679..5dc49f89 100644
--- a/app/localization/locales/zh.json
+++ b/app/localization/locales/zh.json
@@ -1648,5 +1648,15 @@
"BALANCE_TOPPED_UP_CART_SUFFICIENT": "✅ 余额已充值 {amount}!\n\n💰 当前余额:{balance}\n\n🛒 您有一个已保存的购物车,金额为 {cart_total}\n余额足够完成订购。",
"BALANCE_TOPPED_UP_CART_INSUFFICIENT": "✅ 余额已充值 {amount}!\n\n💰 当前余额:{balance}\n\n🛒 您有一个已保存的购物车,金额为 {cart_total}\n还差:{missing}",
"TRAFFIC_WARNING_ALERT": "⚠️ 流量警告\n\n已使用:{used:.1f} / {limit} GB ({percent:.0f}%)\n\n您的流量限制即将用完。",
- "LOW_BALANCE_ALERT": "⚠️ 余额不足\n\n您的余额:{balance} ₽\n通知阈值:{threshold} ₽\n\n请充值以确保订阅自动续费成功。"
+ "LOW_BALANCE_ALERT": "⚠️ 余额不足\n\n您的余额:{balance} ₽\n通知阈值:{threshold} ₽\n\n请充值以确保订阅自动续费成功。",
+
+ "SUBSCRIPTION_REVOKE_BTN": "🔄 重新签发订阅",
+ "SUBSCRIPTION_REVOKE_TITLE": "⚠️ 重新签发订阅",
+ "SUBSCRIPTION_REVOKE_WARNING": "⚠️ 重新签发订阅\n\n此操作将:\n• 生成新的连接链接\n• 断开所有已连接的设备\n• 旧链接将失效\n\n是否继续?",
+ "SUBSCRIPTION_REVOKE_CONFIRM_BTN": "✅ 确认",
+ "SUBSCRIPTION_REVOKE_SUCCESS": "✅ 订阅已重新签发!\n\n新的连接链接已准备就绪。旧链接已失效。\n\n所有设备已断开连接。",
+ "SUBSCRIPTION_REVOKE_COOLDOWN": "⏱ 重新签发将在 {minutes} 分 {seconds} 秒后可用。",
+ "SUBSCRIPTION_REVOKE_DISABLED": "订阅重新签发不可用",
+ "SUBSCRIPTION_REVOKE_ERROR": "❌ 重新签发订阅时出错。请稍后重试。",
+ "SUBSCRIPTION_REVOKE_CONNECT_BTN": "🔗 连接"
}
\ No newline at end of file
diff --git a/migrations/alembic/versions/0071_add_last_revoke_at_to_subscriptions.py b/migrations/alembic/versions/0071_add_last_revoke_at_to_subscriptions.py
new file mode 100644
index 00000000..df1f37ec
--- /dev/null
+++ b/migrations/alembic/versions/0071_add_last_revoke_at_to_subscriptions.py
@@ -0,0 +1,25 @@
+"""add last_revoke_at to subscriptions
+
+Revision ID: 0071
+Revises: 0070
+Create Date: 2026-05-04
+
+"""
+
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = '0071'
+down_revision: Union[str, None] = '0070'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.add_column('subscriptions', sa.Column('last_revoke_at', sa.DateTime(timezone=True), nullable=True))
+
+
+def downgrade() -> None:
+ op.drop_column('subscriptions', 'last_revoke_at')