Restore device management button in disabled mode

This commit is contained in:
Egor
2025-10-31 19:15:48 +03:00
parent 0d7a235715
commit 8407f7cdb1
29 changed files with 1276 additions and 321 deletions

View File

@@ -146,6 +146,10 @@ TRAFFIC_PACKAGES_CONFIG="5:2000:false,10:3500:false,25:7000:false,50:11000:true,
# Цена за дополнительное устройство (DEFAULT_DEVICE_LIMIT идет бесплатно!)
PRICE_PER_DEVICE=10000
# Включить выбор количества устройств при покупке и продлении
DEVICES_SELECTION_ENABLED=true
# Единое количество устройств для режима без выбора (0 — не назначать устройства)
DEVICES_SELECTION_DISABLED_AMOUNT=0
# ===== РЕФЕРАЛЬНАЯ СИСТЕМА =====
REFERRAL_PROGRAM_ENABLED=true

View File

@@ -569,6 +569,9 @@ BASE_PROMO_GROUP_PERIOD_DISCOUNTS=60:10,90:20,180:40,360:70
TRAFFIC_PACKAGES_CONFIG="5:2000:false,10:3500:false,25:7000:false,50:11000:true,100:15000:true,0:20000:true"
PRICE_PER_DEVICE=5000
DEVICES_SELECTION_ENABLED=true
# Единое количество устройств для режима без выбора (0 — не назначать устройства)
DEVICES_SELECTION_DISABLED_AMOUNT=0
# ===== РЕФЕРАЛЬНАЯ СИСТЕМА =====
REFERRAL_PROGRAM_ENABLED=true

View File

