From 1ab1ff90bf6fc243da45a53b2d549464e47c2f31 Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 4 May 2026 08:08:56 +0300 Subject: [PATCH] feat: add subscription reissue with 15-min cooldown - Add revoke handler for classic and multi-tariff modes with 2-step confirmation dialog and TOCTOU-safe cooldown enforcement - Add cabinet API endpoint POST /subscription/revoke with 429 + Retry-After for cooldown, IDOR protection via resolve_subscription - Add last_revoke_at column to subscriptions (Alembic migration 0071) - Add SUBSCRIPTION_REVOKE_ENABLED and COOLDOWN_SECONDS config settings - Add revoke button to classic subscription settings keyboard and multi-tariff detail keyboard (gated by feature toggle) - Add locale keys for revoke UI in all 5 languages (ru, en, ua, zh, fa) --- app/cabinet/routes/subscription.py | 2 + .../routes/subscription_modules/__init__.py | 2 + .../routes/subscription_modules/revoke.py | 92 +++++ app/config.py | 8 + app/database/models.py | 1 + app/handlers/subscription/my_subscriptions.py | 10 + app/handlers/subscription/purchase.py | 11 + app/handlers/subscription/revoke.py | 350 ++++++++++++++++++ app/keyboards/inline.py | 10 + app/localization/locales/en.json | 12 +- app/localization/locales/fa.json | 12 +- app/localization/locales/ru.json | 12 +- app/localization/locales/ua.json | 12 +- app/localization/locales/zh.json | 12 +- ...071_add_last_revoke_at_to_subscriptions.py | 25 ++ 15 files changed, 566 insertions(+), 5 deletions(-) create mode 100644 app/cabinet/routes/subscription_modules/revoke.py create mode 100644 app/handlers/subscription/revoke.py create mode 100644 migrations/alembic/versions/0071_add_last_revoke_at_to_subscriptions.py 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')