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:
@@ -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,
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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": "🔗 اتصال"
|
||||
}
|
||||
@@ -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": "🔗 Подключиться"
|
||||
}
|
||||
@@ -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": "🔗 Підключитися"
|
||||
}
|
||||
@@ -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": "🔗 连接"
|
||||
}
|
||||
Reference in New Issue
Block a user