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)
This commit is contained in:
Fringg
2026-05-04 08:08:56 +03:00
parent 719664208e
commit 1ab1ff90bf
15 changed files with 566 additions and 5 deletions
+2
View File
@@ -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)
@@ -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',
@@ -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,
}
+8
View File
@@ -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'
+1
View File
@@ -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)
@@ -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)
+11
View File
@@ -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')
+350
View File
@@ -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',
(
'⚠️ <b>Перевыпуск подписки</b>\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',
(
'✅ <b>Подписка перевыпущена!</b>\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',
(
'⚠️ <b>Перевыпуск подписки</b>\n\n'
'Это действие:\n'
'• Сгенерирует новую ссылку подключения\n'
'• Сбросит все подключённые устройства\n'
'• Старая ссылка перестанет работать\n\n'
'Продолжить?'
),
),
reply_markup=_build_revoke_confirm_keyboard(db_user.language, multi_tariff=True),
parse_mode='HTML',
)
+10
View File
@@ -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)
+11 -1
View File
@@ -1758,5 +1758,15 @@
"WEBHOOK_TORRENT_DETECTED": "🚫 <b>Torrent detected</b>\n\nTorrent traffic was detected on your connection{tariff_label}. Using torrents may result in subscription restrictions.",
"WEBHOOK_CLOSE_BUTTON": "✖️ Close",
"TRAFFIC_WARNING_ALERT": "⚠️ <b>Traffic Warning</b>\n\nUsed: {used:.1f} / {limit} GB ({percent:.0f}%)\n\nYour traffic limit is almost reached.",
"LOW_BALANCE_ALERT": "⚠️ <b>Low Balance</b>\n\nYour balance: {balance} ₽\nNotification threshold: {threshold} ₽\n\nTop up your balance to ensure automatic subscription renewal."
"LOW_BALANCE_ALERT": "⚠️ <b>Low Balance</b>\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": "⚠️ <b>Reissue Subscription</b>\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": "✅ <b>Subscription reissued!</b>\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"
}
+11 -1
View File
@@ -1779,5 +1779,15 @@
"WEBHOOK_TORRENT_DETECTED": "🚫 <b>تورنت شناسایی شد</b>\n\nترافیک تورنت در اتصال{tariff_label} شما شناسایی شد. استفاده از تورنت ممکن است منجر به محدودیت اشتراک شود.",
"WEBHOOK_CLOSE_BUTTON": "✖️ بستن",
"TRAFFIC_WARNING_ALERT": "⚠️ <b>هشدار ترافیک</b>\n\nاستفاده شده: {used:.1f} / {limit} گیگابایت ({percent:.0f}%)\n\nحد ترافیک شما تقریباً تمام شده است.",
"LOW_BALANCE_ALERT": "⚠️ <b>موجودی کم</b>\n\nموجودی شما: {balance} ₽\nآستانه اطلاع‌رسانی: {threshold} ₽\n\nموجودی خود را شارژ کنید تا تمدید خودکار اشتراک با موفقیت انجام شود."
"LOW_BALANCE_ALERT": "⚠️ <b>موجودی کم</b>\n\nموجودی شما: {balance} ₽\nآستانه اطلاع‌رسانی: {threshold} ₽\n\nموجودی خود را شارژ کنید تا تمدید خودکار اشتراک با موفقیت انجام شود.",
"SUBSCRIPTION_REVOKE_BTN": "🔄 صدور مجدد اشتراک",
"SUBSCRIPTION_REVOKE_TITLE": "⚠️ صدور مجدد اشتراک",
"SUBSCRIPTION_REVOKE_WARNING": "⚠️ <b>صدور مجدد اشتراک</b>\n\nاین عمل:\n• لینک اتصال جدیدی تولید می‌کند\n• تمام دستگاه‌های متصل را قطع می‌کند\n• لینک قدیمی دیگر کار نخواهد کرد\n\nادامه می‌دهید؟",
"SUBSCRIPTION_REVOKE_CONFIRM_BTN": "✅ تأیید",
"SUBSCRIPTION_REVOKE_SUCCESS": "✅ <b>اشتراک مجدداً صادر شد!</b>\n\nلینک اتصال جدید آماده است. لینک قدیمی دیگر معتبر نیست.\n\nتمام دستگاه‌ها قطع شدند.",
"SUBSCRIPTION_REVOKE_COOLDOWN": "⏱ صدور مجدد {minutes} دقیقه و {seconds} ثانیه دیگر در دسترس خواهد بود.",
"SUBSCRIPTION_REVOKE_DISABLED": "صدور مجدد اشتراک در دسترس نیست",
"SUBSCRIPTION_REVOKE_ERROR": "❌ خطا در صدور مجدد اشتراک. لطفاً بعداً دوباره امتحان کنید.",
"SUBSCRIPTION_REVOKE_CONNECT_BTN": "🔗 اتصال"
}
+11 -1
View File
@@ -1779,5 +1779,15 @@
"WEBHOOK_TORRENT_DETECTED": "🚫 <b>Обнаружен торрент</b>\n\nВ вашем подключении{tariff_label} обнаружен торрент-трафик. Использование торрентов может привести к ограничению подписки.",
"WEBHOOK_CLOSE_BUTTON": "✖️ Закрыть",
"TRAFFIC_WARNING_ALERT": "⚠️ <b>Предупреждение о трафике</b>\n\nИспользовано: {used:.1f} / {limit} ГБ ({percent:.0f}%)\n\nВаш лимит трафика почти исчерпан.",
"LOW_BALANCE_ALERT": "⚠️ <b>Низкий баланс</b>\n\nВаш баланс: {balance} ₽\nПорог уведомления: {threshold} ₽\n\nПополните баланс, чтобы автопродление подписки прошло успешно."
"LOW_BALANCE_ALERT": "⚠️ <b>Низкий баланс</b>\n\nВаш баланс: {balance} ₽\nПорог уведомления: {threshold} ₽\n\nПополните баланс, чтобы автопродление подписки прошло успешно.",
"SUBSCRIPTION_REVOKE_BTN": "🔄 Перевыпустить подписку",
"SUBSCRIPTION_REVOKE_TITLE": "⚠️ Перевыпуск подписки",
"SUBSCRIPTION_REVOKE_WARNING": "⚠️ <b>Перевыпуск подписки</b>\n\nЭто действие:\n• Сгенерирует новую ссылку подключения\n• Сбросит все подключённые устройства\n• Старая ссылка перестанет работать\n\nПродолжить?",
"SUBSCRIPTION_REVOKE_CONFIRM_BTN": "✅ Подтвердить",
"SUBSCRIPTION_REVOKE_SUCCESS": "✅ <b>Подписка перевыпущена!</b>\n\nНовая ссылка подключения готова. Старая ссылка больше не действительна.\n\nВсе устройства были отключены.",
"SUBSCRIPTION_REVOKE_COOLDOWN": "⏱ Перевыпуск будет доступен через {minutes} мин. {seconds} сек.",
"SUBSCRIPTION_REVOKE_DISABLED": "Перевыпуск подписки недоступен",
"SUBSCRIPTION_REVOKE_ERROR": "❌ Ошибка при перевыпуске подписки. Попробуйте позже.",
"SUBSCRIPTION_REVOKE_CONNECT_BTN": "🔗 Подключиться"
}
+11 -1
View File
@@ -1650,5 +1650,15 @@
"WEBHOOK_TORRENT_DETECTED": "🚫 <b>Виявлено торент</b>\n\nУ вашому підключенні{tariff_label} виявлено торент-трафік. Використання торентів може призвести до обмеження підписки.",
"WEBHOOK_CLOSE_BUTTON": "✖️ Закрити",
"TRAFFIC_WARNING_ALERT": "⚠️ <b>Попередження про трафік</b>\n\nВикористано: {used:.1f} / {limit} ГБ ({percent:.0f}%)\n\nВаш ліміт трафіку майже вичерпаний.",
"LOW_BALANCE_ALERT": "⚠️ <b>Низький баланс</b>\n\nВаш баланс: {balance} ₽\nПоріг сповіщення: {threshold} ₽\n\nПоповніть баланс, щоб автопродовження підписки пройшло успішно."
"LOW_BALANCE_ALERT": "⚠️ <b>Низький баланс</b>\n\nВаш баланс: {balance} ₽\nПоріг сповіщення: {threshold} ₽\n\nПоповніть баланс, щоб автопродовження підписки пройшло успішно.",
"SUBSCRIPTION_REVOKE_BTN": "🔄 Перевипустити підписку",
"SUBSCRIPTION_REVOKE_TITLE": "⚠️ Перевипуск підписки",
"SUBSCRIPTION_REVOKE_WARNING": "⚠️ <b>Перевипуск підписки</b>\n\nЦя дія:\n• Згенерує нове посилання підключення\n• Скине всі підключені пристрої\n• Старе посилання перестане працювати\n\nПродовжити?",
"SUBSCRIPTION_REVOKE_CONFIRM_BTN": "✅ Підтвердити",
"SUBSCRIPTION_REVOKE_SUCCESS": "✅ <b>Підписку перевипущено!</b>\n\nНове посилання підключення готове. Старе посилання більше не дійсне.\n\nВсі пристрої були відключені.",
"SUBSCRIPTION_REVOKE_COOLDOWN": "⏱ Перевипуск буде доступний через {minutes} хв. {seconds} сек.",
"SUBSCRIPTION_REVOKE_DISABLED": "Перевипуск підписки недоступний",
"SUBSCRIPTION_REVOKE_ERROR": "❌ Помилка при перевипуску підписки. Спробуйте пізніше.",
"SUBSCRIPTION_REVOKE_CONNECT_BTN": "🔗 Підключитися"
}
+11 -1
View File
@@ -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": "⚠️ <b>流量警告</b>\n\n已使用:{used:.1f} / {limit} GB ({percent:.0f}%)\n\n您的流量限制即将用完。",
"LOW_BALANCE_ALERT": "⚠️ <b>余额不足</b>\n\n您的余额:{balance} ₽\n通知阈值:{threshold} ₽\n\n请充值以确保订阅自动续费成功。"
"LOW_BALANCE_ALERT": "⚠️ <b>余额不足</b>\n\n您的余额:{balance} ₽\n通知阈值:{threshold} ₽\n\n请充值以确保订阅自动续费成功。",
"SUBSCRIPTION_REVOKE_BTN": "🔄 重新签发订阅",
"SUBSCRIPTION_REVOKE_TITLE": "⚠️ 重新签发订阅",
"SUBSCRIPTION_REVOKE_WARNING": "⚠️ <b>重新签发订阅</b>\n\n此操作将:\n• 生成新的连接链接\n• 断开所有已连接的设备\n• 旧链接将失效\n\n是否继续?",
"SUBSCRIPTION_REVOKE_CONFIRM_BTN": "✅ 确认",
"SUBSCRIPTION_REVOKE_SUCCESS": "✅ <b>订阅已重新签发!</b>\n\n新的连接链接已准备就绪。旧链接已失效。\n\n所有设备已断开连接。",
"SUBSCRIPTION_REVOKE_COOLDOWN": "⏱ 重新签发将在 {minutes} 分 {seconds} 秒后可用。",
"SUBSCRIPTION_REVOKE_DISABLED": "订阅重新签发不可用",
"SUBSCRIPTION_REVOKE_ERROR": "❌ 重新签发订阅时出错。请稍后重试。",
"SUBSCRIPTION_REVOKE_CONNECT_BTN": "🔗 连接"
}
@@ -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')