refactor: remove modem functionality from classic subscriptions

Remove all modem purchase/management code:
- Delete modem handler, service, and tests
- Remove modem button from keyboards and admin panel
- Remove modem pricing from calculations
- Remove modem REST API endpoint and schemas
- Remove modem decorator, config settings, and notification formatting
- Keep DB column and migration for backwards compatibility
This commit is contained in:
Fringg
2026-02-11 21:14:08 +03:00
parent d05ff678ab
commit ee2e79db31
18 changed files with 4 additions and 1406 deletions

View File

@@ -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 - главный переключатель платной активации
# Если выключен - триал бесплатный, независимо от цены

View File

@@ -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'📡 <b>Модем {action_text}</b>\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_'))

View File

@@ -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,

View File

@@ -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',

View File

@@ -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',
(
'<b>Модем</b>\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',
(
'<b>Модем</b>\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',
(
'<b>Недостаточно средств</b>\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<b>Внимание!</b> До окончания подписки осталось всего <b>{days} дн.</b>\n'
'После продления подписки модем нужно будет оплатить заново!',
).format(days=price_info.remaining_days)
elif warning_level == 'info':
warning_text = texts.t(
'MODEM_PERIOD_NOTE',
'\nДо окончания подписки: <b>{days} дн.</b>\nПосле продления модем нужно будет оплатить заново.',
).format(days=price_info.remaining_days)
else:
warning_text = ''
if price_info.has_discount:
price_text = texts.t(
'MODEM_PRICE_WITH_DISCOUNT',
'Стоимость: <s>{base_price}</s> <b>{final_price}</b> (за {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',
(
'<b>Подтверждение подключения модема</b>\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Модем действует до: <b>{end_date}</b> ({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',
('<b>Модем успешно подключен!</b>\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',
('<b>Модем отключен</b>\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')

View File

@@ -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,
)
# Проверяем баланс пользователя

View File

@@ -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(
[

View File

@@ -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(

View File

@@ -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

View File

@@ -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,

View File

@@ -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,

View File

@@ -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

View File

@@ -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),

View File

@@ -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)

View File

@@ -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,

View File

@@ -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

View File

@@ -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

View File

@@ -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