@@ -123,10 +123,12 @@ class Settings(BaseSettings):
PRICE_TRAFFIC_500GB: int = 19000
PRICE_TRAFFIC_1000GB: int = 19500
PRICE_TRAFFIC_UNLIMITED: int = 20000
TRAFFIC_PACKAGES_CONFIG: str = ""
PRICE_PER_DEVICE: int = 5000
DEVICES_SELECTION_ENABLED: bool = True
DEVICES_SELECTION_DISABLED_AMOUNT: Optional[int] = None
BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED: bool = False
BASE_PROMO_GROUP_PERIOD_DISCOUNTS: str = ""
@@ -797,9 +799,35 @@ class Settings(BaseSettings):
def is_traffic_fixed(self) -> bool:
return self.TRAFFIC_SELECTION_MODE.lower() == "fixed"
def get_fixed_traffic_limit(self) -> int:
return self.FIXED_TRAFFIC_LIMIT_GB
def is_devices_selection_enabled(self) -> bool:
return self.DEVICES_SELECTION_ENABLED
def get_devices_selection_disabled_amount(self) -> Optional[int]:
raw_value = self.DEVICES_SELECTION_DISABLED_AMOUNT
if raw_value in (None, ""):
return None
try:
value = int(raw_value)
except (TypeError, ValueError):
logger.warning(
"Некорректное значение DEVICES_SELECTION_DISABLED_AMOUNT: %s",
raw_value,
)
return None
if value < 0:
return 0
return value
def get_disabled_mode_device_limit(self) -> Optional[int]:
return self.get_devices_selection_disabled_amount()
def is_yookassa_enabled(self) -> bool:
return (self.YOOKASSA_ENABLED and

View File

@@ -41,13 +41,14 @@ async def create_trial_subscription(
user_id: int,
duration_days: int = None,
traffic_limit_gb: int = None,
device_limit: int = None,
device_limit: Optional[int] = None,
squad_uuid: str = None
) -> Subscription:
duration_days = duration_days or settings.TRIAL_DURATION_DAYS
traffic_limit_gb = traffic_limit_gb or settings.TRIAL_TRAFFIC_LIMIT_GB
device_limit = device_limit or settings.TRIAL_DEVICE_LIMIT
if device_limit is None:
device_limit = settings.TRIAL_DEVICE_LIMIT
if not squad_uuid:
try:
from app.database.crud.server_squad import get_random_trial_squad_uuid
@@ -126,13 +127,16 @@ async def create_paid_subscription(
user_id: int,
duration_days: int,
traffic_limit_gb: int = 0,
device_limit: int = 1,
device_limit: Optional[int] = None,
connected_squads: List[str] = None,
update_server_counters: bool = False,
) -> Subscription:
end_date = datetime.utcnow() + timedelta(days=duration_days)
if device_limit is None:
device_limit = settings.DEFAULT_DEVICE_LIMIT
subscription = Subscription(
user_id=user_id,
status=SubscriptionStatus.ACTIVE.value,

View File

@@ -46,6 +46,9 @@ def _format_campaign_summary(campaign, texts) -> str:
bonus_info = f"💰 Бонус на баланс: <b>{bonus_text}</b>"
else:
traffic_text = texts.format_traffic(campaign.subscription_traffic_gb or 0)
device_limit = campaign.subscription_device_limit
if device_limit is None:
device_limit = settings.DEFAULT_DEVICE_LIMIT
bonus_info = (
"📱 Подписка: <b>{days} д.</b>\n"
"🌐 Трафик: <b>{traffic}</b>\n"
@@ -53,7 +56,7 @@ def _format_campaign_summary(campaign, texts) -> str:
).format(
days=campaign.subscription_duration_days or 0,
traffic=traffic_text,
devices=campaign.subscription_device_limit or settings.DEFAULT_DEVICE_LIMIT,
devices=device_limit,
)
return (
@@ -935,7 +938,9 @@ async def start_edit_campaign_subscription_devices(
campaign_edit_message_is_caption=is_caption,
)
current_devices = campaign.subscription_device_limit or settings.DEFAULT_DEVICE_LIMIT
current_devices = campaign.subscription_device_limit
if current_devices is None:
current_devices = settings.DEFAULT_DEVICE_LIMIT
await callback.message.edit_text(
(

View File

@@ -35,6 +35,9 @@ from app.database.crud.server_squad import (
get_server_ids_by_uuids,
)
from app.services.subscription_service import SubscriptionService
from app.utils.subscription_utils import (
resolve_hwid_device_limit_for_payload,
)
logger = logging.getLogger(__name__)
@@ -3338,7 +3341,15 @@ async def _grant_trial_subscription(db: AsyncSession, user_id: int, admin_id: in
logger.error(f"У пользователя {user_id} уже есть подписка")
return False
subscription = await create_trial_subscription(db, user_id)
forced_devices = None
if not settings.is_devices_selection_enabled():
forced_devices = settings.get_disabled_mode_device_limit()
subscription = await create_trial_subscription(
db,
user_id,
device_limit=forced_devices,
)
subscription_service = SubscriptionService()
await subscription_service.create_remnawave_user(db, subscription)
@@ -3382,12 +3393,20 @@ async def _grant_paid_subscription(db: AsyncSession, user_id: int, days: int, ad
if getattr(settings, "TRIAL_SQUAD_UUID", None):
trial_squads = [settings.TRIAL_SQUAD_UUID]
forced_devices = None
if not settings.is_devices_selection_enabled():
forced_devices = settings.get_disabled_mode_device_limit()
device_limit = settings.DEFAULT_DEVICE_LIMIT
if forced_devices is not None:
device_limit = forced_devices
subscription = await create_paid_subscription(
db=db,
user_id=user_id,
duration_days=days,
traffic_limit_gb=settings.DEFAULT_TRAFFIC_LIMIT_GB,
device_limit=settings.DEFAULT_DEVICE_LIMIT,
device_limit=device_limit,
connected_squads=trial_squads,
update_server_counters=True,
)
@@ -3594,7 +3613,9 @@ async def admin_buy_subscription(
text += f"👤 {target_user.full_name} (ID: {target_user.telegram_id})\n"
text += f"💰 Баланс пользователя: {settings.format_price(target_user.balance_kopeks)}\n\n"
traffic_text = "Безлимит" if (subscription.traffic_limit_gb or 0) <= 0 else f"{subscription.traffic_limit_gb} ГБ"
devices_limit = subscription.device_limit or settings.DEFAULT_DEVICE_LIMIT
devices_limit = subscription.device_limit
if devices_limit is None:
devices_limit = settings.DEFAULT_DEVICE_LIMIT
servers_count = len(subscription.connected_squads or [])
text += f"📶 Трафик: {traffic_text}\n"
text += f"📱 Устройства: {devices_limit}\n"
@@ -3685,7 +3706,9 @@ async def admin_buy_subscription_confirm(
text += f"💰 Стоимость: {settings.format_price(price_kopeks)}\n"
text += f"💰 Баланс пользователя: {settings.format_price(target_user.balance_kopeks)}\n\n"
traffic_text = "Безлимит" if (subscription.traffic_limit_gb or 0) <= 0 else f"{subscription.traffic_limit_gb} ГБ"
devices_limit = subscription.device_limit or settings.DEFAULT_DEVICE_LIMIT
devices_limit = subscription.device_limit
if devices_limit is None:
devices_limit = settings.DEFAULT_DEVICE_LIMIT
servers_count = len(subscription.connected_squads or [])
text += f"📶 Трафик: {traffic_text}\n"
text += f"📱 Устройства: {devices_limit}\n"
@@ -3832,40 +3855,50 @@ async def admin_buy_subscription_execute(
from app.external.remnawave_api import UserStatus, TrafficLimitStrategy
remnawave_service = RemnaWaveService()
hwid_limit = resolve_hwid_device_limit_for_payload(subscription)
if target_user.remnawave_uuid:
async with remnawave_service.get_api_client() as api:
remnawave_user = await api.update_user(
update_kwargs = dict(
uuid=target_user.remnawave_uuid,
status=UserStatus.ACTIVE if subscription.is_active else UserStatus.EXPIRED,
expire_at=subscription.end_date,
traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3) if subscription.traffic_limit_gb > 0 else 0,
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
hwid_device_limit=subscription.device_limit,
description=settings.format_remnawave_user_description(
full_name=target_user.full_name,
username=target_user.username,
telegram_id=target_user.telegram_id
),
active_internal_squads=subscription.connected_squads
active_internal_squads=subscription.connected_squads,
)
if hwid_limit is not None:
update_kwargs['hwid_device_limit'] = hwid_limit
remnawave_user = await api.update_user(**update_kwargs)
else:
username = f"user_{target_user.telegram_id}"
async with remnawave_service.get_api_client() as api:
remnawave_user = await api.create_user(
create_kwargs = dict(
username=username,
expire_at=subscription.end_date,
status=UserStatus.ACTIVE if subscription.is_active else UserStatus.EXPIRED,
traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3) if subscription.traffic_limit_gb > 0 else 0,
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
telegram_id=target_user.telegram_id,
hwid_device_limit=subscription.device_limit,
description=settings.format_remnawave_user_description(
full_name=target_user.full_name,
username=target_user.username,
telegram_id=target_user.telegram_id
),
active_internal_squads=subscription.connected_squads
active_internal_squads=subscription.connected_squads,
)
if hwid_limit is not None:
create_kwargs['hwid_device_limit'] = hwid_limit
remnawave_user = await api.create_user(**create_kwargs)
if remnawave_user and hasattr(remnawave_user, 'uuid'):
target_user.remnawave_uuid = remnawave_user.uuid

View File

@@ -16,7 +16,10 @@ from app.services.payment_service import PaymentService
from app.services.subscription_purchase_service import SubscriptionPurchaseService
from app.utils.decorators import error_handler
from app.states import SubscriptionStates
from app.utils.subscription_utils import get_display_subscription_link
from app.utils.subscription_utils import (
get_display_subscription_link,
resolve_simple_subscription_device_limit,
)
from app.utils.pricing_utils import compute_simple_subscription_price
logger = logging.getLogger(__name__)
@@ -35,15 +38,17 @@ async def start_simple_subscription_purchase(
if not settings.SIMPLE_SUBSCRIPTION_ENABLED:
await callback.answer("❌ Простая покупка подписки временно недоступна", show_alert=True)
return
# Проверяем, есть ли у пользователя подписка (информируем, но не блокируем покупку)
from app.database.crud.subscription import get_subscription_by_user_id
current_subscription = await get_subscription_by_user_id(db, db_user.id)
device_limit = resolve_simple_subscription_device_limit()
# Подготовим параметры простой подписки
subscription_params = {
"period_days": settings.SIMPLE_SUBSCRIPTION_PERIOD_DAYS,
"device_limit": settings.SIMPLE_SUBSCRIPTION_DEVICE_LIMIT,
"device_limit": device_limit,
"traffic_limit_gb": settings.SIMPLE_SUBSCRIPTION_TRAFFIC_GB,
"squad_uuid": settings.SIMPLE_SUBSCRIPTION_SQUAD_UUID
}
@@ -111,20 +116,32 @@ async def start_simple_subscription_purchase(
subscription_params,
resolved_squad_uuid,
)
message_text = (
f"⚡ <b>Простая покупка подписки</b>\n\n"
f"📅 Период: {subscription_params['period_days']} дней\n"
f"📱 Устройства: {subscription_params['device_limit']}\n"
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}\n"
f"🌍 Сервер: {server_label}\n\n"
f"💰 Стоимость: {settings.format_price(price_kopeks)}\n"
f"💳 Ваш баланс: {settings.format_price(user_balance_kopeks)}\n\n"
+ (
show_devices = settings.is_devices_selection_enabled()
message_lines = [
"⚡ <b>Простая покупка подписки</b>",
"",
f"📅 Период: {subscription_params['period_days']} дней",
]
if show_devices:
message_lines.append(f"📱 Устройства: {subscription_params['device_limit']}")
message_lines.extend([
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}",
f"🌍 Сервер: {server_label}",
"",
f"💰 Стоимость: {settings.format_price(price_kopeks)}",
f"💳 Ваш баланс: {settings.format_price(user_balance_kopeks)}",
"",
(
"Вы можете оплатить подписку с баланса или выбрать другой способ оплаты."
if can_pay_from_balance
else "Баланс пока недостаточный для мгновенной оплаты. Выберите подходящий способ оплаты:"
)
)
),
])
message_text = "\n".join(message_lines)
if trial_notice:
message_text = f"{trial_notice}\n\n{message_text}"
@@ -433,16 +450,28 @@ async def handle_simple_subscription_pay_with_balance(
subscription_params,
resolved_squad_uuid,
)
success_message = (
f"✅ <b>Подписка успешно активирована!</b>\n\n"
f"📅 Период: {subscription_params['period_days']} дней\n"
f"📱 Устройства: {subscription_params['device_limit']}\n"
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}\n"
f"🌍 Сервер: {server_label}\n\n"
f"💰 Списано с баланса: {settings.format_price(price_kopeks)}\n"
f"💳 Ваш баланс: {settings.format_price(db_user.balance_kopeks)}\n\n"
f"🔗 Для подключения перейдите в раздел 'Подключиться'"
)
show_devices = settings.is_devices_selection_enabled()
success_lines = [
"✅ <b>Подписка успешно активирована!</b>",
"",
f"📅 Период: {subscription_params['period_days']} дней",
]
if show_devices:
success_lines.append(f"📱 Устройства: {subscription_params['device_limit']}")
success_lines.extend([
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}",
f"🌍 Сервер: {server_label}",
"",
f"💰 Списано с баланса: {settings.format_price(price_kopeks)}",
f"💳 Ваш баланс: {settings.format_price(db_user.balance_kopeks)}",
"",
"🔗 Для подключения перейдите в раздел 'Подключиться'",
])
success_message = "\n".join(success_lines)
connect_mode = settings.CONNECT_BUTTON_MODE
subscription_link = get_display_subscription_link(subscription)
@@ -619,19 +648,31 @@ async def handle_simple_subscription_other_payment_methods(
subscription_params,
resolved_squad_uuid,
)
message_text = (
f"💳 <b>Оплата подписки</b>\n\n"
f"📅 Период: {subscription_params['period_days']} дней\n"
f"📱 Устройства: {subscription_params['device_limit']}\n"
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}\n"
f"🌍 Сервер: {server_label}\n\n"
f"💰 Стоимость: {settings.format_price(price_kopeks)}\n\n"
+ (
show_devices = settings.is_devices_selection_enabled()
message_lines = [
"💳 <b>Оплата подписки</b>",
"",
f"📅 Период: {subscription_params['period_days']} дней",
]
if show_devices:
message_lines.append(f"📱 Устройства: {subscription_params['device_limit']}")
message_lines.extend([
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}",
f"🌍 Сервер: {server_label}",
"",
f"💰 Стоимость: {settings.format_price(price_kopeks)}",
"",
(
"Вы можете оплатить подписку с баланса или выбрать другой способ оплаты:"
if can_pay_from_balance
else "Выберите подходящий способ оплаты:"
)
)
),
])
message_text = "\n".join(message_lines)
base_keyboard = _get_simple_subscription_payment_keyboard(db_user.language)
keyboard_rows = []
@@ -854,14 +895,25 @@ async def handle_simple_subscription_payment_method(
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_buttons)
# Подготавливаем текст сообщения
message_text = (
f"💳 <b>Оплата подписки через YooKassa</b>\n\n"
f"📅 Период: {subscription_params['period_days']} дней\n"
f"📱 Устройства: {subscription_params['device_limit']}\n"
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}\n"
f"💰 Сумма: {settings.format_price(price_kopeks)}\n"
f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...\n\n"
)
show_devices = settings.is_devices_selection_enabled()
message_lines = [
"💳 <b>Оплата подписки через YooKassa</b>",
"",
f"📅 Период: {subscription_params['period_days']} дней",
]
if show_devices:
message_lines.append(f"📱 Устройства: {subscription_params['device_limit']}")
message_lines.extend([
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}",
f"💰 Сумма: {settings.format_price(price_kopeks)}",
f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...",
"",
])
message_text = "\n".join(message_lines)
# Добавляем инструкции в зависимости от доступных способов оплаты
if not confirmation_url:

View File

@@ -208,39 +208,20 @@ async def handle_subscription_config_back(
await state.set_state(SubscriptionStates.selecting_period)
elif current_state == SubscriptionStates.selecting_devices.state:
if await _should_show_countries_management(db_user):
countries = await _get_available_countries(db_user.promo_group_id)
data = await state.get_data()
selected_countries = data.get('countries', [])
await callback.message.edit_text(
texts.SELECT_COUNTRIES,
reply_markup=get_countries_keyboard(countries, selected_countries, db_user.language)
)
await state.set_state(SubscriptionStates.selecting_countries)
elif settings.is_traffic_selectable():
await callback.message.edit_text(
texts.SELECT_TRAFFIC,
reply_markup=get_traffic_packages_keyboard(db_user.language)
)
await state.set_state(SubscriptionStates.selecting_traffic)
else:
await callback.message.edit_text(
await _build_subscription_period_prompt(db_user, texts, db),
reply_markup=get_subscription_period_keyboard(db_user.language),
parse_mode="HTML",
)
await state.set_state(SubscriptionStates.selecting_period)
await _show_previous_configuration_step(callback, state, db_user, texts, db)
elif current_state == SubscriptionStates.confirming_purchase.state:
data = await state.get_data()
selected_devices = data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
if settings.is_devices_selection_enabled():
data = await state.get_data()
selected_devices = data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
await callback.message.edit_text(
texts.SELECT_DEVICES,
reply_markup=get_devices_keyboard(selected_devices, db_user.language)
)
await state.set_state(SubscriptionStates.selecting_devices)
await callback.message.edit_text(
texts.SELECT_DEVICES,
reply_markup=get_devices_keyboard(selected_devices, db_user.language)
)
await state.set_state(SubscriptionStates.selecting_devices)
else:
await _show_previous_configuration_step(callback, state, db_user, texts, db)
else:
from app.handlers.menu import show_main_menu
@@ -267,3 +248,37 @@ async def handle_subscription_cancel(
await show_main_menu(callback, db_user, db)
await callback.answer("❌ Покупка отменена")
async def _show_previous_configuration_step(
callback: types.CallbackQuery,
state: FSMContext,
db_user: User,
texts,
db: AsyncSession,
):
if await _should_show_countries_management(db_user):
countries = await _get_available_countries(db_user.promo_group_id)
data = await state.get_data()
selected_countries = data.get('countries', [])
await callback.message.edit_text(
texts.SELECT_COUNTRIES,
reply_markup=get_countries_keyboard(countries, selected_countries, db_user.language)
)
await state.set_state(SubscriptionStates.selecting_countries)
return
if settings.is_traffic_selectable():
await callback.message.edit_text(
texts.SELECT_TRAFFIC,
reply_markup=get_traffic_packages_keyboard(db_user.language)
)
await state.set_state(SubscriptionStates.selecting_traffic)
return
await callback.message.edit_text(
await _build_subscription_period_prompt(db_user, texts, db),
reply_markup=get_subscription_period_keyboard(db_user.language),
parse_mode="HTML",
)
await state.set_state(SubscriptionStates.selecting_period)

View File

@@ -79,6 +79,7 @@ from app.utils.promo_offer import (
)
from .common import _get_addon_discount_percent_for_user, _get_period_hint_from_subscription, logger
from .summary import present_subscription_summary
async def handle_add_countries(
callback: types.CallbackQuery,
@@ -588,6 +589,11 @@ async def countries_continue(
await callback.answer("⚠️ Выберите хотя бы одну страну!", show_alert=True)
return
if not settings.is_devices_selection_enabled():
if await present_subscription_summary(callback, state, db_user, texts):
await callback.answer()
return
selected_devices = data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
await callback.message.edit_text(

View File

@@ -183,6 +183,13 @@ async def handle_change_devices(
texts = get_texts(db_user.language)
subscription = db_user.subscription
if not settings.is_devices_selection_enabled():
await callback.answer(
texts.t("DEVICES_SELECTION_DISABLED", "⚠️ Изменение количества устройств недоступно"),
show_alert=True,
)
return
if not subscription or subscription.is_trial:
await callback.answer(
texts.t("PAID_FEATURE_ONLY", "⚠️ Эта функция доступна только для платных подписок"),
@@ -233,6 +240,13 @@ async def confirm_change_devices(
texts = get_texts(db_user.language)
subscription = db_user.subscription
if not settings.is_devices_selection_enabled():
await callback.answer(
texts.t("DEVICES_SELECTION_DISABLED", "⚠️ Изменение количества устройств недоступно"),
show_alert=True,
)
return
current_devices = subscription.device_limit
if new_devices_count == current_devices:
@@ -379,6 +393,13 @@ async def execute_change_devices(
subscription = db_user.subscription
current_devices = subscription.device_limit
if not settings.is_devices_selection_enabled():
await callback.answer(
texts.t("DEVICES_SELECTION_DISABLED", "⚠️ Изменение количества устройств недоступно"),
show_alert=True,
)
return
try:
if price > 0:
success = await subtract_user_balance(
@@ -863,6 +884,13 @@ async def confirm_add_devices(
texts = get_texts(db_user.language)
subscription = db_user.subscription
if not settings.is_devices_selection_enabled():
await callback.answer(
texts.t("DEVICES_SELECTION_DISABLED", "⚠️ Изменение количества устройств недоступно"),
show_alert=True,
)
return
resume_callback = None
new_total_devices = subscription.device_limit + devices_count

View File

@@ -158,7 +158,18 @@ async def _prepare_subscription_summary(
total_servers_discount += total_discount_for_server
selected_server_prices.append(total_price_for_server)
devices_selected = summary_data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
devices_selection_enabled = settings.is_devices_selection_enabled()
forced_disabled_limit: Optional[int] = None
if devices_selection_enabled:
devices_selected = summary_data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
else:
forced_disabled_limit = settings.get_disabled_mode_device_limit()
if forced_disabled_limit is None:
devices_selected = settings.DEFAULT_DEVICE_LIMIT
else:
devices_selected = forced_disabled_limit
summary_data['devices'] = devices_selected
additional_devices = max(0, devices_selected - settings.DEFAULT_DEVICE_LIMIT)
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
devices_discount_percent = db_user.get_promo_discount(
@@ -275,7 +286,7 @@ async def _prepare_subscription_summary(
f" -{texts.format_price(total_servers_discount)})"
)
details_lines.append(servers_line)
if total_devices_price > 0:
if devices_selection_enabled and total_devices_price > 0:
devices_line = (
f"- Доп. устройства: {texts.format_price(devices_price_per_month)}/мес × {months_in_period}"
f" = {texts.format_price(total_devices_price)}"
@@ -300,17 +311,28 @@ async def _prepare_subscription_summary(
details_text = "\n".join(details_lines)
summary_text = (
"📋 <b>Сводка заказа</b>\n\n"
f"📅 <b>Период:</b> {period_display}\n"
f"📊 <b>Трафик:</b> {traffic_display}\n"
f"🌍 <b>Страны:</b> {', '.join(selected_countries_names)}\n"
f"📱 <b>Устройства:</b> {devices_selected}\n\n"
"💰 <b>Детализация стоимости:</b>\n"
f"{details_text}\n\n"
f"💎 <b>Общая стоимость:</b> {texts.format_price(total_price)}\n\n"
"Подтверждаете покупку?"
)
summary_lines = [
"📋 <b>Сводка заказа</b>",
"",
f"📅 <b>Период:</b> {period_display}",
f"📊 <b>Трафик:</b> {traffic_display}",
f"🌍 <b>Страны:</b> {', '.join(selected_countries_names)}",
]
if devices_selection_enabled:
summary_lines.append(f"📱 <b>Устройства:</b> {devices_selected}")
summary_lines.extend([
"",
"💰 <b>Детализация стоимости:</b>",
details_text,
"",
f"💎 <b>Общая стоимость:</b> {texts.format_price(total_price)}",
"",
"Подтверждаете покупку?",
])
summary_text = "\n".join(summary_lines)
return summary_text, summary_data
@@ -382,7 +404,18 @@ async def get_subscription_cost(subscription, db: AsyncSession) -> int:
)
traffic_cost = settings.get_traffic_price(subscription.traffic_limit_gb)
devices_cost = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
device_limit = subscription.device_limit
if device_limit is None:
if settings.is_devices_selection_enabled():
device_limit = settings.DEFAULT_DEVICE_LIMIT
else:
forced_limit = settings.get_disabled_mode_device_limit()
if forced_limit is None:
device_limit = settings.DEFAULT_DEVICE_LIMIT
else:
device_limit = forced_limit
devices_cost = max(0, (device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
total_cost = base_cost + servers_cost + traffic_cost + devices_cost
@@ -410,7 +443,12 @@ async def get_subscription_cost(subscription, db: AsyncSession) -> int:
return 0
async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSession):
devices_used = await get_current_devices_count(db_user)
devices_selection_enabled = settings.is_devices_selection_enabled()
if devices_selection_enabled:
devices_used = await get_current_devices_count(db_user)
else:
devices_used = 0
countries_info = await _get_countries_info(subscription.connected_squads)
countries_text = ", ".join([c['name'] for c in countries_info]) if countries_info else "Нет"
@@ -439,7 +477,18 @@ async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSess
subscription_cost = await get_subscription_cost(subscription, db)
info_text = texts.SUBSCRIPTION_INFO.format(
info_template = texts.SUBSCRIPTION_INFO
if not devices_selection_enabled:
info_template = info_template.replace(
"\n📱 <b>Устройства:</b> {devices_used} / {devices_limit}",
"",
).replace(
"\n📱 <b>Devices:</b> {devices_used} / {devices_limit}",
"",
)
info_text = info_template.format(
status=status_text,
type=type_text,
end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M"),

View File

@@ -72,9 +72,10 @@ from app.utils.pricing_utils import (
apply_percentage_discount,
)
from app.utils.subscription_utils import (
convert_subscription_link_to_happ_scheme,
get_display_subscription_link,
get_happ_cryptolink_redirect_link,
convert_subscription_link_to_happ_scheme,
resolve_simple_subscription_device_limit,
)
from app.utils.promo_offer import (
build_promo_offer_hint,
@@ -140,6 +141,7 @@ from .traffic import (
handle_switch_traffic,
select_traffic,
)
from .summary import present_subscription_summary
async def show_subscription_info(
callback: types.CallbackQuery,
@@ -237,26 +239,32 @@ async def show_subscription_info(
devices_list = []
devices_count = 0
try:
if db_user.remnawave_uuid:
from app.services.remnawave_service import RemnaWaveService
service = RemnaWaveService()
show_devices = settings.is_devices_selection_enabled()
devices_used_str = ""
devices_list: List[Dict[str, Any]] = []
async with service.get_api_client() as api:
response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}')
if show_devices:
try:
if db_user.remnawave_uuid:
from app.services.remnawave_service import RemnaWaveService
service = RemnaWaveService()
if response and 'response' in response:
devices_info = response['response']
devices_count = devices_info.get('total', 0)
devices_list = devices_info.get('devices', [])
devices_used_str = str(devices_count)
logger.info(f"Найдено {devices_count} устройств для пользователя {db_user.telegram_id}")
else:
logger.warning(f"Не удалось получить информацию об устройствах для {db_user.telegram_id}")
async with service.get_api_client() as api:
response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}')
except Exception as e:
logger.error(f"Ошибка получения устройств для отображения: {e}")
devices_used_str = await get_current_devices_count(db_user)
if response and 'response' in response:
devices_info = response['response']
devices_count = devices_info.get('total', 0)
devices_list = devices_info.get('devices', [])
devices_used_str = str(devices_count)
logger.info(f"Найдено {devices_count} устройств для пользователя {db_user.telegram_id}")
else:
logger.warning(f"Не удалось получить информацию об устройствах для {db_user.telegram_id}")
except Exception as e:
logger.error(f"Ошибка получения устройств для отображения: {e}")
devices_used = await get_current_devices_count(db_user)
devices_used_str = str(devices_used)
servers_names = await get_servers_display_names(subscription.connected_squads)
servers_display = (
@@ -265,7 +273,7 @@ async def show_subscription_info(
else texts.t("SUBSCRIPTION_NO_SERVERS", "Нет серверов")
)
message = texts.t(
message_template = texts.t(
"SUBSCRIPTION_OVERVIEW_TEMPLATE",
"""👤 {full_name}
💰 Баланс: {balance}
@@ -278,7 +286,15 @@ async def show_subscription_info(
📈 Трафик: {traffic}
🌍 Серверы: {servers}
📱 Устройства: {devices_used} / {device_limit}""",
).format(
)
if not show_devices:
message_template = message_template.replace(
"\n📱 Устройства: {devices_used} / {device_limit}",
"",
)
message = message_template.format(
full_name=db_user.full_name,
balance=settings.format_price(db_user.balance_kopeks),
status_emoji=status_emoji,
@@ -293,7 +309,7 @@ async def show_subscription_info(
device_limit=subscription.device_limit,
)
if devices_list and len(devices_list) > 0:
if show_devices and devices_list:
message += "\n\n" + texts.t(
"SUBSCRIPTION_CONNECTED_DEVICES_TITLE",
"<blockquote>📱 <b>Подключенные устройства:</b>\n",
@@ -384,10 +400,20 @@ async def show_trial_offer(
except Exception as e:
logger.error(f"Ошибка получения триального сервера: {e}")
devices_line = ""
if settings.is_devices_selection_enabled():
devices_line_template = texts.t(
"TRIAL_AVAILABLE_DEVICES_LINE",
"\n📱 <b>Устройства:</b> {devices} шт.",
)
devices_line = devices_line_template.format(
devices=settings.TRIAL_DEVICE_LIMIT,
)
trial_text = texts.TRIAL_AVAILABLE.format(
days=settings.TRIAL_DURATION_DAYS,
traffic=settings.TRIAL_TRAFFIC_LIMIT_GB,
devices=settings.TRIAL_DEVICE_LIMIT,
devices_line=devices_line,
server_name=trial_server_name
)
@@ -415,7 +441,15 @@ async def activate_trial(
return
try:
subscription = await create_trial_subscription(db, db_user.id)
forced_devices = None
if not settings.is_devices_selection_enabled():
forced_devices = settings.get_disabled_mode_device_limit()
subscription = await create_trial_subscription(
db,
db_user.id,
device_limit=forced_devices,
)
await db.refresh(db_user)
@@ -589,10 +623,17 @@ async def start_subscription_purchase(
)
subscription = getattr(db_user, 'subscription', None)
initial_devices = settings.DEFAULT_DEVICE_LIMIT
if subscription and getattr(subscription, 'device_limit', None):
initial_devices = max(settings.DEFAULT_DEVICE_LIMIT, subscription.device_limit)
if settings.is_devices_selection_enabled():
initial_devices = settings.DEFAULT_DEVICE_LIMIT
if subscription and getattr(subscription, 'device_limit', None) is not None:
initial_devices = max(settings.DEFAULT_DEVICE_LIMIT, subscription.device_limit)
else:
forced_limit = settings.get_disabled_mode_device_limit()
if forced_limit is None:
initial_devices = settings.DEFAULT_DEVICE_LIMIT
else:
initial_devices = forced_limit
initial_data = {
'period_days': None,
@@ -698,7 +739,63 @@ async def return_to_saved_cart(
return
texts = get_texts(db_user.language)
total_price = cart_data.get('total_price', 0)
preserved_metadata_keys = {
'saved_cart',
'missing_amount',
'return_to_cart',
'user_id',
}
preserved_metadata = {
key: cart_data[key]
for key in preserved_metadata_keys
if key in cart_data
}
prepared_cart_data = dict(cart_data)
if not settings.is_devices_selection_enabled():
try:
from .pricing import _prepare_subscription_summary
_, recalculated_data = await _prepare_subscription_summary(
db_user,
prepared_cart_data,
texts,
)
except ValueError as recalculation_error:
logger.error(
"Не удалось пересчитать сохраненную корзину пользователя %s: %s",
db_user.telegram_id,
recalculation_error,
)
forced_limit = settings.get_disabled_mode_device_limit()
if forced_limit is None:
forced_limit = settings.DEFAULT_DEVICE_LIMIT
prepared_cart_data['devices'] = forced_limit
removed_devices_total = prepared_cart_data.pop('total_devices_price', 0) or 0
if removed_devices_total:
prepared_cart_data['total_price'] = max(
0,
prepared_cart_data.get('total_price', 0) - removed_devices_total,
)
prepared_cart_data.pop('devices_discount_percent', None)
prepared_cart_data.pop('devices_discount_total', None)
prepared_cart_data.pop('devices_discounted_price_per_month', None)
prepared_cart_data.pop('devices_price_per_month', None)
else:
normalized_cart_data = dict(prepared_cart_data)
normalized_cart_data.update(recalculated_data)
for key, value in preserved_metadata.items():
normalized_cart_data[key] = value
prepared_cart_data = normalized_cart_data
if prepared_cart_data != cart_data:
await user_cart_service.save_user_cart(db_user.id, prepared_cart_data)
total_price = prepared_cart_data.get('total_price', 0)
if db_user.balance_kopeks < total_price:
missing_amount = total_price - db_user.balance_kopeks
@@ -717,30 +814,45 @@ async def return_to_saved_cart(
countries = await _get_available_countries(db_user.promo_group_id)
selected_countries_names = []
months_in_period = calculate_months_from_days(cart_data['period_days'])
period_display = format_period_description(cart_data['period_days'], db_user.language)
period_display = format_period_description(prepared_cart_data['period_days'], db_user.language)
for country in countries:
if country['uuid'] in cart_data['countries']:
if country['uuid'] in prepared_cart_data['countries']:
selected_countries_names.append(country['name'])
if settings.is_traffic_fixed():
traffic_display = "Безлимитный" if cart_data['traffic_gb'] == 0 else f"{cart_data['traffic_gb']} ГБ"
traffic_value = prepared_cart_data.get('traffic_gb')
if traffic_value is None:
traffic_value = settings.get_fixed_traffic_limit()
traffic_display = "Безлимитный" if traffic_value == 0 else f"{traffic_value} ГБ"
else:
traffic_display = "Безлимитный" if cart_data['traffic_gb'] == 0 else f"{cart_data['traffic_gb']} ГБ"
traffic_value = prepared_cart_data.get('traffic_gb', 0) or 0
traffic_display = "Безлимитный" if traffic_value == 0 else f"{traffic_value} ГБ"
summary_text = (
"🛒 Восстановленная корзина\n\n"
f"📅 Период: {period_display}\n"
f"📊 Трафик: {traffic_display}\n"
f"🌍 Страны: {', '.join(selected_countries_names)}\n"
f"📱 Устройства: {cart_data['devices']}\n\n"
f"💎 Общая стоимость: {texts.format_price(total_price)}\n\n"
"Подтверждаете покупку?"
)
summary_lines = [
"🛒 Восстановленная корзина",
"",
f"📅 Период: {period_display}",
f"📊 Трафик: {traffic_display}",
f"🌍 Страны: {', '.join(selected_countries_names)}",
]
if settings.is_devices_selection_enabled():
devices_value = prepared_cart_data.get('devices')
if devices_value is not None:
summary_lines.append(f"📱 Устройства: {devices_value}")
summary_lines.extend([
"",
f"💎 Общая стоимость: {texts.format_price(total_price)}",
"",
"Подтверждаете покупку?",
])
summary_text = "\n".join(summary_lines)
# Устанавливаем данные в FSM для продолжения процесса
await state.set_data(cart_data)
await state.set_data(prepared_cart_data)
await state.set_state(SubscriptionStates.confirming_purchase)
await callback.message.edit_text(
@@ -793,7 +905,18 @@ async def handle_extend_subscription(
servers_discount_per_month = servers_price_per_month * servers_discount_percent // 100
total_servers_price = (servers_price_per_month - servers_discount_per_month) * months_in_period
additional_devices = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT)
device_limit = subscription.device_limit
if device_limit is None:
if settings.is_devices_selection_enabled():
device_limit = settings.DEFAULT_DEVICE_LIMIT
else:
forced_limit = settings.get_disabled_mode_device_limit()
if forced_limit is None:
device_limit = settings.DEFAULT_DEVICE_LIMIT
else:
device_limit = forced_limit
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(
"devices",
@@ -872,16 +995,27 @@ async def handle_extend_subscription(
texts=texts,
)
message_text = (
"⏰ Продление подписки\n\n"
f"Осталось дней: {subscription.days_left}\n\n"
f"<b>Ваша текущая конфигурация:</b>\n"
f"🌍 Серверов: {len(subscription.connected_squads)}\n"
f"📊 Трафик: {texts.format_traffic(subscription.traffic_limit_gb)}\n"
f"📱 Устройств: {subscription.device_limit}\n\n"
f"<b>Выберите период продления:</b>\n"
f"{prices_text.rstrip()}\n\n"
)
renewal_lines = [
"⏰ Продление подписки",
"",
f"Осталось дней: {subscription.days_left}",
"",
"<b>Ваша текущая конфигурация:</b>",
f"🌍 Серверов: {len(subscription.connected_squads)}",
f"📊 Трафик: {texts.format_traffic(subscription.traffic_limit_gb)}",
]
if settings.is_devices_selection_enabled():
renewal_lines.append(f"📱 Устройств: {subscription.device_limit}")
renewal_lines.extend([
"",
"<b>Выберите период продления:</b>",
prices_text.rstrip(),
"",
])
message_text = "\n".join(renewal_lines)
if promo_discounts_text:
message_text += f"{promo_discounts_text}\n\n"
@@ -958,7 +1092,18 @@ async def confirm_extend_subscription(
servers_price_per_month * servers_discount_percent // 100
)
additional_devices = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT)
device_limit = subscription.device_limit
if device_limit is None:
if settings.is_devices_selection_enabled():
device_limit = settings.DEFAULT_DEVICE_LIMIT
else:
forced_limit = settings.get_disabled_mode_device_limit()
if forced_limit is None:
device_limit = settings.DEFAULT_DEVICE_LIMIT
else:
device_limit = forced_limit
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(
"devices",
@@ -1248,43 +1393,60 @@ async def select_period(
reply_markup=get_traffic_packages_keyboard(db_user.language)
)
await state.set_state(SubscriptionStates.selecting_traffic)
else:
if await _should_show_countries_management(db_user):
countries = await _get_available_countries(db_user.promo_group_id)
await callback.message.edit_text(
texts.SELECT_COUNTRIES,
reply_markup=get_countries_keyboard(countries, [], db_user.language)
)
await state.set_state(SubscriptionStates.selecting_countries)
else:
countries = await _get_available_countries(db_user.promo_group_id)
available_countries = [c for c in countries if c.get('is_available', True)]
data['countries'] = [available_countries[0]['uuid']] if available_countries else []
await state.set_data(data)
await callback.answer()
return
selected_devices = data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
if await _should_show_countries_management(db_user):
countries = await _get_available_countries(db_user.promo_group_id)
await callback.message.edit_text(
texts.SELECT_COUNTRIES,
reply_markup=get_countries_keyboard(countries, [], db_user.language)
)
await state.set_state(SubscriptionStates.selecting_countries)
await callback.answer()
return
await callback.message.edit_text(
texts.SELECT_DEVICES,
reply_markup=get_devices_keyboard(selected_devices, db_user.language)
)
await state.set_state(SubscriptionStates.selecting_devices)
countries = await _get_available_countries(db_user.promo_group_id)
available_countries = [c for c in countries if c.get('is_available', True)]
data['countries'] = [available_countries[0]['uuid']] if available_countries else []
await state.set_data(data)
await callback.answer()
if settings.is_devices_selection_enabled():
selected_devices = data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
await callback.message.edit_text(
texts.SELECT_DEVICES,
reply_markup=get_devices_keyboard(selected_devices, db_user.language)
)
await state.set_state(SubscriptionStates.selecting_devices)
await callback.answer()
return
if await present_subscription_summary(callback, state, db_user, texts):
await callback.answer()
async def select_devices(
callback: types.CallbackQuery,
state: FSMContext,
db_user: User
):
texts = get_texts(db_user.language)
if not settings.is_devices_selection_enabled():
await callback.answer(
texts.t("DEVICES_SELECTION_DISABLED", "⚠️ Выбор количества устройств недоступен"),
show_alert=True,
)
return
if not callback.data.startswith("devices_") or callback.data == "devices_continue":
await callback.answer("❌ Некорректный запрос", show_alert=True)
await callback.answer(texts.t("DEVICES_INVALID_REQUEST", "❌ Некорректный запрос"), show_alert=True)
return
try:
devices = int(callback.data.split('_')[1])
except (ValueError, IndexError):
await callback.answer("❌ Некорректное количество устройств", show_alert=True)
await callback.answer(texts.t("DEVICES_INVALID_COUNT", "❌ Некорректное количество устройств"), show_alert=True)
return
data = await state.get_data()
@@ -1321,27 +1483,8 @@ async def devices_continue(
await callback.answer("⚠️ Некорректный запрос", show_alert=True)
return
data = await state.get_data()
texts = get_texts(db_user.language)
try:
summary_text, prepared_data = await _prepare_subscription_summary(db_user, data, texts)
except ValueError:
logger.error(f"Ошибка в расчете цены подписки для пользователя {db_user.telegram_id}")
await callback.answer("Ошибка расчета цены. Обратитесь в поддержку.", show_alert=True)
return
await state.set_data(prepared_data)
await save_subscription_checkout_draft(db_user.id, prepared_data)
await callback.message.edit_text(
summary_text,
reply_markup=get_subscription_confirm_keyboard(db_user.language),
parse_mode="HTML",
)
await state.set_state(SubscriptionStates.confirming_purchase)
await callback.answer()
if await present_subscription_summary(callback, state, db_user):
await callback.answer()
async def confirm_purchase(
callback: types.CallbackQuery,
@@ -1436,30 +1579,48 @@ async def confirm_purchase(
total_servers_discount = data.get('servers_discount_total', 0)
servers_discount_percent = data.get('servers_discount_percent', 0)
additional_devices = max(0, data['devices'] - settings.DEFAULT_DEVICE_LIMIT)
devices_selection_enabled = settings.is_devices_selection_enabled()
forced_disabled_limit: Optional[int] = None
if devices_selection_enabled:
devices_selected = data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
else:
forced_disabled_limit = settings.get_disabled_mode_device_limit()
if forced_disabled_limit is None:
devices_selected = settings.DEFAULT_DEVICE_LIMIT
else:
devices_selected = forced_disabled_limit
additional_devices = max(0, devices_selected - settings.DEFAULT_DEVICE_LIMIT)
devices_price_per_month = data.get(
'devices_price_per_month', additional_devices * settings.PRICE_PER_DEVICE
)
if 'devices_discount_percent' in data:
devices_discount_percent = data.get('devices_discount_percent', 0)
discounted_devices_price_per_month = data.get(
'devices_discounted_price_per_month', devices_price_per_month
)
devices_discount_total = data.get('devices_discount_total', 0)
total_devices_price = data.get(
'total_devices_price', discounted_devices_price_per_month * months_in_period
)
else:
devices_discount_percent = db_user.get_promo_discount(
"devices",
data['period_days'],
)
discounted_devices_price_per_month, discount_per_month = apply_percentage_discount(
devices_price_per_month,
devices_discount_percent,
)
devices_discount_total = discount_per_month * months_in_period
total_devices_price = discounted_devices_price_per_month * months_in_period
devices_discount_percent = 0
discounted_devices_price_per_month = 0
devices_discount_total = 0
total_devices_price = 0
if devices_selection_enabled and additional_devices > 0:
if 'devices_discount_percent' in data:
devices_discount_percent = data.get('devices_discount_percent', 0)
discounted_devices_price_per_month = data.get(
'devices_discounted_price_per_month', devices_price_per_month
)
devices_discount_total = data.get('devices_discount_total', 0)
total_devices_price = data.get(
'total_devices_price', discounted_devices_price_per_month * months_in_period
)
else:
devices_discount_percent = db_user.get_promo_discount(
"devices",
data['period_days'],
)
discounted_devices_price_per_month, discount_per_month = apply_percentage_discount(
devices_price_per_month,
devices_discount_percent,
)
devices_discount_total = discount_per_month * months_in_period
total_devices_price = discounted_devices_price_per_month * months_in_period
if settings.is_traffic_fixed():
final_traffic_gb = settings.get_fixed_traffic_limit()
@@ -1666,6 +1827,13 @@ async def confirm_purchase(
return
existing_subscription = db_user.subscription
if devices_selection_enabled:
selected_devices = devices_selected
else:
selected_devices = forced_disabled_limit
should_update_devices = selected_devices is not None
was_trial_conversion = False
current_time = datetime.utcnow()
@@ -1708,7 +1876,8 @@ async def confirm_purchase(
existing_subscription.is_trial = False
existing_subscription.status = SubscriptionStatus.ACTIVE.value
existing_subscription.traffic_limit_gb = final_traffic_gb
existing_subscription.device_limit = data['devices']
if should_update_devices:
existing_subscription.device_limit = selected_devices
existing_subscription.connected_squads = data['countries']
existing_subscription.start_date = current_time
@@ -1723,11 +1892,26 @@ async def confirm_purchase(
else:
logger.info(f"Создаем новую подписку для пользователя {db_user.telegram_id}")
default_device_limit = getattr(settings, "DEFAULT_DEVICE_LIMIT", 1)
resolved_device_limit = selected_devices
if resolved_device_limit is None:
if devices_selection_enabled:
resolved_device_limit = default_device_limit
else:
if forced_disabled_limit is not None:
resolved_device_limit = forced_disabled_limit
else:
resolved_device_limit = default_device_limit
if resolved_device_limit is None and devices_selection_enabled:
resolved_device_limit = default_device_limit
subscription = await create_paid_subscription_with_traffic_mode(
db=db,
user_id=db_user.id,
duration_days=data['period_days'],
device_limit=data['devices'],
device_limit=resolved_device_limit,
connected_squads=data['countries'],
traffic_gb=final_traffic_gb
)
@@ -1745,11 +1929,11 @@ async def confirm_purchase(
await add_user_to_servers(db, server_ids)
logger.info(f"Сохранены цены серверов за весь период: {server_prices}")
await db.refresh(db_user)
subscription_service = SubscriptionService()
if db_user.remnawave_uuid:
remnawave_user = await subscription_service.update_remnawave_user(
db,
@@ -1764,7 +1948,7 @@ async def confirm_purchase(
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
reset_reason="покупка подписки",
)
if not remnawave_user:
logger.error(f"Не удалось создать/обновить RemnaWave пользователя для {db_user.telegram_id}")
remnawave_user = await subscription_service.create_remnawave_user(
@@ -1773,7 +1957,7 @@ async def confirm_purchase(
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
reset_reason="покупка подписки (повторная попытка)",
)
transaction = await create_transaction(
db=db,
user_id=db_user.id,
@@ -1781,7 +1965,7 @@ async def confirm_purchase(
amount_kopeks=final_price,
description=f"Подписка на {data['period_days']} дней ({months_in_period} мес)"
)
try:
notification_service = AdminNotificationService(callback.bot)
await notification_service.send_subscription_purchase_notification(
@@ -1988,7 +2172,7 @@ async def create_paid_subscription_with_traffic_mode(
db: AsyncSession,
user_id: int,
duration_days: int,
device_limit: int,
device_limit: Optional[int],
connected_squads: List[str],
traffic_gb: Optional[int] = None
):
@@ -2002,16 +2186,20 @@ async def create_paid_subscription_with_traffic_mode(
else:
traffic_limit_gb = traffic_gb
subscription = await create_paid_subscription(
create_kwargs = dict(
db=db,
user_id=user_id,
duration_days=duration_days,
traffic_limit_gb=traffic_limit_gb,
device_limit=device_limit,
connected_squads=connected_squads,
update_server_counters=False,
)
if device_limit is not None:
create_kwargs['device_limit'] = device_limit
subscription = await create_paid_subscription(**create_kwargs)
logger.info(f"📋 Создана подписка с трафиком: {traffic_limit_gb} ГБ (режим: {settings.TRAFFIC_SELECTION_MODE})")
return subscription
@@ -2034,9 +2222,14 @@ async def handle_subscription_settings(
)
return
devices_used = await get_current_devices_count(db_user)
show_devices = settings.is_devices_selection_enabled()
settings_text = texts.t(
if show_devices:
devices_used = await get_current_devices_count(db_user)
else:
devices_used = 0
settings_template = texts.t(
"SUBSCRIPTION_SETTINGS_OVERVIEW",
(
"⚙️ <b>Настройки подписки</b>\n\n"
@@ -2046,7 +2239,15 @@ async def handle_subscription_settings(
"📱 Устройства: {devices_used} / {devices_limit}\n\n"
"Выберите что хотите изменить:"
),
).format(
)
if not show_devices:
settings_template = settings_template.replace(
"\n📱 Устройства: {devices_used} / {devices_limit}",
"",
)
settings_text = settings_template.format(
countries_count=len(subscription.connected_squads),
traffic_used=texts.format_traffic(subscription.traffic_used_gb),
traffic_limit=texts.format_traffic(subscription.traffic_limit_gb),
@@ -2384,10 +2585,13 @@ async def handle_simple_subscription_purchase(
await callback.answer("❌ Простая покупка подписки временно недоступна", show_alert=True)
return
# Определяем ограничение по устройствам для текущего режима
simple_device_limit = resolve_simple_subscription_device_limit()
# Проверяем, есть ли у пользователя активная подписка
from app.database.crud.subscription import get_subscription_by_user_id
current_subscription = await get_subscription_by_user_id(db, db_user.id)
# Если у пользователя уже есть активная подписка, продлеваем её
if current_subscription and current_subscription.is_active:
# Продлеваем существующую подписку
@@ -2397,16 +2601,16 @@ async def handle_simple_subscription_purchase(
db=db,
current_subscription=current_subscription,
period_days=settings.SIMPLE_SUBSCRIPTION_PERIOD_DAYS,
device_limit=settings.SIMPLE_SUBSCRIPTION_DEVICE_LIMIT,
device_limit=simple_device_limit,
traffic_limit_gb=settings.SIMPLE_SUBSCRIPTION_TRAFFIC_GB,
squad_uuid=settings.SIMPLE_SUBSCRIPTION_SQUAD_UUID
)
return
# Подготовим параметры простой подписки
subscription_params = {
"period_days": settings.SIMPLE_SUBSCRIPTION_PERIOD_DAYS,
"device_limit": settings.SIMPLE_SUBSCRIPTION_DEVICE_LIMIT,
"device_limit": simple_device_limit,
"traffic_limit_gb": settings.SIMPLE_SUBSCRIPTION_TRAFFIC_GB,
"squad_uuid": settings.SIMPLE_SUBSCRIPTION_SQUAD_UUID
}
@@ -2441,16 +2645,26 @@ async def handle_simple_subscription_purchase(
if user_balance_kopeks >= price_kopeks:
# Если баланс достаточный, предлагаем оплатить с баланса
message_text = (
f"⚡ <b>Простая покупка подписки</b>\n\n"
f"📅 Период: {subscription_params['period_days']} дней\n"
f"📱 Устройства: {subscription_params['device_limit']}\n"
f"📊 Трафик: {traffic_text}\n"
f"🌍 Сервер: {'Любой доступный' if not subscription_params['squad_uuid'] else 'Выбранный'}\n\n"
f"💰 Стоимость: {settings.format_price(price_kopeks)}\n"
f"💳 Ваш баланс: {settings.format_price(user_balance_kopeks)}\n\n"
f"Вы можете оплатить подписку с баланса или выбрать другой способ оплаты."
)
simple_lines = [
"⚡ <b>Простая покупка подписки</b>",
"",
f"📅 Период: {subscription_params['period_days']} дней",
]
if settings.is_devices_selection_enabled():
simple_lines.append(f"📱 Устройства: {subscription_params['device_limit']}")
simple_lines.extend([
f"📊 Трафик: {traffic_text}",
f"🌍 Сервер: {'Любой доступный' if not subscription_params['squad_uuid'] else 'Выбранный'}",
"",
f"💰 Стоимость: {settings.format_price(price_kopeks)}",
f"💳 Ваш баланс: {settings.format_price(user_balance_kopeks)}",
"",
"Вы можете оплатить подписку с баланса или выбрать другой способ оплаты.",
])
message_text = "\n".join(simple_lines)
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="✅ Оплатить с баланса", callback_data="simple_subscription_pay_with_balance")],
@@ -2459,16 +2673,26 @@ async def handle_simple_subscription_purchase(
])
else:
# Если баланс недостаточный, предлагаем внешние способы оплаты
message_text = (
f"⚡ <b>Простая покупка подписки</b>\n\n"
f"📅 Период: {subscription_params['period_days']} дней\n"
f"📱 Устройства: {subscription_params['device_limit']}\n"
f"📊 Трафик: {traffic_text}\n"
f"🌍 Сервер: {'Любой доступный' if not subscription_params['squad_uuid'] else 'Выбранный'}\n\n"
f"💰 Стоимость: {settings.format_price(price_kopeks)}\n"
f"💳 Ваш баланс: {settings.format_price(user_balance_kopeks)}\n\n"
f"Выберите способ оплаты:"
)
simple_lines = [
"⚡ <b>Простая покупка подписки</b>",
"",
f"📅 Период: {subscription_params['period_days']} дней",
]
if settings.is_devices_selection_enabled():
simple_lines.append(f"📱 Устройства: {subscription_params['device_limit']}")
simple_lines.extend([
f"📊 Трафик: {traffic_text}",
f"🌍 Сервер: {'Любой доступный' if not subscription_params['squad_uuid'] else 'Выбранный'}",
"",
f"💰 Стоимость: {settings.format_price(price_kopeks)}",
f"💳 Ваш баланс: {settings.format_price(user_balance_kopeks)}",
"",
"Выберите способ оплаты:",
])
message_text = "\n".join(simple_lines)
keyboard = _get_simple_subscription_payment_keyboard(db_user.language)

View File

@@ -0,0 +1,59 @@
import logging
from typing import Optional, TYPE_CHECKING
from aiogram import types
from aiogram.fsm.context import FSMContext
from app.localization.texts import get_texts
from app.services.subscription_checkout_service import save_subscription_checkout_draft
from app.states import SubscriptionStates
from app.keyboards.inline import get_subscription_confirm_keyboard
if TYPE_CHECKING: # pragma: no cover - only for type checking
from .pricing import _prepare_subscription_summary
logger = logging.getLogger(__name__)
async def present_subscription_summary(
callback: types.CallbackQuery,
state: FSMContext,
db_user,
texts: Optional = None,
) -> bool:
"""Render the subscription purchase summary and switch to the confirmation state.
Returns ``True`` when the summary is shown successfully and ``False`` if
calculation failed (an error is shown to the user in this case).
"""
if texts is None:
texts = get_texts(db_user.language)
data = await state.get_data()
from .pricing import _prepare_subscription_summary
try:
summary_text, prepared_data = await _prepare_subscription_summary(db_user, data, texts)
except ValueError as exc:
logger.error(
"Ошибка в расчете цены подписки для пользователя %s: %s",
db_user.telegram_id,
exc,
)
await callback.answer("Ошибка расчета цены. Обратитесь в поддержку.", show_alert=True)
return False
await state.set_data(prepared_data)
await save_subscription_checkout_draft(db_user.id, prepared_data)
await callback.message.edit_text(
summary_text,
reply_markup=get_subscription_confirm_keyboard(db_user.language),
parse_mode="HTML",
)
await state.set_state(SubscriptionStates.confirming_purchase)
return True

View File

@@ -80,6 +80,7 @@ from app.utils.promo_offer import (
from .common import _apply_addon_discount, _get_addon_discount_percent_for_user, _get_period_hint_from_subscription, get_confirm_switch_traffic_keyboard, get_traffic_switch_keyboard, logger
from .countries import _get_available_countries, _should_show_countries_management
from .summary import present_subscription_summary
async def handle_add_traffic(
callback: types.CallbackQuery,
@@ -352,12 +353,15 @@ async def select_traffic(
reply_markup=get_countries_keyboard(countries, [], db_user.language)
)
await state.set_state(SubscriptionStates.selecting_countries)
else:
countries = await _get_available_countries(db_user.promo_group_id)
available_countries = [c for c in countries if c.get('is_available', True)]
data['countries'] = [available_countries[0]['uuid']] if available_countries else []
await state.set_data(data)
await callback.answer()
return
countries = await _get_available_countries(db_user.promo_group_id)
available_countries = [c for c in countries if c.get('is_available', True)]
data['countries'] = [available_countries[0]['uuid']] if available_countries else []
await state.set_data(data)
if settings.is_devices_selection_enabled():
selected_devices = data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
await callback.message.edit_text(
@@ -365,8 +369,11 @@ async def select_traffic(
reply_markup=get_devices_keyboard(selected_devices, db_user.language)
)
await state.set_state(SubscriptionStates.selecting_devices)
await callback.answer()
return
await callback.answer()
if await present_subscription_summary(callback, state, db_user, texts):
await callback.answer()
async def add_traffic(
callback: types.CallbackQuery,

View File

@@ -2085,33 +2085,39 @@ def get_updated_subscription_settings_keyboard(language: str = DEFAULT_LANGUAGE,
texts = get_texts(language)
keyboard = []
if show_countries_management:
keyboard.append([
InlineKeyboardButton(text=texts.t("ADD_COUNTRIES_BUTTON", "🌐 Добавить страны"), callback_data="subscription_add_countries")
])
keyboard.extend([
[
InlineKeyboardButton(text=texts.t("CHANGE_DEVICES_BUTTON", "📱 Изменить устройства"), callback_data="subscription_change_devices")
],
[
InlineKeyboardButton(text=texts.t("MANAGE_DEVICES_BUTTON", "🔧 Управление устройствами"), callback_data="subscription_manage_devices")
]
])
if settings.is_traffic_selectable():
keyboard.insert(-2, [
InlineKeyboardButton(text=texts.t("SWITCH_TRAFFIC_BUTTON", "🔄 Переключить трафик"), callback_data="subscription_switch_traffic")
])
keyboard.insert(-2, [
keyboard.append([
InlineKeyboardButton(text=texts.t("RESET_TRAFFIC_BUTTON", "🔄 Сбросить трафик"), callback_data="subscription_reset_traffic")
])
keyboard.append([
InlineKeyboardButton(text=texts.t("SWITCH_TRAFFIC_BUTTON", "🔄 Переключить трафик"), callback_data="subscription_switch_traffic")
])
if settings.is_devices_selection_enabled():
keyboard.append([
InlineKeyboardButton(
text=texts.t("CHANGE_DEVICES_BUTTON", "📱 Изменить устройства"),
callback_data="subscription_change_devices"
)
])
keyboard.append([
InlineKeyboardButton(
text=texts.t("MANAGE_DEVICES_BUTTON", "🔧 Управление устройствами"),
callback_data="subscription_manage_devices"
)
])
keyboard.append([
InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")
])
return InlineKeyboardMarkup(inline_keyboard=keyboard)

View File

@@ -856,6 +856,9 @@
"DEVICE_CHANGE_NO_REFUND": "Payments are not refunded",
"DEVICE_CHANGE_NO_REFUND_INFO": " Payments are not refunded",
"DEVICE_CHANGE_RESULT_LINE": "📱 Was: {old} → Now: {new}\n",
"DEVICES_INVALID_REQUEST": "❌ Invalid request",
"DEVICES_INVALID_COUNT": "❌ Invalid device count",
"DEVICES_SELECTION_DISABLED": "⚠️ Device selection is unavailable",
"DEVICE_CONNECTION_HELP": "❓ How to reconnect a device?",
"DEVICE_FETCH_ERROR": "❌ Failed to load devices",
"DEVICE_FETCH_INFO_ERROR": "❌ Failed to load device information",
@@ -1378,7 +1381,8 @@
"TRIAL_ACTIVATED": "🎉 Trial subscription activated!",
"TRIAL_ACTIVATE_BUTTON": "🎁 Activate",
"TRIAL_ALREADY_USED": "❌ The trial subscription has already been used",
"TRIAL_AVAILABLE": "\n🎁 <b>Trial subscription</b>\n\nYou can get a free trial plan:\n\n⏰ <b>Duration:</b> {days} days\n📈 <b>Traffic:</b> {traffic} GB\n📱 <b>Devices:</b> {devices} pcs\n🌍 <b>Server:</b> {server_name}\n\nActivate the trial subscription?\n",
"TRIAL_AVAILABLE": "\n🎁 <b>Trial subscription</b>\n\nYou can get a free trial plan:\n\n⏰ <b>Duration:</b> {days} days\n📈 <b>Traffic:</b> {traffic} GB{devices_line}\n🌍 <b>Server:</b> {server_name}\n\nActivate the trial subscription?\n",
"TRIAL_AVAILABLE_DEVICES_LINE": "\n📱 <b>Devices:</b> {devices} pcs",
"TRIAL_CHANNEL_UNSUBSCRIBED": "\n🚫 <b>Access paused</b>\n\nWe couldn't find your subscription to our channel, so the trial plan has been disabled.\n\nJoin the channel and tap “{check_button}” to restore access.",
"TRIAL_ENDING_SOON": "\n🎁 <b>The trial subscription is ending soon!</b>\n\nYour trial expires in a few hours.\n\n💎 <b>Don't want to lose VPN access?</b>\nSwitch to the full subscription!\n\n🔥 <b>Special offer:</b>\n• 30 days for {price}\n• Unlimited traffic\n• All servers available\n• Speeds up to 1 Gbit/s\n\n⚡ Activate before the trial ends!\n",
"TRIAL_INACTIVE_1H": "⏳ <b>An hour has passed and we haven't seen any traffic yet</b>\n\nOpen the connection guide and follow the steps. We're always ready to help!",

View File

@@ -856,6 +856,9 @@
"DEVICE_CHANGE_NO_REFUND": "Возврат средств не производится",
"DEVICE_CHANGE_NO_REFUND_INFO": " Возврат средств не производится",
"DEVICE_CHANGE_RESULT_LINE": "📱 Было: {old} → Стало: {new}\n",
"DEVICES_INVALID_REQUEST": "❌ Некорректный запрос",
"DEVICES_INVALID_COUNT": "❌ Некорректное количество устройств",
"DEVICES_SELECTION_DISABLED": "⚠️ Выбор количества устройств недоступен",
"DEVICE_CONNECTION_HELP": "❓ Как подключить устройство заново?",
"DEVICE_FETCH_ERROR": "❌ Ошибка получения устройств",
"DEVICE_FETCH_INFO_ERROR": "❌ Ошибка получения информации об устройствах",
@@ -1378,7 +1381,8 @@
"TRIAL_ACTIVATED": "🎉 Тестовая подписка активирована!",
"TRIAL_ACTIVATE_BUTTON": "🎁 Активировать",
"TRIAL_ALREADY_USED": "❌ Тестовая подписка уже была использована",
"TRIAL_AVAILABLE": "\n🎁 <b>Тестовая подписка</b>\n\nВы можете получить бесплатную тестовую подписку:\n\n⏰ <b>Период:</b> {days} дней\n📈 <b>Трафик:</b> {traffic} ГБ\n📱 <b>Устройства:</b> {devices} шт.\n🌍 <b>Сервер:</b> {server_name}\n\nАктивировать тестовую подписку?\n",
"TRIAL_AVAILABLE": "\n🎁 <b>Тестовая подписка</b>\n\nВы можете получить бесплатную тестовую подписку:\n\n⏰ <b>Период:</b> {days} дней\n📈 <b>Трафик:</b> {traffic} ГБ{devices_line}\n🌍 <b>Сервер:</b> {server_name}\n\nАктивировать тестовую подписку?\n",
"TRIAL_AVAILABLE_DEVICES_LINE": "\n📱 <b>Устройства:</b> {devices} шт.",
"TRIAL_CHANNEL_UNSUBSCRIBED": "\n🚫 <b>Доступ приостановлен</b>\n\nМы не нашли вашу подписку на наш канал, поэтому тестовая подписка отключена.\n\nПодпишитесь на канал и нажмите «{check_button}», чтобы вернуть доступ.",
"TRIAL_ENDING_SOON": "\n🎁 <b>Тестовая подписка скоро закончится!</b>\n\nВаша тестовая подписка истекает через несколько часов.\n\n💎 <b>Не хотите остаться без VPN?</b>\nПереходите на полную подписку!\n\n🔥 <b>Специальное предложение:</b>\n• 30 дней всего за {price}\n• Безлимитный трафик \n• Все серверы доступны\n• Скорость до 1ГБит/сек\n\n⚡ Успейте оформить до окончания тестового периода!\n",
"TRIAL_INACTIVE_1H": "⏳ <b>Прошёл час, а подключение не выполнено</b>\n\nЕсли возникли сложности — откройте инструкцию и следуйте шагам. Мы всегда готовы помочь!",

View File

@@ -186,6 +186,14 @@ class AdminNotificationService:
promo_group = await self._get_user_promo_group(db, user)
promo_block = self._format_promo_group_block(promo_group)
trial_device_limit = subscription.device_limit
if trial_device_limit is None:
fallback_forced_limit = settings.get_disabled_mode_device_limit()
if fallback_forced_limit is not None:
trial_device_limit = fallback_forced_limit
else:
trial_device_limit = settings.TRIAL_DEVICE_LIMIT
message = f"""🎯 <b>АКТИВАЦИЯ ТРИАЛА</b>
👤 <b>Пользователь:</b> {user.full_name}
@@ -198,7 +206,7 @@ class AdminNotificationService:
⏰ <b>Параметры триала:</b>
📅 Период: {settings.TRIAL_DURATION_DAYS} дней
📊 Трафик: {settings.TRIAL_TRAFFIC_LIMIT_GB} ГБ
📱 Устройства: {settings.TRIAL_DEVICE_LIMIT}
📱 Устройства: {trial_device_limit}
🌐 Сервер: {subscription.connected_squads[0] if subscription.connected_squads else 'По умолчанию'}
📆 <b>Действует до:</b> {subscription.end_date.strftime('%d.%m.%Y %H:%M')}

View File

@@ -120,9 +120,9 @@ class AdvertisingCampaignService:
return CampaignBonusResult(success=False)
traffic_limit = campaign.subscription_traffic_gb
device_limit = (
campaign.subscription_device_limit or settings.DEFAULT_DEVICE_LIMIT
)
device_limit = campaign.subscription_device_limit
if device_limit is None:
device_limit = settings.DEFAULT_DEVICE_LIMIT
squads = list(campaign.subscription_squads or [])
if not squads:

View File

@@ -38,6 +38,9 @@ from app.database.crud.user import (
subtract_user_balance,
cleanup_expired_promo_offer_discounts,
)
from app.utils.subscription_utils import (
resolve_hwid_device_limit_for_payload,
)
from app.database.models import MonitoringLog, SubscriptionStatus, Subscription, User, Ticket, TicketStatus
from app.localization.texts import get_texts
from app.services.notification_settings_service import NotificationSettingsService
@@ -283,20 +286,26 @@ class MonitoringService:
logger.info(f"📝 Статус подписки {subscription.id} обновлен на 'expired'")
async with self.api as api:
updated_user = await api.update_user(
hwid_limit = resolve_hwid_device_limit_for_payload(subscription)
update_kwargs = dict(
uuid=user.remnawave_uuid,
status=UserStatus.ACTIVE if is_active else UserStatus.EXPIRED,
expire_at=subscription.end_date,
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
hwid_device_limit=subscription.device_limit,
description=settings.format_remnawave_user_description(
full_name=user.full_name,
username=user.username,
telegram_id=user.telegram_id
),
active_internal_squads=subscription.connected_squads
active_internal_squads=subscription.connected_squads,
)
if hwid_limit is not None:
update_kwargs['hwid_device_limit'] = hwid_limit
updated_user = await api.update_user(**update_kwargs)
subscription.subscription_url = updated_user.subscription_url
subscription.subscription_crypto_link = updated_user.happ_crypto_link

View File

@@ -131,12 +131,20 @@ class PromoCodeService:
if getattr(settings, 'TRIAL_SQUAD_UUID', None):
trial_squads = [settings.TRIAL_SQUAD_UUID]
forced_devices = None
if not settings.is_devices_selection_enabled():
forced_devices = settings.get_disabled_mode_device_limit()
device_limit = settings.DEFAULT_DEVICE_LIMIT
if forced_devices is not None:
device_limit = forced_devices
new_subscription = await create_paid_subscription(
db=db,
user_id=user.id,
duration_days=promocode.subscription_days,
traffic_limit_gb=0,
device_limit=1,
device_limit=device_limit,
connected_squads=trial_squads,
update_server_counters=True,
)
@@ -155,10 +163,15 @@ class PromoCodeService:
if not subscription:
trial_days = promocode.subscription_days if promocode.subscription_days > 0 else settings.TRIAL_DURATION_DAYS
forced_devices = None
if not settings.is_devices_selection_enabled():
forced_devices = settings.get_disabled_mode_device_limit()
trial_subscription = await create_trial_subscription(
db,
user.id,
duration_days=trial_days
duration_days=trial_days,
device_limit=forced_devices,
)
await self.subscription_service.create_remnawave_user(db, trial_subscription)

View File

@@ -38,6 +38,9 @@ from app.database.models import (
SubscriptionStatus,
ServerSquad,
)
from app.utils.subscription_utils import (
resolve_hwid_device_limit_for_payload,
)
logger = logging.getLogger(__name__)
@@ -1216,44 +1219,53 @@ class RemnaWaveService:
for user in users:
if not user.subscription:
continue
try:
subscription = user.subscription
hwid_limit = resolve_hwid_device_limit_for_payload(subscription)
if user.remnawave_uuid:
await api.update_user(
update_kwargs = dict(
uuid=user.remnawave_uuid,
status=UserStatus.ACTIVE if subscription.is_active else UserStatus.EXPIRED,
expire_at=subscription.end_date,
traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3) if subscription.traffic_limit_gb > 0 else 0,
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
hwid_device_limit=subscription.device_limit,
description=settings.format_remnawave_user_description(
full_name=user.full_name,
username=user.username,
telegram_id=user.telegram_id
),
active_internal_squads=subscription.connected_squads
active_internal_squads=subscription.connected_squads,
)
if hwid_limit is not None:
update_kwargs['hwid_device_limit'] = hwid_limit
await api.update_user(**update_kwargs)
stats["updated"] += 1
else:
username = f"user_{user.telegram_id}"
new_user = await api.create_user(
create_kwargs = dict(
username=username,
expire_at=subscription.end_date,
status=UserStatus.ACTIVE if subscription.is_active else UserStatus.EXPIRED,
traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3) if subscription.traffic_limit_gb > 0 else 0,
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
telegram_id=user.telegram_id,
hwid_device_limit=subscription.device_limit,
description=settings.format_remnawave_user_description(
full_name=user.full_name,
username=user.username,
telegram_id=user.telegram_id
),
active_internal_squads=subscription.connected_squads
active_internal_squads=subscription.connected_squads,
)
if hwid_limit is not None:
create_kwargs['hwid_device_limit'] = hwid_limit
new_user = await api.create_user(**create_kwargs)
await update_user(db, user, remnawave_uuid=new_user.uuid)
subscription.remnawave_short_uuid = new_user.short_uuid

View File

@@ -17,6 +17,9 @@ from app.utils.pricing_utils import (
calculate_prorated_price,
validate_pricing_calculation
)
from app.utils.subscription_utils import (
resolve_hwid_device_limit_for_payload,
)
logger = logging.getLogger(__name__)
@@ -172,6 +175,7 @@ class SubscriptionService:
return None
async with self.get_api_client() as api:
hwid_limit = resolve_hwid_device_limit_for_payload(subscription)
existing_users = await api.get_user_by_telegram_id(user.telegram_id)
if existing_users:
logger.info(f"🔄 Найден существующий пользователь в панели для {user.telegram_id}")
@@ -183,20 +187,24 @@ class SubscriptionService:
except Exception as hwid_error:
logger.warning(f"⚠️ Не удалось сбросить HWID: {hwid_error}")
updated_user = await api.update_user(
update_kwargs = dict(
uuid=remnawave_user.uuid,
status=UserStatus.ACTIVE,
expire_at=subscription.end_date,
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
traffic_limit_strategy=get_traffic_reset_strategy(),
hwid_device_limit=subscription.device_limit,
description=settings.format_remnawave_user_description(
full_name=user.full_name,
username=user.username,
telegram_id=user.telegram_id
),
active_internal_squads=subscription.connected_squads
active_internal_squads=subscription.connected_squads,
)
if hwid_limit is not None:
update_kwargs['hwid_device_limit'] = hwid_limit
updated_user = await api.update_user(**update_kwargs)
if reset_traffic:
await self._reset_user_traffic(
@@ -209,22 +217,26 @@ class SubscriptionService:
else:
logger.info(f"🆕 Создаем нового пользователя в панели для {user.telegram_id}")
username = f"user_{user.telegram_id}"
updated_user = await api.create_user(
create_kwargs = dict(
username=username,
expire_at=subscription.end_date,
status=UserStatus.ACTIVE,
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
traffic_limit_strategy=get_traffic_reset_strategy(),
telegram_id=user.telegram_id,
hwid_device_limit=subscription.device_limit,
description=settings.format_remnawave_user_description(
full_name=user.full_name,
username=user.username,
telegram_id=user.telegram_id
),
active_internal_squads=subscription.connected_squads
active_internal_squads=subscription.connected_squads,
)
if hwid_limit is not None:
create_kwargs['hwid_device_limit'] = hwid_limit
updated_user = await api.create_user(**create_kwargs)
if reset_traffic:
await self._reset_user_traffic(
api,
@@ -282,20 +294,26 @@ class SubscriptionService:
logger.info(f"🔔 Статус подписки {subscription.id} автоматически изменен на 'expired'")
async with self.get_api_client() as api:
updated_user = await api.update_user(
hwid_limit = resolve_hwid_device_limit_for_payload(subscription)
update_kwargs = dict(
uuid=user.remnawave_uuid,
status=UserStatus.ACTIVE if is_actually_active else UserStatus.EXPIRED,
expire_at=subscription.end_date,
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
traffic_limit_strategy=get_traffic_reset_strategy(),
hwid_device_limit=subscription.device_limit,
description=settings.format_remnawave_user_description(
full_name=user.full_name,
username=user.username,
telegram_id=user.telegram_id
),
active_internal_squads=subscription.connected_squads
active_internal_squads=subscription.connected_squads,
)
if hwid_limit is not None:
update_kwargs['hwid_device_limit'] = hwid_limit
updated_user = await api.update_user(**update_kwargs)
if reset_traffic:
await self._reset_user_traffic(
@@ -565,7 +583,18 @@ class SubscriptionService:
servers_discount = servers_price * servers_discount_percent // 100
discounted_servers_price = servers_price - servers_discount
devices_price = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
device_limit = subscription.device_limit
if device_limit is None:
if settings.is_devices_selection_enabled():
device_limit = settings.DEFAULT_DEVICE_LIMIT
else:
forced_limit = settings.get_disabled_mode_device_limit()
if forced_limit is None:
device_limit = settings.DEFAULT_DEVICE_LIMIT
else:
device_limit = forced_limit
devices_price = max(0, (device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
devices_discount_percent = _resolve_discount_percent(
user,
promo_group,
@@ -617,7 +646,7 @@ class SubscriptionService:
)
logger.info(message)
if devices_price > 0:
message = f" 📱 Устройства ({subscription.device_limit}): {discounted_devices_price/100}"
message = f" 📱 Устройства ({device_limit}): {discounted_devices_price/100}"
if devices_discount > 0:
message += (
f" (скидка {devices_discount_percent}%: -{devices_discount/100}₽ от {devices_price/100}₽)"
@@ -894,7 +923,18 @@ class SubscriptionService:
discounted_servers_per_month = servers_price_per_month - servers_discount_per_month
total_servers_price = discounted_servers_per_month * months_in_period
additional_devices = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT)
device_limit = subscription.device_limit
if device_limit is None:
if settings.is_devices_selection_enabled():
device_limit = settings.DEFAULT_DEVICE_LIMIT
else:
forced_limit = settings.get_disabled_mode_device_limit()
if forced_limit is None:
device_limit = settings.DEFAULT_DEVICE_LIMIT
else:
device_limit = forced_limit
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 = _resolve_discount_percent(
user,

View File

@@ -195,6 +195,8 @@ class BotConfigurationService:
"DEFAULT_TRAFFIC_LIMIT_GB": "SUBSCRIPTIONS_CORE",
"MAX_DEVICES_LIMIT": "SUBSCRIPTIONS_CORE",
"PRICE_PER_DEVICE": "SUBSCRIPTIONS_CORE",
"DEVICES_SELECTION_ENABLED": "SUBSCRIPTIONS_CORE",
"DEVICES_SELECTION_DISABLED_AMOUNT": "SUBSCRIPTIONS_CORE",
"BASE_SUBSCRIPTION_PRICE": "SUBSCRIPTIONS_CORE",
"DEFAULT_TRAFFIC_RESET_STRATEGY": "TRAFFIC",
"RESET_TRAFFIC_ON_PAYMENT": "TRAFFIC",
@@ -450,6 +452,21 @@ class BotConfigurationService:
"example": "d4aa2b8c-9a36-4f31-93a2-6f07dad05fba",
"warning": "Убедитесь, что выбранный сквад активен и доступен для подписки.",
},
"DEVICES_SELECTION_ENABLED": {
"description": "Разрешает пользователям выбирать количество устройств при покупке и продлении подписки.",
"format": "Булево значение.",
"example": "false",
"warning": "При отключении пользователи не смогут докупать устройства из интерфейса бота.",
},
"DEVICES_SELECTION_DISABLED_AMOUNT": {
"description": (
"Лимит устройств, который автоматически назначается, когда выбор количества устройств выключен. "
"Значение 0 отключает назначение устройств."
),
"format": "Целое число от 0 и выше.",
"example": "3",
"warning": "При 0 RemnaWave не получит лимит устройств, пользователям не показываются цифры в интерфейсе.",
},
"CRYPTOBOT_ENABLED": {
"description": "Разрешает принимать криптоплатежи через CryptoBot.",
"format": "Булево значение.",

View File

@@ -174,3 +174,58 @@ def convert_subscription_link_to_happ_scheme(subscription_link: Optional[str]) -
return subscription_link
return urlunparse(parsed_link._replace(scheme="happ"))
def resolve_hwid_device_limit(subscription: Optional[Subscription]) -> Optional[int]:
"""Return a device limit value for RemnaWave payloads when selection is enabled."""
if subscription is None:
return None
if not settings.is_devices_selection_enabled():
forced_limit = settings.get_disabled_mode_device_limit()
return forced_limit
limit = getattr(subscription, "device_limit", None)
if limit is None or limit <= 0:
return None
return limit
def resolve_hwid_device_limit_for_payload(
subscription: Optional[Subscription],
) -> Optional[int]:
"""Return the device limit that should be sent to RemnaWave APIs.
When device selection is disabled and no explicit override is configured,
RemnaWave should continue receiving the subscription's stored limit so the
external panel stays aligned with the bot configuration.
"""
resolved_limit = resolve_hwid_device_limit(subscription)
if resolved_limit is not None:
return resolved_limit
if subscription is None:
return None
fallback_limit = getattr(subscription, "device_limit", None)
if fallback_limit is None or fallback_limit <= 0:
return None
return fallback_limit
def resolve_simple_subscription_device_limit() -> int:
"""Return the effective device limit for simple subscription flows."""
if settings.is_devices_selection_enabled():
return int(getattr(settings, "SIMPLE_SUBSCRIPTION_DEVICE_LIMIT", 0) or 0)
forced_limit = settings.get_disabled_mode_device_limit()
if forced_limit is not None:
return forced_limit
return int(getattr(settings, "SIMPLE_SUBSCRIPTION_DEVICE_LIMIT", 0) or 0)

View File

@@ -3121,8 +3121,16 @@ async def activate_subscription_trial_endpoint(
},
)
forced_devices = None
if not settings.is_devices_selection_enabled():
forced_devices = settings.get_disabled_mode_device_limit()
try:
subscription = await create_trial_subscription(db, user.id)
subscription = await create_trial_subscription(
db,
user.id,
device_limit=forced_devices,
)
except Exception as error: # pragma: no cover - defensive logging
logger.error(
"Failed to activate trial subscription for user %s: %s",
@@ -3638,7 +3646,9 @@ async def _calculate_subscription_renewal_pricing(
if traffic_limit is None:
traffic_limit = settings.DEFAULT_TRAFFIC_LIMIT_GB
devices_limit = subscription.device_limit or settings.DEFAULT_DEVICE_LIMIT
devices_limit = subscription.device_limit
if devices_limit is None:
devices_limit = settings.DEFAULT_DEVICE_LIMIT
total_cost, details = await calculate_subscription_total_cost(
db,
@@ -5044,7 +5054,12 @@ async def update_subscription_devices_endpoint(
},
)
current_devices = int(subscription.device_limit or settings.DEFAULT_DEVICE_LIMIT or 1)
current_devices_value = subscription.device_limit
if current_devices_value is None:
fallback_value = settings.DEFAULT_DEVICE_LIMIT or 1
current_devices_value = fallback_value
current_devices = int(current_devices_value)
old_devices = current_devices
if new_devices == current_devices:

View File

@@ -112,24 +112,38 @@ async def create_subscription(
if existing:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "User already has a subscription")
forced_devices = None
if not settings.is_devices_selection_enabled():
forced_devices = settings.get_disabled_mode_device_limit()
if payload.is_trial:
trial_device_limit = payload.device_limit
if trial_device_limit is None:
trial_device_limit = forced_devices
subscription = await create_trial_subscription(
db,
user_id=payload.user_id,
duration_days=payload.duration_days,
traffic_limit_gb=payload.traffic_limit_gb,
device_limit=payload.device_limit,
device_limit=trial_device_limit,
squad_uuid=payload.squad_uuid,
)
else:
if payload.duration_days is None:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "duration_days is required for paid subscriptions")
device_limit = payload.device_limit
if device_limit is None:
if forced_devices is not None:
device_limit = forced_devices
else:
device_limit = settings.DEFAULT_DEVICE_LIMIT
subscription = await create_paid_subscription(
db,
user_id=payload.user_id,
duration_days=payload.duration_days,
traffic_limit_gb=payload.traffic_limit_gb or settings.DEFAULT_TRAFFIC_LIMIT_GB,
device_limit=payload.device_limit or settings.DEFAULT_DEVICE_LIMIT,
device_limit=device_limit,
connected_squads=payload.connected_squads or [],
update_server_counters=True,
)

View File

@@ -0,0 +1,167 @@
import pytest
from app.utils import subscription_utils
from app.utils.subscription_utils import (
resolve_hwid_device_limit,
resolve_simple_subscription_device_limit,
resolve_hwid_device_limit_for_payload,
)
class DummySubscription:
def __init__(self, device_limit=None):
self.device_limit = device_limit
class StubSettings:
def __init__(
self,
enabled: bool,
disabled_amount,
*,
simple_limit: int = 3,
disabled_selection_amount=None,
):
self._enabled = enabled
self._disabled_amount = disabled_amount
self._disabled_selection_amount = disabled_selection_amount
self.SIMPLE_SUBSCRIPTION_DEVICE_LIMIT = simple_limit
def is_devices_selection_enabled(self) -> bool:
return self._enabled
def get_disabled_mode_device_limit(self):
return self._disabled_amount
def get_devices_selection_disabled_amount(self):
return self._disabled_selection_amount
@pytest.mark.parametrize(
"forced_amount, expected",
[
(None, None),
(0, 0),
(5, 5),
],
)
def test_resolve_hwid_device_limit_disabled_mode(monkeypatch, forced_amount, expected):
subscription = DummySubscription(device_limit=42)
monkeypatch.setattr(
subscription_utils,
"settings",
StubSettings(
enabled=False,
disabled_amount=forced_amount,
disabled_selection_amount=forced_amount,
),
)
assert resolve_hwid_device_limit(subscription) == expected
def test_resolve_hwid_device_limit_enabled_mode(monkeypatch):
subscription = DummySubscription(device_limit=4)
monkeypatch.setattr(
subscription_utils,
"settings",
StubSettings(enabled=True, disabled_amount=None),
)
assert resolve_hwid_device_limit(subscription) == 4
def test_resolve_hwid_device_limit_enabled_ignores_non_positive(monkeypatch):
subscription = DummySubscription(device_limit=0)
monkeypatch.setattr(
subscription_utils,
"settings",
StubSettings(enabled=True, disabled_amount=None),
)
assert resolve_hwid_device_limit(subscription) is None
def test_resolve_hwid_device_limit_for_payload_returns_subscription_limit(monkeypatch):
subscription = DummySubscription(device_limit=42)
monkeypatch.setattr(
subscription_utils,
"settings",
StubSettings(enabled=False, disabled_amount=None, disabled_selection_amount=None),
)
assert resolve_hwid_device_limit(subscription) is None
assert resolve_hwid_device_limit_for_payload(subscription) == 42
def test_resolve_hwid_device_limit_for_payload_ignores_non_positive(monkeypatch):
subscription = DummySubscription(device_limit=0)
monkeypatch.setattr(
subscription_utils,
"settings",
StubSettings(enabled=False, disabled_amount=None, disabled_selection_amount=None),
)
assert resolve_hwid_device_limit(subscription) is None
assert resolve_hwid_device_limit_for_payload(subscription) is None
def test_resolve_hwid_device_limit_for_payload_prefers_forced_limit(monkeypatch):
subscription = DummySubscription(device_limit=42)
monkeypatch.setattr(
subscription_utils,
"settings",
StubSettings(enabled=False, disabled_amount=7, disabled_selection_amount=7),
)
assert resolve_hwid_device_limit_for_payload(subscription) == 7
def test_resolve_hwid_device_limit_for_payload_handles_zero(monkeypatch):
subscription = DummySubscription(device_limit=42)
monkeypatch.setattr(
subscription_utils,
"settings",
StubSettings(enabled=False, disabled_amount=0, disabled_selection_amount=0),
)
assert resolve_hwid_device_limit(subscription) == 0
assert resolve_hwid_device_limit_for_payload(subscription) == 0
@pytest.mark.parametrize(
"enabled, simple_limit, disabled_amount, disabled_selection_amount, expected",
[
(True, 4, None, None, 4),
(False, 4, None, None, 4),
(False, 4, 0, 0, 0),
(False, 4, 7, 7, 7),
],
)
def test_resolve_simple_subscription_device_limit(
monkeypatch,
enabled,
simple_limit,
disabled_amount,
disabled_selection_amount,
expected,
):
monkeypatch.setattr(
subscription_utils,
"settings",
StubSettings(
enabled=enabled,
disabled_amount=disabled_amount,
simple_limit=simple_limit,
disabled_selection_amount=disabled_selection_amount,
),
)
assert resolve_simple_subscription_device_limit() == expected

View File

@@ -142,6 +142,80 @@ async def test_return_to_saved_cart_success(mock_callback_query, mock_state, moc
# В успешном сценарии вызывается callback.answer()
mock_callback_query.answer.assert_called_once()
@pytest.mark.asyncio
async def test_return_to_saved_cart_normalizes_devices_when_disabled(
mock_callback_query,
mock_state,
mock_user,
mock_db,
):
cart_data = {
'period_days': 30,
'countries': ['ru', 'us'],
'devices': 5,
'traffic_gb': 20,
'total_price': 45000,
'total_devices_price': 15000,
'saved_cart': True,
'user_id': mock_user.id,
}
sanitized_summary_data = {
'period_days': 30,
'countries': ['ru', 'us'],
'devices': 3,
'traffic_gb': 20,
'total_price': 30000,
'total_devices_price': 0,
}
with patch('app.handlers.subscription.purchase.user_cart_service') as mock_cart_service, \
patch('app.handlers.subscription.purchase._get_available_countries') as mock_get_countries, \
patch('app.handlers.subscription.purchase.format_period_description') as mock_format_period, \
patch('app.localization.texts.get_texts') as mock_get_texts, \
patch('app.handlers.subscription.purchase.get_subscription_confirm_keyboard_with_cart') as mock_keyboard_func, \
patch('app.handlers.subscription.purchase.settings') as mock_settings, \
patch('app.handlers.subscription.pricing._prepare_subscription_summary', new=AsyncMock(return_value=("ignored", sanitized_summary_data))):
mock_cart_service.get_user_cart = AsyncMock(return_value=cart_data)
mock_cart_service.save_user_cart = AsyncMock()
mock_get_countries.return_value = [{'uuid': 'ru', 'name': 'Russia'}, {'uuid': 'us', 'name': 'USA'}]
mock_format_period.return_value = "30 дней"
mock_keyboard = AsyncMock()
mock_keyboard_func.return_value = mock_keyboard
mock_texts = AsyncMock()
mock_texts.format_price = lambda x: f"{x/100}"
mock_texts.t = lambda key, default=None: default or ""
mock_get_texts.return_value = mock_texts
mock_settings.is_devices_selection_enabled.return_value = False
mock_settings.DEFAULT_DEVICE_LIMIT = 3
mock_settings.is_traffic_fixed.return_value = False
mock_settings.get_fixed_traffic_limit.return_value = 0
mock_user.balance_kopeks = 60000
await return_to_saved_cart(mock_callback_query, mock_state, mock_user, mock_db)
mock_cart_service.save_user_cart.assert_called_once()
_, saved_payload = mock_cart_service.save_user_cart.call_args[0]
assert saved_payload['devices'] == 3
assert saved_payload['total_price'] == 30000
assert saved_payload['saved_cart'] is True
mock_state.set_data.assert_called_once()
normalized_data = mock_state.set_data.call_args[0][0]
assert normalized_data['devices'] == 3
assert normalized_data['total_price'] == 30000
assert normalized_data['saved_cart'] is True
edited_text = mock_callback_query.message.edit_text.call_args[0][0]
assert "📱" not in edited_text
mock_callback_query.answer.assert_called_once()
@pytest.mark.asyncio
async def test_return_to_saved_cart_insufficient_funds(mock_callback_query, mock_state, mock_user, mock_db):
"""Тест возврата к сохраненной корзине с недостаточным балансом"""