diff --git a/app/config.py b/app/config.py
index e81dcb7c..8deced57 100644
--- a/app/config.py
+++ b/app/config.py
@@ -167,11 +167,6 @@ class Settings(BaseSettings):
DEVICES_SELECTION_ENABLED: bool = True
DEVICES_SELECTION_DISABLED_AMOUNT: int | None = None
- # Настройки модема
- MODEM_ENABLED: bool = False
- MODEM_PRICE_PER_MONTH: int = 10000 # Цена модема в копейках за месяц
- MODEM_PERIOD_DISCOUNTS: str = '' # Скидки на модем: "месяцев:процент,месяцев:процент" (напр. "3:10,6:15,12:20")
-
BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED: bool = False
BASE_PROMO_GROUP_PERIOD_DISCOUNTS: str = ''
@@ -1529,9 +1524,6 @@ class Settings(BaseSettings):
def get_disabled_mode_device_limit(self) -> int | None:
return self.get_devices_selection_disabled_amount()
- def is_modem_enabled(self) -> bool:
- return bool(self.MODEM_ENABLED)
-
def is_tariffs_mode(self) -> bool:
"""Проверяет, включен ли режим продаж 'Тарифы'."""
return self.SALES_MODE == 'tariffs'
@@ -1548,62 +1540,6 @@ class Settings(BaseSettings):
"""Возвращает ID тарифа для триала (0 = использовать стандартные настройки)."""
return max(0, self.TRIAL_TARIFF_ID)
- def get_modem_price_per_month(self) -> int:
- try:
- value = int(self.MODEM_PRICE_PER_MONTH)
- except (TypeError, ValueError):
- logger.warning(
- 'Некорректное значение MODEM_PRICE_PER_MONTH: %s',
- self.MODEM_PRICE_PER_MONTH,
- )
- return 10000
- return max(0, value)
-
- def get_modem_period_discounts(self) -> dict[int, int]:
- """Возвращает скидки на модем по количеству месяцев: {месяцев: процент_скидки}"""
- try:
- config_str = (self.MODEM_PERIOD_DISCOUNTS or '').strip()
- if not config_str:
- return {}
-
- discounts: dict[int, int] = {}
- for part in config_str.split(','):
- part = part.strip()
- if not part:
- continue
-
- months_and_discount = part.split(':')
- if len(months_and_discount) != 2:
- continue
-
- months_str, discount_str = months_and_discount
- try:
- months = int(months_str.strip())
- discount_percent = int(discount_str.strip())
- except ValueError:
- continue
-
- discounts[months] = max(0, min(100, discount_percent))
-
- return discounts
- except Exception:
- return {}
-
- def get_modem_period_discount(self, months: int) -> int:
- """Возвращает процент скидки для указанного количества месяцев"""
- if months <= 0:
- return 0
-
- discounts = self.get_modem_period_discounts()
-
- # Ищем точное совпадение или ближайшее меньшее
- applicable_discount = 0
- for discount_months, discount_percent in sorted(discounts.items()):
- if months >= discount_months:
- applicable_discount = discount_percent
-
- return applicable_discount
-
def is_trial_paid_activation_enabled(self) -> bool:
# TRIAL_PAYMENT_ENABLED - главный переключатель платной активации
# Если выключен - триал бесплатный, независимо от цены
diff --git a/app/handlers/admin/users.py b/app/handlers/admin/users.py
index b6982701..94d3f117 100644
--- a/app/handlers/admin/users.py
+++ b/app/handlers/admin/users.py
@@ -901,16 +901,6 @@ async def _render_user_subscription_overview(callback: types.CallbackQuery, db:
],
]
- if settings.is_modem_enabled():
- modem_status = '✅' if getattr(subscription, 'modem_enabled', False) else '❌'
- keyboard.append(
- [
- types.InlineKeyboardButton(
- text=f'📡 Модем ({modem_status})', callback_data=f'admin_user_modem_{user_id}'
- )
- ]
- )
-
# Кнопки тарифов в режиме тарифов
if settings.is_tariffs_mode():
keyboard.append(
@@ -3639,63 +3629,7 @@ async def set_user_devices_button(callback: types.CallbackQuery, db_user: User,
await callback.answer()
-@admin_required
-@error_handler
-async def toggle_user_modem(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
- """Переключение модема для пользователя в админке."""
- user_id = int(callback.data.split('_')[-1])
- user = await get_user_by_id(db, user_id)
- if not user:
- await callback.answer('❌ Пользователь не найден', show_alert=True)
- return
-
- subscription = user.subscription
- if not subscription:
- await callback.answer('❌ У пользователя нет подписки', show_alert=True)
- return
-
- modem_enabled = getattr(subscription, 'modem_enabled', False) or False
-
- if modem_enabled:
- # Отключаем модем
- subscription.modem_enabled = False
- if subscription.device_limit and subscription.device_limit > 1:
- subscription.device_limit = subscription.device_limit - 1
- action_text = 'отключен'
- else:
- # Включаем модем
- subscription.modem_enabled = True
- subscription.device_limit = (subscription.device_limit or 1) + 1
- action_text = 'подключен'
-
- subscription.updated_at = datetime.utcnow()
- await db.commit()
-
- # Обновляем в RemnaWave
- try:
- subscription_service = SubscriptionService()
- await subscription_service.update_remnawave_user(db, subscription)
- except Exception as e:
- logger.error(f'Ошибка обновления RemnaWave при переключении модема: {e}')
-
- await db.refresh(subscription)
-
- modem_status = '✅ Подключен' if subscription.modem_enabled else '❌ Отключен'
-
- await callback.message.edit_text(
- f'📡 Модем {action_text}\n\nСтатус модема: {modem_status}\nЛимит устройств: {subscription.device_limit}',
- reply_markup=types.InlineKeyboardMarkup(
- inline_keyboard=[
- [
- types.InlineKeyboardButton(
- text='📱 Подписка и настройки', callback_data=f'admin_user_subscription_{user_id}'
- )
- ]
- ]
- ),
- parse_mode='HTML',
- )
logger.info(f'Админ {db_user.telegram_id} {action_text} модем для пользователя {user_id}')
await callback.answer()
@@ -5578,8 +5512,6 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(set_user_devices_button, F.data.startswith('admin_user_devices_set_'))
- dp.callback_query.register(toggle_user_modem, F.data.startswith('admin_user_modem_'))
-
# Смена тарифа пользователя
dp.callback_query.register(show_admin_tariff_change, F.data.startswith('admin_sub_change_tariff_'))
diff --git a/app/handlers/simple_subscription.py b/app/handlers/simple_subscription.py
index 4e046781..095fd2c8 100644
--- a/app/handlers/simple_subscription.py
+++ b/app/handlers/simple_subscription.py
@@ -70,24 +70,15 @@ async def start_simple_subscription_purchase(
# (независимо от того, включён ли выбор устройств)
if current_subscription:
current_device_limit = current_subscription.device_limit or device_limit
- # Модем добавляет +1 к device_limit, но оплачивается отдельно
- if getattr(current_subscription, 'modem_enabled', False):
- current_device_limit = max(1, current_device_limit - 1)
# Используем максимум из текущего и дефолтного
device_limit = max(device_limit, current_device_limit)
- # Проверяем, включён ли модем у текущей подписки
- modem_enabled = False
- if current_subscription:
- modem_enabled = getattr(current_subscription, 'modem_enabled', False)
-
# Подготовим параметры простой подписки
subscription_params = {
'period_days': settings.SIMPLE_SUBSCRIPTION_PERIOD_DAYS,
'device_limit': device_limit,
'traffic_limit_gb': settings.SIMPLE_SUBSCRIPTION_TRAFFIC_GB,
'squad_uuid': settings.SIMPLE_SUBSCRIPTION_SQUAD_UUID,
- 'modem_enabled': modem_enabled,
}
# Сохраняем параметры в состояние
@@ -113,13 +104,12 @@ async def start_simple_subscription_purchase(
user_balance_kopeks = getattr(db_user, 'balance_kopeks', 0)
logger.warning(
- 'SIMPLE_SUBSCRIPTION_DEBUG_START | user=%s | period=%s | base=%s | traffic=%s | devices=%s | modem=%s | servers=%s | discount=%s | total=%s | squads=%s',
+ 'SIMPLE_SUBSCRIPTION_DEBUG_START | user=%s | period=%s | base=%s | traffic=%s | devices=%s | servers=%s | discount=%s | total=%s | squads=%s',
db_user.id,
period_days,
price_breakdown.get('base_price', 0),
price_breakdown.get('traffic_price', 0),
price_breakdown.get('devices_price', 0),
- price_breakdown.get('modem_price', 0),
price_breakdown.get('servers_price', 0),
price_breakdown.get('total_discount', 0),
price_kopeks,
diff --git a/app/handlers/subscription/__init__.py b/app/handlers/subscription/__init__.py
index 9585cb97..8d4b3637 100644
--- a/app/handlers/subscription/__init__.py
+++ b/app/handlers/subscription/__init__.py
@@ -64,13 +64,6 @@ from .links import (
handle_connect_subscription,
handle_open_subscription_link,
)
-from .modem import (
- handle_modem_confirm,
- handle_modem_disable,
- handle_modem_enable,
- handle_modem_menu,
- register_modem_handlers,
-)
from .notifications import (
send_extension_notification,
send_purchase_notification,
@@ -172,10 +165,6 @@ __all__ = [
'handle_happ_download_platform_choice',
'handle_happ_download_request',
'handle_manage_country',
- 'handle_modem_confirm',
- 'handle_modem_disable',
- 'handle_modem_enable',
- 'handle_modem_menu',
'handle_no_traffic_packages',
'handle_open_subscription_link',
'handle_promo_offer_close',
@@ -190,7 +179,6 @@ __all__ = [
'load_app_config',
'refresh_traffic_config',
'register_handlers',
- 'register_modem_handlers',
'resume_subscription_checkout',
'return_to_saved_cart',
'save_cart_and_redirect_to_topup',
diff --git a/app/handlers/subscription/modem.py b/app/handlers/subscription/modem.py
deleted file mode 100644
index d65e0546..00000000
--- a/app/handlers/subscription/modem.py
+++ /dev/null
@@ -1,323 +0,0 @@
-"""
-Хендлеры для управления модемом в подписке.
-
-Модем - это дополнительное устройство, которое можно подключить к подписке
-за отдельную плату. При подключении увеличивается лимит устройств.
-"""
-
-import logging
-
-from aiogram import Dispatcher, F, types
-from sqlalchemy.ext.asyncio import AsyncSession
-
-from app.config import settings
-from app.database.models import User
-from app.keyboards.inline import get_back_keyboard, get_insufficient_balance_keyboard
-from app.localization.texts import get_texts
-from app.services.modem_service import (
- ModemError,
- get_modem_service,
-)
-from app.utils.decorators import error_handler, modem_available
-
-
-logger = logging.getLogger(__name__)
-
-
-def get_modem_keyboard(language: str, modem_enabled: bool):
- """Клавиатура управления модемом."""
- texts = get_texts(language)
- keyboard = []
-
- if modem_enabled:
- keyboard.append(
- [
- types.InlineKeyboardButton(
- text=texts.t('MODEM_DISABLE_BUTTON', 'Отключить модем'), callback_data='modem_disable'
- )
- ]
- )
- else:
- keyboard.append(
- [
- types.InlineKeyboardButton(
- text=texts.t('MODEM_ENABLE_BUTTON', 'Подключить модем'), callback_data='modem_enable'
- )
- ]
- )
-
- keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data='subscription_settings')])
-
- return types.InlineKeyboardMarkup(inline_keyboard=keyboard)
-
-
-def get_modem_confirm_keyboard(language: str):
- """Клавиатура подтверждения подключения модема."""
- texts = get_texts(language)
- return types.InlineKeyboardMarkup(
- inline_keyboard=[
- [
- types.InlineKeyboardButton(
- text=texts.t('MODEM_CONFIRM_BUTTON', 'Подтвердить подключение'), callback_data='modem_confirm'
- )
- ],
- [types.InlineKeyboardButton(text=texts.CANCEL, callback_data='subscription_modem')],
- ]
- )
-
-
-@error_handler
-@modem_available()
-async def handle_modem_menu(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
- """Показывает меню управления модемом."""
- texts = get_texts(db_user.language)
- subscription = db_user.subscription
- service = get_modem_service()
-
- modem_enabled = service.get_modem_enabled(subscription)
- modem_price = settings.get_modem_price_per_month()
-
- if modem_enabled:
- status_text = texts.t('MODEM_STATUS_ENABLED', 'Подключен')
- info_text = texts.t(
- 'MODEM_INFO_ENABLED',
- (
- 'Модем\n\n'
- 'Статус: {status}\n\n'
- 'Модем подключен к вашей подписке.\n'
- 'Ежемесячная плата: {price}\n\n'
- 'При отключении модема возврат средств не производится.'
- ),
- ).format(
- status=status_text,
- price=texts.format_price(modem_price),
- )
- else:
- status_text = texts.t('MODEM_STATUS_DISABLED', 'Не подключен')
- info_text = texts.t(
- 'MODEM_INFO_DISABLED',
- (
- 'Модем\n\n'
- 'Статус: {status}\n\n'
- 'Подключите модем к вашей подписке.\n'
- 'Ежемесячная плата: {price}\n\n'
- 'При подключении модема будет добавлено дополнительное устройство.'
- ),
- ).format(
- status=status_text,
- price=texts.format_price(modem_price),
- )
-
- await callback.message.edit_text(
- info_text, reply_markup=get_modem_keyboard(db_user.language, modem_enabled), parse_mode='HTML'
- )
- await callback.answer()
-
-
-@error_handler
-@modem_available(for_enable=True)
-async def handle_modem_enable(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
- """Обработчик подключения модема - показывает информацию о цене."""
- texts = get_texts(db_user.language)
- subscription = db_user.subscription
- service = get_modem_service()
-
- price_info = service.calculate_price(subscription)
- modem_price_per_month = settings.get_modem_price_per_month()
-
- has_funds, missing_kopeks = service.check_balance(db_user, price_info.final_price)
-
- if not has_funds:
- if price_info.has_discount:
- required_text = (
- f'{texts.format_price(price_info.final_price)} '
- f'(за {price_info.charged_months} мес, скидка {price_info.discount_percent}%)'
- )
- else:
- required_text = f'{texts.format_price(price_info.final_price)} (за {price_info.charged_months} мес)'
-
- message_text = texts.t(
- 'MODEM_INSUFFICIENT_FUNDS',
- (
- 'Недостаточно средств\n\n'
- 'Стоимость подключения модема: {required}\n'
- 'На балансе: {balance}\n'
- 'Не хватает: {missing}\n\n'
- 'Выберите способ пополнения.'
- ),
- ).format(
- required=required_text,
- balance=texts.format_price(db_user.balance_kopeks),
- missing=texts.format_price(missing_kopeks),
- )
-
- await callback.message.edit_text(
- message_text,
- reply_markup=get_insufficient_balance_keyboard(
- db_user.language,
- amount_kopeks=missing_kopeks,
- ),
- parse_mode='HTML',
- )
- await callback.answer()
- return
-
- warning_level = service.get_period_warning_level(price_info.remaining_days)
-
- if warning_level == 'critical':
- warning_text = texts.t(
- 'MODEM_SHORT_PERIOD_WARNING',
- '\nВнимание! До окончания подписки осталось всего {days} дн.\n'
- 'После продления подписки модем нужно будет оплатить заново!',
- ).format(days=price_info.remaining_days)
- elif warning_level == 'info':
- warning_text = texts.t(
- 'MODEM_PERIOD_NOTE',
- '\nДо окончания подписки: {days} дн.\nПосле продления модем нужно будет оплатить заново.',
- ).format(days=price_info.remaining_days)
- else:
- warning_text = ''
-
- if price_info.has_discount:
- price_text = texts.t(
- 'MODEM_PRICE_WITH_DISCOUNT',
- 'Стоимость: {base_price} {final_price} (за {months} мес)\n'
- 'Скидка {discount}%: -{discount_amount}',
- ).format(
- base_price=texts.format_price(price_info.base_price),
- final_price=texts.format_price(price_info.final_price),
- months=price_info.charged_months,
- discount=price_info.discount_percent,
- discount_amount=texts.format_price(price_info.discount_amount),
- )
- else:
- price_text = texts.t('MODEM_PRICE_NO_DISCOUNT', 'Стоимость: {price} (за {months} мес)').format(
- price=texts.format_price(price_info.final_price),
- months=price_info.charged_months,
- )
-
- confirm_text = texts.t(
- 'MODEM_CONFIRM_ENABLE_BASE',
- (
- 'Подтверждение подключения модема\n\n'
- '{price_text}\n\n'
- 'При подключении модема:\n'
- 'К подписке добавится дополнительное устройство\n'
- 'Ежемесячная плата увеличится на {monthly_price}\n\n'
- 'Подтвердить подключение?'
- ),
- ).format(
- price_text=price_text,
- monthly_price=texts.format_price(modem_price_per_month),
- )
-
- end_date_str = price_info.end_date.strftime('%d.%m.%Y')
- period_info = texts.t('MODEM_PERIOD_INFO', '\nМодем действует до: {end_date} ({days} дн.)').format(
- end_date=end_date_str, days=price_info.remaining_days
- )
-
- confirm_text += period_info + warning_text
-
- await callback.message.edit_text(
- confirm_text, reply_markup=get_modem_confirm_keyboard(db_user.language), parse_mode='HTML'
- )
- await callback.answer()
-
-
-@error_handler
-@modem_available(for_enable=True)
-async def handle_modem_confirm(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
- """Подтверждение и активация модема."""
- texts = get_texts(db_user.language)
- subscription = db_user.subscription
- service = get_modem_service()
-
- result = await service.enable_modem(db, db_user, subscription)
-
- if not result.success:
- error_messages = {
- ModemError.INSUFFICIENT_FUNDS: texts.t('MODEM_INSUFFICIENT_FUNDS_SHORT', 'Недостаточно средств на балансе'),
- ModemError.CHARGE_ERROR: texts.t('PAYMENT_CHARGE_ERROR', 'Ошибка списания средств'),
- ModemError.UPDATE_ERROR: texts.ERROR,
- }
-
- error_text = error_messages.get(result.error, texts.ERROR)
-
- if result.error == ModemError.INSUFFICIENT_FUNDS:
- await callback.message.edit_text(
- error_text, reply_markup=get_back_keyboard(db_user.language, 'modem_enable'), parse_mode='HTML'
- )
- else:
- await callback.answer(error_text, show_alert=True)
- return
-
- try:
- from app.services.admin_notification_service import AdminNotificationService
-
- notification_service = AdminNotificationService(callback.bot)
- await notification_service.send_subscription_update_notification(
- db, db_user, subscription, 'modem', False, True, result.charged_amount
- )
- except Exception as e:
- logger.error(f'Ошибка отправки уведомления о подключении модема: {e}')
-
- success_text = texts.t(
- 'MODEM_ENABLED_SUCCESS',
- ('Модем успешно подключен!\n\nМодем активирован\nДобавлено устройство для модема\n'),
- )
- if result.charged_amount > 0:
- success_text += texts.t(
- 'MODEM_CHARGED',
- 'Списано: {amount}',
- ).format(amount=texts.format_price(result.charged_amount))
-
- await callback.message.edit_text(
- success_text, reply_markup=get_back_keyboard(db_user.language, 'subscription_settings'), parse_mode='HTML'
- )
- await callback.answer()
-
-
-@error_handler
-@modem_available(for_disable=True)
-async def handle_modem_disable(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
- """Отключение модема."""
- texts = get_texts(db_user.language)
- subscription = db_user.subscription
- service = get_modem_service()
-
- result = await service.disable_modem(db, db_user, subscription)
-
- if not result.success:
- await callback.answer(texts.ERROR, show_alert=True)
- return
-
- try:
- from app.services.admin_notification_service import AdminNotificationService
-
- notification_service = AdminNotificationService(callback.bot)
- await notification_service.send_subscription_update_notification(
- db, db_user, subscription, 'modem', True, False, 0
- )
- except Exception as e:
- logger.error(f'Ошибка отправки уведомления об отключении модема: {e}')
-
- success_text = texts.t(
- 'MODEM_DISABLED_SUCCESS',
- ('Модем отключен\n\nМодем деактивирован\nВозврат средств не производится'),
- )
-
- await callback.message.edit_text(
- success_text, reply_markup=get_back_keyboard(db_user.language, 'subscription_settings'), parse_mode='HTML'
- )
- await callback.answer()
-
-
-def register_modem_handlers(dp: Dispatcher):
- """Регистрация обработчиков модема."""
- dp.callback_query.register(handle_modem_menu, F.data == 'subscription_modem')
-
- dp.callback_query.register(handle_modem_enable, F.data == 'modem_enable')
-
- dp.callback_query.register(handle_modem_confirm, F.data == 'modem_confirm')
-
- dp.callback_query.register(handle_modem_disable, F.data == 'modem_disable')
diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py
index 723d0d4f..7c714850 100644
--- a/app/handlers/subscription/purchase.py
+++ b/app/handlers/subscription/purchase.py
@@ -426,14 +426,7 @@ async def show_subscription_info(callback: types.CallbackQuery, db_user: User, d
'',
)
- # Формируем отображение лимита устройств с учётом модема
- modem_enabled = getattr(subscription, 'modem_enabled', False) or False
- if modem_enabled and settings.is_modem_enabled():
- # Показываем лимит без модема + модем
- visible_device_limit = (subscription.device_limit or 1) - 1
- device_limit_display = f'{visible_device_limit} + модем'
- else:
- device_limit_display = str(subscription.device_limit)
+ device_limit_display = str(subscription.device_limit)
message = message_template.format(
full_name=db_user.full_name,
@@ -1603,11 +1596,6 @@ async def handle_extend_subscription(callback: types.CallbackQuery, db_user: Use
else:
device_limit = forced_limit
- # Модем добавляет +1 к device_limit, но оплачивается отдельно,
- # поэтому не должен учитываться как платное устройство при продлении
- if getattr(subscription, 'modem_enabled', False):
- device_limit = max(1, device_limit - 1)
-
additional_devices = max(0, (device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT)
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
devices_total_base = devices_price_per_month * months_in_period
@@ -1814,11 +1802,6 @@ async def confirm_extend_subscription(callback: types.CallbackQuery, db_user: Us
else:
device_limit = forced_limit
- # Модем добавляет +1 к device_limit, но оплачивается отдельно,
- # поэтому не должен учитываться как платное устройство при продлении
- if getattr(subscription, 'modem_enabled', False):
- device_limit = max(1, device_limit - 1)
-
additional_devices = max(0, (device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT)
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
devices_discount_percent = db_user.get_promo_discount(
@@ -3065,13 +3048,7 @@ async def handle_subscription_settings(callback: types.CallbackQuery, db_user: U
'',
)
- # Формируем отображение лимита устройств с учётом модема
- modem_enabled = getattr(subscription, 'modem_enabled', False) or False
- if modem_enabled and settings.is_modem_enabled():
- visible_device_limit = (subscription.device_limit or 1) - 1
- devices_limit_display = f'{visible_device_limit} + модем'
- else:
- devices_limit_display = str(subscription.device_limit)
+ devices_limit_display = str(subscription.device_limit)
settings_text = settings_template.format(
countries_count=len(subscription.connected_squads),
@@ -4118,11 +4095,6 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(show_device_connection_help, F.data == 'device_connection_help')
- # Регистрируем обработчики модема
- from .modem import register_modem_handlers
-
- register_modem_handlers(dp)
-
# Регистрируем обработчики покупки по тарифам
from .tariff_purchase import register_tariff_purchase_handlers
@@ -4157,10 +4129,6 @@ async def handle_simple_subscription_purchase(
if current_subscription and current_subscription.is_active:
# При продлении используем текущие устройства подписки, а не дефолтные
extend_device_limit = current_subscription.device_limit or simple_device_limit
- # Модем добавляет +1 к device_limit, но оплачивается отдельно
- modem_enabled = getattr(current_subscription, 'modem_enabled', False)
- if modem_enabled:
- extend_device_limit = max(1, extend_device_limit - 1)
# Используем максимум из текущего и дефолтного
extend_device_limit = max(simple_device_limit, extend_device_limit)
@@ -4174,7 +4142,6 @@ async def handle_simple_subscription_purchase(
device_limit=extend_device_limit,
traffic_limit_gb=settings.SIMPLE_SUBSCRIPTION_TRAFFIC_GB,
squad_uuid=settings.SIMPLE_SUBSCRIPTION_SQUAD_UUID,
- modem_enabled=modem_enabled,
)
return
@@ -4294,7 +4261,6 @@ async def _extend_existing_subscription(
device_limit: int,
traffic_limit_gb: int,
squad_uuid: str,
- modem_enabled: bool = False,
):
"""Продлевает существующую подписку."""
from datetime import datetime, timedelta
@@ -4312,7 +4278,6 @@ async def _extend_existing_subscription(
'device_limit': device_limit,
'traffic_limit_gb': traffic_limit_gb,
'squad_uuid': squad_uuid,
- 'modem_enabled': modem_enabled,
}
price_kopeks, price_breakdown = await _calculate_simple_subscription_price(
db,
@@ -4321,17 +4286,15 @@ async def _extend_existing_subscription(
resolved_squad_uuid=squad_uuid,
)
logger.warning(
- 'SIMPLE_SUBSCRIPTION_EXTEND_PRICE | user=%s | total=%s | base=%s | traffic=%s | devices=%s | modem=%s | servers=%s | discount=%s | device_limit=%s | modem_enabled=%s',
+ 'SIMPLE_SUBSCRIPTION_EXTEND_PRICE | user=%s | total=%s | base=%s | traffic=%s | devices=%s | servers=%s | discount=%s | device_limit=%s',
db_user.id,
price_kopeks,
price_breakdown.get('base_price', 0),
price_breakdown.get('traffic_price', 0),
price_breakdown.get('devices_price', 0),
- price_breakdown.get('modem_price', 0),
price_breakdown.get('servers_price', 0),
price_breakdown.get('total_discount', 0),
device_limit,
- modem_enabled,
)
# Проверяем баланс пользователя
diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py
index 30938555..8ef7adae 100644
--- a/app/keyboards/inline.py
+++ b/app/keyboards/inline.py
@@ -2620,10 +2620,6 @@ def get_updated_subscription_settings_keyboard(
]
)
- if settings.is_modem_enabled() and not has_tariff:
- keyboard.append(
- [InlineKeyboardButton(text=texts.t('MODEM_BUTTON', '📡 Модем'), callback_data='subscription_modem')]
- )
keyboard.append(
[
diff --git a/app/services/admin_notification_service.py b/app/services/admin_notification_service.py
index 297f016e..29e3267d 100644
--- a/app/services/admin_notification_service.py
+++ b/app/services/admin_notification_service.py
@@ -1534,7 +1534,6 @@ class AdminNotificationService:
'traffic': '📊 ДОКУПКА ТРАФИКА',
'devices': '📱 ДОКУПКА УСТРОЙСТВ',
'servers': '🌐 СМЕНА СЕРВЕРОВ',
- 'modem': '📡 МОДЕМ',
}
title = update_titles.get(update_type, '⚙️ ИЗМЕНЕНИЕ ПОДПИСКИ')
@@ -1570,10 +1569,6 @@ class AdminNotificationService:
message_lines.append(f'🔄 {old_formatted} → {new_formatted}')
elif update_type == 'devices':
message_lines.append(f'🔄 {old_value} → {new_value} устр.')
- elif update_type == 'modem':
- old_state = '✅ Вкл' if old_value else '❌ Выкл'
- new_state = '✅ Вкл' if new_value else '❌ Выкл'
- message_lines.append(f'🔄 {old_state} → {new_state}')
else:
message_lines.append(f'🔄 {old_value} → {new_value}')
@@ -1638,8 +1633,6 @@ class AdminNotificationService:
if isinstance(value, list):
return f'{len(value)} серверов'
return str(value)
- if update_type == 'modem':
- return '✅ Включён' if value else '❌ Выключен'
return str(value)
async def send_bulk_ban_notification(
diff --git a/app/services/modem_service.py b/app/services/modem_service.py
deleted file mode 100644
index d410ebcb..00000000
--- a/app/services/modem_service.py
+++ /dev/null
@@ -1,349 +0,0 @@
-"""
-Сервис для управления модемом в подписке.
-
-Модем - это дополнительное устройство, которое можно подключить к подписке
-за отдельную плату. При подключении увеличивается лимит устройств.
-"""
-
-import logging
-from dataclasses import dataclass
-from datetime import datetime
-from enum import Enum
-
-from sqlalchemy.ext.asyncio import AsyncSession
-
-from app.config import settings
-from app.database.crud.transaction import create_transaction
-from app.database.crud.user import subtract_user_balance
-from app.database.models import Subscription, TransactionType, User
-from app.services.subscription_service import SubscriptionService
-from app.utils.pricing_utils import calculate_prorated_price
-
-
-logger = logging.getLogger(__name__)
-
-
-class ModemError(Enum):
- """Типы ошибок при работе с модемом."""
-
- NO_SUBSCRIPTION = 'no_subscription'
- TRIAL_SUBSCRIPTION = 'trial_subscription'
- MODEM_DISABLED = 'modem_disabled'
- ALREADY_ENABLED = 'already_enabled'
- NOT_ENABLED = 'not_enabled'
- INSUFFICIENT_FUNDS = 'insufficient_funds'
- CHARGE_ERROR = 'charge_error'
- UPDATE_ERROR = 'update_error'
-
-
-@dataclass
-class ModemAvailabilityResult:
- """Результат проверки доступности модема."""
-
- available: bool
- error: ModemError | None = None
- modem_enabled: bool = False
-
-
-@dataclass
-class ModemPriceResult:
- """Результат расчёта цены модема."""
-
- base_price: int
- final_price: int
- discount_percent: int
- discount_amount: int
- charged_months: int
- remaining_days: int
- end_date: datetime
-
- @property
- def has_discount(self) -> bool:
- return self.discount_percent > 0
-
-
-@dataclass
-class ModemEnableResult:
- """Результат подключения модема."""
-
- success: bool
- error: ModemError | None = None
- charged_amount: int = 0
- new_device_limit: int = 0
-
-
-@dataclass
-class ModemDisableResult:
- """Результат отключения модема."""
-
- success: bool
- error: ModemError | None = None
- new_device_limit: int = 0
-
-
-# Константы для предупреждений о сроке действия
-MODEM_WARNING_DAYS_CRITICAL = 7
-MODEM_WARNING_DAYS_INFO = 30
-
-
-class ModemService:
- """
- Сервис для управления модемом в подписке.
-
- Инкапсулирует всю бизнес-логику:
- - Проверки доступности
- - Расчёт цен и скидок
- - Подключение/отключение модема
- - Синхронизация с RemnaWave
- """
-
- def __init__(self):
- self._subscription_service = SubscriptionService()
-
- @staticmethod
- def is_modem_feature_enabled() -> bool:
- """Проверяет, включена ли функция модема в настройках."""
- return settings.is_modem_enabled()
-
- @staticmethod
- def get_modem_enabled(subscription: Subscription | None) -> bool:
- """Безопасно получает статус модема из подписки."""
- if subscription is None:
- return False
- return getattr(subscription, 'modem_enabled', False) or False
-
- def check_availability(
- self, user: User, for_enable: bool = False, for_disable: bool = False
- ) -> ModemAvailabilityResult:
- """
- Проверяет доступность модема для пользователя.
-
- Args:
- user: Пользователь
- for_enable: Проверка для подключения (модем должен быть отключен)
- for_disable: Проверка для отключения (модем должен быть включен)
-
- Returns:
- ModemAvailabilityResult с результатом проверки
- """
- subscription = user.subscription
- modem_enabled = self.get_modem_enabled(subscription)
-
- if not subscription:
- return ModemAvailabilityResult(
- available=False, error=ModemError.NO_SUBSCRIPTION, modem_enabled=modem_enabled
- )
-
- if subscription.is_trial:
- return ModemAvailabilityResult(
- available=False, error=ModemError.TRIAL_SUBSCRIPTION, modem_enabled=modem_enabled
- )
-
- if not self.is_modem_feature_enabled():
- return ModemAvailabilityResult(
- available=False, error=ModemError.MODEM_DISABLED, modem_enabled=modem_enabled
- )
-
- if for_enable and modem_enabled:
- return ModemAvailabilityResult(
- available=False, error=ModemError.ALREADY_ENABLED, modem_enabled=modem_enabled
- )
-
- if for_disable and not modem_enabled:
- return ModemAvailabilityResult(available=False, error=ModemError.NOT_ENABLED, modem_enabled=modem_enabled)
-
- return ModemAvailabilityResult(available=True, modem_enabled=modem_enabled)
-
- def calculate_price(self, subscription: Subscription) -> ModemPriceResult:
- """
- Рассчитывает стоимость подключения модема.
-
- Использует пропорциональную цену на основе оставшегося времени подписки
- и применяет скидки в зависимости от периода.
-
- Args:
- subscription: Подписка пользователя
-
- Returns:
- ModemPriceResult с детализацией цены
- """
- modem_price_per_month = settings.get_modem_price_per_month()
-
- base_price, charged_months = calculate_prorated_price(
- modem_price_per_month,
- subscription.end_date,
- )
-
- now = datetime.utcnow()
- remaining_days = max(0, (subscription.end_date - now).days)
-
- discount_percent = settings.get_modem_period_discount(charged_months)
- if discount_percent > 0:
- discount_amount = base_price * discount_percent // 100
- final_price = base_price - discount_amount
- else:
- discount_amount = 0
- final_price = base_price
-
- return ModemPriceResult(
- base_price=base_price,
- final_price=final_price,
- discount_percent=discount_percent,
- discount_amount=discount_amount,
- charged_months=charged_months,
- remaining_days=remaining_days,
- end_date=subscription.end_date,
- )
-
- def check_balance(self, user: User, price: int) -> tuple[bool, int]:
- """
- Проверяет достаточность баланса.
-
- Args:
- user: Пользователь
- price: Требуемая сумма
-
- Returns:
- Tuple[достаточно ли средств, недостающая сумма]
- """
- if price <= 0:
- return True, 0
-
- if user.balance_kopeks >= price:
- return True, 0
-
- missing = price - user.balance_kopeks
- return False, missing
-
- async def enable_modem(self, db: AsyncSession, user: User, subscription: Subscription) -> ModemEnableResult:
- """
- Подключает модем к подписке.
-
- Выполняет:
- 1. Расчёт цены
- 2. Проверку баланса
- 3. Списание средств
- 4. Создание транзакции
- 5. Обновление подписки
- 6. Синхронизацию с RemnaWave
-
- Args:
- db: Сессия базы данных
- user: Пользователь
- subscription: Подписка
-
- Returns:
- ModemEnableResult с результатом операции
- """
- price_info = self.calculate_price(subscription)
- price = price_info.final_price
-
- has_funds, _ = self.check_balance(user, price)
- if not has_funds:
- return ModemEnableResult(success=False, error=ModemError.INSUFFICIENT_FUNDS)
-
- try:
- if price > 0:
- success = await subtract_user_balance(db, user, price, 'Подключение модема')
-
- if not success:
- return ModemEnableResult(success=False, error=ModemError.CHARGE_ERROR)
-
- await create_transaction(
- db=db,
- user_id=user.id,
- type=TransactionType.SUBSCRIPTION_PAYMENT,
- amount_kopeks=price,
- description=f'Подключение модема на {price_info.charged_months} мес',
- )
-
- subscription.modem_enabled = True
- subscription.device_limit = (subscription.device_limit or 1) + 1
- subscription.updated_at = datetime.utcnow()
-
- await db.commit()
-
- await self._subscription_service.update_remnawave_user(db, subscription)
-
- await db.refresh(user)
- await db.refresh(subscription)
-
- user_id_display = user.telegram_id or user.email or f'#{user.id}'
- logger.info(f'Пользователь {user_id_display} подключил модем, списано: {price / 100}₽')
-
- return ModemEnableResult(success=True, charged_amount=price, new_device_limit=subscription.device_limit)
-
- except Exception as e:
- user_id_display = user.telegram_id or user.email or f'#{user.id}'
- logger.error(f'Ошибка подключения модема для пользователя {user_id_display}: {e}')
- await db.rollback()
- return ModemEnableResult(success=False, error=ModemError.UPDATE_ERROR)
-
- async def disable_modem(self, db: AsyncSession, user: User, subscription: Subscription) -> ModemDisableResult:
- """
- Отключает модем от подписки.
-
- Возврат средств не производится.
-
- Args:
- db: Сессия базы данных
- user: Пользователь
- subscription: Подписка
-
- Returns:
- ModemDisableResult с результатом операции
- """
- try:
- subscription.modem_enabled = False
- if subscription.device_limit and subscription.device_limit > 1:
- subscription.device_limit = subscription.device_limit - 1
- subscription.updated_at = datetime.utcnow()
-
- await db.commit()
-
- await self._subscription_service.update_remnawave_user(db, subscription)
-
- await db.refresh(user)
- await db.refresh(subscription)
-
- user_id_display = user.telegram_id or user.email or f'#{user.id}'
- logger.info(f'Пользователь {user_id_display} отключил модем')
-
- return ModemDisableResult(success=True, new_device_limit=subscription.device_limit)
-
- except Exception as e:
- user_id_display = user.telegram_id or user.email or f'#{user.id}'
- logger.error(f'Ошибка отключения модема для пользователя {user_id_display}: {e}')
- await db.rollback()
- return ModemDisableResult(success=False, error=ModemError.UPDATE_ERROR)
-
- @staticmethod
- def get_period_warning_level(remaining_days: int) -> str | None:
- """
- Определяет уровень предупреждения о сроке действия.
-
- Args:
- remaining_days: Оставшиеся дни подписки
-
- Returns:
- "critical" если <= 7 дней
- "info" если <= 30 дней
- None если больше 30 дней
- """
- if remaining_days <= MODEM_WARNING_DAYS_CRITICAL:
- return 'critical'
- if remaining_days <= MODEM_WARNING_DAYS_INFO:
- return 'info'
- return None
-
-
-# Singleton instance для использования в хендлерах
-_modem_service: ModemService | None = None
-
-
-def get_modem_service() -> ModemService:
- """Возвращает singleton экземпляр ModemService."""
- global _modem_service
- if _modem_service is None:
- _modem_service = ModemService()
- return _modem_service
diff --git a/app/services/subscription_renewal_service.py b/app/services/subscription_renewal_service.py
index 7a79333b..12ff3782 100644
--- a/app/services/subscription_renewal_service.py
+++ b/app/services/subscription_renewal_service.py
@@ -336,11 +336,6 @@ class SubscriptionRenewalService:
if devices_limit is None:
devices_limit = settings.DEFAULT_DEVICE_LIMIT
- # Модем добавляет +1 к device_limit, но оплачивается отдельно,
- # поэтому не должен учитываться как платное устройство при продлении
- if getattr(subscription, 'modem_enabled', False):
- devices_limit = max(1, devices_limit - 1)
-
total_cost, details = await calculate_subscription_total_cost(
db,
period_days,
diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py
index e8d37dcc..c5f99548 100644
--- a/app/services/subscription_service.py
+++ b/app/services/subscription_service.py
@@ -790,11 +790,6 @@ class SubscriptionService:
else:
device_limit = forced_limit
- # Модем добавляет +1 к device_limit, но оплачивается отдельно,
- # поэтому не должен учитываться как платное устройство при продлении
- if getattr(subscription, 'modem_enabled', False):
- device_limit = max(1, device_limit - 1)
-
devices_price = max(0, (device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
devices_discount_percent = _resolve_discount_percent(
user,
diff --git a/app/utils/decorators.py b/app/utils/decorators.py
index 6f917634..646b0b75 100644
--- a/app/utils/decorators.py
+++ b/app/utils/decorators.py
@@ -175,73 +175,4 @@ def rate_limit(rate: float = 1.0, key: str = None):
return decorator
-def modem_available(for_enable: bool = False, for_disable: bool = False):
- """
- Декоратор для проверки доступности модема.
- Проверяет:
- - Наличие подписки
- - Подписка не триальная
- - Функция модема включена в настройках
- - (опционально) Модем ещё не подключен (for_enable=True)
- - (опционально) Модем уже подключен (for_disable=True)
-
- Args:
- for_enable: Проверять, что модем ещё не подключен
- for_disable: Проверять, что модем подключен
-
- Usage:
- @modem_available()
- async def handle_modem_menu(callback, db_user, db): ...
-
- @modem_available(for_enable=True)
- async def handle_modem_enable(callback, db_user, db): ...
- """
-
- def decorator(func: Callable) -> Callable:
- @functools.wraps(func)
- async def wrapper(event: types.Update, *args, **kwargs) -> Any:
- db_user = kwargs.get('db_user')
-
- if not db_user:
- logger.warning('modem_available: нет db_user в kwargs')
- return None
-
- from app.services.modem_service import ModemError, get_modem_service
-
- service = get_modem_service()
- result = service.check_availability(db_user, for_enable=for_enable, for_disable=for_disable)
-
- if not result.available:
- texts = get_texts(db_user.language if db_user else 'ru')
-
- error_messages = {
- ModemError.NO_SUBSCRIPTION: texts.t(
- 'MODEM_PAID_ONLY', 'Модем доступен только для платных подписок'
- ),
- ModemError.TRIAL_SUBSCRIPTION: texts.t(
- 'MODEM_PAID_ONLY', 'Модем доступен только для платных подписок'
- ),
- ModemError.MODEM_DISABLED: texts.t('MODEM_DISABLED', 'Функция модема отключена'),
- ModemError.ALREADY_ENABLED: texts.t('MODEM_ALREADY_ENABLED', 'Модем уже подключен'),
- ModemError.NOT_ENABLED: texts.t('MODEM_NOT_ENABLED', 'Модем не подключен'),
- }
-
- error_text = error_messages.get(result.error, texts.ERROR)
-
- try:
- if isinstance(event, types.CallbackQuery):
- await event.answer(error_text, show_alert=True)
- elif isinstance(event, types.Message):
- await event.answer(error_text)
- except TelegramBadRequest as e:
- if 'query is too old' not in str(e).lower():
- raise
-
- return None
-
- return await func(event, *args, **kwargs)
-
- return wrapper
-
- return decorator
diff --git a/app/utils/pricing_utils.py b/app/utils/pricing_utils.py
index 5993f4bd..f5175358 100644
--- a/app/utils/pricing_utils.py
+++ b/app/utils/pricing_utils.py
@@ -129,14 +129,6 @@ async def compute_simple_subscription_price(
additional_devices = max(0, device_limit - settings.DEFAULT_DEVICE_LIMIT)
devices_price_original = additional_devices * settings.PRICE_PER_DEVICE
- # Расчёт цены модема (если включён)
- modem_enabled = params.get('modem_enabled', False)
- modem_price_original = 0
- if modem_enabled and settings.is_modem_enabled():
- modem_price_per_month = settings.get_modem_price_per_month()
- months = calculate_months_from_days(period_days)
- modem_price_original = modem_price_per_month * months
-
promo_group: PromoGroup | None = params.get('promo_group')
if promo_group is None:
@@ -260,7 +252,6 @@ async def compute_simple_subscription_price(
+ traffic_price_original
+ devices_price_original
+ servers_price_original
- + modem_price_original
)
total_discount = base_discount + traffic_discount + devices_discount + servers_discount_total
@@ -274,8 +265,6 @@ async def compute_simple_subscription_price(
'traffic_discount': traffic_discount,
'devices_price': devices_price_original,
'devices_discount': devices_discount,
- 'modem_price': modem_price_original,
- 'modem_enabled': modem_enabled,
'servers_price': servers_price_original,
'servers_discount': servers_discount_total,
'servers_final': sum(item['final_price'] for item in server_breakdown),
diff --git a/app/webapi/routes/subscriptions.py b/app/webapi/routes/subscriptions.py
index 979a34a2..d694a25e 100644
--- a/app/webapi/routes/subscriptions.py
+++ b/app/webapi/routes/subscriptions.py
@@ -30,7 +30,6 @@ from ..schemas.subscriptions import (
SubscriptionCreateRequest,
SubscriptionDevicesRequest,
SubscriptionExtendRequest,
- SubscriptionModemRequest,
SubscriptionResponse,
SubscriptionSquadRequest,
SubscriptionTrafficRequest,
@@ -54,7 +53,6 @@ def _serialize_subscription(subscription: Subscription) -> SubscriptionResponse:
traffic_limit_gb=subscription.traffic_limit_gb,
traffic_used_gb=subscription.traffic_used_gb,
device_limit=subscription.device_limit,
- modem_enabled=getattr(subscription, 'modem_enabled', False) or False,
autopay_enabled=subscription.autopay_enabled,
autopay_days_before=subscription.autopay_days_before,
subscription_url=subscription.subscription_url,
@@ -325,39 +323,3 @@ async def delete_subscription(
return _serialize_subscription(subscription)
-@router.post('/{subscription_id}/modem', response_model=SubscriptionResponse)
-async def set_subscription_modem(
- subscription_id: int,
- payload: SubscriptionModemRequest,
- _: Any = Security(require_api_token),
- db: AsyncSession = Depends(get_db_session),
-) -> SubscriptionResponse:
- """Включить или выключить модем для подписки."""
- subscription = await _get_subscription(db, subscription_id)
-
- if subscription.is_trial:
- raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Modem is not available for trial subscriptions')
-
- if not settings.is_modem_enabled():
- raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Modem feature is disabled')
-
- current_modem = getattr(subscription, 'modem_enabled', False) or False
-
- if payload.enabled == current_modem:
- return _serialize_subscription(subscription)
-
- if payload.enabled:
- subscription.modem_enabled = True
- subscription.device_limit = (subscription.device_limit or 1) + 1
- else:
- subscription.modem_enabled = False
- if subscription.device_limit and subscription.device_limit > 1:
- subscription.device_limit = subscription.device_limit - 1
-
- await db.commit()
-
- subscription_service = SubscriptionService()
- await subscription_service.update_remnawave_user(db, subscription)
-
- subscription = await _get_subscription(db, subscription.id)
- return _serialize_subscription(subscription)
diff --git a/app/webapi/routes/users.py b/app/webapi/routes/users.py
index 5dda9800..e5c7c28e 100644
--- a/app/webapi/routes/users.py
+++ b/app/webapi/routes/users.py
@@ -70,7 +70,6 @@ def _serialize_subscription(subscription: Subscription | None) -> SubscriptionSu
traffic_limit_gb=subscription.traffic_limit_gb,
traffic_used_gb=subscription.traffic_used_gb,
device_limit=subscription.device_limit,
- modem_enabled=getattr(subscription, 'modem_enabled', False) or False,
autopay_enabled=subscription.autopay_enabled,
autopay_days_before=subscription.autopay_days_before,
subscription_url=subscription.subscription_url,
diff --git a/app/webapi/schemas/subscriptions.py b/app/webapi/schemas/subscriptions.py
index 811cff60..aea035c4 100644
--- a/app/webapi/schemas/subscriptions.py
+++ b/app/webapi/schemas/subscriptions.py
@@ -16,7 +16,6 @@ class SubscriptionResponse(BaseModel):
traffic_limit_gb: int
traffic_used_gb: float
device_limit: int
- modem_enabled: bool = False
autopay_enabled: bool
autopay_days_before: int | None = None
subscription_url: str | None = None
@@ -53,5 +52,3 @@ class SubscriptionSquadRequest(BaseModel):
squad_uuid: str
-class SubscriptionModemRequest(BaseModel):
- enabled: bool
diff --git a/app/webapi/schemas/users.py b/app/webapi/schemas/users.py
index 0d3ff5ac..1154c06f 100644
--- a/app/webapi/schemas/users.py
+++ b/app/webapi/schemas/users.py
@@ -24,7 +24,6 @@ class SubscriptionSummary(BaseModel):
traffic_limit_gb: int
traffic_used_gb: float
device_limit: int
- modem_enabled: bool = False
autopay_enabled: bool
autopay_days_before: int | None = None
subscription_url: str | None = None
diff --git a/tests/services/test_modem_service.py b/tests/services/test_modem_service.py
deleted file mode 100644
index 9f2dc33c..00000000
--- a/tests/services/test_modem_service.py
+++ /dev/null
@@ -1,395 +0,0 @@
-"""
-Тесты для ModemService - управление модемом в подписке.
-"""
-
-from datetime import datetime, timedelta
-from types import SimpleNamespace
-from unittest.mock import AsyncMock, MagicMock
-
-from app.services.modem_service import (
- ModemError,
- ModemService,
- get_modem_service,
-)
-
-
-def create_mock_settings():
- """Создаёт мок настроек приложения."""
- settings = MagicMock()
- settings.is_modem_enabled.return_value = True
- settings.get_modem_price_per_month.return_value = 10000 # 100 рублей
- settings.get_modem_period_discount.return_value = 0
- return settings
-
-
-def create_sample_user():
- """Создаёт пример пользователя."""
- user = SimpleNamespace(
- id=1,
- telegram_id=123456789,
- balance_kopeks=50000, # 500 рублей
- language='ru',
- subscription=None,
- )
- return user
-
-
-def create_sample_subscription():
- """Создаёт пример подписки."""
- subscription = SimpleNamespace(
- id=1,
- user_id=1,
- is_trial=False,
- modem_enabled=False,
- device_limit=2,
- end_date=datetime.utcnow() + timedelta(days=30),
- updated_at=datetime.utcnow(),
- )
- return subscription
-
-
-def create_trial_subscription():
- """Создаёт триальную подписку."""
- subscription = SimpleNamespace(
- id=2,
- user_id=1,
- is_trial=True,
- modem_enabled=False,
- device_limit=1,
- end_date=datetime.utcnow() + timedelta(days=7),
- updated_at=datetime.utcnow(),
- )
- return subscription
-
-
-def create_modem_service(monkeypatch):
- """Создаёт ModemService с замоканными настройками."""
- mock_settings = create_mock_settings()
- monkeypatch.setattr('app.services.modem_service.settings', mock_settings)
- return ModemService(), mock_settings
-
-
-class TestModemServiceAvailability:
- """Тесты проверки доступности модема."""
-
- def test_check_availability_no_subscription(self, monkeypatch):
- """Модем недоступен без подписки."""
- modem_service, _ = create_modem_service(monkeypatch)
- sample_user = create_sample_user()
- sample_user.subscription = None
-
- result = modem_service.check_availability(sample_user)
-
- assert not result.available
- assert result.error == ModemError.NO_SUBSCRIPTION
- assert not result.modem_enabled
-
- def test_check_availability_trial_subscription(self, monkeypatch):
- """Модем недоступен для триальной подписки."""
- modem_service, _ = create_modem_service(monkeypatch)
- sample_user = create_sample_user()
- trial_subscription = create_trial_subscription()
- sample_user.subscription = trial_subscription
-
- result = modem_service.check_availability(sample_user)
-
- assert not result.available
- assert result.error == ModemError.TRIAL_SUBSCRIPTION
- assert not result.modem_enabled
-
- def test_check_availability_modem_disabled_in_settings(self, monkeypatch):
- """Модем недоступен, если отключён в настройках."""
- modem_service, mock_settings = create_modem_service(monkeypatch)
- sample_user = create_sample_user()
- sample_subscription = create_sample_subscription()
- sample_user.subscription = sample_subscription
- mock_settings.is_modem_enabled.return_value = False
-
- result = modem_service.check_availability(sample_user)
-
- assert not result.available
- assert result.error == ModemError.MODEM_DISABLED
-
- def test_check_availability_success(self, monkeypatch):
- """Модем доступен для платной подписки."""
- modem_service, _ = create_modem_service(monkeypatch)
- sample_user = create_sample_user()
- sample_subscription = create_sample_subscription()
- sample_user.subscription = sample_subscription
-
- result = modem_service.check_availability(sample_user)
-
- assert result.available
- assert result.error is None
- assert not result.modem_enabled
-
- def test_check_availability_for_enable_already_enabled(self, monkeypatch):
- """Нельзя подключить уже подключенный модем."""
- modem_service, _ = create_modem_service(monkeypatch)
- sample_user = create_sample_user()
- sample_subscription = create_sample_subscription()
- sample_subscription.modem_enabled = True
- sample_user.subscription = sample_subscription
-
- result = modem_service.check_availability(sample_user, for_enable=True)
-
- assert not result.available
- assert result.error == ModemError.ALREADY_ENABLED
- assert result.modem_enabled
-
- def test_check_availability_for_disable_not_enabled(self, monkeypatch):
- """Нельзя отключить неподключенный модем."""
- modem_service, _ = create_modem_service(monkeypatch)
- sample_user = create_sample_user()
- sample_subscription = create_sample_subscription()
- sample_subscription.modem_enabled = False
- sample_user.subscription = sample_subscription
-
- result = modem_service.check_availability(sample_user, for_disable=True)
-
- assert not result.available
- assert result.error == ModemError.NOT_ENABLED
- assert not result.modem_enabled
-
-
-class TestModemServicePricing:
- """Тесты расчёта цены модема."""
-
- def test_calculate_price_one_month(self, monkeypatch):
- """Расчёт цены на 1 месяц."""
- modem_service, _ = create_modem_service(monkeypatch)
- sample_subscription = create_sample_subscription()
- sample_subscription.end_date = datetime.utcnow() + timedelta(days=30)
-
- result = modem_service.calculate_price(sample_subscription)
-
- assert result.base_price == 10000
- assert result.final_price == 10000
- assert result.charged_months == 1
- assert result.discount_percent == 0
- assert not result.has_discount
-
- def test_calculate_price_three_months(self, monkeypatch):
- """Расчёт цены на 3 месяца."""
- modem_service, _ = create_modem_service(monkeypatch)
- sample_subscription = create_sample_subscription()
- sample_subscription.end_date = datetime.utcnow() + timedelta(days=90)
-
- result = modem_service.calculate_price(sample_subscription)
-
- assert result.base_price == 30000 # 3 * 10000
- assert result.charged_months == 3
-
- def test_calculate_price_with_discount(self, monkeypatch):
- """Расчёт цены со скидкой."""
- modem_service, mock_settings = create_modem_service(monkeypatch)
- sample_subscription = create_sample_subscription()
- sample_subscription.end_date = datetime.utcnow() + timedelta(days=90)
- mock_settings.get_modem_period_discount.return_value = 10 # 10% скидка
-
- result = modem_service.calculate_price(sample_subscription)
-
- assert result.base_price == 30000
- assert result.discount_percent == 10
- assert result.discount_amount == 3000
- assert result.final_price == 27000
- assert result.has_discount
-
-
-class TestModemServiceBalance:
- """Тесты проверки баланса."""
-
- def test_check_balance_sufficient(self, monkeypatch):
- """Баланса достаточно."""
- modem_service, _ = create_modem_service(monkeypatch)
- sample_user = create_sample_user()
- sample_user.balance_kopeks = 50000
-
- has_funds, missing = modem_service.check_balance(sample_user, 10000)
-
- assert has_funds
- assert missing == 0
-
- def test_check_balance_insufficient(self, monkeypatch):
- """Баланса недостаточно."""
- modem_service, _ = create_modem_service(monkeypatch)
- sample_user = create_sample_user()
- sample_user.balance_kopeks = 5000
-
- has_funds, missing = modem_service.check_balance(sample_user, 10000)
-
- assert not has_funds
- assert missing == 5000
-
- def test_check_balance_zero_price(self, monkeypatch):
- """Нулевая цена - всегда достаточно."""
- modem_service, _ = create_modem_service(monkeypatch)
- sample_user = create_sample_user()
- sample_user.balance_kopeks = 0
-
- has_funds, missing = modem_service.check_balance(sample_user, 0)
-
- assert has_funds
- assert missing == 0
-
-
-class TestModemServicePeriodWarning:
- """Тесты предупреждений о сроке действия."""
-
- def test_warning_critical(self, monkeypatch):
- """Критическое предупреждение при <= 7 днях."""
- modem_service, _ = create_modem_service(monkeypatch)
- assert modem_service.get_period_warning_level(7) == 'critical'
- assert modem_service.get_period_warning_level(5) == 'critical'
- assert modem_service.get_period_warning_level(1) == 'critical'
-
- def test_warning_info(self, monkeypatch):
- """Информационное предупреждение при <= 30 днях."""
- modem_service, _ = create_modem_service(monkeypatch)
- assert modem_service.get_period_warning_level(30) == 'info'
- assert modem_service.get_period_warning_level(15) == 'info'
- assert modem_service.get_period_warning_level(8) == 'info'
-
- def test_warning_none(self, monkeypatch):
- """Нет предупреждения при > 30 днях."""
- modem_service, _ = create_modem_service(monkeypatch)
- assert modem_service.get_period_warning_level(31) is None
- assert modem_service.get_period_warning_level(60) is None
- assert modem_service.get_period_warning_level(90) is None
-
-
-class TestModemServiceEnable:
- """Тесты подключения модема."""
-
- async def test_enable_modem_success(self, monkeypatch):
- """Успешное подключение модема."""
- modem_service, _ = create_modem_service(monkeypatch)
- sample_user = create_sample_user()
- sample_subscription = create_sample_subscription()
- sample_user.subscription = sample_subscription
- sample_user.balance_kopeks = 50000
-
- mock_db = AsyncMock()
- mock_subtract = AsyncMock(return_value=True)
- mock_create_transaction = AsyncMock()
- mock_update_remnawave = AsyncMock()
-
- monkeypatch.setattr('app.services.modem_service.subtract_user_balance', mock_subtract)
- monkeypatch.setattr('app.services.modem_service.create_transaction', mock_create_transaction)
- modem_service._subscription_service.update_remnawave_user = mock_update_remnawave
-
- result = await modem_service.enable_modem(mock_db, sample_user, sample_subscription)
-
- assert result.success
- assert result.error is None
- assert result.charged_amount == 10000
- assert sample_subscription.modem_enabled is True
- assert sample_subscription.device_limit == 3 # было 2, стало 3
-
- async def test_enable_modem_insufficient_funds(self, monkeypatch):
- """Недостаточно средств для подключения."""
- modem_service, _ = create_modem_service(monkeypatch)
- sample_user = create_sample_user()
- sample_subscription = create_sample_subscription()
- sample_user.subscription = sample_subscription
- sample_user.balance_kopeks = 1000 # недостаточно
-
- mock_db = AsyncMock()
-
- result = await modem_service.enable_modem(mock_db, sample_user, sample_subscription)
-
- assert not result.success
- assert result.error == ModemError.INSUFFICIENT_FUNDS
-
- async def test_enable_modem_charge_error(self, monkeypatch):
- """Ошибка списания средств."""
- modem_service, _ = create_modem_service(monkeypatch)
- sample_user = create_sample_user()
- sample_subscription = create_sample_subscription()
- sample_user.subscription = sample_subscription
- sample_user.balance_kopeks = 50000
-
- mock_db = AsyncMock()
- mock_subtract = AsyncMock(return_value=False) # ошибка списания
-
- monkeypatch.setattr('app.services.modem_service.subtract_user_balance', mock_subtract)
-
- result = await modem_service.enable_modem(mock_db, sample_user, sample_subscription)
-
- assert not result.success
- assert result.error == ModemError.CHARGE_ERROR
-
-
-class TestModemServiceDisable:
- """Тесты отключения модема."""
-
- async def test_disable_modem_success(self, monkeypatch):
- """Успешное отключение модема."""
- modem_service, _ = create_modem_service(monkeypatch)
- sample_user = create_sample_user()
- sample_subscription = create_sample_subscription()
- sample_subscription.modem_enabled = True
- sample_subscription.device_limit = 3
- sample_user.subscription = sample_subscription
-
- mock_db = AsyncMock()
- mock_update_remnawave = AsyncMock()
- modem_service._subscription_service.update_remnawave_user = mock_update_remnawave
-
- result = await modem_service.disable_modem(mock_db, sample_user, sample_subscription)
-
- assert result.success
- assert result.error is None
- assert sample_subscription.modem_enabled is False
- assert sample_subscription.device_limit == 2 # было 3, стало 2
-
-
-class TestModemServiceSingleton:
- """Тесты singleton паттерна."""
-
- def test_get_modem_service_returns_same_instance(self, monkeypatch):
- """get_modem_service возвращает один и тот же экземпляр."""
- # Сбрасываем глобальный экземпляр
- import app.services.modem_service as modem_module
-
- modem_module._modem_service = None
-
- mock_settings = create_mock_settings()
- monkeypatch.setattr('app.services.modem_service.settings', mock_settings)
-
- service1 = get_modem_service()
- service2 = get_modem_service()
-
- assert service1 is service2
-
-
-class TestModemEnabledGetter:
- """Тесты безопасного получения статуса модема."""
-
- def test_get_modem_enabled_true(self, monkeypatch):
- """Модем включён."""
- modem_service, _ = create_modem_service(monkeypatch)
- sample_subscription = create_sample_subscription()
- sample_subscription.modem_enabled = True
-
- assert modem_service.get_modem_enabled(sample_subscription) is True
-
- def test_get_modem_enabled_false(self, monkeypatch):
- """Модем выключен."""
- modem_service, _ = create_modem_service(monkeypatch)
- sample_subscription = create_sample_subscription()
- sample_subscription.modem_enabled = False
-
- assert modem_service.get_modem_enabled(sample_subscription) is False
-
- def test_get_modem_enabled_none_subscription(self, monkeypatch):
- """Подписка None."""
- modem_service, _ = create_modem_service(monkeypatch)
- assert modem_service.get_modem_enabled(None) is False
-
- def test_get_modem_enabled_no_attribute(self, monkeypatch):
- """У подписки нет атрибута modem_enabled."""
- modem_service, _ = create_modem_service(monkeypatch)
- subscription = SimpleNamespace(id=1) # без modem_enabled
-
- assert modem_service.get_modem_enabled(subscription) is False