diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py
deleted file mode 100644
index 6839d8b8..00000000
--- a/app/handlers/subscription.py
+++ /dev/null
@@ -1,6838 +0,0 @@
-import base64
-import json
-import logging
-from datetime import datetime, timedelta
-from typing import Dict, List, Any, Tuple, Optional
-from urllib.parse import quote
-
-from aiogram import Dispatcher, types, F
-from aiogram.fsm.context import FSMContext
-from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
-from sqlalchemy.ext.asyncio import AsyncSession
-
-from app.config import settings, PERIOD_PRICES, get_traffic_prices
-from app.database.crud.discount_offer import (
- get_offer_by_id,
- mark_offer_claimed,
-)
-from app.database.crud.promo_offer_template import get_promo_offer_template_by_id
-from app.database.crud.subscription import (
- create_trial_subscription,
- create_paid_subscription, add_subscription_traffic, add_subscription_devices,
- update_subscription_autopay
-)
-from app.database.crud.transaction import create_transaction
-from app.database.crud.user import subtract_user_balance
-from app.database.models import (
- User, TransactionType, SubscriptionStatus,
- Subscription
-)
-from app.keyboards.inline import (
- get_subscription_keyboard, get_trial_keyboard,
- get_subscription_period_keyboard, get_traffic_packages_keyboard,
- get_countries_keyboard, get_devices_keyboard,
- get_subscription_confirm_keyboard, get_autopay_keyboard,
- get_autopay_days_keyboard, get_back_keyboard,
- get_add_traffic_keyboard,
- get_change_devices_keyboard, get_reset_traffic_confirm_keyboard,
- get_manage_countries_keyboard,
- get_device_selection_keyboard, get_connection_guide_keyboard,
- get_app_selection_keyboard, get_specific_app_keyboard,
- get_updated_subscription_settings_keyboard, get_insufficient_balance_keyboard,
- get_extend_subscription_keyboard_with_prices, get_confirm_change_devices_keyboard,
- get_devices_management_keyboard, get_device_management_help_keyboard,
- get_happ_cryptolink_keyboard,
- get_happ_download_platform_keyboard, get_happ_download_link_keyboard,
- get_happ_download_button_row,
- get_payment_methods_keyboard_with_cart,
- get_subscription_confirm_keyboard_with_cart,
- get_insufficient_balance_keyboard_with_cart
-)
-from app.localization.texts import get_texts
-from app.services.admin_notification_service import AdminNotificationService
-from app.services.remnawave_service import RemnaWaveService
-from app.services.subscription_checkout_service import (
- clear_subscription_checkout_draft,
- get_subscription_checkout_draft,
- save_subscription_checkout_draft,
- should_offer_checkout_resume,
-)
-from app.services.subscription_service import SubscriptionService
-from app.utils.miniapp_buttons import build_miniapp_or_callback_button
-from app.services.promo_offer_service import promo_offer_service
-from app.states import SubscriptionStates
-from app.utils.pagination import paginate_list
-from app.utils.pricing_utils import (
- calculate_months_from_days,
- get_remaining_months,
- calculate_prorated_price,
- validate_pricing_calculation,
- format_period_description,
- apply_percentage_discount,
-)
-from app.utils.subscription_utils import (
- get_display_subscription_link,
- get_happ_cryptolink_redirect_link,
- convert_subscription_link_to_happ_scheme,
-)
-from app.utils.promo_offer import (
- build_promo_offer_hint,
- get_user_active_promo_discount_percent,
-)
-
-logger = logging.getLogger(__name__)
-
-TRAFFIC_PRICES = get_traffic_prices()
-
-
-class _SafeFormatDict(dict):
- def __missing__(self, key: str) -> str: # pragma: no cover - defensive fallback
- return "{" + key + "}"
-
-
-def _format_text_with_placeholders(template: str, values: Dict[str, Any]) -> str:
- if not isinstance(template, str):
- return template
-
- safe_values = _SafeFormatDict()
- safe_values.update(values)
-
- try:
- return template.format_map(safe_values)
- except Exception: # pragma: no cover - defensive logging
- logger.warning("Failed to format template '%s' with values %s", template, values)
- return template
-
-
-def _get_addon_discount_percent_for_user(
- user: Optional[User],
- category: str,
- period_days_hint: Optional[int] = None,
-) -> int:
- if user is None:
- return 0
-
- promo_group = getattr(user, "promo_group", None)
- if promo_group is None:
- return 0
-
- if not getattr(promo_group, "apply_discounts_to_addons", True):
- return 0
-
- try:
- return user.get_promo_discount(category, period_days_hint)
- except AttributeError:
- return 0
-
-
-def _apply_addon_discount(
- user: Optional[User],
- category: str,
- amount: int,
- period_days_hint: Optional[int] = None,
-) -> Dict[str, int]:
- percent = _get_addon_discount_percent_for_user(user, category, period_days_hint)
- discounted_amount, discount_value = apply_percentage_discount(amount, percent)
-
- return {
- "discounted": discounted_amount,
- "discount": discount_value,
- "percent": percent,
- }
-
-
-def _get_promo_offer_discount_percent(user: Optional[User]) -> int:
- return get_user_active_promo_discount_percent(user)
-
-
-def _apply_promo_offer_discount(user: Optional[User], amount: int) -> Dict[str, int]:
- percent = _get_promo_offer_discount_percent(user)
-
- if amount <= 0 or percent <= 0:
- return {"discounted": amount, "discount": 0, "percent": 0}
-
- discounted, discount_value = apply_percentage_discount(amount, percent)
- return {"discounted": discounted, "discount": discount_value, "percent": percent}
-
-
-async def _get_promo_offer_hint(
- db: AsyncSession,
- db_user: User,
- texts,
- percent: Optional[int] = None,
-) -> Optional[str]:
- return await build_promo_offer_hint(db, db_user, texts, percent)
-
-
-def _get_period_hint_from_subscription(subscription: Optional[Subscription]) -> Optional[int]:
- if not subscription:
- return None
-
- months_remaining = get_remaining_months(subscription.end_date)
- if months_remaining <= 0:
- return None
-
- return months_remaining * 30
-
-
-def _apply_discount_to_monthly_component(
- amount_per_month: int,
- percent: int,
- months: int,
-) -> Dict[str, int]:
- discounted_per_month, discount_per_month = apply_percentage_discount(amount_per_month, percent)
-
- return {
- "original_per_month": amount_per_month,
- "discounted_per_month": discounted_per_month,
- "discount_percent": max(0, min(100, percent)),
- "discount_per_month": discount_per_month,
- "total": discounted_per_month * months,
- "discount_total": discount_per_month * months,
- }
-
-
-async def _prepare_subscription_summary(
- db_user: User,
- data: Dict[str, Any],
- texts,
-) -> Tuple[str, Dict[str, Any]]:
- summary_data = dict(data)
- countries = await _get_available_countries(db_user.promo_group_id)
-
- months_in_period = calculate_months_from_days(summary_data['period_days'])
- period_display = format_period_description(summary_data['period_days'], db_user.language)
-
- base_price_original = PERIOD_PRICES[summary_data['period_days']]
- period_discount_percent = db_user.get_promo_discount(
- "period",
- summary_data['period_days'],
- )
- base_price, base_discount_total = apply_percentage_discount(
- base_price_original,
- period_discount_percent,
- )
-
- if settings.is_traffic_fixed():
- traffic_limit = settings.get_fixed_traffic_limit()
- traffic_price_per_month = settings.get_traffic_price(traffic_limit)
- final_traffic_gb = traffic_limit
- else:
- traffic_gb = summary_data.get('traffic_gb', 0)
- traffic_price_per_month = settings.get_traffic_price(traffic_gb)
- final_traffic_gb = traffic_gb
-
- traffic_discount_percent = db_user.get_promo_discount(
- "traffic",
- summary_data['period_days'],
- )
- traffic_component = _apply_discount_to_monthly_component(
- traffic_price_per_month,
- traffic_discount_percent,
- months_in_period,
- )
- total_traffic_price = traffic_component["total"]
-
- countries_price_per_month = 0
- selected_countries_names: List[str] = []
- selected_server_prices: List[int] = []
- server_monthly_prices: List[int] = []
-
- selected_country_ids = set(summary_data.get('countries', []))
- for country in countries:
- if country['uuid'] in selected_country_ids:
- server_price_per_month = country['price_kopeks']
- countries_price_per_month += server_price_per_month
- selected_countries_names.append(country['name'])
- server_monthly_prices.append(server_price_per_month)
-
- servers_discount_percent = db_user.get_promo_discount(
- "servers",
- summary_data['period_days'],
- )
- total_countries_price = 0
- total_servers_discount = 0
- discounted_servers_price_per_month = 0
-
- for server_price_per_month in server_monthly_prices:
- discounted_per_month, discount_per_month = apply_percentage_discount(
- server_price_per_month,
- servers_discount_percent,
- )
- total_price_for_server = discounted_per_month * months_in_period
- total_discount_for_server = discount_per_month * months_in_period
-
- discounted_servers_price_per_month += discounted_per_month
- total_countries_price += total_price_for_server
- 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)
- 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(
- "devices",
- summary_data['period_days'],
- )
- devices_component = _apply_discount_to_monthly_component(
- devices_price_per_month,
- devices_discount_percent,
- months_in_period,
- )
- total_devices_price = devices_component["total"]
-
- total_price = base_price + total_traffic_price + total_countries_price + total_devices_price
-
- discounted_monthly_additions = (
- traffic_component["discounted_per_month"]
- + discounted_servers_price_per_month
- + devices_component["discounted_per_month"]
- )
-
- is_valid = validate_pricing_calculation(
- base_price,
- discounted_monthly_additions,
- months_in_period,
- total_price,
- )
-
- if not is_valid:
- raise ValueError("Subscription price calculation validation failed")
-
- original_total_price = total_price
- promo_offer_component = _apply_promo_offer_discount(db_user, total_price)
- if promo_offer_component["discount"] > 0:
- total_price = promo_offer_component["discounted"]
-
- summary_data['total_price'] = total_price
- if promo_offer_component["discount"] > 0:
- summary_data['promo_offer_discount_percent'] = promo_offer_component["percent"]
- summary_data['promo_offer_discount_value'] = promo_offer_component["discount"]
- summary_data['total_price_before_promo_offer'] = original_total_price
- else:
- summary_data.pop('promo_offer_discount_percent', None)
- summary_data.pop('promo_offer_discount_value', None)
- summary_data.pop('total_price_before_promo_offer', None)
- summary_data['server_prices_for_period'] = selected_server_prices
- summary_data['months_in_period'] = months_in_period
- summary_data['base_price'] = base_price
- summary_data['base_price_original'] = base_price_original
- summary_data['base_discount_percent'] = period_discount_percent
- summary_data['base_discount_total'] = base_discount_total
- summary_data['final_traffic_gb'] = final_traffic_gb
- summary_data['traffic_price_per_month'] = traffic_price_per_month
- summary_data['traffic_discount_percent'] = traffic_component["discount_percent"]
- summary_data['traffic_discount_total'] = traffic_component["discount_total"]
- summary_data['traffic_discounted_price_per_month'] = traffic_component["discounted_per_month"]
- summary_data['total_traffic_price'] = total_traffic_price
- summary_data['servers_price_per_month'] = countries_price_per_month
- summary_data['countries_price_per_month'] = countries_price_per_month
- summary_data['servers_discount_percent'] = servers_discount_percent
- summary_data['servers_discount_total'] = total_servers_discount
- summary_data['servers_discounted_price_per_month'] = discounted_servers_price_per_month
- summary_data['total_servers_price'] = total_countries_price
- summary_data['total_countries_price'] = total_countries_price
- summary_data['devices_price_per_month'] = devices_price_per_month
- summary_data['devices_discount_percent'] = devices_component["discount_percent"]
- summary_data['devices_discount_total'] = devices_component["discount_total"]
- summary_data['devices_discounted_price_per_month'] = devices_component["discounted_per_month"]
- summary_data['total_devices_price'] = total_devices_price
- summary_data['discounted_monthly_additions'] = discounted_monthly_additions
-
- if settings.is_traffic_fixed():
- if final_traffic_gb == 0:
- traffic_display = "Безлимитный"
- else:
- traffic_display = f"{final_traffic_gb} ГБ"
- else:
- if summary_data.get('traffic_gb', 0) == 0:
- traffic_display = "Безлимитный"
- else:
- traffic_display = f"{summary_data.get('traffic_gb', 0)} ГБ"
-
- if base_discount_total > 0:
- base_line = (
- f"- Базовый период: {texts.format_price(base_price_original)} "
- f"{texts.format_price(base_price)}"
- f" (скидка {period_discount_percent}%:"
- f" -{texts.format_price(base_discount_total)})"
- )
- else:
- base_line = f"- Базовый период: {texts.format_price(base_price_original)}"
-
- details_lines = [base_line]
-
- if total_traffic_price > 0:
- traffic_line = (
- f"- Трафик: {texts.format_price(traffic_price_per_month)}/мес × {months_in_period}"
- f" = {texts.format_price(total_traffic_price)}"
- )
- if traffic_component["discount_total"] > 0:
- traffic_line += (
- f" (скидка {traffic_component['discount_percent']}%:"
- f" -{texts.format_price(traffic_component['discount_total'])})"
- )
- details_lines.append(traffic_line)
- if total_countries_price > 0:
- servers_line = (
- f"- Серверы: {texts.format_price(countries_price_per_month)}/мес × {months_in_period}"
- f" = {texts.format_price(total_countries_price)}"
- )
- if total_servers_discount > 0:
- servers_line += (
- f" (скидка {servers_discount_percent}%:"
- f" -{texts.format_price(total_servers_discount)})"
- )
- details_lines.append(servers_line)
- if total_devices_price > 0:
- devices_line = (
- f"- Доп. устройства: {texts.format_price(devices_price_per_month)}/мес × {months_in_period}"
- f" = {texts.format_price(total_devices_price)}"
- )
- if devices_component["discount_total"] > 0:
- devices_line += (
- f" (скидка {devices_component['discount_percent']}%:"
- f" -{texts.format_price(devices_component['discount_total'])})"
- )
- details_lines.append(devices_line)
-
- if promo_offer_component["discount"] > 0:
- details_lines.append(
- texts.t(
- "SUBSCRIPTION_SUMMARY_PROMO_DISCOUNT",
- "- Промо-предложение: -{amount} ({percent}% дополнительно)",
- ).format(
- amount=texts.format_price(promo_offer_component["discount"]),
- percent=promo_offer_component["percent"],
- )
- )
-
- details_text = "\n".join(details_lines)
-
- summary_text = (
- "📋 Сводка заказа\n\n"
- f"📅 Период: {period_display}\n"
- f"📊 Трафик: {traffic_display}\n"
- f"🌍 Страны: {', '.join(selected_countries_names)}\n"
- f"📱 Устройства: {devices_selected}\n\n"
- "💰 Детализация стоимости:\n"
- f"{details_text}\n\n"
- f"💎 Общая стоимость: {texts.format_price(total_price)}\n\n"
- "Подтверждаете покупку?"
- )
-
- return summary_text, summary_data
-
-
-def _build_promo_group_discount_text(
- db_user: User,
- periods: Optional[List[int]] = None,
- texts=None,
-) -> str:
- promo_group = getattr(db_user, "promo_group", None)
-
- if not promo_group:
- return ""
-
- if texts is None:
- texts = get_texts(db_user.language)
-
- service_lines: List[str] = []
-
- if promo_group.server_discount_percent > 0:
- service_lines.append(
- texts.PROMO_GROUP_DISCOUNT_SERVERS.format(
- percent=promo_group.server_discount_percent
- )
- )
-
- if promo_group.traffic_discount_percent > 0:
- service_lines.append(
- texts.PROMO_GROUP_DISCOUNT_TRAFFIC.format(
- percent=promo_group.traffic_discount_percent
- )
- )
-
- if promo_group.device_discount_percent > 0:
- service_lines.append(
- texts.PROMO_GROUP_DISCOUNT_DEVICES.format(
- percent=promo_group.device_discount_percent
- )
- )
-
- period_lines: List[str] = []
-
- period_candidates: set[int] = set(periods or [])
-
- raw_period_discounts = getattr(promo_group, "period_discounts", None)
- if isinstance(raw_period_discounts, dict):
- for key in raw_period_discounts.keys():
- try:
- period_candidates.add(int(key))
- except (TypeError, ValueError):
- continue
-
- for period_days in sorted(period_candidates):
- percent = promo_group.get_discount_percent("period", period_days)
-
- if percent <= 0:
- continue
-
- period_display = format_period_description(period_days, db_user.language)
- period_lines.append(
- texts.PROMO_GROUP_PERIOD_DISCOUNT_ITEM.format(
- period=period_display,
- percent=percent,
- )
- )
-
- if not service_lines and not period_lines:
- return ""
-
- lines: List[str] = [texts.PROMO_GROUP_DISCOUNTS_HEADER]
-
- if service_lines:
- lines.extend(service_lines)
-
- if period_lines:
- if service_lines:
- lines.append("")
-
- lines.append(texts.PROMO_GROUP_PERIOD_DISCOUNTS_HEADER)
- lines.extend(period_lines)
-
- return "\n".join(lines)
-
-
-async def _build_subscription_period_prompt(
- db_user: User,
- texts,
- db: AsyncSession,
-) -> str:
- base_text = texts.BUY_SUBSCRIPTION_START.rstrip()
-
- lines: List[str] = [base_text]
-
- promo_offer_hint = await _get_promo_offer_hint(db, db_user, texts)
- if promo_offer_hint:
- lines.extend(["", promo_offer_hint])
-
- promo_text = _build_promo_group_discount_text(
- db_user,
- settings.get_available_subscription_periods(),
- texts=texts,
- )
-
- if promo_text:
- lines.extend(["", promo_text])
-
- return "\n".join(lines) + "\n"
-
-
-async def show_subscription_info(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession
-):
- await db.refresh(db_user)
-
- texts = get_texts(db_user.language)
- subscription = db_user.subscription
-
- if not subscription:
- await callback.message.edit_text(
- texts.SUBSCRIPTION_NONE,
- reply_markup=get_back_keyboard(db_user.language)
- )
- await callback.answer()
- return
-
- from app.database.crud.subscription import check_and_update_subscription_status
- subscription = await check_and_update_subscription_status(db, subscription)
-
- subscription_service = SubscriptionService()
- await subscription_service.sync_subscription_usage(db, subscription)
-
- await db.refresh(subscription)
-
- current_time = datetime.utcnow()
-
- if subscription.status == "expired" or subscription.end_date <= current_time:
- actual_status = "expired"
- status_display = texts.t("SUBSCRIPTION_STATUS_EXPIRED", "Истекла")
- status_emoji = "🔴"
- elif subscription.status == "active" and subscription.end_date > current_time:
- if subscription.is_trial:
- actual_status = "trial_active"
- status_display = texts.t("SUBSCRIPTION_STATUS_TRIAL", "Тестовая")
- status_emoji = "🎯"
- else:
- actual_status = "paid_active"
- status_display = texts.t("SUBSCRIPTION_STATUS_ACTIVE", "Активна")
- status_emoji = "💎"
- else:
- actual_status = "unknown"
- status_display = texts.t("SUBSCRIPTION_STATUS_UNKNOWN", "Неизвестно")
- status_emoji = "❓"
-
- if subscription.end_date <= current_time:
- days_left = 0
- time_left_text = texts.t("SUBSCRIPTION_TIME_LEFT_EXPIRED", "истёк")
- warning_text = ""
- else:
- delta = subscription.end_date - current_time
- days_left = delta.days
- hours_left = delta.seconds // 3600
-
- if days_left > 1:
- time_left_text = texts.t("SUBSCRIPTION_TIME_LEFT_DAYS", "{days} дн.").format(days=days_left)
- warning_text = ""
- elif days_left == 1:
- time_left_text = texts.t("SUBSCRIPTION_TIME_LEFT_DAYS", "{days} дн.").format(days=days_left)
- warning_text = texts.t("SUBSCRIPTION_WARNING_TOMORROW", "\n⚠️ истекает завтра!")
- elif hours_left > 0:
- time_left_text = texts.t("SUBSCRIPTION_TIME_LEFT_HOURS", "{hours} ч.").format(hours=hours_left)
- warning_text = texts.t("SUBSCRIPTION_WARNING_TODAY", "\n⚠️ истекает сегодня!")
- else:
- minutes_left = (delta.seconds % 3600) // 60
- time_left_text = texts.t("SUBSCRIPTION_TIME_LEFT_MINUTES", "{minutes} мин.").format(
- minutes=minutes_left
- )
- warning_text = texts.t(
- "SUBSCRIPTION_WARNING_MINUTES",
- "\n🔴 истекает через несколько минут!",
- )
-
- subscription_type = (
- texts.t("SUBSCRIPTION_TYPE_TRIAL", "Триал")
- if subscription.is_trial
- else texts.t("SUBSCRIPTION_TYPE_PAID", "Платная")
- )
-
- used_traffic = f"{subscription.traffic_used_gb:.1f}"
- if subscription.traffic_limit_gb == 0:
- traffic_used_display = texts.t(
- "SUBSCRIPTION_TRAFFIC_UNLIMITED",
- "∞ (безлимит) | Использовано: {used} ГБ",
- ).format(used=used_traffic)
- else:
- traffic_used_display = texts.t(
- "SUBSCRIPTION_TRAFFIC_LIMITED",
- "{used} / {limit} ГБ",
- ).format(used=used_traffic, limit=subscription.traffic_limit_gb)
-
- devices_used_str = "—"
- devices_list = []
- devices_count = 0
-
- try:
- if db_user.remnawave_uuid:
- from app.services.remnawave_service import RemnaWaveService
- service = RemnaWaveService()
-
- async with service.get_api_client() as api:
- response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}')
-
- 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_str = await get_current_devices_count(db_user)
-
- servers_names = await get_servers_display_names(subscription.connected_squads)
- servers_display = (
- servers_names
- if servers_names
- else texts.t("SUBSCRIPTION_NO_SERVERS", "Нет серверов")
- )
-
- message = texts.t(
- "SUBSCRIPTION_OVERVIEW_TEMPLATE",
- """👤 {full_name}
-💰 Баланс: {balance}
-📱 Подписка: {status_emoji} {status_display}{warning}
-
-📱 Информация о подписке
-🎭 Тип: {subscription_type}
-📅 Действует до: {end_date}
-⏰ Осталось: {time_left}
-📈 Трафик: {traffic}
-🌍 Серверы: {servers}
-📱 Устройства: {devices_used} / {device_limit}""",
- ).format(
- full_name=db_user.full_name,
- balance=settings.format_price(db_user.balance_kopeks),
- status_emoji=status_emoji,
- status_display=status_display,
- warning=warning_text,
- subscription_type=subscription_type,
- end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M"),
- time_left=time_left_text,
- traffic=traffic_used_display,
- servers=servers_display,
- devices_used=devices_used_str,
- device_limit=subscription.device_limit,
- )
-
- if devices_list and len(devices_list) > 0:
- message += "\n\n" + texts.t(
- "SUBSCRIPTION_CONNECTED_DEVICES_TITLE",
- "
📱 Подключенные устройства:\n", - ) - for device in devices_list[:5]: - platform = device.get('platform', 'Unknown') - device_model = device.get('deviceModel', 'Unknown') - device_info = f"{platform} - {device_model}" - - if len(device_info) > 35: - device_info = device_info[:32] + "..." - message += f"• {device_info}\n" - message += texts.t("SUBSCRIPTION_CONNECTED_DEVICES_FOOTER", "") - - subscription_link = get_display_subscription_link(subscription) - hide_subscription_link = settings.should_hide_subscription_link() - - if ( - subscription_link - and actual_status in ["trial_active", "paid_active"] - and not hide_subscription_link - ): - message += "\n\n" + texts.t( - "SUBSCRIPTION_CONNECT_LINK_SECTION", - "🔗 Ссылка для подключения:\n
{subscription_url}",
- ).format(subscription_url=subscription_link)
- message += "\n\n" + texts.t(
- "SUBSCRIPTION_CONNECT_LINK_PROMPT",
- "📱 Скопируйте ссылку и добавьте в ваше VPN приложение",
- )
-
- await callback.message.edit_text(
- message,
- reply_markup=get_subscription_keyboard(
- db_user.language,
- has_subscription=True,
- is_trial=subscription.is_trial,
- subscription=subscription
- ),
- parse_mode="HTML"
- )
- await callback.answer()
-
-
-async def get_current_devices_detailed(db_user: User) -> dict:
- try:
- if not db_user.remnawave_uuid:
- return {"count": 0, "devices": []}
-
- from app.services.remnawave_service import RemnaWaveService
- service = RemnaWaveService()
-
- async with service.get_api_client() as api:
- response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}')
-
- if response and 'response' in response:
- devices_info = response['response']
- total_devices = devices_info.get('total', 0)
- devices_list = devices_info.get('devices', [])
-
- return {
- "count": total_devices,
- "devices": devices_list[:5]
- }
- else:
- return {"count": 0, "devices": []}
-
- except Exception as e:
- logger.error(f"Ошибка получения детальной информации об устройствах: {e}")
- return {"count": 0, "devices": []}
-
-
-async def get_servers_display_names(squad_uuids: List[str]) -> str:
- if not squad_uuids:
- return "Нет серверов"
-
- try:
- from app.database.database import AsyncSessionLocal
- from app.database.crud.server_squad import get_server_squad_by_uuid
-
- server_names = []
-
- async with AsyncSessionLocal() as db:
- for uuid in squad_uuids:
- server = await get_server_squad_by_uuid(db, uuid)
- if server:
- server_names.append(server.display_name)
- logger.debug(f"Найден сервер в БД: {uuid} -> {server.display_name}")
- else:
- logger.warning(f"Сервер с UUID {uuid} не найден в БД")
-
- if not server_names:
- countries = await _get_available_countries()
- for uuid in squad_uuids:
- for country in countries:
- if country['uuid'] == uuid:
- server_names.append(country['name'])
- logger.debug(f"Найден сервер в кэше: {uuid} -> {country['name']}")
- break
-
- if not server_names:
- if len(squad_uuids) == 1:
- return "🎯 Тестовый сервер"
- return f"{len(squad_uuids)} стран"
-
- if len(server_names) > 6:
- displayed = ", ".join(server_names[:6])
- remaining = len(server_names) - 6
- return f"{displayed} и ещё {remaining}"
- else:
- return ", ".join(server_names)
-
- except Exception as e:
- logger.error(f"Ошибка получения названий серверов: {e}")
- if len(squad_uuids) == 1:
- return "🎯 Тестовый сервер"
- return f"{len(squad_uuids)} стран"
-
-
-async def get_current_devices_count(db_user: User) -> str:
- try:
- if not db_user.remnawave_uuid:
- return "—"
-
- from app.services.remnawave_service import RemnaWaveService
- service = RemnaWaveService()
-
- async with service.get_api_client() as api:
- response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}')
-
- if response and 'response' in response:
- total_devices = response['response'].get('total', 0)
- return str(total_devices)
- else:
- return "—"
-
- except Exception as e:
- logger.error(f"Ошибка получения количества устройств: {e}")
- return "—"
-
-
-async def get_subscription_cost(subscription, db: AsyncSession) -> int:
- try:
- if subscription.is_trial:
- return 0
-
- from app.config import settings
- from app.services.subscription_service import SubscriptionService
-
- subscription_service = SubscriptionService()
-
- base_cost_original = PERIOD_PRICES.get(30, 0)
- try:
- owner = subscription.user
- except AttributeError:
- owner = None
-
- promo_group_id = getattr(owner, "promo_group_id", None) if owner else None
-
- period_discount_percent = 0
- if owner:
- try:
- period_discount_percent = owner.get_promo_discount("period", 30)
- except AttributeError:
- period_discount_percent = 0
-
- base_cost, _ = apply_percentage_discount(
- base_cost_original,
- period_discount_percent,
- )
-
- try:
- servers_cost, _ = await subscription_service.get_countries_price_by_uuids(
- subscription.connected_squads,
- db,
- promo_group_id=promo_group_id,
- )
- except AttributeError:
- servers_cost, _ = await get_countries_price_by_uuids_fallback(
- subscription.connected_squads,
- db,
- promo_group_id=promo_group_id,
- )
-
- 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
-
- total_cost = base_cost + servers_cost + traffic_cost + devices_cost
-
- logger.info(f"📊 Месячная стоимость конфигурации подписки {subscription.id}:")
- base_log = f" 📅 Базовый тариф (30 дней): {base_cost_original / 100}₽"
- if period_discount_percent > 0:
- discount_value = base_cost_original * period_discount_percent // 100
- base_log += (
- f" → {base_cost / 100}₽"
- f" (скидка {period_discount_percent}%: -{discount_value / 100}₽)"
- )
- logger.info(base_log)
- if servers_cost > 0:
- logger.info(f" 🌍 Серверы: {servers_cost / 100}₽")
- if traffic_cost > 0:
- logger.info(f" 📊 Трафик: {traffic_cost / 100}₽")
- if devices_cost > 0:
- logger.info(f" 📱 Устройства: {devices_cost / 100}₽")
- logger.info(f" 💎 ИТОГО: {total_cost / 100}₽")
-
- return total_cost
-
- except Exception as e:
- logger.error(f"⚠️ Ошибка расчета стоимости подписки: {e}")
- return 0
-
-
-async def show_trial_offer(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession
-):
- texts = get_texts(db_user.language)
-
- if db_user.subscription or db_user.has_had_paid_subscription:
- await callback.message.edit_text(
- texts.TRIAL_ALREADY_USED,
- reply_markup=get_back_keyboard(db_user.language)
- )
- await callback.answer()
- return
-
- trial_server_name = texts.t("TRIAL_SERVER_DEFAULT_NAME", "🎯 Тестовый сервер")
- try:
- from app.database.crud.server_squad import (
- get_server_squad_by_uuid,
- get_trial_eligible_server_squads,
- )
-
- trial_squads = await get_trial_eligible_server_squads(db, include_unavailable=True)
-
- if trial_squads:
- if len(trial_squads) == 1:
- trial_server_name = trial_squads[0].display_name
- else:
- trial_server_name = texts.t(
- "TRIAL_SERVER_RANDOM_POOL",
- "🎲 Случайный из {count} серверов",
- ).format(count=len(trial_squads))
- elif settings.TRIAL_SQUAD_UUID:
- trial_server = await get_server_squad_by_uuid(db, settings.TRIAL_SQUAD_UUID)
- if trial_server:
- trial_server_name = trial_server.display_name
- else:
- logger.warning(
- "Триальный сервер с UUID %s не найден в БД",
- settings.TRIAL_SQUAD_UUID,
- )
- else:
- logger.warning("Не настроены сквады для выдачи триалов")
-
- except Exception as e:
- logger.error(f"Ошибка получения триального сервера: {e}")
-
- trial_text = texts.TRIAL_AVAILABLE.format(
- days=settings.TRIAL_DURATION_DAYS,
- traffic=settings.TRIAL_TRAFFIC_LIMIT_GB,
- devices=settings.TRIAL_DEVICE_LIMIT,
- server_name=trial_server_name
- )
-
- await callback.message.edit_text(
- trial_text,
- reply_markup=get_trial_keyboard(db_user.language)
- )
- await callback.answer()
-
-
-async def activate_trial(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession
-):
- from app.services.admin_notification_service import AdminNotificationService
-
- texts = get_texts(db_user.language)
-
- if db_user.subscription or db_user.has_had_paid_subscription:
- await callback.message.edit_text(
- texts.TRIAL_ALREADY_USED,
- reply_markup=get_back_keyboard(db_user.language)
- )
- await callback.answer()
- return
-
- try:
- subscription = await create_trial_subscription(db, db_user.id)
-
- await db.refresh(db_user)
-
- subscription_service = SubscriptionService()
- remnawave_user = await subscription_service.create_remnawave_user(
- db, subscription
- )
-
- await db.refresh(db_user)
-
- try:
- notification_service = AdminNotificationService(callback.bot)
- await notification_service.send_trial_activation_notification(db, db_user, subscription)
- except Exception as e:
- logger.error(f"Ошибка отправки уведомления о триале: {e}")
-
- subscription_link = get_display_subscription_link(subscription)
- hide_subscription_link = settings.should_hide_subscription_link()
-
- if remnawave_user and subscription_link:
- if settings.is_happ_cryptolink_mode():
- trial_success_text = (
- f"{texts.TRIAL_ACTIVATED}\n\n"
- + texts.t(
- "SUBSCRIPTION_HAPP_LINK_PROMPT",
- "🔒 Ссылка на подписку создана. Нажмите кнопку \"Подключиться\" ниже, чтобы открыть её в Happ.",
- )
- + "\n\n"
- + texts.t(
- "SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT",
- "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве",
- )
- )
- elif hide_subscription_link:
- trial_success_text = (
- f"{texts.TRIAL_ACTIVATED}\n\n"
- + texts.t(
- "SUBSCRIPTION_LINK_HIDDEN_NOTICE",
- "ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе \"Моя подписка\".",
- )
- + "\n\n"
- + texts.t(
- "SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT",
- "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве",
- )
- )
- else:
- subscription_import_link = texts.t(
- "SUBSCRIPTION_IMPORT_LINK_SECTION",
- "🔗 Ваша ссылка для импорта в VPN приложение:\n{subscription_url}",
- ).format(subscription_url=subscription_link)
-
- trial_success_text = (
- f"{texts.TRIAL_ACTIVATED}\n\n"
- f"{subscription_import_link}\n\n"
- f"{texts.t('SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT', '📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве')}"
- )
-
- connect_mode = settings.CONNECT_BUTTON_MODE
-
- if connect_mode == "miniapp_subscription":
- connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[
- [
- InlineKeyboardButton(
- text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
- web_app=types.WebAppInfo(url=subscription_link),
- )
- ],
- [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
- callback_data="back_to_menu")],
- ])
- elif connect_mode == "miniapp_custom":
- if not settings.MINIAPP_CUSTOM_URL:
- await callback.answer(
- texts.t(
- "CUSTOM_MINIAPP_URL_NOT_SET",
- "⚠ Кастомная ссылка для мини-приложения не настроена",
- ),
- show_alert=True,
- )
- return
-
- connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[
- [
- InlineKeyboardButton(
- text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
- web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL),
- )
- ],
- [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
- callback_data="back_to_menu")],
- ])
- elif connect_mode == "link":
- rows = [
- [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=subscription_link)]
- ]
- happ_row = get_happ_download_button_row(texts)
- if happ_row:
- rows.append(happ_row)
- rows.append([
- InlineKeyboardButton(
- text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
- callback_data="back_to_menu"
- )
- ])
- connect_keyboard = InlineKeyboardMarkup(inline_keyboard=rows)
- elif connect_mode == "happ_cryptolink":
- rows = [
- [
- InlineKeyboardButton(
- text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
- callback_data="open_subscription_link",
- )
- ]
- ]
- happ_row = get_happ_download_button_row(texts)
- if happ_row:
- rows.append(happ_row)
- rows.append([
- InlineKeyboardButton(
- text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
- callback_data="back_to_menu"
- )
- ])
- connect_keyboard = InlineKeyboardMarkup(inline_keyboard=rows)
- else:
- connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[
- [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
- callback_data="subscription_connect")],
- [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
- callback_data="back_to_menu")],
- ])
-
- await callback.message.edit_text(
- trial_success_text,
- reply_markup=connect_keyboard,
- parse_mode="HTML"
- )
- else:
- await callback.message.edit_text(
- f"{texts.TRIAL_ACTIVATED}\n\n⚠️ Ссылка генерируется, попробуйте перейти в раздел 'Моя подписка' через несколько секунд.",
- reply_markup=get_back_keyboard(db_user.language)
- )
-
- logger.info(f"✅ Активирована тестовая подписка для пользователя {db_user.telegram_id}")
-
- except Exception as e:
- logger.error(f"Ошибка активации триала: {e}")
- await callback.message.edit_text(
- texts.ERROR,
- reply_markup=get_back_keyboard(db_user.language)
- )
-
- await callback.answer()
-
-
-async def start_subscription_purchase(
- callback: types.CallbackQuery,
- state: FSMContext,
- db_user: User,
- db: AsyncSession,
-):
- texts = get_texts(db_user.language)
-
- 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",
- )
-
- 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)
-
- initial_data = {
- 'period_days': None,
- 'countries': [],
- 'devices': initial_devices,
- 'total_price': 0
- }
-
- if settings.is_traffic_fixed():
- initial_data['traffic_gb'] = settings.get_fixed_traffic_limit()
- else:
- initial_data['traffic_gb'] = None
-
- await state.set_data(initial_data)
- await state.set_state(SubscriptionStates.selecting_period)
- await callback.answer()
-
-
-async def save_cart_and_redirect_to_topup(
- callback: types.CallbackQuery,
- state: FSMContext,
- db_user: User,
- missing_amount: int
-):
- texts = get_texts(db_user.language)
- data = await state.get_data()
-
- await state.set_state(SubscriptionStates.cart_saved_for_topup)
- await state.update_data({
- **data,
- 'saved_cart': True,
- 'missing_amount': missing_amount,
- 'return_to_cart': True
- })
-
- await callback.message.edit_text(
- f"💰 Недостаточно средств для оформления подписки\n\n"
- f"Требуется: {texts.format_price(missing_amount)}\n"
- f"У вас: {texts.format_price(db_user.balance_kopeks)}\n\n"
- f"🛒 Ваша корзина сохранена!\n"
- f"После пополнения баланса вы сможете вернуться к оформлению подписки.\n\n"
- f"Выберите способ пополнения:",
- reply_markup=get_payment_methods_keyboard_with_cart(
- db_user.language,
- missing_amount,
- ),
- parse_mode="HTML"
- )
-
-
-async def return_to_saved_cart(
- callback: types.CallbackQuery,
- state: FSMContext,
- db_user: User,
- db: AsyncSession
-):
- data = await state.get_data()
- texts = get_texts(db_user.language)
-
- if not data.get('saved_cart'):
- await callback.answer("❌ Сохраненная корзина не найдена", show_alert=True)
- return
-
- total_price = data.get('total_price', 0)
-
- if db_user.balance_kopeks < total_price:
- missing_amount = total_price - db_user.balance_kopeks
- await callback.message.edit_text(
- f"❌ Все еще недостаточно средств\n\n"
- f"Требуется: {texts.format_price(total_price)}\n"
- f"У вас: {texts.format_price(db_user.balance_kopeks)}\n"
- f"Не хватает: {texts.format_price(missing_amount)}",
- reply_markup=get_insufficient_balance_keyboard_with_cart(
- db_user.language,
- missing_amount,
- )
- )
- return
-
- countries = await _get_available_countries(db_user.promo_group_id)
- selected_countries_names = []
-
- months_in_period = calculate_months_from_days(data['period_days'])
- period_display = format_period_description(data['period_days'], db_user.language)
-
- for country in countries:
- if country['uuid'] in data['countries']:
- selected_countries_names.append(country['name'])
-
- if settings.is_traffic_fixed():
- traffic_display = "Безлимитный" if data['traffic_gb'] == 0 else f"{data['traffic_gb']} ГБ"
- else:
- traffic_display = "Безлимитный" if data['traffic_gb'] == 0 else f"{data['traffic_gb']} ГБ"
-
- summary_text = (
- "🛒 Восстановленная корзина\n\n"
- f"📅 Период: {period_display}\n"
- f"📊 Трафик: {traffic_display}\n"
- f"🌍 Страны: {', '.join(selected_countries_names)}\n"
- f"📱 Устройства: {data['devices']}\n\n"
- f"💎 Общая стоимость: {texts.format_price(total_price)}\n\n"
- "Подтверждаете покупку?"
- )
-
- await callback.message.edit_text(
- summary_text,
- reply_markup=get_subscription_confirm_keyboard_with_cart(db_user.language),
- parse_mode="HTML"
- )
-
- await state.set_state(SubscriptionStates.confirming_purchase)
- await callback.answer("✅ Корзина восстановлена!")
-
-
-async def handle_add_countries(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession,
- state: FSMContext
-):
- if not await _should_show_countries_management(db_user):
- texts = get_texts(db_user.language)
- await callback.answer(
- texts.t(
- "COUNTRY_MANAGEMENT_UNAVAILABLE",
- "ℹ️ Управление серверами недоступно - доступен только один сервер",
- ),
- show_alert=True,
- )
- return
-
- texts = get_texts(db_user.language)
- subscription = db_user.subscription
-
- if not subscription or subscription.is_trial:
- await callback.answer(
- texts.t("PAID_FEATURE_ONLY", "⚠ Эта функция доступна только для платных подписок"),
- show_alert=True,
- )
- return
-
- countries = await _get_available_countries(db_user.promo_group_id)
- current_countries = subscription.connected_squads
-
- period_hint_days = _get_period_hint_from_subscription(subscription)
- servers_discount_percent = _get_addon_discount_percent_for_user(
- db_user,
- "servers",
- period_hint_days,
- )
-
- current_countries_names = []
- for country in countries:
- if country['uuid'] in current_countries:
- current_countries_names.append(country['name'])
-
- current_list = (
- "\n".join(f"• {name}" for name in current_countries_names)
- if current_countries_names
- else texts.t("COUNTRY_MANAGEMENT_NONE", "Нет подключенных стран")
- )
-
- text = texts.t(
- "COUNTRY_MANAGEMENT_PROMPT",
- (
- "🌍 Управление странами подписки\n\n"
- "📋 Текущие страны ({current_count}):\n"
- "{current_list}\n\n"
- "💡 Инструкция:\n"
- "✅ - страна подключена\n"
- "➕ - будет добавлена (платно)\n"
- "➖ - будет отключена (бесплатно)\n"
- "⚪ - не выбрана\n\n"
- "⚠️ Важно: Повторное подключение отключенных стран будет платным!"
- ),
- ).format(
- current_count=len(current_countries),
- current_list=current_list,
- )
-
- await state.update_data(countries=current_countries.copy())
-
- await callback.message.edit_text(
- text,
- reply_markup=get_manage_countries_keyboard(
- countries,
- current_countries.copy(),
- current_countries,
- db_user.language,
- subscription.end_date,
- servers_discount_percent,
- ),
- parse_mode="HTML"
- )
-
- await callback.answer()
-
-
-async def get_countries_price_by_uuids_fallback(
- country_uuids: List[str],
- db: AsyncSession,
- promo_group_id: Optional[int] = None,
-) -> Tuple[int, List[int]]:
- try:
- from app.database.crud.server_squad import get_server_squad_by_uuid
-
- total_price = 0
- prices_list = []
-
- for country_uuid in country_uuids:
- try:
- server = await get_server_squad_by_uuid(db, country_uuid)
- is_allowed = True
- if promo_group_id is not None and server:
- allowed_ids = {pg.id for pg in server.allowed_promo_groups}
- is_allowed = promo_group_id in allowed_ids
-
- if server and server.is_available and not server.is_full and is_allowed:
- price = server.price_kopeks
- total_price += price
- prices_list.append(price)
- else:
- default_price = 0
- total_price += default_price
- prices_list.append(default_price)
- except Exception:
- default_price = 0
- total_price += default_price
- prices_list.append(default_price)
-
- return total_price, prices_list
-
- except Exception as e:
- logger.error(f"Ошибка fallback функции: {e}")
- default_prices = [0] * len(country_uuids)
- return sum(default_prices), default_prices
-
-
-async def handle_manage_country(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession,
- state: FSMContext
-):
- logger.info(f"🔍 Управление страной: {callback.data}")
-
- country_uuid = callback.data.split('_')[2]
-
- subscription = db_user.subscription
- if not subscription or subscription.is_trial:
- texts = get_texts(db_user.language)
- await callback.answer(
- texts.t("PAID_FEATURE_ONLY_SHORT", "⚠ Только для платных подписок"),
- show_alert=True,
- )
- return
-
- data = await state.get_data()
- current_selected = data.get('countries', subscription.connected_squads.copy())
-
- countries = await _get_available_countries(db_user.promo_group_id)
- allowed_country_ids = {country['uuid'] for country in countries}
-
- if country_uuid not in allowed_country_ids and country_uuid not in current_selected:
- texts = get_texts(db_user.language)
- await callback.answer(
- texts.t(
- "COUNTRY_NOT_AVAILABLE_PROMOGROUP",
- "❌ Сервер недоступен для вашей промогруппы",
- ),
- show_alert=True,
- )
- return
-
- if country_uuid in current_selected:
- current_selected.remove(country_uuid)
- action = "removed"
- else:
- current_selected.append(country_uuid)
- action = "added"
-
- logger.info(f"🔍 Страна {country_uuid} {action}")
-
- await state.update_data(countries=current_selected)
-
- period_hint_days = _get_period_hint_from_subscription(subscription)
- servers_discount_percent = _get_addon_discount_percent_for_user(
- db_user,
- "servers",
- period_hint_days,
- )
-
- try:
- await callback.message.edit_reply_markup(
- reply_markup=get_manage_countries_keyboard(
- countries,
- current_selected,
- subscription.connected_squads,
- db_user.language,
- subscription.end_date,
- servers_discount_percent,
- )
- )
- logger.info(f"✅ Клавиатура обновлена")
-
- except Exception as e:
- logger.error(f"⚠ Ошибка обновления клавиатуры: {e}")
-
- await callback.answer()
-
-
-async def apply_countries_changes(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession,
- state: FSMContext
-):
- logger.info(f"🔧 Применение изменений стран")
-
- data = await state.get_data()
- texts = get_texts(db_user.language)
-
- await save_subscription_checkout_draft(db_user.id, dict(data))
- resume_callback = (
- "subscription_resume_checkout"
- if should_offer_checkout_resume(db_user, True)
- else None
- )
- subscription = db_user.subscription
-
- selected_countries = data.get('countries', [])
- current_countries = subscription.connected_squads
-
- countries = await _get_available_countries(db_user.promo_group_id)
- allowed_country_ids = {country['uuid'] for country in countries}
-
- selected_countries = [
- country_uuid
- for country_uuid in selected_countries
- if country_uuid in allowed_country_ids or country_uuid in current_countries
- ]
-
- added = [c for c in selected_countries if c not in current_countries]
- removed = [c for c in current_countries if c not in selected_countries]
-
- if not added and not removed:
- await callback.answer(
- texts.t("COUNTRY_CHANGES_NOT_FOUND", "⚠️ Изменения не обнаружены"),
- show_alert=True,
- )
- return
-
- logger.info(f"🔧 Добавлено: {added}, Удалено: {removed}")
-
- months_to_pay = get_remaining_months(subscription.end_date)
-
- period_hint_days = months_to_pay * 30 if months_to_pay > 0 else None
- servers_discount_percent = _get_addon_discount_percent_for_user(
- db_user,
- "servers",
- period_hint_days,
- )
-
- cost_per_month = 0
- added_names = []
- removed_names = []
-
- added_server_components: List[Dict[str, int]] = []
-
- for country in countries:
- if not country.get('is_available', True):
- continue
-
- if country['uuid'] in added:
- server_price_per_month = country['price_kopeks']
- discounted_per_month, discount_per_month = apply_percentage_discount(
- server_price_per_month,
- servers_discount_percent,
- )
- cost_per_month += discounted_per_month
- added_names.append(country['name'])
- added_server_components.append(
- {
- "discounted_per_month": discounted_per_month,
- "discount_per_month": discount_per_month,
- "original_per_month": server_price_per_month,
- }
- )
- if country['uuid'] in removed:
- removed_names.append(country['name'])
-
- total_cost, charged_months = calculate_prorated_price(cost_per_month, subscription.end_date)
-
- added_server_prices = [
- component["discounted_per_month"] * charged_months
- for component in added_server_components
- ]
-
- total_discount = sum(
- component["discount_per_month"] * charged_months
- for component in added_server_components
- )
-
- if added_names:
- logger.info(
- "Стоимость новых серверов: %.2f₽/мес × %s мес = %.2f₽ (скидка %.2f₽)",
- cost_per_month / 100,
- charged_months,
- total_cost / 100,
- total_discount / 100,
- )
-
- if total_cost > 0 and db_user.balance_kopeks < total_cost:
- missing_kopeks = total_cost - db_user.balance_kopeks
- required_text = f"{texts.format_price(total_cost)} (за {charged_months} мес)"
- message_text = texts.t(
- "ADDON_INSUFFICIENT_FUNDS_MESSAGE",
- (
- "⚠️ Недостаточно средств\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.answer(
- message_text,
- reply_markup=get_insufficient_balance_keyboard(
- db_user.language,
- resume_callback=resume_callback,
- amount_kopeks=missing_kopeks,
- ),
- parse_mode="HTML",
- )
- await callback.answer()
- return
-
- try:
- if added and total_cost > 0:
- success = await subtract_user_balance(
- db, db_user, total_cost,
- f"Добавление стран: {', '.join(added_names)} на {charged_months} мес"
- )
- if not success:
- await callback.answer(
- texts.t("PAYMENT_CHARGE_ERROR", "⚠️ Ошибка списания средств"),
- show_alert=True,
- )
- return
-
- await create_transaction(
- db=db,
- user_id=db_user.id,
- type=TransactionType.SUBSCRIPTION_PAYMENT,
- amount_kopeks=total_cost,
- description=f"Добавление стран к подписке: {', '.join(added_names)} на {charged_months} мес"
- )
-
- if added:
- from app.database.crud.server_squad import get_server_ids_by_uuids, add_user_to_servers
- from app.database.crud.subscription import add_subscription_servers
-
- added_server_ids = await get_server_ids_by_uuids(db, added)
-
- if added_server_ids:
- await add_subscription_servers(db, subscription, added_server_ids, added_server_prices)
- await add_user_to_servers(db, added_server_ids)
-
- logger.info(
- f"📊 Добавлены серверы с ценами за {charged_months} мес: {list(zip(added_server_ids, added_server_prices))}")
-
- subscription.connected_squads = selected_countries
- subscription.updated_at = datetime.utcnow()
- await db.commit()
-
- subscription_service = SubscriptionService()
- await subscription_service.update_remnawave_user(db, subscription)
-
- await db.refresh(subscription)
-
- 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, "servers", current_countries, selected_countries, total_cost
- )
- except Exception as e:
- logger.error(f"Ошибка отправки уведомления об изменении серверов: {e}")
-
- success_text = texts.t(
- "COUNTRY_CHANGES_SUCCESS_HEADER",
- "✅ Страны успешно обновлены!\n\n",
- )
-
- if added_names:
- success_text += texts.t(
- "COUNTRY_CHANGES_ADDED_HEADER",
- "➕ Добавлены страны:\n",
- )
- success_text += "\n".join(f"• {name}" for name in added_names)
- if total_cost > 0:
- success_text += "\n" + texts.t(
- "COUNTRY_CHANGES_CHARGED",
- "💰 Списано: {amount} (за {months} мес)",
- ).format(
- amount=texts.format_price(total_cost),
- months=charged_months,
- )
- if total_discount > 0:
- success_text += texts.t(
- "COUNTRY_CHANGES_DISCOUNT_INFO",
- " (скидка {percent}%: -{amount})",
- ).format(
- percent=servers_discount_percent,
- amount=texts.format_price(total_discount),
- )
- success_text += "\n"
-
- if removed_names:
- success_text += "\n" + texts.t(
- "COUNTRY_CHANGES_REMOVED_HEADER",
- "➖ Отключены страны:\n",
- )
- success_text += "\n".join(f"• {name}" for name in removed_names)
- success_text += "\n" + texts.t(
- "COUNTRY_CHANGES_REMOVED_WARNING",
- "ℹ️ Повторное подключение будет платным",
- ) + "\n"
-
- success_text += "\n" + texts.t(
- "COUNTRY_CHANGES_ACTIVE_COUNT",
- "🌐 Активных стран: {count}",
- ).format(count=len(selected_countries))
-
- await callback.message.edit_text(
- success_text,
- reply_markup=get_back_keyboard(db_user.language),
- parse_mode="HTML"
- )
-
- await state.clear()
- logger.info(
- f"✅ Пользователь {db_user.telegram_id} обновил страны. Добавлено: {len(added)}, удалено: {len(removed)}, заплатил: {total_cost / 100}₽")
-
- except Exception as e:
- logger.error(f"⚠️ Ошибка применения изменений: {e}")
- await callback.message.edit_text(
- texts.ERROR,
- reply_markup=get_back_keyboard(db_user.language)
- )
-
- await callback.answer()
-
-
-async def handle_add_traffic(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession
-):
- from app.config import settings
-
- texts = get_texts(db_user.language)
-
- if settings.is_traffic_fixed():
- await callback.answer(
- texts.t(
- "TRAFFIC_FIXED_MODE",
- "⚠️ В текущем режиме трафик фиксированный и не может быть изменен",
- ),
- show_alert=True,
- )
- return
-
- subscription = db_user.subscription
-
- if not subscription or subscription.is_trial:
- await callback.answer(
- texts.t("PAID_FEATURE_ONLY", "⚠ Эта функция доступна только для платных подписок"),
- show_alert=True,
- )
- return
-
- if subscription.traffic_limit_gb == 0:
- await callback.answer(
- texts.t("TRAFFIC_ALREADY_UNLIMITED", "⚠ У вас уже безлимитный трафик"),
- show_alert=True,
- )
- return
-
- current_traffic = subscription.traffic_limit_gb
- period_hint_days = _get_period_hint_from_subscription(subscription)
- traffic_discount_percent = _get_addon_discount_percent_for_user(
- db_user,
- "traffic",
- period_hint_days,
- )
-
- prompt_text = texts.t(
- "ADD_TRAFFIC_PROMPT",
- (
- "📈 Добавить трафик к подписке\n\n"
- "Текущий лимит: {current_traffic}\n"
- "Выберите дополнительный трафик:"
- ),
- ).format(current_traffic=texts.format_traffic(current_traffic))
-
- await callback.message.edit_text(
- prompt_text,
- reply_markup=get_add_traffic_keyboard(
- db_user.language,
- subscription.end_date,
- traffic_discount_percent,
- ),
- parse_mode="HTML"
- )
-
- await callback.answer()
-
-
-async def handle_change_devices(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession
-):
- texts = get_texts(db_user.language)
- subscription = db_user.subscription
-
- if not subscription or subscription.is_trial:
- await callback.answer(
- texts.t("PAID_FEATURE_ONLY", "⚠️ Эта функция доступна только для платных подписок"),
- show_alert=True,
- )
- return
-
- current_devices = subscription.device_limit
-
- period_hint_days = _get_period_hint_from_subscription(subscription)
- devices_discount_percent = _get_addon_discount_percent_for_user(
- db_user,
- "devices",
- period_hint_days,
- )
-
- prompt_text = texts.t(
- "CHANGE_DEVICES_PROMPT",
- (
- "📱 Изменение количества устройств\n\n"
- "Текущий лимит: {current_devices} устройств\n"
- "Выберите новое количество устройств:\n\n"
- "💡 Важно:\n"
- "• При увеличении - доплата пропорционально оставшемуся времени\n"
- "• При уменьшении - возврат средств не производится"
- ),
- ).format(current_devices=current_devices)
-
- await callback.message.edit_text(
- prompt_text,
- reply_markup=get_change_devices_keyboard(
- current_devices,
- db_user.language,
- subscription.end_date,
- devices_discount_percent,
- ),
- parse_mode="HTML"
- )
-
- await callback.answer()
-
-
-async def confirm_change_devices(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession
-):
- new_devices_count = int(callback.data.split('_')[2])
- texts = get_texts(db_user.language)
- subscription = db_user.subscription
-
- current_devices = subscription.device_limit
-
- if new_devices_count == current_devices:
- await callback.answer(
- texts.t("DEVICES_NO_CHANGE", "ℹ️ Количество устройств не изменилось"),
- show_alert=True,
- )
- return
-
- if settings.MAX_DEVICES_LIMIT > 0 and new_devices_count > settings.MAX_DEVICES_LIMIT:
- await callback.answer(
- texts.t(
- "DEVICES_LIMIT_EXCEEDED",
- "⚠️ Превышен максимальный лимит устройств ({limit})",
- ).format(limit=settings.MAX_DEVICES_LIMIT),
- show_alert=True
- )
- return
-
- devices_difference = new_devices_count - current_devices
-
- if devices_difference > 0:
- additional_devices = devices_difference
-
- if current_devices < settings.DEFAULT_DEVICE_LIMIT:
- free_devices = settings.DEFAULT_DEVICE_LIMIT - current_devices
- chargeable_devices = max(0, additional_devices - free_devices)
- else:
- chargeable_devices = additional_devices
-
- devices_price_per_month = chargeable_devices * settings.PRICE_PER_DEVICE
- months_hint = get_remaining_months(subscription.end_date)
- period_hint_days = months_hint * 30 if months_hint > 0 else None
- devices_discount_percent = _get_addon_discount_percent_for_user(
- db_user,
- "devices",
- period_hint_days,
- )
- discounted_per_month, discount_per_month = apply_percentage_discount(
- devices_price_per_month,
- devices_discount_percent,
- )
- price, charged_months = calculate_prorated_price(
- discounted_per_month,
- subscription.end_date,
- )
- total_discount = discount_per_month * charged_months
-
- if price > 0 and db_user.balance_kopeks < price:
- missing_kopeks = price - db_user.balance_kopeks
- required_text = f"{texts.format_price(price)} (за {charged_months} мес)"
- message_text = texts.t(
- "ADDON_INSUFFICIENT_FUNDS_MESSAGE",
- (
- "⚠️ Недостаточно средств\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.answer(
- message_text,
- reply_markup=get_insufficient_balance_keyboard(
- db_user.language,
- amount_kopeks=missing_kopeks,
- ),
- parse_mode="HTML",
- )
- await callback.answer()
- return
-
- action_text = texts.t(
- "DEVICE_CHANGE_ACTION_INCREASE",
- "увеличить до {count}",
- ).format(count=new_devices_count)
- if price > 0:
- cost_text = texts.t(
- "DEVICE_CHANGE_EXTRA_COST",
- "Доплата: {amount} (за {months} мес)",
- ).format(
- amount=texts.format_price(price),
- months=charged_months,
- )
- if total_discount > 0:
- cost_text += texts.t(
- "DEVICE_CHANGE_DISCOUNT_INFO",
- " (скидка {percent}%: -{amount})",
- ).format(
- percent=devices_discount_percent,
- amount=texts.format_price(total_discount),
- )
- else:
- cost_text = texts.t("DEVICE_CHANGE_FREE", "Бесплатно")
-
- else:
- price = 0
- action_text = texts.t(
- "DEVICE_CHANGE_ACTION_DECREASE",
- "уменьшить до {count}",
- ).format(count=new_devices_count)
- cost_text = texts.t("DEVICE_CHANGE_NO_REFUND", "Возврат средств не производится")
-
- confirm_text = texts.t(
- "DEVICE_CHANGE_CONFIRMATION",
- (
- "📱 Подтверждение изменения\n\n"
- "Текущее количество: {current} устройств\n"
- "Новое количество: {new} устройств\n\n"
- "Действие: {action}\n"
- "💰 {cost}\n\n"
- "Подтвердить изменение?"
- ),
- ).format(
- current=current_devices,
- new=new_devices_count,
- action=action_text,
- cost=cost_text,
- )
-
- await callback.message.edit_text(
- confirm_text,
- reply_markup=get_confirm_change_devices_keyboard(new_devices_count, price, db_user.language),
- parse_mode="HTML"
- )
-
- await callback.answer()
-
-
-async def execute_change_devices(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession
-):
- callback_parts = callback.data.split('_')
- new_devices_count = int(callback_parts[3])
- price = int(callback_parts[4])
-
- texts = get_texts(db_user.language)
- subscription = db_user.subscription
- current_devices = subscription.device_limit
-
- try:
- if price > 0:
- success = await subtract_user_balance(
- db, db_user, price,
- f"Изменение количества устройств с {current_devices} до {new_devices_count}"
- )
-
- if not success:
- await callback.answer(
- texts.t("PAYMENT_CHARGE_ERROR", "⚠️ Ошибка списания средств"),
- show_alert=True,
- )
- return
-
- charged_months = get_remaining_months(subscription.end_date)
- await create_transaction(
- db=db,
- user_id=db_user.id,
- type=TransactionType.SUBSCRIPTION_PAYMENT,
- amount_kopeks=price,
- description=f"Изменение устройств с {current_devices} до {new_devices_count} на {charged_months} мес"
- )
-
- subscription.device_limit = new_devices_count
- subscription.updated_at = datetime.utcnow()
-
- await db.commit()
-
- subscription_service = SubscriptionService()
- await subscription_service.update_remnawave_user(db, subscription)
-
- await db.refresh(db_user)
- await db.refresh(subscription)
-
- 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, "devices", current_devices, new_devices_count, price
- )
- except Exception as e:
- logger.error(f"Ошибка отправки уведомления об изменении устройств: {e}")
-
- if new_devices_count > current_devices:
- success_text = texts.t(
- "DEVICE_CHANGE_INCREASE_SUCCESS",
- "✅ Количество устройств увеличено!\n\n",
- )
- success_text += texts.t(
- "DEVICE_CHANGE_RESULT_LINE",
- "📱 Было: {old} → Стало: {new}\n",
- ).format(old=current_devices, new=new_devices_count)
- if price > 0:
- success_text += texts.t(
- "DEVICE_CHANGE_CHARGED",
- "💰 Списано: {amount}",
- ).format(amount=texts.format_price(price))
- else:
- success_text = texts.t(
- "DEVICE_CHANGE_DECREASE_SUCCESS",
- "✅ Количество устройств уменьшено!\n\n",
- )
- success_text += texts.t(
- "DEVICE_CHANGE_RESULT_LINE",
- "📱 Было: {old} → Стало: {new}\n",
- ).format(old=current_devices, new=new_devices_count)
- success_text += texts.t(
- "DEVICE_CHANGE_NO_REFUND_INFO",
- "ℹ️ Возврат средств не производится",
- )
-
- await callback.message.edit_text(
- success_text,
- reply_markup=get_back_keyboard(db_user.language)
- )
-
- logger.info(
- f"✅ Пользователь {db_user.telegram_id} изменил количество устройств с {current_devices} на {new_devices_count}, доплата: {price / 100}₽")
-
- except Exception as e:
- logger.error(f"Ошибка изменения количества устройств: {e}")
- await callback.message.edit_text(
- texts.ERROR,
- reply_markup=get_back_keyboard(db_user.language)
- )
-
- await callback.answer()
-
-
-async def handle_device_management(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession
-):
- texts = get_texts(db_user.language)
- subscription = db_user.subscription
-
- if not subscription or subscription.is_trial:
- await callback.answer(
- texts.t("PAID_FEATURE_ONLY", "⚠️ Эта функция доступна только для платных подписок"),
- show_alert=True,
- )
- return
-
- if not db_user.remnawave_uuid:
- await callback.answer(
- texts.t("DEVICE_UUID_NOT_FOUND", "❌ UUID пользователя не найден"),
- show_alert=True,
- )
- return
-
- try:
- from app.services.remnawave_service import RemnaWaveService
- service = RemnaWaveService()
-
- async with service.get_api_client() as api:
- response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}')
-
- if response and 'response' in response:
- devices_info = response['response']
- total_devices = devices_info.get('total', 0)
- devices_list = devices_info.get('devices', [])
-
- if total_devices == 0:
- await callback.message.edit_text(
- texts.t("DEVICE_NONE_CONNECTED", "ℹ️ У вас нет подключенных устройств"),
- reply_markup=get_back_keyboard(db_user.language)
- )
- await callback.answer()
- return
-
- await show_devices_page(callback, db_user, devices_list, page=1)
- else:
- await callback.answer(
- texts.t(
- "DEVICE_FETCH_INFO_ERROR",
- "❌ Ошибка получения информации об устройствах",
- ),
- show_alert=True,
- )
-
- except Exception as e:
- logger.error(f"Ошибка получения списка устройств: {e}")
- await callback.answer(
- texts.t(
- "DEVICE_FETCH_INFO_ERROR",
- "❌ Ошибка получения информации об устройствах",
- ),
- show_alert=True,
- )
-
- await callback.answer()
-
-
-async def show_devices_page(
- callback: types.CallbackQuery,
- db_user: User,
- devices_list: List[dict],
- page: int = 1
-):
- texts = get_texts(db_user.language)
- devices_per_page = 5
-
- pagination = paginate_list(devices_list, page=page, per_page=devices_per_page)
-
- devices_text = texts.t(
- "DEVICE_MANAGEMENT_OVERVIEW",
- (
- "🔄 Управление устройствами\n\n"
- "📊 Всего подключено: {total} устройств\n"
- "📄 Страница {page} из {pages}\n\n"
- ),
- ).format(total=len(devices_list), page=pagination.page, pages=pagination.total_pages)
-
- if pagination.items:
- devices_text += texts.t(
- "DEVICE_MANAGEMENT_CONNECTED_HEADER",
- "Подключенные устройства:\n",
- )
- for i, device in enumerate(pagination.items, 1):
- platform = device.get('platform', 'Unknown')
- device_model = device.get('deviceModel', 'Unknown')
- device_info = f"{platform} - {device_model}"
-
- if len(device_info) > 35:
- device_info = device_info[:32] + "..."
-
- devices_text += texts.t(
- "DEVICE_MANAGEMENT_LIST_ITEM",
- "• {device}\n",
- ).format(device=device_info)
-
- devices_text += texts.t(
- "DEVICE_MANAGEMENT_ACTIONS",
- (
- "\n💡 Действия:\n"
- "• Выберите устройство для сброса\n"
- "• Или сбросьте все устройства сразу"
- ),
- )
-
- await callback.message.edit_text(
- devices_text,
- reply_markup=get_devices_management_keyboard(
- pagination.items,
- pagination,
- db_user.language
- ),
- parse_mode="HTML"
- )
-
-
-async def handle_devices_page(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession
-):
- page = int(callback.data.split('_')[2])
- texts = get_texts(db_user.language)
-
- try:
- from app.services.remnawave_service import RemnaWaveService
- service = RemnaWaveService()
-
- async with service.get_api_client() as api:
- response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}')
-
- if response and 'response' in response:
- devices_list = response['response'].get('devices', [])
- await show_devices_page(callback, db_user, devices_list, page=page)
- else:
- await callback.answer(
- texts.t("DEVICE_FETCH_ERROR", "❌ Ошибка получения устройств"),
- show_alert=True,
- )
-
- except Exception as e:
- logger.error(f"Ошибка перехода на страницу устройств: {e}")
- await callback.answer(
- texts.t("DEVICE_PAGE_LOAD_ERROR", "❌ Ошибка загрузки страницы"),
- show_alert=True,
- )
-
-
-async def handle_single_device_reset(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession
-):
- try:
- callback_parts = callback.data.split('_')
- if len(callback_parts) < 4:
- logger.error(f"Некорректный формат callback_data: {callback.data}")
- await callback.answer(
- texts.t("DEVICE_RESET_INVALID_REQUEST", "❌ Ошибка: некорректный запрос"),
- show_alert=True,
- )
- return
-
- device_index = int(callback_parts[2])
- page = int(callback_parts[3])
-
- logger.info(f"🔧 Сброс устройства: index={device_index}, page={page}")
-
- except (ValueError, IndexError) as e:
- logger.error(f"❌ Ошибка парсинга callback_data {callback.data}: {e}")
- await callback.answer(
- texts.t("DEVICE_RESET_PARSE_ERROR", "❌ Ошибка обработки запроса"),
- show_alert=True,
- )
- return
-
- texts = get_texts(db_user.language)
-
- try:
- from app.services.remnawave_service import RemnaWaveService
- service = RemnaWaveService()
-
- async with service.get_api_client() as api:
- response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}')
-
- if response and 'response' in response:
- devices_list = response['response'].get('devices', [])
-
- devices_per_page = 5
- pagination = paginate_list(devices_list, page=page, per_page=devices_per_page)
-
- if device_index < len(pagination.items):
- device = pagination.items[device_index]
- device_hwid = device.get('hwid')
-
- if device_hwid:
- delete_data = {
- "userUuid": db_user.remnawave_uuid,
- "hwid": device_hwid
- }
-
- await api._make_request('POST', '/api/hwid/devices/delete', data=delete_data)
-
- platform = device.get('platform', 'Unknown')
- device_model = device.get('deviceModel', 'Unknown')
- device_info = f"{platform} - {device_model}"
-
- await callback.answer(
- texts.t(
- "DEVICE_RESET_SUCCESS",
- "✅ Устройство {device} успешно сброшено!",
- ).format(device=device_info),
- show_alert=True,
- )
-
- updated_response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}')
- if updated_response and 'response' in updated_response:
- updated_devices = updated_response['response'].get('devices', [])
-
- if updated_devices:
- updated_pagination = paginate_list(updated_devices, page=page,
- per_page=devices_per_page)
- if not updated_pagination.items and page > 1:
- page = page - 1
-
- await show_devices_page(callback, db_user, updated_devices, page=page)
- else:
- await callback.message.edit_text(
- texts.t(
- "DEVICE_RESET_ALL_DONE",
- "ℹ️ Все устройства сброшены",
- ),
- reply_markup=get_back_keyboard(db_user.language)
- )
-
- logger.info(f"✅ Пользователь {db_user.telegram_id} сбросил устройство {device_info}")
- else:
- await callback.answer(
- texts.t(
- "DEVICE_RESET_ID_FAILED",
- "❌ Не удалось получить ID устройства",
- ),
- show_alert=True,
- )
- else:
- await callback.answer(
- texts.t("DEVICE_RESET_NOT_FOUND", "❌ Устройство не найдено"),
- show_alert=True,
- )
- else:
- await callback.answer(
- texts.t("DEVICE_FETCH_ERROR", "❌ Ошибка получения устройств"),
- show_alert=True,
- )
-
- except Exception as e:
- logger.error(f"Ошибка сброса устройства: {e}")
- await callback.answer(
- texts.t("DEVICE_RESET_ERROR", "❌ Ошибка сброса устройства"),
- show_alert=True,
- )
-
-
-async def handle_all_devices_reset_from_management(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession
-):
- texts = get_texts(db_user.language)
-
- if not db_user.remnawave_uuid:
- await callback.answer(
- texts.t("DEVICE_UUID_NOT_FOUND", "❌ UUID пользователя не найден"),
- show_alert=True,
- )
- return
-
- try:
- from app.services.remnawave_service import RemnaWaveService
- service = RemnaWaveService()
-
- async with service.get_api_client() as api:
- devices_response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}')
-
- if not devices_response or 'response' not in devices_response:
- await callback.answer(
- texts.t(
- "DEVICE_LIST_FETCH_ERROR",
- "❌ Ошибка получения списка устройств",
- ),
- show_alert=True,
- )
- return
-
- devices_list = devices_response['response'].get('devices', [])
-
- if not devices_list:
- await callback.answer(
- texts.t("DEVICE_NONE_CONNECTED", "ℹ️ У вас нет подключенных устройств"),
- show_alert=True,
- )
- return
-
- logger.info(f"🔧 Найдено {len(devices_list)} устройств для сброса")
-
- success_count = 0
- failed_count = 0
-
- for device in devices_list:
- device_hwid = device.get('hwid')
- if device_hwid:
- try:
- delete_data = {
- "userUuid": db_user.remnawave_uuid,
- "hwid": device_hwid
- }
-
- await api._make_request('POST', '/api/hwid/devices/delete', data=delete_data)
- success_count += 1
- logger.info(f"✅ Устройство {device_hwid} удалено")
-
- except Exception as device_error:
- failed_count += 1
- logger.error(f"❌ Ошибка удаления устройства {device_hwid}: {device_error}")
- else:
- failed_count += 1
- logger.warning(f"⚠️ У устройства нет HWID: {device}")
-
- if success_count > 0:
- if failed_count == 0:
- await callback.message.edit_text(
- texts.t(
- "DEVICE_RESET_ALL_SUCCESS_MESSAGE",
- (
- "✅ Все устройства успешно сброшены!\n\n"
- "🔄 Сброшено: {count} устройств\n"
- "📱 Теперь вы можете заново подключить свои устройства\n\n"
- "💡 Используйте ссылку из раздела 'Моя подписка' для повторного подключения"
- ),
- ).format(count=success_count),
- reply_markup=get_back_keyboard(db_user.language),
- parse_mode="HTML"
- )
- logger.info(f"✅ Пользователь {db_user.telegram_id} успешно сбросил {success_count} устройств")
- else:
- await callback.message.edit_text(
- texts.t(
- "DEVICE_RESET_PARTIAL_MESSAGE",
- (
- "⚠️ Частичный сброс устройств\n\n"
- "✅ Удалено: {success} устройств\n"
- "❌ Не удалось удалить: {failed} устройств\n\n"
- "Попробуйте еще раз или обратитесь в поддержку."
- ),
- ).format(success=success_count, failed=failed_count),
- reply_markup=get_back_keyboard(db_user.language),
- parse_mode="HTML"
- )
- logger.warning(
- f"⚠️ Частичный сброс у пользователя {db_user.telegram_id}: {success_count}/{len(devices_list)}")
- else:
- await callback.message.edit_text(
- texts.t(
- "DEVICE_RESET_ALL_FAILED_MESSAGE",
- (
- "❌ Не удалось сбросить устройства\n\n"
- "Попробуйте еще раз позже или обратитесь в техподдержку.\n\n"
- "Всего устройств: {total}"
- ),
- ).format(total=len(devices_list)),
- reply_markup=get_back_keyboard(db_user.language),
- parse_mode="HTML"
- )
- logger.error(f"❌ Не удалось сбросить ни одного устройства у пользователя {db_user.telegram_id}")
-
- except Exception as e:
- logger.error(f"Ошибка сброса всех устройств: {e}")
- await callback.message.edit_text(
- texts.ERROR,
- reply_markup=get_back_keyboard(db_user.language)
- )
-
- await callback.answer()
-
-
-async def handle_extend_subscription(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession
-):
- texts = get_texts(db_user.language)
- subscription = db_user.subscription
-
- if not subscription or subscription.is_trial:
- await callback.answer("⚠ Продление доступно только для платных подписок", show_alert=True)
- return
-
- subscription_service = SubscriptionService()
-
- available_periods = settings.get_available_renewal_periods()
- renewal_prices = {}
- promo_offer_percent = _get_promo_offer_discount_percent(db_user)
-
- for days in available_periods:
- try:
- months_in_period = calculate_months_from_days(days)
-
- from app.config import PERIOD_PRICES
- base_price_original = PERIOD_PRICES.get(days, 0)
- period_discount_percent = db_user.get_promo_discount("period", days)
- base_price, _ = apply_percentage_discount(
- base_price_original,
- period_discount_percent,
- )
-
- servers_price_per_month, _ = await subscription_service.get_countries_price_by_uuids(
- subscription.connected_squads,
- db,
- promo_group_id=db_user.promo_group_id,
- )
- servers_discount_percent = db_user.get_promo_discount(
- "servers",
- days,
- )
- 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)
- devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
- devices_discount_percent = db_user.get_promo_discount(
- "devices",
- days,
- )
- devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100
- total_devices_price = (devices_price_per_month - devices_discount_per_month) * months_in_period
-
- traffic_price_per_month = settings.get_traffic_price(subscription.traffic_limit_gb)
- traffic_discount_percent = db_user.get_promo_discount(
- "traffic",
- days,
- )
- traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100
- total_traffic_price = (traffic_price_per_month - traffic_discount_per_month) * months_in_period
-
- total_original_price = (
- base_price_original
- + servers_price_per_month * months_in_period
- + devices_price_per_month * months_in_period
- + traffic_price_per_month * months_in_period
- )
-
- price = base_price + total_servers_price + total_devices_price + total_traffic_price
- promo_component = _apply_promo_offer_discount(db_user, price)
-
- renewal_prices[days] = {
- "final": promo_component["discounted"],
- "original": total_original_price,
- }
-
- except Exception as e:
- logger.error(f"Ошибка расчета цены для периода {days}: {e}")
- continue
-
- if not renewal_prices:
- await callback.answer("⚠ Нет доступных периодов для продления", show_alert=True)
- return
-
- prices_text = ""
-
- for days in available_periods:
- if days not in renewal_prices:
- continue
-
- price_info = renewal_prices[days]
-
- if isinstance(price_info, dict):
- final_price = price_info.get("final")
- if final_price is None:
- final_price = price_info.get("original", 0)
- original_price = price_info.get("original", final_price)
- else:
- final_price = price_info
- original_price = final_price
-
- has_discount = original_price > final_price
-
- period_display = format_period_description(days, db_user.language)
-
- if has_discount:
- prices_text += (
- "📅 "
- f"{period_display} - {subscription_url}"
-
- return info_text
-
-
-def format_traffic_display(traffic_gb: int, is_fixed_mode: bool = None) -> str:
- if is_fixed_mode is None:
- is_fixed_mode = settings.is_traffic_fixed()
-
- if traffic_gb == 0:
- if is_fixed_mode:
- return "Безлимитный"
- else:
- return "Безлимитный"
- else:
- if is_fixed_mode:
- return f"{traffic_gb} ГБ"
- else:
- return f"{traffic_gb} ГБ"
-
-
-async def select_traffic(
- callback: types.CallbackQuery,
- state: FSMContext,
- db_user: User
-):
- traffic_gb = int(callback.data.split('_')[1])
- texts = get_texts(db_user.language)
-
- data = await state.get_data()
- data['traffic_gb'] = traffic_gb
-
- traffic_price = settings.get_traffic_price(traffic_gb)
- data['total_price'] += traffic_price
-
- await state.set_data(data)
-
- 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)
-
- 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()
-
-
-async def select_country(
- callback: types.CallbackQuery,
- state: FSMContext,
- db_user: User,
- db: AsyncSession
-):
- country_uuid = callback.data.split('_')[1]
- data = await state.get_data()
-
- selected_countries = data.get('countries', [])
- if country_uuid in selected_countries:
- selected_countries.remove(country_uuid)
- else:
- selected_countries.append(country_uuid)
-
- countries = await _get_available_countries(db_user.promo_group_id)
- allowed_country_ids = {country['uuid'] for country in countries}
-
- if country_uuid not in allowed_country_ids and country_uuid not in selected_countries:
- await callback.answer("❌ Сервер недоступен для вашей промогруппы", show_alert=True)
- return
-
- period_base_price = PERIOD_PRICES[data['period_days']]
-
- discounted_base_price, _ = apply_percentage_discount(
- period_base_price,
- db_user.get_promo_discount("period", data['period_days']),
- )
-
- base_price = discounted_base_price + settings.get_traffic_price(data['traffic_gb'])
-
- try:
- subscription_service = SubscriptionService()
- countries_price, _ = await subscription_service.get_countries_price_by_uuids(
- selected_countries,
- db,
- promo_group_id=db_user.promo_group_id,
- )
- except AttributeError:
- logger.warning("Используем fallback функцию для расчета цен стран")
- countries_price, _ = await get_countries_price_by_uuids_fallback(
- selected_countries,
- db,
- promo_group_id=db_user.promo_group_id,
- )
-
- data['countries'] = selected_countries
- data['total_price'] = base_price + countries_price
- await state.set_data(data)
-
- await callback.message.edit_reply_markup(
- reply_markup=get_countries_keyboard(countries, selected_countries, db_user.language)
- )
- await callback.answer()
-
-
-async def countries_continue(
- callback: types.CallbackQuery,
- state: FSMContext,
- db_user: User
-):
- data = await state.get_data()
- texts = get_texts(db_user.language)
-
- if not data.get('countries'):
- await callback.answer("⚠️ Выберите хотя бы одну страну!", show_alert=True)
- return
-
- 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()
-
-
-async def select_devices(
- callback: types.CallbackQuery,
- state: FSMContext,
- db_user: User
-):
- if not callback.data.startswith("devices_") or callback.data == "devices_continue":
- await callback.answer("❌ Некорректный запрос", show_alert=True)
- return
-
- try:
- devices = int(callback.data.split('_')[1])
- except (ValueError, IndexError):
- await callback.answer("❌ Некорректное количество устройств", show_alert=True)
- return
-
- data = await state.get_data()
-
- base_price = (
- PERIOD_PRICES[data['period_days']] +
- settings.get_traffic_price(data['traffic_gb'])
- )
-
- countries = await _get_available_countries(db_user.promo_group_id)
- countries_price = sum(
- c['price_kopeks'] for c in countries
- if c['uuid'] in data['countries']
- )
-
- devices_price = max(0, devices - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
-
- data['devices'] = devices
- data['total_price'] = base_price + countries_price + devices_price
- await state.set_data(data)
-
- await callback.message.edit_reply_markup(
- reply_markup=get_devices_keyboard(devices, db_user.language)
- )
- await callback.answer()
-
-
-async def devices_continue(
- callback: types.CallbackQuery,
- state: FSMContext,
- db_user: User,
- db: AsyncSession
-):
- if not callback.data == "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()
-
-
-async def confirm_purchase(
- callback: types.CallbackQuery,
- state: FSMContext,
- db_user: User,
- db: AsyncSession
-):
- from app.services.admin_notification_service import AdminNotificationService
-
- data = await state.get_data()
- texts = get_texts(db_user.language)
-
- await save_subscription_checkout_draft(db_user.id, dict(data))
- resume_callback = (
- "subscription_resume_checkout"
- if should_offer_checkout_resume(db_user, True)
- else None
- )
-
- countries = await _get_available_countries(db_user.promo_group_id)
-
- months_in_period = data.get(
- 'months_in_period', calculate_months_from_days(data['period_days'])
- )
-
- base_price = data.get('base_price')
- base_price_original = data.get('base_price_original')
- base_discount_percent = data.get('base_discount_percent')
- base_discount_total = data.get('base_discount_total')
-
- if base_price is None:
- base_price_original = PERIOD_PRICES[data['period_days']]
- base_discount_percent = db_user.get_promo_discount(
- "period",
- data['period_days'],
- )
- base_price, base_discount_total = apply_percentage_discount(
- base_price_original,
- base_discount_percent,
- )
- else:
- if base_price_original is None:
- base_price_original = PERIOD_PRICES[data['period_days']]
- if base_discount_percent is None:
- base_discount_percent = db_user.get_promo_discount(
- "period",
- data['period_days'],
- )
- if base_discount_total is None:
- _, base_discount_total = apply_percentage_discount(
- base_price_original,
- base_discount_percent,
- )
- server_prices = data.get('server_prices_for_period', [])
-
- if not server_prices:
- countries_price_per_month = 0
- per_month_prices: List[int] = []
- for country in countries:
- if country['uuid'] in data['countries']:
- server_price_per_month = country['price_kopeks']
- countries_price_per_month += server_price_per_month
- per_month_prices.append(server_price_per_month)
-
- servers_discount_percent = db_user.get_promo_discount(
- "servers",
- data['period_days'],
- )
- total_servers_price = 0
- total_servers_discount = 0
- discounted_servers_price_per_month = 0
- server_prices = []
-
- for server_price_per_month in per_month_prices:
- discounted_per_month, discount_per_month = apply_percentage_discount(
- server_price_per_month,
- servers_discount_percent,
- )
- total_price_for_server = discounted_per_month * months_in_period
- total_discount_for_server = discount_per_month * months_in_period
-
- discounted_servers_price_per_month += discounted_per_month
- total_servers_price += total_price_for_server
- total_servers_discount += total_discount_for_server
- server_prices.append(total_price_for_server)
-
- total_countries_price = total_servers_price
- else:
- total_countries_price = data.get('total_servers_price', sum(server_prices))
- countries_price_per_month = data.get('servers_price_per_month', 0)
- discounted_servers_price_per_month = data.get('servers_discounted_price_per_month', countries_price_per_month)
- 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_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
-
- if settings.is_traffic_fixed():
- final_traffic_gb = settings.get_fixed_traffic_limit()
- traffic_price_per_month = data.get(
- 'traffic_price_per_month', settings.get_traffic_price(final_traffic_gb)
- )
- else:
- final_traffic_gb = data.get('final_traffic_gb', data.get('traffic_gb'))
- traffic_price_per_month = data.get(
- 'traffic_price_per_month', settings.get_traffic_price(data['traffic_gb'])
- )
-
- if 'traffic_discount_percent' in data:
- traffic_discount_percent = data.get('traffic_discount_percent', 0)
- discounted_traffic_price_per_month = data.get(
- 'traffic_discounted_price_per_month', traffic_price_per_month
- )
- traffic_discount_total = data.get('traffic_discount_total', 0)
- total_traffic_price = data.get(
- 'total_traffic_price', discounted_traffic_price_per_month * months_in_period
- )
- else:
- traffic_discount_percent = db_user.get_promo_discount(
- "traffic",
- data['period_days'],
- )
- discounted_traffic_price_per_month, discount_per_month = apply_percentage_discount(
- traffic_price_per_month,
- traffic_discount_percent,
- )
- traffic_discount_total = discount_per_month * months_in_period
- total_traffic_price = discounted_traffic_price_per_month * months_in_period
-
- total_servers_price = data.get('total_servers_price', total_countries_price)
-
- cached_total_price = data['total_price']
- cached_promo_discount_value = data.get('promo_offer_discount_value', 0)
-
- validation_total_price = data.get('total_price_before_promo_offer')
- if validation_total_price is None and cached_promo_discount_value > 0:
- validation_total_price = cached_total_price + cached_promo_discount_value
- if validation_total_price is None:
- validation_total_price = cached_total_price
-
- current_promo_offer_percent = _get_promo_offer_discount_percent(db_user)
- if current_promo_offer_percent > 0:
- final_price, promo_offer_discount_value = apply_percentage_discount(
- validation_total_price,
- current_promo_offer_percent,
- )
- promo_offer_discount_percent = current_promo_offer_percent
- else:
- final_price = validation_total_price
- promo_offer_discount_value = 0
- promo_offer_discount_percent = 0
-
- discounted_monthly_additions = data.get(
- 'discounted_monthly_additions',
- discounted_traffic_price_per_month
- + discounted_servers_price_per_month
- + discounted_devices_price_per_month,
- )
-
- is_valid = validate_pricing_calculation(
- base_price,
- discounted_monthly_additions,
- months_in_period,
- validation_total_price,
- )
-
- if not is_valid:
- logger.error(f"Ошибка в расчете цены подписки для пользователя {db_user.telegram_id}")
- await callback.answer("Ошибка расчета цены. Обратитесь в поддержку.", show_alert=True)
- return
-
- logger.info(f"Расчет покупки подписки на {data['period_days']} дней ({months_in_period} мес):")
- base_log = f" Период: {base_price_original / 100}₽"
- if base_discount_total and base_discount_total > 0:
- base_log += (
- f" → {base_price / 100}₽"
- f" (скидка {base_discount_percent}%: -{base_discount_total / 100}₽)"
- )
- logger.info(base_log)
- if total_traffic_price > 0:
- message = (
- f" Трафик: {traffic_price_per_month / 100}₽/мес × {months_in_period}"
- f" = {total_traffic_price / 100}₽"
- )
- if traffic_discount_total > 0:
- message += (
- f" (скидка {traffic_discount_percent}%:"
- f" -{traffic_discount_total / 100}₽)"
- )
- logger.info(message)
- if total_servers_price > 0:
- message = (
- f" Серверы: {countries_price_per_month / 100}₽/мес × {months_in_period}"
- f" = {total_servers_price / 100}₽"
- )
- if total_servers_discount > 0:
- message += (
- f" (скидка {servers_discount_percent}%:"
- f" -{total_servers_discount / 100}₽)"
- )
- logger.info(message)
- if total_devices_price > 0:
- message = (
- f" Устройства: {devices_price_per_month / 100}₽/мес × {months_in_period}"
- f" = {total_devices_price / 100}₽"
- )
- if devices_discount_total > 0:
- message += (
- f" (скидка {devices_discount_percent}%:"
- f" -{devices_discount_total / 100}₽)"
- )
- logger.info(message)
- if promo_offer_discount_value > 0:
- logger.info(
- " 🎯 Промо-предложение: -%s₽ (%s%%)",
- promo_offer_discount_value / 100,
- promo_offer_discount_percent,
- )
- logger.info(f" ИТОГО: {final_price / 100}₽")
-
- if db_user.balance_kopeks < final_price:
- missing_kopeks = final_price - db_user.balance_kopeks
- message_text = texts.t(
- "ADDON_INSUFFICIENT_FUNDS_MESSAGE",
- (
- "⚠️ Недостаточно средств\n\n"
- "Стоимость услуги: {required}\n"
- "На балансе: {balance}\n"
- "Не хватает: {missing}\n\n"
- "Выберите способ пополнения. Сумма подставится автоматически."
- ),
- ).format(
- required=texts.format_price(final_price),
- 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,
- resume_callback=resume_callback,
- amount_kopeks=missing_kopeks,
- ),
- parse_mode="HTML",
- )
- await callback.answer()
- return
-
- purchase_completed = False
-
- try:
- success = await subtract_user_balance(
- db,
- db_user,
- final_price,
- f"Покупка подписки на {data['period_days']} дней",
- consume_promo_offer=promo_offer_discount_value > 0,
- )
-
- if not success:
- missing_kopeks = final_price - db_user.balance_kopeks
- message_text = texts.t(
- "ADDON_INSUFFICIENT_FUNDS_MESSAGE",
- (
- "⚠️ Недостаточно средств\n\n"
- "Стоимость услуги: {required}\n"
- "На балансе: {balance}\n"
- "Не хватает: {missing}\n\n"
- "Выберите способ пополнения. Сумма подставится автоматически."
- ),
- ).format(
- required=texts.format_price(final_price),
- 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,
- resume_callback=resume_callback,
- amount_kopeks=missing_kopeks,
- ),
- parse_mode="HTML",
- )
- await callback.answer()
- return
-
- existing_subscription = db_user.subscription
- was_trial_conversion = False
- current_time = datetime.utcnow()
-
- if existing_subscription:
- logger.info(f"Обновляем существующую подписку пользователя {db_user.telegram_id}")
-
- bonus_period = timedelta()
-
- if existing_subscription.is_trial:
- logger.info(f"Конверсия из триала в платную для пользователя {db_user.telegram_id}")
- was_trial_conversion = True
-
- trial_duration = (current_time - existing_subscription.start_date).days
-
- if settings.TRIAL_ADD_REMAINING_DAYS_TO_PAID and existing_subscription.end_date:
- remaining_trial_delta = existing_subscription.end_date - current_time
- if remaining_trial_delta.total_seconds() > 0:
- bonus_period = remaining_trial_delta
- logger.info(
- "Добавляем оставшееся время триала (%s) к новой подписке пользователя %s",
- bonus_period,
- db_user.telegram_id,
- )
-
- try:
- from app.database.crud.subscription_conversion import create_subscription_conversion
- await create_subscription_conversion(
- db=db,
- user_id=db_user.id,
- trial_duration_days=trial_duration,
- payment_method="balance",
- first_payment_amount_kopeks=final_price,
- first_paid_period_days=data['period_days']
- )
- logger.info(
- f"Записана конверсия: {trial_duration} дн. триал → {data['period_days']} дн. платная за {final_price / 100}₽")
- except Exception as conversion_error:
- logger.error(f"Ошибка записи конверсии: {conversion_error}")
-
- 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']
- existing_subscription.connected_squads = data['countries']
-
- existing_subscription.start_date = current_time
- existing_subscription.end_date = current_time + timedelta(days=data['period_days']) + bonus_period
- existing_subscription.updated_at = current_time
-
- existing_subscription.traffic_used_gb = 0.0
-
- await db.commit()
- await db.refresh(existing_subscription)
- subscription = existing_subscription
-
- else:
- logger.info(f"Создаем новую подписку для пользователя {db_user.telegram_id}")
- subscription = await create_paid_subscription_with_traffic_mode(
- db=db,
- user_id=db_user.id,
- duration_days=data['period_days'],
- device_limit=data['devices'],
- connected_squads=data['countries'],
- traffic_gb=final_traffic_gb
- )
-
- from app.utils.user_utils import mark_user_as_had_paid_subscription
- await mark_user_as_had_paid_subscription(db, db_user)
-
- from app.database.crud.server_squad import get_server_ids_by_uuids, add_user_to_servers
- from app.database.crud.subscription import add_subscription_servers
-
- server_ids = await get_server_ids_by_uuids(db, data['countries'])
-
- if server_ids:
- await add_subscription_servers(db, subscription, server_ids, server_prices)
- 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,
- subscription,
- reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
- reset_reason="покупка подписки",
- )
- else:
- remnawave_user = await subscription_service.create_remnawave_user(
- db,
- subscription,
- 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(
- db,
- subscription,
- reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
- reset_reason="покупка подписки (повторная попытка)",
- )
-
- transaction = await create_transaction(
- db=db,
- user_id=db_user.id,
- type=TransactionType.SUBSCRIPTION_PAYMENT,
- 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(
- db, db_user, subscription, transaction, data['period_days'], was_trial_conversion
- )
- except Exception as e:
- logger.error(f"Ошибка отправки уведомления о покупке: {e}")
-
- await db.refresh(db_user)
- await db.refresh(subscription)
-
- subscription_link = get_display_subscription_link(subscription)
- hide_subscription_link = settings.should_hide_subscription_link()
-
- discount_note = ""
- if promo_offer_discount_value > 0:
- discount_note = texts.t(
- "SUBSCRIPTION_PROMO_DISCOUNT_NOTE",
- "⚡ Доп. скидка {percent}%: -{amount}",
- ).format(
- percent=promo_offer_discount_percent,
- amount=texts.format_price(promo_offer_discount_value),
- )
-
- if remnawave_user and subscription_link:
- if settings.is_happ_cryptolink_mode():
- success_text = (
- f"{texts.SUBSCRIPTION_PURCHASED}\n\n"
- + texts.t(
- "SUBSCRIPTION_HAPP_LINK_PROMPT",
- "🔒 Ссылка на подписку создана. Нажмите кнопку \"Подключиться\" ниже, чтобы открыть её в Happ.",
- )
- + "\n\n"
- + texts.t(
- "SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT",
- "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве",
- )
- )
- elif hide_subscription_link:
- success_text = (
- f"{texts.SUBSCRIPTION_PURCHASED}\n\n"
- + texts.t(
- "SUBSCRIPTION_LINK_HIDDEN_NOTICE",
- "ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе \"Моя подписка\".",
- )
- + "\n\n"
- + texts.t(
- "SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT",
- "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве",
- )
- )
- else:
- import_link_section = texts.t(
- "SUBSCRIPTION_IMPORT_LINK_SECTION",
- "🔗 Ваша ссылка для импорта в VPN приложение:\\n{subscription_url}",
- ).format(subscription_url=subscription_link)
-
- success_text = (
- f"{texts.SUBSCRIPTION_PURCHASED}\n\n"
- f"{import_link_section}\n\n"
- f"{texts.t('SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT', '📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве')}"
- )
-
- if discount_note:
- success_text = f"{success_text}\n\n{discount_note}"
-
- connect_mode = settings.CONNECT_BUTTON_MODE
-
- if connect_mode == "miniapp_subscription":
- connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[
- [
- InlineKeyboardButton(
- text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
- web_app=types.WebAppInfo(url=subscription_link),
- )
- ],
- [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
- callback_data="back_to_menu")],
- ])
- elif connect_mode == "miniapp_custom":
- if not settings.MINIAPP_CUSTOM_URL:
- await callback.answer(
- texts.t(
- "CUSTOM_MINIAPP_URL_NOT_SET",
- "⚠ Кастомная ссылка для мини-приложения не настроена",
- ),
- show_alert=True,
- )
- return
-
- connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[
- [
- InlineKeyboardButton(
- text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
- web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL),
- )
- ],
- [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
- callback_data="back_to_menu")],
- ])
- elif connect_mode == "link":
- rows = [
- [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=subscription_link)]
- ]
- happ_row = get_happ_download_button_row(texts)
- if happ_row:
- rows.append(happ_row)
- rows.append([InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
- callback_data="back_to_menu")])
- connect_keyboard = InlineKeyboardMarkup(inline_keyboard=rows)
- elif connect_mode == "happ_cryptolink":
- rows = [
- [
- InlineKeyboardButton(
- text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
- callback_data="open_subscription_link",
- )
- ]
- ]
- happ_row = get_happ_download_button_row(texts)
- if happ_row:
- rows.append(happ_row)
- rows.append([InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
- callback_data="back_to_menu")])
- connect_keyboard = InlineKeyboardMarkup(inline_keyboard=rows)
- else:
- connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[
- [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
- callback_data="subscription_connect")],
- [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
- callback_data="back_to_menu")],
- ])
-
- await callback.message.edit_text(
- success_text,
- reply_markup=connect_keyboard,
- parse_mode="HTML"
- )
- else:
- purchase_text = texts.SUBSCRIPTION_PURCHASED
- if discount_note:
- purchase_text = f"{purchase_text}\n\n{discount_note}"
- await callback.message.edit_text(
- texts.t(
- "SUBSCRIPTION_LINK_GENERATING_NOTICE",
- "{purchase_text}\n\nСсылка генерируется, перейдите в раздел 'Моя подписка' через несколько секунд.",
- ).format(purchase_text=purchase_text),
- reply_markup=get_back_keyboard(db_user.language)
- )
-
- purchase_completed = True
- logger.info(
- f"Пользователь {db_user.telegram_id} купил подписку на {data['period_days']} дней за {final_price / 100}₽")
-
- except Exception as e:
- logger.error(f"Ошибка покупки подписки: {e}")
- await callback.message.edit_text(
- texts.ERROR,
- reply_markup=get_back_keyboard(db_user.language)
- )
-
- if purchase_completed:
- await clear_subscription_checkout_draft(db_user.id)
-
- await state.clear()
- await callback.answer()
-
-
-async def resume_subscription_checkout(
- callback: types.CallbackQuery,
- state: FSMContext,
- db_user: User,
-):
- texts = get_texts(db_user.language)
-
- draft = await get_subscription_checkout_draft(db_user.id)
-
- if not draft:
- await callback.answer(texts.NO_SAVED_SUBSCRIPTION_ORDER, show_alert=True)
- return
-
- try:
- summary_text, prepared_data = await _prepare_subscription_summary(db_user, draft, texts)
- except ValueError as exc:
- logger.error(
- f"Ошибка восстановления заказа подписки для пользователя {db_user.telegram_id}: {exc}"
- )
- await clear_subscription_checkout_draft(db_user.id)
- await callback.answer(texts.NO_SAVED_SUBSCRIPTION_ORDER, show_alert=True)
- return
-
- await state.set_data(prepared_data)
- await state.set_state(SubscriptionStates.confirming_purchase)
- 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 callback.answer()
-
-
-async def add_traffic(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession
-):
- if settings.is_traffic_fixed():
- await callback.answer("⚠️ В текущем режиме трафик фиксированный", show_alert=True)
- return
-
- traffic_gb = int(callback.data.split('_')[2])
- texts = get_texts(db_user.language)
- subscription = db_user.subscription
-
- base_price = settings.get_traffic_price(traffic_gb)
-
- if base_price == 0 and traffic_gb != 0:
- await callback.answer("⚠️ Цена для этого пакета не настроена", show_alert=True)
- return
-
- period_hint_days = _get_period_hint_from_subscription(subscription)
- discount_result = _apply_addon_discount(
- db_user,
- "traffic",
- base_price,
- period_hint_days,
- )
-
- discounted_per_month = discount_result["discounted"]
- discount_per_month = discount_result["discount"]
- charged_months = 1
-
- if subscription:
- price, charged_months = calculate_prorated_price(
- discounted_per_month,
- subscription.end_date,
- )
- else:
- price = discounted_per_month
-
- total_discount_value = discount_per_month * charged_months
-
- if db_user.balance_kopeks < price:
- missing_kopeks = price - db_user.balance_kopeks
- message_text = texts.t(
- "ADDON_INSUFFICIENT_FUNDS_MESSAGE",
- (
- "⚠️ Недостаточно средств\n\n"
- "Стоимость услуги: {required}\n"
- "На балансе: {balance}\n"
- "Не хватает: {missing}\n\n"
- "Выберите способ пополнения. Сумма подставится автоматически."
- ),
- ).format(
- required=texts.format_price(price),
- 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
-
- try:
- success = await subtract_user_balance(
- db,
- db_user,
- price,
- f"Добавление {traffic_gb} ГБ трафика",
- )
-
- if not success:
- await callback.answer("⚠️ Ошибка списания средств", show_alert=True)
- return
-
- if traffic_gb == 0:
- subscription.traffic_limit_gb = 0
- else:
- await add_subscription_traffic(db, subscription, traffic_gb)
-
- subscription_service = SubscriptionService()
- await subscription_service.update_remnawave_user(db, subscription)
-
- await create_transaction(
- db=db,
- user_id=db_user.id,
- type=TransactionType.SUBSCRIPTION_PAYMENT,
- amount_kopeks=price,
- description=f"Добавление {traffic_gb} ГБ трафика",
- )
-
- await db.refresh(db_user)
- await db.refresh(subscription)
-
- success_text = f"✅ Трафик успешно добавлен!\n\n"
- if traffic_gb == 0:
- success_text += "🎉 Теперь у вас безлимитный трафик!"
- else:
- success_text += f"📈 Добавлено: {traffic_gb} ГБ\n"
- success_text += f"Новый лимит: {texts.format_traffic(subscription.traffic_limit_gb)}"
-
- if price > 0:
- success_text += f"\n💰 Списано: {texts.format_price(price)}"
- if total_discount_value > 0:
- success_text += (
- f" (скидка {discount_result['percent']}%:"
- f" -{texts.format_price(total_discount_value)})"
- )
-
- await callback.message.edit_text(
- success_text,
- reply_markup=get_back_keyboard(db_user.language)
- )
-
- logger.info(f"✅ Пользователь {db_user.telegram_id} добавил {traffic_gb} ГБ трафика")
-
- except Exception as e:
- logger.error(f"Ошибка добавления трафика: {e}")
- await callback.message.edit_text(
- texts.ERROR,
- reply_markup=get_back_keyboard(db_user.language)
- )
-
- await callback.answer()
-
-
-async def create_paid_subscription_with_traffic_mode(
- db: AsyncSession,
- user_id: int,
- duration_days: int,
- device_limit: int,
- connected_squads: List[str],
- traffic_gb: Optional[int] = None
-):
- from app.config import settings
-
- if traffic_gb is None:
- if settings.is_traffic_fixed():
- traffic_limit_gb = settings.get_fixed_traffic_limit()
- else:
- traffic_limit_gb = 0
- else:
- traffic_limit_gb = traffic_gb
-
- subscription = await create_paid_subscription(
- 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,
- )
-
- logger.info(f"📋 Создана подписка с трафиком: {traffic_limit_gb} ГБ (режим: {settings.TRAFFIC_SELECTION_MODE})")
-
- return subscription
-
-
-def validate_traffic_price(gb: int) -> bool:
- from app.config import settings
-
- price = settings.get_traffic_price(gb)
- if gb == 0:
- return True
-
- return price > 0
-
-
-async def handle_subscription_settings(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession
-):
- texts = get_texts(db_user.language)
- subscription = db_user.subscription
-
- if not subscription or subscription.is_trial:
- await callback.answer(
- texts.t(
- "SUBSCRIPTION_SETTINGS_PAID_ONLY",
- "⚠️ Настройки доступны только для платных подписок",
- ),
- show_alert=True,
- )
- return
-
- devices_used = await get_current_devices_count(db_user)
-
- settings_text = texts.t(
- "SUBSCRIPTION_SETTINGS_OVERVIEW",
- (
- "⚙️ Настройки подписки\n\n"
- "📊 Текущие параметры:\n"
- "🌐 Стран: {countries_count}\n"
- "📈 Трафик: {traffic_used} / {traffic_limit}\n"
- "📱 Устройства: {devices_used} / {devices_limit}\n\n"
- "Выберите что хотите изменить:"
- ),
- ).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),
- devices_used=devices_used,
- devices_limit=subscription.device_limit,
- )
-
- show_countries = await _should_show_countries_management(db_user)
-
- await callback.message.edit_text(
- settings_text,
- reply_markup=get_updated_subscription_settings_keyboard(db_user.language, show_countries),
- parse_mode="HTML"
- )
- await callback.answer()
-
-
-async def handle_autopay_menu(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession
-):
- texts = get_texts(db_user.language)
- subscription = db_user.subscription
- if not subscription:
- await callback.answer(
- texts.t("SUBSCRIPTION_ACTIVE_REQUIRED", "⚠️ У вас нет активной подписки!"),
- show_alert=True,
- )
- return
-
- status = (
- texts.t("AUTOPAY_STATUS_ENABLED", "включен")
- if subscription.autopay_enabled
- else texts.t("AUTOPAY_STATUS_DISABLED", "выключен")
- )
- days = subscription.autopay_days_before
-
- text = texts.t(
- "AUTOPAY_MENU_TEXT",
- (
- "💳 Автоплатеж\n\n"
- "📊 Статус: {status}\n"
- "⏰ Списание за: {days} дн. до окончания\n\n"
- "Выберите действие:"
- ),
- ).format(status=status, days=days)
-
- await callback.message.edit_text(
- text,
- reply_markup=get_autopay_keyboard(db_user.language),
- parse_mode="HTML",
- )
- await callback.answer()
-
-
-async def toggle_autopay(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession
-):
- subscription = db_user.subscription
- enable = callback.data == "autopay_enable"
-
- await update_subscription_autopay(db, subscription, enable)
-
- texts = get_texts(db_user.language)
- status = (
- texts.t("AUTOPAY_STATUS_ENABLED", "включен")
- if enable
- else texts.t("AUTOPAY_STATUS_DISABLED", "выключен")
- )
- await callback.answer(
- texts.t("AUTOPAY_TOGGLE_SUCCESS", "✅ Автоплатеж {status}!").format(status=status)
- )
-
- await handle_autopay_menu(callback, db_user, db)
-
-
-async def show_autopay_days(
- callback: types.CallbackQuery,
- db_user: User
-):
- texts = get_texts(db_user.language)
- await callback.message.edit_text(
- texts.t(
- "AUTOPAY_SELECT_DAYS_PROMPT",
- "⏰ Выберите за сколько дней до окончания списывать средства:",
- ),
- reply_markup=get_autopay_days_keyboard(db_user.language)
- )
- await callback.answer()
-
-
-async def set_autopay_days(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession
-):
- days = int(callback.data.split('_')[2])
- subscription = db_user.subscription
-
- await update_subscription_autopay(
- db, subscription, subscription.autopay_enabled, days
- )
-
- texts = get_texts(db_user.language)
- await callback.answer(
- texts.t("AUTOPAY_DAYS_SET", "✅ Установлено {days} дней!").format(days=days)
- )
-
- await handle_autopay_menu(callback, db_user, db)
-
-
-async def handle_subscription_config_back(
- callback: types.CallbackQuery,
- state: FSMContext,
- db_user: User,
- db: AsyncSession
-):
- current_state = await state.get_state()
- texts = get_texts(db_user.language)
-
- if current_state == SubscriptionStates.selecting_traffic.state:
- 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)
-
- elif current_state == SubscriptionStates.selecting_countries.state:
- 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)
- 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)
-
- 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)
-
- else:
- from app.handlers.menu import show_main_menu
- await show_main_menu(callback, db_user, db)
- await state.clear()
-
- await callback.answer()
-
-
-async def handle_subscription_cancel(
- callback: types.CallbackQuery,
- state: FSMContext,
- db_user: User,
- db: AsyncSession
-):
- texts = get_texts(db_user.language)
-
- await state.clear()
- await clear_subscription_checkout_draft(db_user.id)
-
- from app.handlers.menu import show_main_menu
- await show_main_menu(callback, db_user, db)
-
- await callback.answer("❌ Покупка отменена")
-
-
-async def _get_available_countries(promo_group_id: Optional[int] = None):
- from app.utils.cache import cache, cache_key
- from app.database.database import AsyncSessionLocal
- from app.database.crud.server_squad import get_available_server_squads
-
- cache_key_value = cache_key("available_countries", promo_group_id or "all")
- cached_countries = await cache.get(cache_key_value)
- if cached_countries:
- return cached_countries
-
- try:
- async with AsyncSessionLocal() as db:
- available_servers = await get_available_server_squads(
- db, promo_group_id=promo_group_id
- )
-
- if promo_group_id is not None and not available_servers:
- logger.info(
- "Промогруппа %s не имеет доступных серверов, возврат пустого списка",
- promo_group_id,
- )
- await cache.set(cache_key_value, [], 60)
- return []
-
- countries = []
- for server in available_servers:
- countries.append({
- "uuid": server.squad_uuid,
- "name": server.display_name,
- "price_kopeks": server.price_kopeks,
- "country_code": server.country_code,
- "is_available": server.is_available and not server.is_full
- })
-
- if not countries:
- logger.info("🔄 Серверов в БД нет, получаем из RemnaWave...")
- from app.services.remnawave_service import RemnaWaveService
-
- service = RemnaWaveService()
- squads = await service.get_all_squads()
-
- for squad in squads:
- squad_name = squad["name"]
-
- if not any(flag in squad_name for flag in
- ["🇳🇱", "🇩🇪", "🇺🇸", "🇫🇷", "🇬🇧", "🇮🇹", "🇪🇸", "🇨🇦", "🇯🇵", "🇸🇬", "🇦🇺"]):
- name_lower = squad_name.lower()
- if "netherlands" in name_lower or "нидерланды" in name_lower or "nl" in name_lower:
- squad_name = f"🇳🇱 {squad_name}"
- elif "germany" in name_lower or "германия" in name_lower or "de" in name_lower:
- squad_name = f"🇩🇪 {squad_name}"
- elif "usa" in name_lower or "сша" in name_lower or "america" in name_lower or "us" in name_lower:
- squad_name = f"🇺🇸 {squad_name}"
- else:
- squad_name = f"🌐 {squad_name}"
-
- countries.append({
- "uuid": squad["uuid"],
- "name": squad_name,
- "price_kopeks": 0,
- "is_available": True
- })
-
- await cache.set(cache_key_value, countries, 300)
- return countries
-
- except Exception as e:
- logger.error(f"Ошибка получения списка стран: {e}")
- fallback_countries = [
- {"uuid": "default-free", "name": "🆓 Бесплатный сервер", "price_kopeks": 0, "is_available": True},
- ]
-
- await cache.set(cache_key_value, fallback_countries, 60)
- return fallback_countries
-
-
-async def _get_countries_info(squad_uuids):
- countries = await _get_available_countries()
- return [c for c in countries if c['uuid'] in squad_uuids]
-
-
-async def handle_reset_devices(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession
-):
- await handle_device_management(callback, db_user, db)
-
-
-async def handle_add_country_to_subscription(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession,
- state: FSMContext
-):
- logger.info(f"🔍 handle_add_country_to_subscription вызван для {db_user.telegram_id}")
- logger.info(f"🔍 Callback data: {callback.data}")
-
- current_state = await state.get_state()
- logger.info(f"🔍 Текущее состояние: {current_state}")
-
- country_uuid = callback.data.split('_')[1]
- data = await state.get_data()
- logger.info(f"🔍 Данные состояния: {data}")
-
- selected_countries = data.get('countries', [])
- countries = await _get_available_countries(db_user.promo_group_id)
- allowed_country_ids = {country['uuid'] for country in countries}
-
- if country_uuid not in allowed_country_ids and country_uuid not in selected_countries:
- await callback.answer("❌ Сервер недоступен для вашей промогруппы", show_alert=True)
- return
-
- if country_uuid in selected_countries:
- selected_countries.remove(country_uuid)
- logger.info(f"🔍 Удалена страна: {country_uuid}")
- else:
- selected_countries.append(country_uuid)
- logger.info(f"🔍 Добавлена страна: {country_uuid}")
-
- total_price = 0
- subscription = db_user.subscription
- period_hint_days = _get_period_hint_from_subscription(subscription)
- servers_discount_percent = _get_addon_discount_percent_for_user(
- db_user,
- "servers",
- period_hint_days,
- )
-
- for country in countries:
- if not country.get('is_available', True):
- continue
-
- if (
- country['uuid'] in selected_countries
- and country['uuid'] not in subscription.connected_squads
- ):
- server_price = country['price_kopeks']
- if servers_discount_percent > 0 and server_price > 0:
- discounted_price, _ = apply_percentage_discount(
- server_price,
- servers_discount_percent,
- )
- else:
- discounted_price = server_price
- total_price += discounted_price
-
- data['countries'] = selected_countries
- data['total_price'] = total_price
- await state.set_data(data)
-
- logger.info(f"🔍 Новые выбранные страны: {selected_countries}")
- logger.info(f"🔍 Общая стоимость: {total_price}")
-
- try:
- from app.keyboards.inline import get_manage_countries_keyboard
- await callback.message.edit_reply_markup(
- reply_markup=get_manage_countries_keyboard(
- countries,
- selected_countries,
- subscription.connected_squads,
- db_user.language,
- subscription.end_date,
- servers_discount_percent,
- )
- )
- logger.info(f"✅ Клавиатура обновлена")
- except Exception as e:
- logger.error(f"❌ Ошибка обновления клавиатуры: {e}")
-
- await callback.answer()
-
-
-async def _should_show_countries_management(user: Optional[User] = None) -> bool:
- try:
- promo_group_id = user.promo_group_id if user else None
-
- promo_group = getattr(user, "promo_group", None) if user else None
- if promo_group and getattr(promo_group, "server_squads", None):
- allowed_servers = [
- server
- for server in promo_group.server_squads
- if server.is_available and not server.is_full
- ]
-
- if allowed_servers:
- if len(allowed_servers) > 1:
- logger.debug(
- "Промогруппа %s имеет %s доступных серверов, показываем управление странами",
- promo_group.id,
- len(allowed_servers),
- )
- return True
-
- logger.debug(
- "Промогруппа %s имеет всего %s доступный сервер, пропускаем шаг выбора стран",
- promo_group.id,
- len(allowed_servers),
- )
- return False
-
- countries = await _get_available_countries(promo_group_id)
- available_countries = [c for c in countries if c.get('is_available', True)]
- return len(available_countries) > 1
- except Exception as e:
- logger.error(f"Ошибка проверки доступных серверов: {e}")
- return True
-
-
-async def confirm_add_countries_to_subscription(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession,
- state: FSMContext
-):
- data = await state.get_data()
- texts = get_texts(db_user.language)
- subscription = db_user.subscription
-
- selected_countries = data.get('countries', [])
- current_countries = subscription.connected_squads
-
- countries = await _get_available_countries(db_user.promo_group_id)
- allowed_country_ids = {country['uuid'] for country in countries}
-
- selected_countries = [
- country_uuid
- for country_uuid in selected_countries
- if country_uuid in allowed_country_ids or country_uuid in current_countries
- ]
-
- new_countries = [c for c in selected_countries if c not in current_countries]
- removed_countries = [c for c in current_countries if c not in selected_countries]
-
- if not new_countries and not removed_countries:
- await callback.answer("⚠️ Изменения не обнаружены", show_alert=True)
- return
-
- total_price = 0
- new_countries_names = []
- removed_countries_names = []
-
- period_hint_days = _get_period_hint_from_subscription(subscription)
- servers_discount_percent = _get_addon_discount_percent_for_user(
- db_user,
- "servers",
- period_hint_days,
- )
- total_discount_value = 0
-
- for country in countries:
- if not country.get('is_available', True):
- continue
-
- if country['uuid'] in new_countries:
- server_price = country['price_kopeks']
- if servers_discount_percent > 0 and server_price > 0:
- discounted_per_month, discount_per_month = apply_percentage_discount(
- server_price,
- servers_discount_percent,
- )
- else:
- discounted_per_month = server_price
- discount_per_month = 0
-
- charged_price, charged_months = calculate_prorated_price(
- discounted_per_month,
- subscription.end_date,
- )
-
- total_price += charged_price
- total_discount_value += discount_per_month * charged_months
- new_countries_names.append(country['name'])
- if country['uuid'] in removed_countries:
- removed_countries_names.append(country['name'])
-
- if new_countries and db_user.balance_kopeks < total_price:
- missing_kopeks = total_price - db_user.balance_kopeks
- message_text = texts.t(
- "ADDON_INSUFFICIENT_FUNDS_MESSAGE",
- (
- "⚠️ Недостаточно средств\n\n"
- "Стоимость услуги: {required}\n"
- "На балансе: {balance}\n"
- "Не хватает: {missing}\n\n"
- "Выберите способ пополнения. Сумма подставится автоматически."
- ),
- ).format(
- required=texts.format_price(total_price),
- 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 state.clear()
- await callback.answer()
- return
-
- try:
- if new_countries and total_price > 0:
- success = await subtract_user_balance(
- db, db_user, total_price,
- f"Добавление стран к подписке: {', '.join(new_countries_names)}"
- )
-
- if not success:
- await callback.answer("❌ Ошибка списания средств", show_alert=True)
- return
-
- await create_transaction(
- db=db,
- user_id=db_user.id,
- type=TransactionType.SUBSCRIPTION_PAYMENT,
- amount_kopeks=total_price,
- description=f"Добавление стран к подписке: {', '.join(new_countries_names)}"
- )
-
- subscription.connected_squads = selected_countries
- subscription.updated_at = datetime.utcnow()
- await db.commit()
-
- subscription_service = SubscriptionService()
- await subscription_service.update_remnawave_user(db, subscription)
-
- await db.refresh(db_user)
- await db.refresh(subscription)
-
- success_text = "✅ Страны успешно обновлены!\n\n"
-
- if new_countries_names:
- success_text += f"➕ Добавлены страны:\n{chr(10).join(f'• {name}' for name in new_countries_names)}\n"
- if total_price > 0:
- success_text += f"💰 Списано: {texts.format_price(total_price)}"
- if total_discount_value > 0:
- success_text += (
- f" (скидка {servers_discount_percent}%:"
- f" -{texts.format_price(total_discount_value)})"
- )
- success_text += "\n"
-
- if removed_countries_names:
- success_text += f"\n➖ Отключены страны:\n{chr(10).join(f'• {name}' for name in removed_countries_names)}\n"
- success_text += "ℹ️ Повторное подключение будет платным\n"
-
- success_text += f"\n🌍 Активных стран: {len(selected_countries)}"
-
- await callback.message.edit_text(
- success_text,
- reply_markup=get_back_keyboard(db_user.language)
- )
-
- logger.info(
- f"✅ Пользователь {db_user.telegram_id} обновил страны подписки. Добавлено: {len(new_countries)}, убрано: {len(removed_countries)}")
-
- except Exception as e:
- logger.error(f"Ошибка обновления стран подписки: {e}")
- await callback.message.edit_text(
- texts.ERROR,
- reply_markup=get_back_keyboard(db_user.language)
- )
-
- await state.clear()
- await callback.answer()
-
-
-async def confirm_reset_devices(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession
-):
- await handle_device_management(callback, db_user, db)
-
-
-async def handle_happ_download_request(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession
-):
- texts = get_texts(db_user.language)
- prompt_text = texts.t(
- "HAPP_DOWNLOAD_PROMPT",
- "📥 Скачать Happ\nВыберите ваше устройство:",
- )
-
- keyboard = get_happ_download_platform_keyboard(db_user.language)
-
- await callback.message.answer(prompt_text, reply_markup=keyboard, parse_mode="HTML")
- await callback.answer()
-
-
-async def handle_happ_download_platform_choice(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession
-):
- platform = callback.data.split('_')[-1]
- if platform == "pc":
- platform = "windows"
- texts = get_texts(db_user.language)
- link = settings.get_happ_download_link(platform)
-
- if not link:
- await callback.answer(
- texts.t("HAPP_DOWNLOAD_LINK_NOT_SET", "❌ Ссылка для этого устройства не настроена"),
- show_alert=True,
- )
- return
-
- platform_names = {
- "ios": texts.t("HAPP_PLATFORM_IOS", "🍎 iOS"),
- "android": texts.t("HAPP_PLATFORM_ANDROID", "🤖 Android"),
- "macos": texts.t("HAPP_PLATFORM_MACOS", "🖥️ Mac OS"),
- "windows": texts.t("HAPP_PLATFORM_WINDOWS", "💻 Windows"),
- }
-
- link_text = texts.t(
- "HAPP_DOWNLOAD_LINK_MESSAGE",
- "⬇️ Скачайте Happ для {platform}:",
- ).format(platform=platform_names.get(platform, platform.upper()))
-
- keyboard = get_happ_download_link_keyboard(db_user.language, link)
-
- await callback.message.edit_text(link_text, reply_markup=keyboard, parse_mode="HTML")
- await callback.answer()
-
-
-async def handle_happ_download_close(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession
-):
- try:
- await callback.message.delete()
- except Exception:
- pass
-
- await callback.answer()
-
-
-async def handle_happ_download_back(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession
-):
- texts = get_texts(db_user.language)
- prompt_text = texts.t(
- "HAPP_DOWNLOAD_PROMPT",
- "📥 Скачать Happ\nВыберите ваше устройство:",
- )
-
- keyboard = get_happ_download_platform_keyboard(db_user.language)
-
- await callback.message.edit_text(prompt_text, reply_markup=keyboard, parse_mode="HTML")
- await callback.answer()
-
-
-async def handle_connect_subscription(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession
-):
- texts = get_texts(db_user.language)
- subscription = db_user.subscription
- subscription_link = get_display_subscription_link(subscription)
- hide_subscription_link = settings.should_hide_subscription_link()
-
- if not subscription_link:
- await callback.answer(
- texts.t(
- "SUBSCRIPTION_NO_ACTIVE_LINK",
- "⚠ У вас нет активной подписки или ссылка еще генерируется",
- ),
- show_alert=True,
- )
- return
-
- connect_mode = settings.CONNECT_BUTTON_MODE
-
- if connect_mode == "miniapp_subscription":
- keyboard = InlineKeyboardMarkup(inline_keyboard=[
- [
- InlineKeyboardButton(
- text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
- web_app=types.WebAppInfo(url=subscription_link)
- )
- ],
- [
- InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")
- ]
- ])
-
- await callback.message.edit_text(
- texts.t(
- "SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE",
- """📱 Подключить подписку
-
-🚀 Нажмите кнопку ниже, чтобы открыть подписку в мини-приложении Telegram:""",
- ),
- reply_markup=keyboard,
- parse_mode="HTML"
- )
-
- elif connect_mode == "miniapp_custom":
- if not settings.MINIAPP_CUSTOM_URL:
- await callback.answer(
- texts.t(
- "CUSTOM_MINIAPP_URL_NOT_SET",
- "⚠ Кастомная ссылка для мини-приложения не настроена",
- ),
- show_alert=True,
- )
- return
-
- keyboard = InlineKeyboardMarkup(inline_keyboard=[
- [
- InlineKeyboardButton(
- text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
- web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL)
- )
- ],
- [
- InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")
- ]
- ])
-
- await callback.message.edit_text(
- texts.t(
- "SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE",
- """🚀 Подключить подписку
-
-📱 Нажмите кнопку ниже, чтобы открыть приложение:""",
- ),
- reply_markup=keyboard,
- parse_mode="HTML"
- )
-
- elif connect_mode == "link":
- rows = [
- [
- InlineKeyboardButton(
- text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
- url=subscription_link
- )
- ]
- ]
- happ_row = get_happ_download_button_row(texts)
- if happ_row:
- rows.append(happ_row)
- rows.append([
- InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")
- ])
-
- keyboard = InlineKeyboardMarkup(inline_keyboard=rows)
-
- await callback.message.edit_text(
- texts.t(
- "SUBSCRIPTION_CONNECT_LINK_MESSAGE",
- """🚀 Подключить подписку",
-
-🔗 Нажмите кнопку ниже, чтобы открыть ссылку подписки:""",
- ),
- reply_markup=keyboard,
- parse_mode="HTML"
- )
- elif connect_mode == "happ_cryptolink":
- rows = [
- [
- InlineKeyboardButton(
- text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
- callback_data="open_subscription_link",
- )
- ]
- ]
- happ_row = get_happ_download_button_row(texts)
- if happ_row:
- rows.append(happ_row)
- rows.append([
- InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")
- ])
-
- keyboard = InlineKeyboardMarkup(inline_keyboard=rows)
-
- await callback.message.edit_text(
- texts.t(
- "SUBSCRIPTION_CONNECT_LINK_MESSAGE",
- """🚀 Подключить подписку",
-
-🔗 Нажмите кнопку ниже, чтобы открыть ссылку подписки:""",
- ),
- reply_markup=keyboard,
- parse_mode="HTML"
- )
- else:
- if hide_subscription_link:
- device_text = texts.t(
- "SUBSCRIPTION_CONNECT_DEVICE_MESSAGE_HIDDEN",
- """📱 Подключить подписку
-
-ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе "Моя подписка".
-
-💡 Выберите ваше устройство для получения подробной инструкции по настройке:""",
- )
- else:
- device_text = texts.t(
- "SUBSCRIPTION_CONNECT_DEVICE_MESSAGE",
- """📱 Подключить подписку
-
-🔗 Ссылка подписки:
-{subscription_url}
-
-💡 Выберите ваше устройство для получения подробной инструкции по настройке:""",
- ).format(subscription_url=subscription_link)
-
- await callback.message.edit_text(
- device_text,
- reply_markup=get_device_selection_keyboard(db_user.language),
- parse_mode="HTML"
- )
-
- await callback.answer()
-
-
-async def claim_discount_offer(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession,
-):
- texts = get_texts(db_user.language)
-
- try:
- offer_id = int(callback.data.split("_")[-1])
- except (ValueError, AttributeError):
- await callback.answer(
- texts.get("DISCOUNT_CLAIM_NOT_FOUND", "❌ Предложение не найдено"),
- show_alert=True,
- )
- return
-
- offer = await get_offer_by_id(db, offer_id)
- if not offer or offer.user_id != db_user.id:
- await callback.answer(
- texts.get("DISCOUNT_CLAIM_NOT_FOUND", "❌ Предложение не найдено"),
- show_alert=True,
- )
- return
-
- now = datetime.utcnow()
- if offer.claimed_at is not None:
- await callback.answer(
- texts.get("DISCOUNT_CLAIM_ALREADY", "ℹ️ Скидка уже была активирована"),
- show_alert=True,
- )
- return
-
- if not offer.is_active or offer.expires_at <= now:
- offer.is_active = False
- await db.commit()
- await callback.answer(
- texts.get("DISCOUNT_CLAIM_EXPIRED", "⚠️ Время действия предложения истекло"),
- show_alert=True,
- )
- return
-
- effect_type = (offer.effect_type or "percent_discount").lower()
- if effect_type == "balance_bonus":
- effect_type = "percent_discount"
-
- if effect_type == "test_access":
- success, newly_added, expires_at, error_code = await promo_offer_service.grant_test_access(
- db,
- db_user,
- offer,
- )
-
- if not success:
- if error_code == "subscription_missing":
- error_message = texts.get(
- "TEST_ACCESS_NO_SUBSCRIPTION",
- "❌ Для активации предложения необходима действующая подписка.",
- )
- elif error_code == "squads_missing":
- error_message = texts.get(
- "TEST_ACCESS_NO_SQUADS",
- "❌ Не удалось определить список серверов для теста. Обратитесь к администратору.",
- )
- elif error_code == "already_connected":
- error_message = texts.get(
- "TEST_ACCESS_ALREADY_CONNECTED",
- "ℹ️ Этот сервер уже подключен к вашей подписке.",
- )
- elif error_code == "remnawave_sync_failed":
- error_message = texts.get(
- "TEST_ACCESS_REMNAWAVE_ERROR",
- "❌ Не удалось подключить серверы. Попробуйте позже или обратитесь в поддержку.",
- )
- else:
- error_message = texts.get(
- "TEST_ACCESS_UNKNOWN_ERROR",
- "❌ Не удалось активировать предложение. Попробуйте позже.",
- )
- await callback.answer(error_message, show_alert=True)
- return
-
- await mark_offer_claimed(
- db,
- offer,
- details={
- "context": "test_access_claim",
- "new_squads": newly_added,
- "expires_at": expires_at.isoformat() if expires_at else None,
- },
- )
-
- expires_text = expires_at.strftime("%d.%m.%Y %H:%M") if expires_at else ""
- success_message = texts.get(
- "TEST_ACCESS_ACTIVATED_MESSAGE",
- "🎉 Тестовые сервера подключены! Доступ активен до {expires_at}.",
- ).format(expires_at=expires_text)
-
- popup_text = texts.get("TEST_ACCESS_ACTIVATED_POPUP", "✅ Доступ выдан!")
- await callback.answer(popup_text, show_alert=True)
- back_keyboard = InlineKeyboardMarkup(
- inline_keyboard=[
- [
- InlineKeyboardButton(
- text=texts.get("BACK_TO_MENU", "🏠 В главное меню"),
- callback_data="back_to_menu",
- )
- ]
- ]
- )
- await callback.message.answer(success_message, reply_markup=back_keyboard)
- return
-
- discount_percent = int(offer.discount_percent or 0)
- if discount_percent <= 0:
- await callback.answer(
- texts.get("DISCOUNT_CLAIM_ERROR", "❌ Не удалось активировать скидку. Попробуйте позже."),
- show_alert=True,
- )
- return
-
- db_user.promo_offer_discount_percent = discount_percent
- db_user.promo_offer_discount_source = offer.notification_type
- db_user.updated_at = now
-
- extra_data = offer.extra_data or {}
- raw_duration = extra_data.get("active_discount_hours")
- template_id = extra_data.get("template_id")
-
- if raw_duration in (None, "") and template_id:
- try:
- template = await get_promo_offer_template_by_id(db, int(template_id))
- except (ValueError, TypeError):
- template = None
- if template and template.active_discount_hours:
- raw_duration = template.active_discount_hours
-
- try:
- duration_hours = int(raw_duration) if raw_duration is not None else None
- except (TypeError, ValueError):
- duration_hours = None
-
- if duration_hours and duration_hours > 0:
- discount_expires_at = now + timedelta(hours=duration_hours)
- else:
- discount_expires_at = None
-
- db_user.promo_offer_discount_expires_at = discount_expires_at
-
- await mark_offer_claimed(
- db,
- offer,
- details={
- "context": "discount_claim",
- "discount_percent": discount_percent,
- "discount_expires_at": discount_expires_at.isoformat() if discount_expires_at else None,
- },
- )
- await db.refresh(db_user)
-
- success_template = texts.get(
- "DISCOUNT_CLAIM_SUCCESS",
- "🎉 Скидка {percent}% активирована! Она автоматически применится при следующей оплате.",
- )
-
- expires_text = (
- discount_expires_at.strftime("%d.%m.%Y %H:%M") if discount_expires_at else ""
- )
-
- format_values: Dict[str, Any] = {"percent": discount_percent}
-
- if duration_hours and duration_hours > 0:
- format_values.setdefault("hours", duration_hours)
- format_values.setdefault("duration_hours", duration_hours)
-
- if discount_expires_at:
- format_values.setdefault("expires_at", expires_text)
- format_values.setdefault("expires_at_iso", discount_expires_at.isoformat())
- try:
- expires_timestamp = int(discount_expires_at.timestamp())
- except (OverflowError, OSError, ValueError):
- expires_timestamp = None
- if expires_timestamp:
- format_values.setdefault("expires_at_ts", expires_timestamp)
- remaining_hours = int((discount_expires_at - now).total_seconds() // 3600)
- if remaining_hours > 0:
- format_values.setdefault("expires_in_hours", remaining_hours)
-
- amount_text = ""
- if isinstance(extra_data, dict):
- raw_amount_text = (
- extra_data.get("amount_text")
- or extra_data.get("discount_amount_text")
- or extra_data.get("formatted_amount")
- )
- if isinstance(raw_amount_text, str) and raw_amount_text.strip():
- amount_text = raw_amount_text.strip()
- else:
- raw_amount = extra_data.get("amount") or extra_data.get("discount_amount")
- if isinstance(raw_amount, (int, float)):
- amount_text = settings.format_price(int(raw_amount))
- elif isinstance(raw_amount, str) and raw_amount.strip():
- amount_text = raw_amount.strip()
-
- if not amount_text:
- for key in ("discount_amount_kopeks", "amount_kopeks", "bonus_amount_kopeks"):
- maybe_amount = extra_data.get(key)
- try:
- amount_value = int(maybe_amount)
- except (TypeError, ValueError):
- continue
- if amount_value > 0:
- amount_text = settings.format_price(amount_value)
- break
-
- for key, value in extra_data.items():
- if (
- isinstance(key, str)
- and key.isidentifier()
- and key not in format_values
- and isinstance(value, (str, int, float))
- ):
- format_values[key] = value
-
- if not amount_text:
- try:
- bonus_amount = int(getattr(offer, "bonus_amount_kopeks", 0))
- except (TypeError, ValueError):
- bonus_amount = 0
- if bonus_amount > 0:
- amount_text = settings.format_price(bonus_amount)
-
- if amount_text:
- format_values.setdefault("amount", amount_text)
-
- success_message = _format_text_with_placeholders(success_template, format_values)
-
- await callback.answer("✅ Скидка активирована!", show_alert=True)
-
- offer_type = None
- if isinstance(extra_data, dict):
- offer_type = extra_data.get("offer_type")
-
- subscription = getattr(db_user, "subscription", None)
-
- if offer_type == "purchase_discount":
- button_text = texts.get("MENU_BUY_SUBSCRIPTION", "💎 Купить подписку")
- button_callback = "subscription_upgrade"
- elif offer_type == "extend_discount":
- button_text = texts.get("SUBSCRIPTION_EXTEND", "💎 Продлить подписку")
- button_callback = "subscription_extend"
- else:
- has_active_paid_subscription = bool(
- subscription
- and getattr(subscription, "is_active", False)
- and not getattr(subscription, "is_trial", False)
- )
-
- if has_active_paid_subscription:
- button_text = texts.get("SUBSCRIPTION_EXTEND", "💎 Продлить подписку")
- button_callback = "subscription_extend"
- else:
- button_text = texts.get("MENU_BUY_SUBSCRIPTION", "💎 Купить подписку")
- button_callback = "subscription_upgrade"
-
- buy_keyboard = InlineKeyboardMarkup(
- inline_keyboard=[
- [
- build_miniapp_or_callback_button(
- text=button_text,
- callback_data=button_callback,
- )
- ]
- ]
- )
- await callback.message.answer(success_message, reply_markup=buy_keyboard)
-
-
-async def handle_promo_offer_close(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession,
-):
- try:
- await callback.message.delete()
- except Exception:
- try:
- await callback.message.edit_reply_markup()
- except Exception:
- pass
-
- await callback.answer()
-
-
-async def handle_device_guide(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession
-):
- device_type = callback.data.split('_')[2]
- texts = get_texts(db_user.language)
- subscription = db_user.subscription
- subscription_link = get_display_subscription_link(subscription)
-
- if not subscription_link:
- await callback.answer(
- texts.t("SUBSCRIPTION_LINK_UNAVAILABLE", "❌ Ссылка подписки недоступна"),
- show_alert=True,
- )
- return
-
- apps = get_apps_for_device(device_type, db_user.language)
- hide_subscription_link = settings.should_hide_subscription_link()
-
- if not apps:
- await callback.answer(
- texts.t("SUBSCRIPTION_DEVICE_APPS_NOT_FOUND", "❌ Приложения для этого устройства не найдены"),
- show_alert=True,
- )
- return
-
- featured_app = next((app for app in apps if app.get('isFeatured', False)), apps[0])
- featured_app_id = featured_app.get('id')
- other_apps = [
- app for app in apps
- if isinstance(app, dict) and app.get('id') and app.get('id') != featured_app_id
- ]
-
- other_app_names = ", ".join(
- str(app.get('name')).strip()
- for app in other_apps
- if isinstance(app.get('name'), str) and app.get('name').strip()
- )
-
- if hide_subscription_link:
- link_section = (
- texts.t("SUBSCRIPTION_DEVICE_LINK_TITLE", "🔗 Ссылка подписки:")
- + "\n"
- + texts.t(
- "SUBSCRIPTION_LINK_HIDDEN_NOTICE",
- "ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе \"Моя подписка\".",
- )
- + "\n\n"
- )
- else:
- link_section = (
- texts.t("SUBSCRIPTION_DEVICE_LINK_TITLE", "🔗 Ссылка подписки:")
- + f"\n{subscription_link}\n\n"
- )
-
- installation_description = get_step_description(featured_app, "installationStep", db_user.language)
- add_description = get_step_description(featured_app, "addSubscriptionStep", db_user.language)
- connect_description = get_step_description(featured_app, "connectAndUseStep", db_user.language)
- additional_before_text = format_additional_section(
- featured_app.get("additionalBeforeAddSubscriptionStep"),
- texts,
- db_user.language,
- )
- additional_after_text = format_additional_section(
- featured_app.get("additionalAfterAddSubscriptionStep"),
- texts,
- db_user.language,
- )
-
- guide_text = (
- texts.t(
- "SUBSCRIPTION_DEVICE_GUIDE_TITLE",
- "📱 Настройка для {device_name}",
- ).format(device_name=get_device_name(device_type, db_user.language))
- + "\n\n"
- + link_section
- + texts.t(
- "SUBSCRIPTION_DEVICE_FEATURED_APP",
- "📋 Рекомендуемое приложение: {app_name}",
- ).format(app_name=featured_app.get('name', ''))
- )
-
- if other_app_names:
- guide_text += "\n\n" + texts.t(
- "SUBSCRIPTION_DEVICE_OTHER_APPS",
- "📦 Другие приложения: {app_list}",
- ).format(app_list=other_app_names)
- guide_text += "\n" + texts.t(
- "SUBSCRIPTION_DEVICE_OTHER_APPS_HINT",
- "Нажмите кнопку \"Другие приложения\" ниже, чтобы выбрать приложение.",
- )
-
- guide_text += "\n\n" + texts.t("SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE", "Шаг 1 - Установка:")
- if installation_description:
- guide_text += f"\n{installation_description}"
-
- if additional_before_text:
- guide_text += f"\n\n{additional_before_text}"
-
- guide_text += "\n\n" + texts.t("SUBSCRIPTION_DEVICE_STEP_ADD_TITLE", "Шаг 2 - Добавление подписки:")
- if add_description:
- guide_text += f"\n{add_description}"
-
- guide_text += "\n\n" + texts.t("SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE", "Шаг 3 - Подключение:")
- if connect_description:
- guide_text += f"\n{connect_description}"
-
- guide_text += "\n\n" + texts.t("SUBSCRIPTION_DEVICE_HOW_TO_TITLE", "💡 Как подключить:")
- guide_text += "\n" + "\n".join(
- [
- texts.t(
- "SUBSCRIPTION_DEVICE_HOW_TO_STEP1",
- "1. Установите приложение по ссылке выше",
- ),
- texts.t(
- "SUBSCRIPTION_DEVICE_HOW_TO_STEP2",
- "2. Нажмите кнопку \"Подключиться\" ниже",
- ),
- texts.t(
- "SUBSCRIPTION_DEVICE_HOW_TO_STEP3",
- "3. Откройте приложение и вставьте ссылку",
- ),
- texts.t(
- "SUBSCRIPTION_DEVICE_HOW_TO_STEP4",
- "4. Подключитесь к серверу",
- ),
- ]
- )
-
- if additional_after_text:
- guide_text += f"\n\n{additional_after_text}"
-
- await callback.message.edit_text(
- guide_text,
- reply_markup=get_connection_guide_keyboard(
- subscription_link,
- featured_app,
- device_type,
- db_user.language,
- has_other_apps=bool(other_apps),
- ),
- parse_mode="HTML"
- )
- await callback.answer()
-
-
-async def handle_app_selection(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession
-):
- device_type = callback.data.split('_')[2]
- texts = get_texts(db_user.language)
- subscription = db_user.subscription
-
- apps = get_apps_for_device(device_type, db_user.language)
-
- if not apps:
- await callback.answer(
- texts.t("SUBSCRIPTION_DEVICE_APPS_NOT_FOUND", "❌ Приложения для этого устройства не найдены"),
- show_alert=True,
- )
- return
-
- app_text = (
- texts.t(
- "SUBSCRIPTION_APPS_TITLE",
- "📱 Приложения для {device_name}",
- ).format(device_name=get_device_name(device_type, db_user.language))
- + "\n\n"
- + texts.t("SUBSCRIPTION_APPS_PROMPT", "Выберите приложение для подключения:")
- )
-
- await callback.message.edit_text(
- app_text,
- reply_markup=get_app_selection_keyboard(device_type, apps, db_user.language),
- parse_mode="HTML"
- )
- await callback.answer()
-
-
-async def handle_specific_app_guide(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession
-):
- _, device_type, app_id = callback.data.split('_')
- texts = get_texts(db_user.language)
- subscription = db_user.subscription
-
- subscription_link = get_display_subscription_link(subscription)
-
- if not subscription_link:
- await callback.answer(
- texts.t("SUBSCRIPTION_LINK_UNAVAILABLE", "❌ Ссылка подписки недоступна"),
- show_alert=True,
- )
- return
-
- apps = get_apps_for_device(device_type, db_user.language)
- app = next((a for a in apps if a['id'] == app_id), None)
-
- if not app:
- await callback.answer(
- texts.t("SUBSCRIPTION_APP_NOT_FOUND", "❌ Приложение не найдено"),
- show_alert=True,
- )
- return
-
- hide_subscription_link = settings.should_hide_subscription_link()
-
- if hide_subscription_link:
- link_section = (
- texts.t("SUBSCRIPTION_DEVICE_LINK_TITLE", "🔗 Ссылка подписки:")
- + "\n"
- + texts.t(
- "SUBSCRIPTION_LINK_HIDDEN_NOTICE",
- "ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе \"Моя подписка\".",
- )
- + "\n\n"
- )
- else:
- link_section = (
- texts.t("SUBSCRIPTION_DEVICE_LINK_TITLE", "🔗 Ссылка подписки:")
- + f"\n{subscription_link}\n\n"
- )
-
- installation_description = get_step_description(app, "installationStep", db_user.language)
- add_description = get_step_description(app, "addSubscriptionStep", db_user.language)
- connect_description = get_step_description(app, "connectAndUseStep", db_user.language)
- additional_before_text = format_additional_section(
- app.get("additionalBeforeAddSubscriptionStep"),
- texts,
- db_user.language,
- )
- additional_after_text = format_additional_section(
- app.get("additionalAfterAddSubscriptionStep"),
- texts,
- db_user.language,
- )
-
- guide_text = (
- texts.t(
- "SUBSCRIPTION_SPECIFIC_APP_TITLE",
- "📱 {app_name} - {device_name}",
- ).format(app_name=app.get('name', ''), device_name=get_device_name(device_type, db_user.language))
- + "\n\n"
- + link_section
- )
-
- guide_text += texts.t("SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE", "Шаг 1 - Установка:")
- if installation_description:
- guide_text += f"\n{installation_description}"
-
- if additional_before_text:
- guide_text += f"\n\n{additional_before_text}"
-
- guide_text += "\n\n" + texts.t("SUBSCRIPTION_DEVICE_STEP_ADD_TITLE", "Шаг 2 - Добавление подписки:")
- if add_description:
- guide_text += f"\n{add_description}"
-
- guide_text += "\n\n" + texts.t("SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE", "Шаг 3 - Подключение:")
- if connect_description:
- guide_text += f"\n{connect_description}"
-
- if additional_after_text:
- guide_text += f"\n\n{additional_after_text}"
-
- await callback.message.edit_text(
- guide_text,
- reply_markup=get_specific_app_keyboard(
- subscription_link,
- app,
- device_type,
- db_user.language
- ),
- parse_mode="HTML"
- )
- await callback.answer()
-
-
-async def handle_no_traffic_packages(
- callback: types.CallbackQuery,
- db_user: User
-):
- await callback.answer(
- "⚠️ В данный момент нет доступных пакетов трафика. "
- "Обратитесь в техподдержку для получения информации.",
- show_alert=True
- )
-
-
-async def handle_open_subscription_link(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession
-):
- texts = get_texts(db_user.language)
- subscription = db_user.subscription
- subscription_link = get_display_subscription_link(subscription)
-
- if not subscription_link:
- await callback.answer(
- texts.t("SUBSCRIPTION_LINK_UNAVAILABLE", "❌ Ссылка подписки недоступна"),
- show_alert=True,
- )
- return
-
- if settings.is_happ_cryptolink_mode():
- redirect_link = get_happ_cryptolink_redirect_link(subscription_link)
- happ_scheme_link = convert_subscription_link_to_happ_scheme(subscription_link)
- happ_message = (
- texts.t(
- "SUBSCRIPTION_HAPP_OPEN_TITLE",
- "🔗 Подключение через Happ",
- )
- + "\n\n"
- + texts.t(
- "SUBSCRIPTION_HAPP_OPEN_LINK",
- "🔓 Открыть ссылку в Happ",
- ).format(subscription_link=happ_scheme_link)
- + "\n\n"
- + texts.t(
- "SUBSCRIPTION_HAPP_OPEN_HINT",
- "💡 Если ссылка не открывается автоматически, скопируйте её вручную:",
- )
- )
-
- if redirect_link:
- happ_message += "\n\n" + texts.t(
- "SUBSCRIPTION_HAPP_OPEN_BUTTON_HINT",
- "▶️ Нажмите кнопку \"Подключиться\" ниже, чтобы открыть Happ и добавить подписку автоматически.",
- )
-
- happ_message += "\n\n" + texts.t(
- "SUBSCRIPTION_HAPP_CRYPTOLINK_BLOCK",
- "{crypto_link}",
- ).format(crypto_link=subscription_link)
-
- keyboard = get_happ_cryptolink_keyboard(
- subscription_link,
- db_user.language,
- redirect_link=redirect_link,
- )
-
- await callback.message.answer(
- happ_message,
- parse_mode="HTML",
- disable_web_page_preview=True,
- reply_markup=keyboard,
- )
- await callback.answer()
- return
-
- link_text = (
- texts.t("SUBSCRIPTION_DEVICE_LINK_TITLE", "🔗 Ссылка подписки:")
- + "\n\n"
- + f"{subscription_link}\n\n"
- + texts.t("SUBSCRIPTION_LINK_USAGE_TITLE", "📱 Как использовать:")
- + "\n"
- + "\n".join(
- [
- texts.t(
- "SUBSCRIPTION_LINK_STEP1",
- "1. Нажмите на ссылку выше чтобы её скопировать",
- ),
- texts.t(
- "SUBSCRIPTION_LINK_STEP2",
- "2. Откройте ваше VPN приложение",
- ),
- texts.t(
- "SUBSCRIPTION_LINK_STEP3",
- "3. Найдите функцию \"Добавить подписку\" или \"Import\"",
- ),
- texts.t(
- "SUBSCRIPTION_LINK_STEP4",
- "4. Вставьте скопированную ссылку",
- ),
- ]
- )
- + "\n\n"
- + texts.t(
- "SUBSCRIPTION_LINK_HINT",
- "💡 Если ссылка не скопировалась, выделите её вручную и скопируйте.",
- )
- )
-
- await callback.message.edit_text(
- link_text,
- reply_markup=InlineKeyboardMarkup(inline_keyboard=[
- [
- InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
- callback_data="subscription_connect")
- ],
- [
- InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")
- ]
- ]),
- parse_mode="HTML"
- )
- await callback.answer()
-
-
-def load_app_config() -> Dict[str, Any]:
- try:
- from app.config import settings
- config_path = settings.get_app_config_path()
-
- with open(config_path, 'r', encoding='utf-8') as f:
- data = json.load(f)
- if isinstance(data, dict):
- return data
- logger.error("Некорректный формат app-config.json: ожидается объект")
- except Exception as e:
- logger.error(f"Ошибка загрузки конфига приложений: {e}")
-
- return {}
-
-
-def get_localized_value(values: Any, language: str, default_language: str = "en") -> str:
- if not isinstance(values, dict):
- return ""
-
- candidates: List[str] = []
- normalized_language = (language or "").strip().lower()
-
- if normalized_language:
- candidates.append(normalized_language)
- if "-" in normalized_language:
- candidates.append(normalized_language.split("-")[0])
-
- default_language = (default_language or "").strip().lower()
- if default_language and default_language not in candidates:
- candidates.append(default_language)
-
- for candidate in candidates:
- if not candidate:
- continue
- value = values.get(candidate)
- if isinstance(value, str) and value.strip():
- return value
-
- for value in values.values():
- if isinstance(value, str) and value.strip():
- return value
-
- return ""
-
-
-def get_step_description(app: Dict[str, Any], step_key: str, language: str) -> str:
- if not isinstance(app, dict):
- return ""
-
- step = app.get(step_key)
- if not isinstance(step, dict):
- return ""
-
- description = step.get("description")
- return get_localized_value(description, language)
-
-
-def format_additional_section(additional: Any, texts, language: str) -> str:
- if not isinstance(additional, dict):
- return ""
-
- title = get_localized_value(additional.get("title"), language)
- description = get_localized_value(additional.get("description"), language)
-
- parts: List[str] = []
-
- if title:
- parts.append(
- texts.t(
- "SUBSCRIPTION_ADDITIONAL_STEP_TITLE",
- "{title}:",
- ).format(title=title)
- )
-
- if description:
- parts.append(description)
-
- return "\n".join(parts)
-
-
-def build_redirect_link(target_link: Optional[str], template: Optional[str]) -> Optional[str]:
- if not target_link or not template:
- return None
-
- normalized_target = str(target_link).strip()
- normalized_template = str(template).strip()
-
- if not normalized_target or not normalized_template:
- return None
-
- encoded_target = quote(normalized_target, safe="")
- result = normalized_template
- replaced = False
-
- replacements = [
- ("{subscription_link}", encoded_target),
- ("{link}", encoded_target),
- ("{subscription_link_raw}", normalized_target),
- ("{link_raw}", normalized_target),
- ]
-
- for placeholder, replacement in replacements:
- if placeholder in result:
- result = result.replace(placeholder, replacement)
- replaced = True
-
- if not replaced:
- result = f"{result}{encoded_target}"
-
- return result
-
-
-def get_apps_for_device(device_type: str, language: str = "ru") -> List[Dict[str, Any]]:
- config = load_app_config()
- platforms = config.get("platforms", {}) if isinstance(config, dict) else {}
-
- if not isinstance(platforms, dict):
- return []
-
- device_mapping = {
- 'ios': 'ios',
- 'android': 'android',
- 'windows': 'windows',
- 'mac': 'macos',
- 'tv': 'androidTV',
- 'appletv': 'appleTV',
- 'apple_tv': 'appleTV',
- }
-
- config_key = device_mapping.get(device_type, device_type)
- apps = platforms.get(config_key, [])
- return apps if isinstance(apps, list) else []
-
-
-def get_device_name(device_type: str, language: str = "ru") -> str:
- names = {
- 'ios': 'iPhone/iPad',
- 'android': 'Android',
- 'windows': 'Windows',
- 'mac': 'macOS',
- 'tv': 'Android TV',
- 'appletv': 'Apple TV',
- 'apple_tv': 'Apple TV',
- }
-
- return names.get(device_type, device_type)
-
-
-def create_deep_link(app: Dict[str, Any], subscription_url: str) -> Optional[str]:
- if not subscription_url:
- return None
-
- if not isinstance(app, dict):
- return subscription_url
-
- scheme = str(app.get("urlScheme", "")).strip()
- payload = subscription_url
-
- if app.get("isNeedBase64Encoding"):
- try:
- payload = base64.b64encode(subscription_url.encode("utf-8")).decode("utf-8")
- except Exception as exc:
- logger.warning(
- "Не удалось закодировать ссылку подписки в base64 для приложения %s: %s",
- app.get("id"),
- exc,
- )
- payload = subscription_url
-
- scheme_link = f"{scheme}{payload}" if scheme else None
-
- template = settings.get_happ_cryptolink_redirect_template()
- redirect_link = build_redirect_link(scheme_link, template) if scheme_link and template else None
-
- return redirect_link or scheme_link or subscription_url
-
-
-def get_reset_devices_confirm_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
- texts = get_texts(language)
- return InlineKeyboardMarkup(inline_keyboard=[
- [
- InlineKeyboardButton(
- text="✅ Да, сбросить все устройства",
- callback_data="confirm_reset_devices"
- )
- ],
- [
- InlineKeyboardButton(text="❌ Отмена", callback_data="menu_subscription")
- ]
- ])
-
-
-async def send_trial_notification(callback: types.CallbackQuery, db: AsyncSession, db_user: User,
- subscription: Subscription):
- try:
- notification_service = AdminNotificationService(callback.bot)
- await notification_service.send_trial_activation_notification(db, db_user, subscription)
- except Exception as e:
- logger.error(f"Ошибка отправки уведомления о триале: {e}")
-
-
-async def show_device_connection_help(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession
-):
- subscription = db_user.subscription
- subscription_link = get_display_subscription_link(subscription)
-
- if not subscription_link:
- await callback.answer("❌ Ссылка подписки недоступна", show_alert=True)
- return
-
- help_text = f"""
-📱 Как подключить устройство заново
-
-После сброса устройства вам нужно:
-
-1. Получить ссылку подписки:
-📋 Скопируйте ссылку ниже или найдите её в разделе "Моя подписка"
-
-2. Настроить VPN приложение:
-• Откройте ваше VPN приложение
-• Найдите функцию "Добавить подписку" или "Import"
-• Вставьте скопированную ссылку
-
-3. Подключиться:
-• Выберите сервер
-• Нажмите "Подключить"
-
-🔗 Ваша ссылка подписки:
-{subscription_link}
-
-💡 Совет: Сохраните эту ссылку - она понадобится для подключения новых устройств
-"""
-
- await callback.message.edit_text(
- help_text,
- reply_markup=get_device_management_help_keyboard(db_user.language),
- parse_mode="HTML"
- )
- await callback.answer()
-
-
-async def send_purchase_notification(
- callback: types.CallbackQuery,
- db: AsyncSession,
- db_user: User,
- subscription: Subscription,
- transaction_id: int,
- period_days: int,
- was_trial_conversion: bool = False
-):
- try:
- from app.database.crud.transaction import get_transaction_by_id
-
- transaction = await get_transaction_by_id(db, transaction_id)
- if transaction:
- notification_service = AdminNotificationService(callback.bot)
- await notification_service.send_subscription_purchase_notification(
- db, db_user, subscription, transaction, period_days, was_trial_conversion
- )
- except Exception as e:
- logger.error(f"Ошибка отправки уведомления о покупке: {e}")
-
-
-async def send_extension_notification(
- callback: types.CallbackQuery,
- db: AsyncSession,
- db_user: User,
- subscription: Subscription,
- transaction_id: int,
- extended_days: int,
- old_end_date: datetime
-):
- try:
- from app.database.crud.transaction import get_transaction_by_id
-
- transaction = await get_transaction_by_id(db, transaction_id)
- if transaction:
- notification_service = AdminNotificationService(callback.bot)
- await notification_service.send_subscription_extension_notification(
- db, db_user, subscription, transaction, extended_days, old_end_date
- )
- except Exception as e:
- logger.error(f"Ошибка отправки уведомления о продлении: {e}")
-
-
-async def handle_switch_traffic(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession
-):
- from app.config import settings
-
- if settings.is_traffic_fixed():
- await callback.answer("⚠️ В текущем режиме трафик фиксированный", show_alert=True)
- return
-
- texts = get_texts(db_user.language)
- subscription = db_user.subscription
-
- if not subscription or subscription.is_trial:
- await callback.answer("⚠️ Эта функция доступна только для платных подписок", show_alert=True)
- return
-
- current_traffic = subscription.traffic_limit_gb
- period_hint_days = _get_period_hint_from_subscription(subscription)
- traffic_discount_percent = _get_addon_discount_percent_for_user(
- db_user,
- "traffic",
- period_hint_days,
- )
-
- await callback.message.edit_text(
- f"🔄 Переключение лимита трафика\n\n"
- f"Текущий лимит: {texts.format_traffic(current_traffic)}\n"
- f"Выберите новый лимит трафика:\n\n"
- f"💡 Важно:\n"
- f"• При увеличении - доплата за разницу\n"
- f"• При уменьшении - возврат средств не производится",
- reply_markup=get_traffic_switch_keyboard(
- current_traffic,
- db_user.language,
- subscription.end_date,
- traffic_discount_percent,
- ),
- parse_mode="HTML"
- )
-
- await callback.answer()
-
-
-async def confirm_switch_traffic(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession
-):
- new_traffic_gb = int(callback.data.split('_')[2])
- texts = get_texts(db_user.language)
- subscription = db_user.subscription
-
- current_traffic = subscription.traffic_limit_gb
-
- if new_traffic_gb == current_traffic:
- await callback.answer("ℹ️ Лимит трафика не изменился", show_alert=True)
- return
-
- old_price_per_month = settings.get_traffic_price(current_traffic)
- new_price_per_month = settings.get_traffic_price(new_traffic_gb)
-
- months_remaining = get_remaining_months(subscription.end_date)
- period_hint_days = months_remaining * 30 if months_remaining > 0 else None
- traffic_discount_percent = _get_addon_discount_percent_for_user(
- db_user,
- "traffic",
- period_hint_days,
- )
-
- discounted_old_per_month, _ = apply_percentage_discount(
- old_price_per_month,
- traffic_discount_percent,
- )
- discounted_new_per_month, _ = apply_percentage_discount(
- new_price_per_month,
- traffic_discount_percent,
- )
- price_difference_per_month = discounted_new_per_month - discounted_old_per_month
- discount_savings_per_month = (
- (new_price_per_month - old_price_per_month) - price_difference_per_month
- )
-
- if price_difference_per_month > 0:
- total_price_difference = price_difference_per_month * months_remaining
-
- if db_user.balance_kopeks < total_price_difference:
- missing_kopeks = total_price_difference - db_user.balance_kopeks
- message_text = texts.t(
- "ADDON_INSUFFICIENT_FUNDS_MESSAGE",
- (
- "⚠️ Недостаточно средств\n\n"
- "Стоимость услуги: {required}\n"
- "На балансе: {balance}\n"
- "Не хватает: {missing}\n\n"
- "Выберите способ пополнения. Сумма подставится автоматически."
- ),
- ).format(
- required=f"{texts.format_price(total_price_difference)} (за {months_remaining} мес)",
- 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
-
- action_text = f"увеличить до {texts.format_traffic(new_traffic_gb)}"
- cost_text = f"Доплата: {texts.format_price(total_price_difference)} (за {months_remaining} мес)"
- if discount_savings_per_month > 0:
- total_discount_savings = discount_savings_per_month * months_remaining
- cost_text += (
- f" (скидка {traffic_discount_percent}%:"
- f" -{texts.format_price(total_discount_savings)})"
- )
- else:
- total_price_difference = 0
- action_text = f"уменьшить до {texts.format_traffic(new_traffic_gb)}"
- cost_text = "Возврат средств не производится"
-
- confirm_text = f"🔄 Подтверждение переключения трафика\n\n"
- confirm_text += f"Текущий лимит: {texts.format_traffic(current_traffic)}\n"
- confirm_text += f"Новый лимит: {texts.format_traffic(new_traffic_gb)}\n\n"
- confirm_text += f"Действие: {action_text}\n"
- confirm_text += f"💰 {cost_text}\n\n"
- confirm_text += "Подтвердить переключение?"
-
- await callback.message.edit_text(
- confirm_text,
- reply_markup=get_confirm_switch_traffic_keyboard(new_traffic_gb, total_price_difference, db_user.language),
- parse_mode="HTML"
- )
-
- await callback.answer()
-
-
-async def clear_saved_cart(
- callback: types.CallbackQuery,
- state: FSMContext,
- db_user: User,
- db: AsyncSession
-):
- await state.clear()
-
- from app.handlers.menu import show_main_menu
- await show_main_menu(callback, db_user, db)
-
- await callback.answer("🗑️ Корзина очищена")
-
-
-async def execute_switch_traffic(
- callback: types.CallbackQuery,
- db_user: User,
- db: AsyncSession
-):
- callback_parts = callback.data.split('_')
- new_traffic_gb = int(callback_parts[3])
- price_difference = int(callback_parts[4])
-
- texts = get_texts(db_user.language)
- subscription = db_user.subscription
- current_traffic = subscription.traffic_limit_gb
-
- try:
- if price_difference > 0:
- success = await subtract_user_balance(
- db, db_user, price_difference,
- f"Переключение трафика с {current_traffic}GB на {new_traffic_gb}GB"
- )
-
- if not success:
- await callback.answer("⚠️ Ошибка списания средств", show_alert=True)
- return
-
- months_remaining = get_remaining_months(subscription.end_date)
- await create_transaction(
- db=db,
- user_id=db_user.id,
- type=TransactionType.SUBSCRIPTION_PAYMENT,
- amount_kopeks=price_difference,
- description=f"Переключение трафика с {current_traffic}GB на {new_traffic_gb}GB на {months_remaining} мес"
- )
-
- subscription.traffic_limit_gb = new_traffic_gb
- subscription.updated_at = datetime.utcnow()
-
- await db.commit()
-
- subscription_service = SubscriptionService()
- await subscription_service.update_remnawave_user(db, subscription)
-
- await db.refresh(db_user)
- await db.refresh(subscription)
-
- 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, "traffic", current_traffic, new_traffic_gb, price_difference
- )
- except Exception as e:
- logger.error(f"Ошибка отправки уведомления об изменении трафика: {e}")
-
- if new_traffic_gb > current_traffic:
- success_text = f"✅ Лимит трафика увеличен!\n\n"
- success_text += f"📊 Было: {texts.format_traffic(current_traffic)} → "
- success_text += f"Стало: {texts.format_traffic(new_traffic_gb)}\n"
- if price_difference > 0:
- success_text += f"💰 Списано: {texts.format_price(price_difference)}"
- elif new_traffic_gb < current_traffic:
- success_text = f"✅ Лимит трафика уменьшен!\n\n"
- success_text += f"📊 Было: {texts.format_traffic(current_traffic)} → "
- success_text += f"Стало: {texts.format_traffic(new_traffic_gb)}\n"
- success_text += f"ℹ️ Возврат средств не производится"
-
- await callback.message.edit_text(
- success_text,
- reply_markup=get_back_keyboard(db_user.language)
- )
-
- logger.info(
- f"✅ Пользователь {db_user.telegram_id} переключил трафик с {current_traffic}GB на {new_traffic_gb}GB, доплата: {price_difference / 100}₽")
-
- except Exception as e:
- logger.error(f"Ошибка переключения трафика: {e}")
- await callback.message.edit_text(
- texts.ERROR,
- reply_markup=get_back_keyboard(db_user.language)
- )
-
- await callback.answer()
-
-
-def get_traffic_switch_keyboard(
- current_traffic_gb: int,
- language: str = "ru",
- subscription_end_date: datetime = None,
- discount_percent: int = 0,
-) -> InlineKeyboardMarkup:
- from app.config import settings
-
- months_multiplier = 1
- period_text = ""
- if subscription_end_date:
- months_multiplier = get_remaining_months(subscription_end_date)
- if months_multiplier > 1:
- period_text = f" (за {months_multiplier} мес)"
-
- packages = settings.get_traffic_packages()
- enabled_packages = [pkg for pkg in packages if pkg['enabled']]
-
- current_price_per_month = settings.get_traffic_price(current_traffic_gb)
- discounted_current_per_month, _ = apply_percentage_discount(
- current_price_per_month,
- discount_percent,
- )
-
- buttons = []
-
- for package in enabled_packages:
- gb = package['gb']
- price_per_month = package['price']
- discounted_price_per_month, _ = apply_percentage_discount(
- price_per_month,
- discount_percent,
- )
-
- price_diff_per_month = discounted_price_per_month - discounted_current_per_month
- total_price_diff = price_diff_per_month * months_multiplier
-
- if gb == current_traffic_gb:
- emoji = "✅"
- action_text = " (текущий)"
- price_text = ""
- elif total_price_diff > 0:
- emoji = "⬆️"
- action_text = ""
- price_text = f" (+{total_price_diff // 100}₽{period_text})"
- if discount_percent > 0:
- discount_total = (
- (price_per_month - current_price_per_month) * months_multiplier
- - total_price_diff
- )
- if discount_total > 0:
- price_text += f" (скидка {discount_percent}%: -{discount_total // 100}₽)"
- elif total_price_diff < 0:
- emoji = "⬇️"
- action_text = ""
- price_text = " (без возврата)"
- else:
- emoji = "🔄"
- action_text = ""
- price_text = " (бесплатно)"
-
- if gb == 0:
- traffic_text = "Безлимит"
- else:
- traffic_text = f"{gb} ГБ"
-
- button_text = f"{emoji} {traffic_text}{action_text}{price_text}"
-
- buttons.append([
- InlineKeyboardButton(text=button_text, callback_data=f"switch_traffic_{gb}")
- ])
-
- buttons.append([
- InlineKeyboardButton(
- text="⬅️ Назад" if language == "ru" else "⬅️ Back",
- callback_data="subscription_settings"
- )
- ])
-
- return InlineKeyboardMarkup(inline_keyboard=buttons)
-
-
-def get_confirm_switch_traffic_keyboard(
- new_traffic_gb: int,
- price_difference: int,
- language: str = "ru"
-) -> InlineKeyboardMarkup:
- return InlineKeyboardMarkup(inline_keyboard=[
- [
- InlineKeyboardButton(
- text="✅ Подтвердить переключение",
- callback_data=f"confirm_switch_traffic_{new_traffic_gb}_{price_difference}"
- )
- ],
- [
- InlineKeyboardButton(
- text="❌ Отмена",
- callback_data="subscription_settings"
- )
- ]
- ])
-
-
-def register_handlers(dp: Dispatcher):
- update_traffic_prices()
-
- dp.callback_query.register(
- show_subscription_info,
- F.data == "menu_subscription"
- )
-
- dp.callback_query.register(
- show_trial_offer,
- F.data == "menu_trial"
- )
-
- dp.callback_query.register(
- activate_trial,
- F.data == "trial_activate"
- )
-
- dp.callback_query.register(
- start_subscription_purchase,
- F.data.in_(["menu_buy", "subscription_upgrade"])
- )
-
- dp.callback_query.register(
- handle_add_countries,
- F.data == "subscription_add_countries"
- )
-
- dp.callback_query.register(
- handle_switch_traffic,
- F.data == "subscription_switch_traffic"
- )
-
- dp.callback_query.register(
- confirm_switch_traffic,
- F.data.startswith("switch_traffic_")
- )
-
- dp.callback_query.register(
- execute_switch_traffic,
- F.data.startswith("confirm_switch_traffic_")
- )
-
- dp.callback_query.register(
- handle_change_devices,
- F.data == "subscription_change_devices"
- )
-
- dp.callback_query.register(
- confirm_change_devices,
- F.data.startswith("change_devices_")
- )
-
- dp.callback_query.register(
- execute_change_devices,
- F.data.startswith("confirm_change_devices_")
- )
-
- dp.callback_query.register(
- handle_extend_subscription,
- F.data == "subscription_extend"
- )
-
- dp.callback_query.register(
- handle_reset_traffic,
- F.data == "subscription_reset_traffic"
- )
-
- dp.callback_query.register(
- confirm_add_devices,
- F.data.startswith("add_devices_")
- )
-
- dp.callback_query.register(
- confirm_extend_subscription,
- F.data.startswith("extend_period_")
- )
-
- dp.callback_query.register(
- confirm_reset_traffic,
- F.data == "confirm_reset_traffic"
- )
-
- dp.callback_query.register(
- handle_reset_devices,
- F.data == "subscription_reset_devices"
- )
-
- dp.callback_query.register(
- confirm_reset_devices,
- F.data == "confirm_reset_devices"
- )
-
- dp.callback_query.register(
- select_period,
- F.data.startswith("period_"),
- SubscriptionStates.selecting_period
- )
-
- dp.callback_query.register(
- select_traffic,
- F.data.startswith("traffic_"),
- SubscriptionStates.selecting_traffic
- )
-
- dp.callback_query.register(
- select_devices,
- F.data.startswith("devices_") & ~F.data.in_(["devices_continue"]),
- SubscriptionStates.selecting_devices
- )
-
- dp.callback_query.register(
- devices_continue,
- F.data == "devices_continue",
- SubscriptionStates.selecting_devices
- )
-
- dp.callback_query.register(
- confirm_purchase,
- F.data == "subscription_confirm",
- SubscriptionStates.confirming_purchase
- )
-
- dp.callback_query.register(
- resume_subscription_checkout,
- F.data == "subscription_resume_checkout",
- )
-
- dp.callback_query.register(
- return_to_saved_cart,
- F.data == "return_to_saved_cart",
- )
-
- dp.callback_query.register(
- clear_saved_cart,
- F.data == "clear_saved_cart",
- )
-
- dp.callback_query.register(
- handle_autopay_menu,
- F.data == "subscription_autopay"
- )
-
- dp.callback_query.register(
- toggle_autopay,
- F.data.in_(["autopay_enable", "autopay_disable"])
- )
-
- dp.callback_query.register(
- show_autopay_days,
- F.data == "autopay_set_days"
- )
-
- dp.callback_query.register(
- handle_subscription_config_back,
- F.data == "subscription_config_back"
- )
-
- dp.callback_query.register(
- handle_subscription_cancel,
- F.data == "subscription_cancel"
- )
-
- dp.callback_query.register(
- set_autopay_days,
- F.data.startswith("autopay_days_")
- )
-
- dp.callback_query.register(
- select_country,
- F.data.startswith("country_"),
- SubscriptionStates.selecting_countries
- )
-
- dp.callback_query.register(
- countries_continue,
- F.data == "countries_continue",
- SubscriptionStates.selecting_countries
- )
-
- dp.callback_query.register(
- handle_manage_country,
- F.data.startswith("country_manage_")
- )
-
- dp.callback_query.register(
- apply_countries_changes,
- F.data == "countries_apply"
- )
-
- dp.callback_query.register(
- claim_discount_offer,
- F.data.startswith("claim_discount_")
- )
-
- dp.callback_query.register(
- handle_promo_offer_close,
- F.data == "promo_offer_close",
- )
-
- dp.callback_query.register(
- handle_happ_download_request,
- F.data == "subscription_happ_download"
- )
-
- dp.callback_query.register(
- handle_happ_download_platform_choice,
- F.data.in_([
- "happ_download_ios",
- "happ_download_android",
- "happ_download_pc",
- "happ_download_macos",
- "happ_download_windows",
- ])
- )
-
- dp.callback_query.register(
- handle_happ_download_close,
- F.data == "happ_download_close"
- )
-
- dp.callback_query.register(
- handle_happ_download_back,
- F.data == "happ_download_back"
- )
-
- dp.callback_query.register(
- handle_connect_subscription,
- F.data == "subscription_connect"
- )
-
- dp.callback_query.register(
- handle_device_guide,
- F.data.startswith("device_guide_")
- )
-
- dp.callback_query.register(
- handle_app_selection,
- F.data.startswith("app_list_")
- )
-
- dp.callback_query.register(
- handle_specific_app_guide,
- F.data.startswith("app_")
- )
-
- dp.callback_query.register(
- handle_open_subscription_link,
- F.data == "open_subscription_link"
- )
-
- dp.callback_query.register(
- handle_subscription_settings,
- F.data == "subscription_settings"
- )
-
- dp.callback_query.register(
- handle_no_traffic_packages,
- F.data == "no_traffic_packages"
- )
-
- dp.callback_query.register(
- handle_device_management,
- F.data == "subscription_manage_devices"
- )
-
- dp.callback_query.register(
- handle_devices_page,
- F.data.startswith("devices_page_")
- )
-
- dp.callback_query.register(
- handle_single_device_reset,
- F.data.regexp(r"^reset_device_\d+_\d+$")
- )
-
- dp.callback_query.register(
- handle_all_devices_reset_from_management,
- F.data == "reset_all_devices"
- )
-
- dp.callback_query.register(
- show_device_connection_help,
- F.data == "device_connection_help"
- )
\ No newline at end of file
diff --git a/app/handlers/subscription/__init__.py b/app/handlers/subscription/__init__.py
new file mode 100644
index 00000000..6e373666
--- /dev/null
+++ b/app/handlers/subscription/__init__.py
@@ -0,0 +1,211 @@
+# Automatically generated module exports
+
+from .autopay import (
+ handle_autopay_menu,
+ handle_subscription_cancel,
+ handle_subscription_config_back,
+ set_autopay_days,
+ show_autopay_days,
+ toggle_autopay,
+)
+
+from .common import (
+ build_redirect_link,
+ create_deep_link,
+ format_additional_section,
+ format_traffic_display,
+ get_apps_for_device,
+ get_confirm_switch_traffic_keyboard,
+ get_device_name,
+ get_localized_value,
+ get_reset_devices_confirm_keyboard,
+ get_step_description,
+ get_traffic_switch_keyboard,
+ load_app_config,
+ update_traffic_prices,
+ validate_traffic_price,
+)
+
+from .countries import (
+ apply_countries_changes,
+ confirm_add_countries_to_subscription,
+ countries_continue,
+ get_countries_price_by_uuids_fallback,
+ handle_add_countries,
+ handle_add_country_to_subscription,
+ handle_manage_country,
+ select_country,
+)
+
+from .devices import (
+ confirm_add_devices,
+ confirm_change_devices,
+ confirm_reset_devices,
+ execute_change_devices,
+ get_current_devices_count,
+ get_current_devices_detailed,
+ get_servers_display_names,
+ handle_all_devices_reset_from_management,
+ handle_app_selection,
+ handle_change_devices,
+ handle_device_guide,
+ handle_device_management,
+ handle_devices_page,
+ handle_reset_devices,
+ handle_single_device_reset,
+ handle_specific_app_guide,
+ show_device_connection_help,
+ show_devices_page,
+)
+
+from .happ import (
+ handle_happ_download_back,
+ handle_happ_download_close,
+ handle_happ_download_platform_choice,
+ handle_happ_download_request,
+)
+
+from .links import (
+ handle_connect_subscription,
+ handle_open_subscription_link,
+)
+
+from .notifications import (
+ send_extension_notification,
+ send_purchase_notification,
+ send_trial_notification,
+)
+
+from .pricing import (
+ get_subscription_cost,
+ get_subscription_info_text,
+)
+
+from .promo import (
+ claim_discount_offer,
+ handle_promo_offer_close,
+)
+
+from .purchase import (
+ activate_trial,
+ clear_saved_cart,
+ confirm_extend_subscription,
+ confirm_purchase,
+ create_paid_subscription_with_traffic_mode,
+ devices_continue,
+ handle_extend_subscription,
+ handle_subscription_settings,
+ register_handlers,
+ resume_subscription_checkout,
+ return_to_saved_cart,
+ save_cart_and_redirect_to_topup,
+ select_devices,
+ select_period,
+ show_subscription_info,
+ show_trial_offer,
+ start_subscription_purchase,
+)
+
+from .traffic import (
+ add_traffic,
+ confirm_reset_traffic,
+ confirm_switch_traffic,
+ execute_switch_traffic,
+ get_traffic_packages_info,
+ handle_add_traffic,
+ handle_no_traffic_packages,
+ handle_reset_traffic,
+ handle_switch_traffic,
+ refresh_traffic_config,
+ select_traffic,
+)
+
+__all__ = [
+ 'activate_trial',
+ 'add_traffic',
+ 'apply_countries_changes',
+ 'build_redirect_link',
+ 'claim_discount_offer',
+ 'clear_saved_cart',
+ 'confirm_add_countries_to_subscription',
+ 'confirm_add_devices',
+ 'confirm_change_devices',
+ 'confirm_extend_subscription',
+ 'confirm_purchase',
+ 'confirm_reset_devices',
+ 'confirm_reset_traffic',
+ 'confirm_switch_traffic',
+ 'countries_continue',
+ 'create_deep_link',
+ 'create_paid_subscription_with_traffic_mode',
+ 'devices_continue',
+ 'execute_change_devices',
+ 'execute_switch_traffic',
+ 'format_additional_section',
+ 'format_traffic_display',
+ 'get_apps_for_device',
+ 'get_confirm_switch_traffic_keyboard',
+ 'get_countries_price_by_uuids_fallback',
+ 'get_current_devices_count',
+ 'get_current_devices_detailed',
+ 'get_device_name',
+ 'get_localized_value',
+ 'get_reset_devices_confirm_keyboard',
+ 'get_servers_display_names',
+ 'get_step_description',
+ 'get_subscription_cost',
+ 'get_subscription_info_text',
+ 'get_traffic_packages_info',
+ 'get_traffic_switch_keyboard',
+ 'handle_add_countries',
+ 'handle_add_country_to_subscription',
+ 'handle_add_traffic',
+ 'handle_all_devices_reset_from_management',
+ 'handle_app_selection',
+ 'handle_autopay_menu',
+ 'handle_change_devices',
+ 'handle_connect_subscription',
+ 'handle_device_guide',
+ 'handle_device_management',
+ 'handle_devices_page',
+ 'handle_extend_subscription',
+ 'handle_happ_download_back',
+ 'handle_happ_download_close',
+ 'handle_happ_download_platform_choice',
+ 'handle_happ_download_request',
+ 'handle_manage_country',
+ 'handle_no_traffic_packages',
+ 'handle_open_subscription_link',
+ 'handle_promo_offer_close',
+ 'handle_reset_devices',
+ 'handle_reset_traffic',
+ 'handle_single_device_reset',
+ 'handle_specific_app_guide',
+ 'handle_subscription_cancel',
+ 'handle_subscription_config_back',
+ 'handle_subscription_settings',
+ 'handle_switch_traffic',
+ 'load_app_config',
+ 'refresh_traffic_config',
+ 'register_handlers',
+ 'resume_subscription_checkout',
+ 'return_to_saved_cart',
+ 'save_cart_and_redirect_to_topup',
+ 'select_country',
+ 'select_devices',
+ 'select_period',
+ 'select_traffic',
+ 'send_extension_notification',
+ 'send_purchase_notification',
+ 'send_trial_notification',
+ 'set_autopay_days',
+ 'show_autopay_days',
+ 'show_device_connection_help',
+ 'show_devices_page',
+ 'show_subscription_info',
+ 'show_trial_offer',
+ 'start_subscription_purchase',
+ 'toggle_autopay',
+ 'update_traffic_prices',
+ 'validate_traffic_price',
+]
diff --git a/app/handlers/subscription/autopay.py b/app/handlers/subscription/autopay.py
new file mode 100644
index 00000000..b7319740
--- /dev/null
+++ b/app/handlers/subscription/autopay.py
@@ -0,0 +1,255 @@
+import base64
+import json
+import logging
+from datetime import datetime, timedelta
+from typing import Dict, List, Any, Tuple, Optional
+from urllib.parse import quote
+from aiogram import Dispatcher, types, F
+from aiogram.fsm.context import FSMContext
+from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
+from sqlalchemy.ext.asyncio import AsyncSession
+from app.config import settings, PERIOD_PRICES, get_traffic_prices
+from app.database.crud.discount_offer import (
+ get_offer_by_id,
+ mark_offer_claimed,
+)
+from app.database.crud.promo_offer_template import get_promo_offer_template_by_id
+from app.database.crud.subscription import (
+ create_trial_subscription,
+ create_paid_subscription, add_subscription_traffic, add_subscription_devices,
+ update_subscription_autopay
+)
+from app.database.crud.transaction import create_transaction
+from app.database.crud.user import subtract_user_balance
+from app.database.models import (
+ User, TransactionType, SubscriptionStatus,
+ Subscription
+)
+from app.keyboards.inline import (
+ get_subscription_keyboard, get_trial_keyboard,
+ get_subscription_period_keyboard, get_traffic_packages_keyboard,
+ get_countries_keyboard, get_devices_keyboard,
+ get_subscription_confirm_keyboard, get_autopay_keyboard,
+ get_autopay_days_keyboard, get_back_keyboard,
+ get_add_traffic_keyboard,
+ get_change_devices_keyboard, get_reset_traffic_confirm_keyboard,
+ get_manage_countries_keyboard,
+ get_device_selection_keyboard, get_connection_guide_keyboard,
+ get_app_selection_keyboard, get_specific_app_keyboard,
+ get_updated_subscription_settings_keyboard, get_insufficient_balance_keyboard,
+ get_extend_subscription_keyboard_with_prices, get_confirm_change_devices_keyboard,
+ get_devices_management_keyboard, get_device_management_help_keyboard,
+ get_happ_cryptolink_keyboard,
+ get_happ_download_platform_keyboard, get_happ_download_link_keyboard,
+ get_happ_download_button_row,
+ get_payment_methods_keyboard_with_cart,
+ get_subscription_confirm_keyboard_with_cart,
+ get_insufficient_balance_keyboard_with_cart
+)
+from app.localization.texts import get_texts
+from app.services.admin_notification_service import AdminNotificationService
+from app.services.remnawave_service import RemnaWaveService
+from app.services.subscription_checkout_service import (
+ clear_subscription_checkout_draft,
+ get_subscription_checkout_draft,
+ save_subscription_checkout_draft,
+ should_offer_checkout_resume,
+)
+from app.services.subscription_service import SubscriptionService
+from app.utils.miniapp_buttons import build_miniapp_or_callback_button
+from app.services.promo_offer_service import promo_offer_service
+from app.states import SubscriptionStates
+from app.utils.pagination import paginate_list
+from app.utils.pricing_utils import (
+ calculate_months_from_days,
+ get_remaining_months,
+ calculate_prorated_price,
+ validate_pricing_calculation,
+ format_period_description,
+ apply_percentage_discount,
+)
+from app.utils.subscription_utils import (
+ get_display_subscription_link,
+ get_happ_cryptolink_redirect_link,
+ convert_subscription_link_to_happ_scheme,
+)
+from app.utils.promo_offer import (
+ build_promo_offer_hint,
+ get_user_active_promo_discount_percent,
+)
+
+from .countries import _get_available_countries, _should_show_countries_management
+from .pricing import _build_subscription_period_prompt
+
+async def handle_autopay_menu(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
+):
+ texts = get_texts(db_user.language)
+ subscription = db_user.subscription
+ if not subscription:
+ await callback.answer(
+ texts.t("SUBSCRIPTION_ACTIVE_REQUIRED", "⚠️ У вас нет активной подписки!"),
+ show_alert=True,
+ )
+ return
+
+ status = (
+ texts.t("AUTOPAY_STATUS_ENABLED", "включен")
+ if subscription.autopay_enabled
+ else texts.t("AUTOPAY_STATUS_DISABLED", "выключен")
+ )
+ days = subscription.autopay_days_before
+
+ text = texts.t(
+ "AUTOPAY_MENU_TEXT",
+ (
+ "💳 Автоплатеж\n\n"
+ "📊 Статус: {status}\n"
+ "⏰ Списание за: {days} дн. до окончания\n\n"
+ "Выберите действие:"
+ ),
+ ).format(status=status, days=days)
+
+ await callback.message.edit_text(
+ text,
+ reply_markup=get_autopay_keyboard(db_user.language),
+ parse_mode="HTML",
+ )
+ await callback.answer()
+
+async def toggle_autopay(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
+):
+ subscription = db_user.subscription
+ enable = callback.data == "autopay_enable"
+
+ await update_subscription_autopay(db, subscription, enable)
+
+ texts = get_texts(db_user.language)
+ status = (
+ texts.t("AUTOPAY_STATUS_ENABLED", "включен")
+ if enable
+ else texts.t("AUTOPAY_STATUS_DISABLED", "выключен")
+ )
+ await callback.answer(
+ texts.t("AUTOPAY_TOGGLE_SUCCESS", "✅ Автоплатеж {status}!").format(status=status)
+ )
+
+ await handle_autopay_menu(callback, db_user, db)
+
+async def show_autopay_days(
+ callback: types.CallbackQuery,
+ db_user: User
+):
+ texts = get_texts(db_user.language)
+ await callback.message.edit_text(
+ texts.t(
+ "AUTOPAY_SELECT_DAYS_PROMPT",
+ "⏰ Выберите за сколько дней до окончания списывать средства:",
+ ),
+ reply_markup=get_autopay_days_keyboard(db_user.language)
+ )
+ await callback.answer()
+
+async def set_autopay_days(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
+):
+ days = int(callback.data.split('_')[2])
+ subscription = db_user.subscription
+
+ await update_subscription_autopay(
+ db, subscription, subscription.autopay_enabled, days
+ )
+
+ texts = get_texts(db_user.language)
+ await callback.answer(
+ texts.t("AUTOPAY_DAYS_SET", "✅ Установлено {days} дней!").format(days=days)
+ )
+
+ await handle_autopay_menu(callback, db_user, db)
+
+async def handle_subscription_config_back(
+ callback: types.CallbackQuery,
+ state: FSMContext,
+ db_user: User,
+ db: AsyncSession
+):
+ current_state = await state.get_state()
+ texts = get_texts(db_user.language)
+
+ if current_state == SubscriptionStates.selecting_traffic.state:
+ 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)
+
+ elif current_state == SubscriptionStates.selecting_countries.state:
+ 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)
+ 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)
+
+ 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)
+
+ else:
+ from app.handlers.menu import show_main_menu
+ await show_main_menu(callback, db_user, db)
+ await state.clear()
+
+ await callback.answer()
+
+async def handle_subscription_cancel(
+ callback: types.CallbackQuery,
+ state: FSMContext,
+ db_user: User,
+ db: AsyncSession
+):
+ texts = get_texts(db_user.language)
+
+ await state.clear()
+ await clear_subscription_checkout_draft(db_user.id)
+
+ from app.handlers.menu import show_main_menu
+ await show_main_menu(callback, db_user, db)
+
+ await callback.answer("❌ Покупка отменена")
diff --git a/app/handlers/subscription/common.py b/app/handlers/subscription/common.py
new file mode 100644
index 00000000..c619913e
--- /dev/null
+++ b/app/handlers/subscription/common.py
@@ -0,0 +1,487 @@
+import base64
+import json
+import logging
+from datetime import datetime, timedelta
+from typing import Dict, List, Any, Tuple, Optional
+from urllib.parse import quote
+from aiogram import Dispatcher, types, F
+from aiogram.fsm.context import FSMContext
+from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
+from sqlalchemy.ext.asyncio import AsyncSession
+from app.config import settings, PERIOD_PRICES, get_traffic_prices
+from app.database.crud.discount_offer import (
+ get_offer_by_id,
+ mark_offer_claimed,
+)
+from app.database.crud.promo_offer_template import get_promo_offer_template_by_id
+from app.database.crud.subscription import (
+ create_trial_subscription,
+ create_paid_subscription, add_subscription_traffic, add_subscription_devices,
+ update_subscription_autopay
+)
+from app.database.crud.transaction import create_transaction
+from app.database.crud.user import subtract_user_balance
+from app.database.models import (
+ User, TransactionType, SubscriptionStatus,
+ Subscription
+)
+from app.keyboards.inline import (
+ get_subscription_keyboard, get_trial_keyboard,
+ get_subscription_period_keyboard, get_traffic_packages_keyboard,
+ get_countries_keyboard, get_devices_keyboard,
+ get_subscription_confirm_keyboard, get_autopay_keyboard,
+ get_autopay_days_keyboard, get_back_keyboard,
+ get_add_traffic_keyboard,
+ get_change_devices_keyboard, get_reset_traffic_confirm_keyboard,
+ get_manage_countries_keyboard,
+ get_device_selection_keyboard, get_connection_guide_keyboard,
+ get_app_selection_keyboard, get_specific_app_keyboard,
+ get_updated_subscription_settings_keyboard, get_insufficient_balance_keyboard,
+ get_extend_subscription_keyboard_with_prices, get_confirm_change_devices_keyboard,
+ get_devices_management_keyboard, get_device_management_help_keyboard,
+ get_happ_cryptolink_keyboard,
+ get_happ_download_platform_keyboard, get_happ_download_link_keyboard,
+ get_happ_download_button_row,
+ get_payment_methods_keyboard_with_cart,
+ get_subscription_confirm_keyboard_with_cart,
+ get_insufficient_balance_keyboard_with_cart
+)
+from app.localization.texts import get_texts
+from app.services.admin_notification_service import AdminNotificationService
+from app.services.remnawave_service import RemnaWaveService
+from app.services.subscription_checkout_service import (
+ clear_subscription_checkout_draft,
+ get_subscription_checkout_draft,
+ save_subscription_checkout_draft,
+ should_offer_checkout_resume,
+)
+from app.services.subscription_service import SubscriptionService
+from app.utils.miniapp_buttons import build_miniapp_or_callback_button
+from app.services.promo_offer_service import promo_offer_service
+from app.states import SubscriptionStates
+from app.utils.pagination import paginate_list
+from app.utils.pricing_utils import (
+ calculate_months_from_days,
+ get_remaining_months,
+ calculate_prorated_price,
+ validate_pricing_calculation,
+ format_period_description,
+ apply_percentage_discount,
+)
+from app.utils.subscription_utils import (
+ get_display_subscription_link,
+ get_happ_cryptolink_redirect_link,
+ convert_subscription_link_to_happ_scheme,
+)
+from app.utils.promo_offer import (
+ build_promo_offer_hint,
+ get_user_active_promo_discount_percent,
+)
+
+logger = logging.getLogger(__name__)
+
+TRAFFIC_PRICES = get_traffic_prices()
+
+class _SafeFormatDict(dict):
+ def __missing__(self, key: str) -> str: # pragma: no cover - defensive fallback
+ return "{" + key + "}"
+
+def _format_text_with_placeholders(template: str, values: Dict[str, Any]) -> str:
+ if not isinstance(template, str):
+ return template
+
+ safe_values = _SafeFormatDict()
+ safe_values.update(values)
+
+ try:
+ return template.format_map(safe_values)
+ except Exception: # pragma: no cover - defensive logging
+ logger.warning("Failed to format template '%s' with values %s", template, values)
+ return template
+
+def _get_addon_discount_percent_for_user(
+ user: Optional[User],
+ category: str,
+ period_days_hint: Optional[int] = None,
+) -> int:
+ if user is None:
+ return 0
+
+ promo_group = getattr(user, "promo_group", None)
+ if promo_group is None:
+ return 0
+
+ if not getattr(promo_group, "apply_discounts_to_addons", True):
+ return 0
+
+ try:
+ return user.get_promo_discount(category, period_days_hint)
+ except AttributeError:
+ return 0
+
+def _apply_addon_discount(
+ user: Optional[User],
+ category: str,
+ amount: int,
+ period_days_hint: Optional[int] = None,
+) -> Dict[str, int]:
+ percent = _get_addon_discount_percent_for_user(user, category, period_days_hint)
+ discounted_amount, discount_value = apply_percentage_discount(amount, percent)
+
+ return {
+ "discounted": discounted_amount,
+ "discount": discount_value,
+ "percent": percent,
+ }
+
+def _get_promo_offer_discount_percent(user: Optional[User]) -> int:
+ return get_user_active_promo_discount_percent(user)
+
+def _apply_promo_offer_discount(user: Optional[User], amount: int) -> Dict[str, int]:
+ percent = _get_promo_offer_discount_percent(user)
+
+ if amount <= 0 or percent <= 0:
+ return {"discounted": amount, "discount": 0, "percent": 0}
+
+ discounted, discount_value = apply_percentage_discount(amount, percent)
+ return {"discounted": discounted, "discount": discount_value, "percent": percent}
+
+def _get_period_hint_from_subscription(subscription: Optional[Subscription]) -> Optional[int]:
+ if not subscription:
+ return None
+
+ months_remaining = get_remaining_months(subscription.end_date)
+ if months_remaining <= 0:
+ return None
+
+ return months_remaining * 30
+
+def _apply_discount_to_monthly_component(
+ amount_per_month: int,
+ percent: int,
+ months: int,
+) -> Dict[str, int]:
+ discounted_per_month, discount_per_month = apply_percentage_discount(amount_per_month, percent)
+
+ return {
+ "original_per_month": amount_per_month,
+ "discounted_per_month": discounted_per_month,
+ "discount_percent": max(0, min(100, percent)),
+ "discount_per_month": discount_per_month,
+ "total": discounted_per_month * months,
+ "discount_total": discount_per_month * months,
+ }
+
+def update_traffic_prices():
+ from app.config import refresh_traffic_prices
+ refresh_traffic_prices()
+ logger.info("🔄 TRAFFIC_PRICES обновлены из конфигурации")
+
+def format_traffic_display(traffic_gb: int, is_fixed_mode: bool = None) -> str:
+ if is_fixed_mode is None:
+ is_fixed_mode = settings.is_traffic_fixed()
+
+ if traffic_gb == 0:
+ if is_fixed_mode:
+ return "Безлимитный"
+ else:
+ return "Безлимитный"
+ else:
+ if is_fixed_mode:
+ return f"{traffic_gb} ГБ"
+ else:
+ return f"{traffic_gb} ГБ"
+
+def validate_traffic_price(gb: int) -> bool:
+ from app.config import settings
+
+ price = settings.get_traffic_price(gb)
+ if gb == 0:
+ return True
+
+ return price > 0
+
+def load_app_config() -> Dict[str, Any]:
+ try:
+ from app.config import settings
+ config_path = settings.get_app_config_path()
+
+ with open(config_path, 'r', encoding='utf-8') as f:
+ data = json.load(f)
+ if isinstance(data, dict):
+ return data
+ logger.error("Некорректный формат app-config.json: ожидается объект")
+ except Exception as e:
+ logger.error(f"Ошибка загрузки конфига приложений: {e}")
+
+ return {}
+
+def get_localized_value(values: Any, language: str, default_language: str = "en") -> str:
+ if not isinstance(values, dict):
+ return ""
+
+ candidates: List[str] = []
+ normalized_language = (language or "").strip().lower()
+
+ if normalized_language:
+ candidates.append(normalized_language)
+ if "-" in normalized_language:
+ candidates.append(normalized_language.split("-")[0])
+
+ default_language = (default_language or "").strip().lower()
+ if default_language and default_language not in candidates:
+ candidates.append(default_language)
+
+ for candidate in candidates:
+ if not candidate:
+ continue
+ value = values.get(candidate)
+ if isinstance(value, str) and value.strip():
+ return value
+
+ for value in values.values():
+ if isinstance(value, str) and value.strip():
+ return value
+
+ return ""
+
+def get_step_description(app: Dict[str, Any], step_key: str, language: str) -> str:
+ if not isinstance(app, dict):
+ return ""
+
+ step = app.get(step_key)
+ if not isinstance(step, dict):
+ return ""
+
+ description = step.get("description")
+ return get_localized_value(description, language)
+
+def format_additional_section(additional: Any, texts, language: str) -> str:
+ if not isinstance(additional, dict):
+ return ""
+
+ title = get_localized_value(additional.get("title"), language)
+ description = get_localized_value(additional.get("description"), language)
+
+ parts: List[str] = []
+
+ if title:
+ parts.append(
+ texts.t(
+ "SUBSCRIPTION_ADDITIONAL_STEP_TITLE",
+ "{title}:",
+ ).format(title=title)
+ )
+
+ if description:
+ parts.append(description)
+
+ return "\n".join(parts)
+
+def build_redirect_link(target_link: Optional[str], template: Optional[str]) -> Optional[str]:
+ if not target_link or not template:
+ return None
+
+ normalized_target = str(target_link).strip()
+ normalized_template = str(template).strip()
+
+ if not normalized_target or not normalized_template:
+ return None
+
+ encoded_target = quote(normalized_target, safe="")
+ result = normalized_template
+ replaced = False
+
+ replacements = [
+ ("{subscription_link}", encoded_target),
+ ("{link}", encoded_target),
+ ("{subscription_link_raw}", normalized_target),
+ ("{link_raw}", normalized_target),
+ ]
+
+ for placeholder, replacement in replacements:
+ if placeholder in result:
+ result = result.replace(placeholder, replacement)
+ replaced = True
+
+ if not replaced:
+ result = f"{result}{encoded_target}"
+
+ return result
+
+def get_apps_for_device(device_type: str, language: str = "ru") -> List[Dict[str, Any]]:
+ config = load_app_config()
+ platforms = config.get("platforms", {}) if isinstance(config, dict) else {}
+
+ if not isinstance(platforms, dict):
+ return []
+
+ device_mapping = {
+ 'ios': 'ios',
+ 'android': 'android',
+ 'windows': 'windows',
+ 'mac': 'macos',
+ 'tv': 'androidTV',
+ 'appletv': 'appleTV',
+ 'apple_tv': 'appleTV',
+ }
+
+ config_key = device_mapping.get(device_type, device_type)
+ apps = platforms.get(config_key, [])
+ return apps if isinstance(apps, list) else []
+
+def get_device_name(device_type: str, language: str = "ru") -> str:
+ names = {
+ 'ios': 'iPhone/iPad',
+ 'android': 'Android',
+ 'windows': 'Windows',
+ 'mac': 'macOS',
+ 'tv': 'Android TV',
+ 'appletv': 'Apple TV',
+ 'apple_tv': 'Apple TV',
+ }
+
+ return names.get(device_type, device_type)
+
+def create_deep_link(app: Dict[str, Any], subscription_url: str) -> Optional[str]:
+ if not subscription_url:
+ return None
+
+ if not isinstance(app, dict):
+ return subscription_url
+
+ scheme = str(app.get("urlScheme", "")).strip()
+ payload = subscription_url
+
+ if app.get("isNeedBase64Encoding"):
+ try:
+ payload = base64.b64encode(subscription_url.encode("utf-8")).decode("utf-8")
+ except Exception as exc:
+ logger.warning(
+ "Не удалось закодировать ссылку подписки в base64 для приложения %s: %s",
+ app.get("id"),
+ exc,
+ )
+ payload = subscription_url
+
+ scheme_link = f"{scheme}{payload}" if scheme else None
+
+ template = settings.get_happ_cryptolink_redirect_template()
+ redirect_link = build_redirect_link(scheme_link, template) if scheme_link and template else None
+
+ return redirect_link or scheme_link or subscription_url
+
+def get_reset_devices_confirm_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
+ texts = get_texts(language)
+ return InlineKeyboardMarkup(inline_keyboard=[
+ [
+ InlineKeyboardButton(
+ text="✅ Да, сбросить все устройства",
+ callback_data="confirm_reset_devices"
+ )
+ ],
+ [
+ InlineKeyboardButton(text="❌ Отмена", callback_data="menu_subscription")
+ ]
+ ])
+
+def get_traffic_switch_keyboard(
+ current_traffic_gb: int,
+ language: str = "ru",
+ subscription_end_date: datetime = None,
+ discount_percent: int = 0,
+) -> InlineKeyboardMarkup:
+ from app.config import settings
+
+ months_multiplier = 1
+ period_text = ""
+ if subscription_end_date:
+ months_multiplier = get_remaining_months(subscription_end_date)
+ if months_multiplier > 1:
+ period_text = f" (за {months_multiplier} мес)"
+
+ packages = settings.get_traffic_packages()
+ enabled_packages = [pkg for pkg in packages if pkg['enabled']]
+
+ current_price_per_month = settings.get_traffic_price(current_traffic_gb)
+ discounted_current_per_month, _ = apply_percentage_discount(
+ current_price_per_month,
+ discount_percent,
+ )
+
+ buttons = []
+
+ for package in enabled_packages:
+ gb = package['gb']
+ price_per_month = package['price']
+ discounted_price_per_month, _ = apply_percentage_discount(
+ price_per_month,
+ discount_percent,
+ )
+
+ price_diff_per_month = discounted_price_per_month - discounted_current_per_month
+ total_price_diff = price_diff_per_month * months_multiplier
+
+ if gb == current_traffic_gb:
+ emoji = "✅"
+ action_text = " (текущий)"
+ price_text = ""
+ elif total_price_diff > 0:
+ emoji = "⬆️"
+ action_text = ""
+ price_text = f" (+{total_price_diff // 100}₽{period_text})"
+ if discount_percent > 0:
+ discount_total = (
+ (price_per_month - current_price_per_month) * months_multiplier
+ - total_price_diff
+ )
+ if discount_total > 0:
+ price_text += f" (скидка {discount_percent}%: -{discount_total // 100}₽)"
+ elif total_price_diff < 0:
+ emoji = "⬇️"
+ action_text = ""
+ price_text = " (без возврата)"
+ else:
+ emoji = "🔄"
+ action_text = ""
+ price_text = " (бесплатно)"
+
+ if gb == 0:
+ traffic_text = "Безлимит"
+ else:
+ traffic_text = f"{gb} ГБ"
+
+ button_text = f"{emoji} {traffic_text}{action_text}{price_text}"
+
+ buttons.append([
+ InlineKeyboardButton(text=button_text, callback_data=f"switch_traffic_{gb}")
+ ])
+
+ buttons.append([
+ InlineKeyboardButton(
+ text="⬅️ Назад" if language == "ru" else "⬅️ Back",
+ callback_data="subscription_settings"
+ )
+ ])
+
+ return InlineKeyboardMarkup(inline_keyboard=buttons)
+
+def get_confirm_switch_traffic_keyboard(
+ new_traffic_gb: int,
+ price_difference: int,
+ language: str = "ru"
+) -> InlineKeyboardMarkup:
+ return InlineKeyboardMarkup(inline_keyboard=[
+ [
+ InlineKeyboardButton(
+ text="✅ Подтвердить переключение",
+ callback_data=f"confirm_switch_traffic_{new_traffic_gb}_{price_difference}"
+ )
+ ],
+ [
+ InlineKeyboardButton(
+ text="❌ Отмена",
+ callback_data="subscription_settings"
+ )
+ ]
+ ])
diff --git a/app/handlers/subscription/countries.py b/app/handlers/subscription/countries.py
new file mode 100644
index 00000000..1e964189
--- /dev/null
+++ b/app/handlers/subscription/countries.py
@@ -0,0 +1,958 @@
+import base64
+import json
+import logging
+from datetime import datetime, timedelta
+from typing import Dict, List, Any, Tuple, Optional
+from urllib.parse import quote
+from aiogram import Dispatcher, types, F
+from aiogram.fsm.context import FSMContext
+from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
+from sqlalchemy.ext.asyncio import AsyncSession
+from app.config import settings, PERIOD_PRICES, get_traffic_prices
+from app.database.crud.discount_offer import (
+ get_offer_by_id,
+ mark_offer_claimed,
+)
+from app.database.crud.promo_offer_template import get_promo_offer_template_by_id
+from app.database.crud.subscription import (
+ create_trial_subscription,
+ create_paid_subscription, add_subscription_traffic, add_subscription_devices,
+ update_subscription_autopay
+)
+from app.database.crud.transaction import create_transaction
+from app.database.crud.user import subtract_user_balance
+from app.database.models import (
+ User, TransactionType, SubscriptionStatus,
+ Subscription
+)
+from app.keyboards.inline import (
+ get_subscription_keyboard, get_trial_keyboard,
+ get_subscription_period_keyboard, get_traffic_packages_keyboard,
+ get_countries_keyboard, get_devices_keyboard,
+ get_subscription_confirm_keyboard, get_autopay_keyboard,
+ get_autopay_days_keyboard, get_back_keyboard,
+ get_add_traffic_keyboard,
+ get_change_devices_keyboard, get_reset_traffic_confirm_keyboard,
+ get_manage_countries_keyboard,
+ get_device_selection_keyboard, get_connection_guide_keyboard,
+ get_app_selection_keyboard, get_specific_app_keyboard,
+ get_updated_subscription_settings_keyboard, get_insufficient_balance_keyboard,
+ get_extend_subscription_keyboard_with_prices, get_confirm_change_devices_keyboard,
+ get_devices_management_keyboard, get_device_management_help_keyboard,
+ get_happ_cryptolink_keyboard,
+ get_happ_download_platform_keyboard, get_happ_download_link_keyboard,
+ get_happ_download_button_row,
+ get_payment_methods_keyboard_with_cart,
+ get_subscription_confirm_keyboard_with_cart,
+ get_insufficient_balance_keyboard_with_cart
+)
+from app.localization.texts import get_texts
+from app.services.admin_notification_service import AdminNotificationService
+from app.services.remnawave_service import RemnaWaveService
+from app.services.subscription_checkout_service import (
+ clear_subscription_checkout_draft,
+ get_subscription_checkout_draft,
+ save_subscription_checkout_draft,
+ should_offer_checkout_resume,
+)
+from app.services.subscription_service import SubscriptionService
+from app.utils.miniapp_buttons import build_miniapp_or_callback_button
+from app.services.promo_offer_service import promo_offer_service
+from app.states import SubscriptionStates
+from app.utils.pagination import paginate_list
+from app.utils.pricing_utils import (
+ calculate_months_from_days,
+ get_remaining_months,
+ calculate_prorated_price,
+ validate_pricing_calculation,
+ format_period_description,
+ apply_percentage_discount,
+)
+from app.utils.subscription_utils import (
+ get_display_subscription_link,
+ get_happ_cryptolink_redirect_link,
+ convert_subscription_link_to_happ_scheme,
+)
+from app.utils.promo_offer import (
+ build_promo_offer_hint,
+ get_user_active_promo_discount_percent,
+)
+
+from .common import _get_addon_discount_percent_for_user, _get_period_hint_from_subscription, logger
+
+async def handle_add_countries(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession,
+ state: FSMContext
+):
+ if not await _should_show_countries_management(db_user):
+ texts = get_texts(db_user.language)
+ await callback.answer(
+ texts.t(
+ "COUNTRY_MANAGEMENT_UNAVAILABLE",
+ "ℹ️ Управление серверами недоступно - доступен только один сервер",
+ ),
+ show_alert=True,
+ )
+ return
+
+ texts = get_texts(db_user.language)
+ subscription = db_user.subscription
+
+ if not subscription or subscription.is_trial:
+ await callback.answer(
+ texts.t("PAID_FEATURE_ONLY", "⚠ Эта функция доступна только для платных подписок"),
+ show_alert=True,
+ )
+ return
+
+ countries = await _get_available_countries(db_user.promo_group_id)
+ current_countries = subscription.connected_squads
+
+ period_hint_days = _get_period_hint_from_subscription(subscription)
+ servers_discount_percent = _get_addon_discount_percent_for_user(
+ db_user,
+ "servers",
+ period_hint_days,
+ )
+
+ current_countries_names = []
+ for country in countries:
+ if country['uuid'] in current_countries:
+ current_countries_names.append(country['name'])
+
+ current_list = (
+ "\n".join(f"• {name}" for name in current_countries_names)
+ if current_countries_names
+ else texts.t("COUNTRY_MANAGEMENT_NONE", "Нет подключенных стран")
+ )
+
+ text = texts.t(
+ "COUNTRY_MANAGEMENT_PROMPT",
+ (
+ "🌍 Управление странами подписки\n\n"
+ "📋 Текущие страны ({current_count}):\n"
+ "{current_list}\n\n"
+ "💡 Инструкция:\n"
+ "✅ - страна подключена\n"
+ "➕ - будет добавлена (платно)\n"
+ "➖ - будет отключена (бесплатно)\n"
+ "⚪ - не выбрана\n\n"
+ "⚠️ Важно: Повторное подключение отключенных стран будет платным!"
+ ),
+ ).format(
+ current_count=len(current_countries),
+ current_list=current_list,
+ )
+
+ await state.update_data(countries=current_countries.copy())
+
+ await callback.message.edit_text(
+ text,
+ reply_markup=get_manage_countries_keyboard(
+ countries,
+ current_countries.copy(),
+ current_countries,
+ db_user.language,
+ subscription.end_date,
+ servers_discount_percent,
+ ),
+ parse_mode="HTML"
+ )
+
+ await callback.answer()
+
+async def get_countries_price_by_uuids_fallback(
+ country_uuids: List[str],
+ db: AsyncSession,
+ promo_group_id: Optional[int] = None,
+) -> Tuple[int, List[int]]:
+ try:
+ from app.database.crud.server_squad import get_server_squad_by_uuid
+
+ total_price = 0
+ prices_list = []
+
+ for country_uuid in country_uuids:
+ try:
+ server = await get_server_squad_by_uuid(db, country_uuid)
+ is_allowed = True
+ if promo_group_id is not None and server:
+ allowed_ids = {pg.id for pg in server.allowed_promo_groups}
+ is_allowed = promo_group_id in allowed_ids
+
+ if server and server.is_available and not server.is_full and is_allowed:
+ price = server.price_kopeks
+ total_price += price
+ prices_list.append(price)
+ else:
+ default_price = 0
+ total_price += default_price
+ prices_list.append(default_price)
+ except Exception:
+ default_price = 0
+ total_price += default_price
+ prices_list.append(default_price)
+
+ return total_price, prices_list
+
+ except Exception as e:
+ logger.error(f"Ошибка fallback функции: {e}")
+ default_prices = [0] * len(country_uuids)
+ return sum(default_prices), default_prices
+
+async def handle_manage_country(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession,
+ state: FSMContext
+):
+ logger.info(f"🔍 Управление страной: {callback.data}")
+
+ country_uuid = callback.data.split('_')[2]
+
+ subscription = db_user.subscription
+ if not subscription or subscription.is_trial:
+ texts = get_texts(db_user.language)
+ await callback.answer(
+ texts.t("PAID_FEATURE_ONLY_SHORT", "⚠ Только для платных подписок"),
+ show_alert=True,
+ )
+ return
+
+ data = await state.get_data()
+ current_selected = data.get('countries', subscription.connected_squads.copy())
+
+ countries = await _get_available_countries(db_user.promo_group_id)
+ allowed_country_ids = {country['uuid'] for country in countries}
+
+ if country_uuid not in allowed_country_ids and country_uuid not in current_selected:
+ texts = get_texts(db_user.language)
+ await callback.answer(
+ texts.t(
+ "COUNTRY_NOT_AVAILABLE_PROMOGROUP",
+ "❌ Сервер недоступен для вашей промогруппы",
+ ),
+ show_alert=True,
+ )
+ return
+
+ if country_uuid in current_selected:
+ current_selected.remove(country_uuid)
+ action = "removed"
+ else:
+ current_selected.append(country_uuid)
+ action = "added"
+
+ logger.info(f"🔍 Страна {country_uuid} {action}")
+
+ await state.update_data(countries=current_selected)
+
+ period_hint_days = _get_period_hint_from_subscription(subscription)
+ servers_discount_percent = _get_addon_discount_percent_for_user(
+ db_user,
+ "servers",
+ period_hint_days,
+ )
+
+ try:
+ await callback.message.edit_reply_markup(
+ reply_markup=get_manage_countries_keyboard(
+ countries,
+ current_selected,
+ subscription.connected_squads,
+ db_user.language,
+ subscription.end_date,
+ servers_discount_percent,
+ )
+ )
+ logger.info(f"✅ Клавиатура обновлена")
+
+ except Exception as e:
+ logger.error(f"⚠ Ошибка обновления клавиатуры: {e}")
+
+ await callback.answer()
+
+async def apply_countries_changes(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession,
+ state: FSMContext
+):
+ logger.info(f"🔧 Применение изменений стран")
+
+ data = await state.get_data()
+ texts = get_texts(db_user.language)
+
+ await save_subscription_checkout_draft(db_user.id, dict(data))
+ resume_callback = (
+ "subscription_resume_checkout"
+ if should_offer_checkout_resume(db_user, True)
+ else None
+ )
+ subscription = db_user.subscription
+
+ selected_countries = data.get('countries', [])
+ current_countries = subscription.connected_squads
+
+ countries = await _get_available_countries(db_user.promo_group_id)
+ allowed_country_ids = {country['uuid'] for country in countries}
+
+ selected_countries = [
+ country_uuid
+ for country_uuid in selected_countries
+ if country_uuid in allowed_country_ids or country_uuid in current_countries
+ ]
+
+ added = [c for c in selected_countries if c not in current_countries]
+ removed = [c for c in current_countries if c not in selected_countries]
+
+ if not added and not removed:
+ await callback.answer(
+ texts.t("COUNTRY_CHANGES_NOT_FOUND", "⚠️ Изменения не обнаружены"),
+ show_alert=True,
+ )
+ return
+
+ logger.info(f"🔧 Добавлено: {added}, Удалено: {removed}")
+
+ months_to_pay = get_remaining_months(subscription.end_date)
+
+ period_hint_days = months_to_pay * 30 if months_to_pay > 0 else None
+ servers_discount_percent = _get_addon_discount_percent_for_user(
+ db_user,
+ "servers",
+ period_hint_days,
+ )
+
+ cost_per_month = 0
+ added_names = []
+ removed_names = []
+
+ added_server_components: List[Dict[str, int]] = []
+
+ for country in countries:
+ if not country.get('is_available', True):
+ continue
+
+ if country['uuid'] in added:
+ server_price_per_month = country['price_kopeks']
+ discounted_per_month, discount_per_month = apply_percentage_discount(
+ server_price_per_month,
+ servers_discount_percent,
+ )
+ cost_per_month += discounted_per_month
+ added_names.append(country['name'])
+ added_server_components.append(
+ {
+ "discounted_per_month": discounted_per_month,
+ "discount_per_month": discount_per_month,
+ "original_per_month": server_price_per_month,
+ }
+ )
+ if country['uuid'] in removed:
+ removed_names.append(country['name'])
+
+ total_cost, charged_months = calculate_prorated_price(cost_per_month, subscription.end_date)
+
+ added_server_prices = [
+ component["discounted_per_month"] * charged_months
+ for component in added_server_components
+ ]
+
+ total_discount = sum(
+ component["discount_per_month"] * charged_months
+ for component in added_server_components
+ )
+
+ if added_names:
+ logger.info(
+ "Стоимость новых серверов: %.2f₽/мес × %s мес = %.2f₽ (скидка %.2f₽)",
+ cost_per_month / 100,
+ charged_months,
+ total_cost / 100,
+ total_discount / 100,
+ )
+
+ if total_cost > 0 and db_user.balance_kopeks < total_cost:
+ missing_kopeks = total_cost - db_user.balance_kopeks
+ required_text = f"{texts.format_price(total_cost)} (за {charged_months} мес)"
+ message_text = texts.t(
+ "ADDON_INSUFFICIENT_FUNDS_MESSAGE",
+ (
+ "⚠️ Недостаточно средств\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.answer(
+ message_text,
+ reply_markup=get_insufficient_balance_keyboard(
+ db_user.language,
+ resume_callback=resume_callback,
+ amount_kopeks=missing_kopeks,
+ ),
+ parse_mode="HTML",
+ )
+ await callback.answer()
+ return
+
+ try:
+ if added and total_cost > 0:
+ success = await subtract_user_balance(
+ db, db_user, total_cost,
+ f"Добавление стран: {', '.join(added_names)} на {charged_months} мес"
+ )
+ if not success:
+ await callback.answer(
+ texts.t("PAYMENT_CHARGE_ERROR", "⚠️ Ошибка списания средств"),
+ show_alert=True,
+ )
+ return
+
+ await create_transaction(
+ db=db,
+ user_id=db_user.id,
+ type=TransactionType.SUBSCRIPTION_PAYMENT,
+ amount_kopeks=total_cost,
+ description=f"Добавление стран к подписке: {', '.join(added_names)} на {charged_months} мес"
+ )
+
+ if added:
+ from app.database.crud.server_squad import get_server_ids_by_uuids, add_user_to_servers
+ from app.database.crud.subscription import add_subscription_servers
+
+ added_server_ids = await get_server_ids_by_uuids(db, added)
+
+ if added_server_ids:
+ await add_subscription_servers(db, subscription, added_server_ids, added_server_prices)
+ await add_user_to_servers(db, added_server_ids)
+
+ logger.info(
+ f"📊 Добавлены серверы с ценами за {charged_months} мес: {list(zip(added_server_ids, added_server_prices))}")
+
+ subscription.connected_squads = selected_countries
+ subscription.updated_at = datetime.utcnow()
+ await db.commit()
+
+ subscription_service = SubscriptionService()
+ await subscription_service.update_remnawave_user(db, subscription)
+
+ await db.refresh(subscription)
+
+ 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, "servers", current_countries, selected_countries, total_cost
+ )
+ except Exception as e:
+ logger.error(f"Ошибка отправки уведомления об изменении серверов: {e}")
+
+ success_text = texts.t(
+ "COUNTRY_CHANGES_SUCCESS_HEADER",
+ "✅ Страны успешно обновлены!\n\n",
+ )
+
+ if added_names:
+ success_text += texts.t(
+ "COUNTRY_CHANGES_ADDED_HEADER",
+ "➕ Добавлены страны:\n",
+ )
+ success_text += "\n".join(f"• {name}" for name in added_names)
+ if total_cost > 0:
+ success_text += "\n" + texts.t(
+ "COUNTRY_CHANGES_CHARGED",
+ "💰 Списано: {amount} (за {months} мес)",
+ ).format(
+ amount=texts.format_price(total_cost),
+ months=charged_months,
+ )
+ if total_discount > 0:
+ success_text += texts.t(
+ "COUNTRY_CHANGES_DISCOUNT_INFO",
+ " (скидка {percent}%: -{amount})",
+ ).format(
+ percent=servers_discount_percent,
+ amount=texts.format_price(total_discount),
+ )
+ success_text += "\n"
+
+ if removed_names:
+ success_text += "\n" + texts.t(
+ "COUNTRY_CHANGES_REMOVED_HEADER",
+ "➖ Отключены страны:\n",
+ )
+ success_text += "\n".join(f"• {name}" for name in removed_names)
+ success_text += "\n" + texts.t(
+ "COUNTRY_CHANGES_REMOVED_WARNING",
+ "ℹ️ Повторное подключение будет платным",
+ ) + "\n"
+
+ success_text += "\n" + texts.t(
+ "COUNTRY_CHANGES_ACTIVE_COUNT",
+ "🌐 Активных стран: {count}",
+ ).format(count=len(selected_countries))
+
+ await callback.message.edit_text(
+ success_text,
+ reply_markup=get_back_keyboard(db_user.language),
+ parse_mode="HTML"
+ )
+
+ await state.clear()
+ logger.info(
+ f"✅ Пользователь {db_user.telegram_id} обновил страны. Добавлено: {len(added)}, удалено: {len(removed)}, заплатил: {total_cost / 100}₽")
+
+ except Exception as e:
+ logger.error(f"⚠️ Ошибка применения изменений: {e}")
+ await callback.message.edit_text(
+ texts.ERROR,
+ reply_markup=get_back_keyboard(db_user.language)
+ )
+
+ await callback.answer()
+
+async def select_country(
+ callback: types.CallbackQuery,
+ state: FSMContext,
+ db_user: User,
+ db: AsyncSession
+):
+ country_uuid = callback.data.split('_')[1]
+ data = await state.get_data()
+
+ selected_countries = data.get('countries', [])
+ if country_uuid in selected_countries:
+ selected_countries.remove(country_uuid)
+ else:
+ selected_countries.append(country_uuid)
+
+ countries = await _get_available_countries(db_user.promo_group_id)
+ allowed_country_ids = {country['uuid'] for country in countries}
+
+ if country_uuid not in allowed_country_ids and country_uuid not in selected_countries:
+ await callback.answer("❌ Сервер недоступен для вашей промогруппы", show_alert=True)
+ return
+
+ period_base_price = PERIOD_PRICES[data['period_days']]
+
+ discounted_base_price, _ = apply_percentage_discount(
+ period_base_price,
+ db_user.get_promo_discount("period", data['period_days']),
+ )
+
+ base_price = discounted_base_price + settings.get_traffic_price(data['traffic_gb'])
+
+ try:
+ subscription_service = SubscriptionService()
+ countries_price, _ = await subscription_service.get_countries_price_by_uuids(
+ selected_countries,
+ db,
+ promo_group_id=db_user.promo_group_id,
+ )
+ except AttributeError:
+ logger.warning("Используем fallback функцию для расчета цен стран")
+ countries_price, _ = await get_countries_price_by_uuids_fallback(
+ selected_countries,
+ db,
+ promo_group_id=db_user.promo_group_id,
+ )
+
+ data['countries'] = selected_countries
+ data['total_price'] = base_price + countries_price
+ await state.set_data(data)
+
+ await callback.message.edit_reply_markup(
+ reply_markup=get_countries_keyboard(countries, selected_countries, db_user.language)
+ )
+ await callback.answer()
+
+async def countries_continue(
+ callback: types.CallbackQuery,
+ state: FSMContext,
+ db_user: User
+):
+ data = await state.get_data()
+ texts = get_texts(db_user.language)
+
+ if not data.get('countries'):
+ await callback.answer("⚠️ Выберите хотя бы одну страну!", show_alert=True)
+ return
+
+ 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()
+
+async def _get_available_countries(promo_group_id: Optional[int] = None):
+ from app.utils.cache import cache, cache_key
+ from app.database.database import AsyncSessionLocal
+ from app.database.crud.server_squad import get_available_server_squads
+
+ cache_key_value = cache_key("available_countries", promo_group_id or "all")
+ cached_countries = await cache.get(cache_key_value)
+ if cached_countries:
+ return cached_countries
+
+ try:
+ async with AsyncSessionLocal() as db:
+ available_servers = await get_available_server_squads(
+ db, promo_group_id=promo_group_id
+ )
+
+ if promo_group_id is not None and not available_servers:
+ logger.info(
+ "Промогруппа %s не имеет доступных серверов, возврат пустого списка",
+ promo_group_id,
+ )
+ await cache.set(cache_key_value, [], 60)
+ return []
+
+ countries = []
+ for server in available_servers:
+ countries.append({
+ "uuid": server.squad_uuid,
+ "name": server.display_name,
+ "price_kopeks": server.price_kopeks,
+ "country_code": server.country_code,
+ "is_available": server.is_available and not server.is_full
+ })
+
+ if not countries:
+ logger.info("🔄 Серверов в БД нет, получаем из RemnaWave...")
+ from app.services.remnawave_service import RemnaWaveService
+
+ service = RemnaWaveService()
+ squads = await service.get_all_squads()
+
+ for squad in squads:
+ squad_name = squad["name"]
+
+ if not any(flag in squad_name for flag in
+ ["🇳🇱", "🇩🇪", "🇺🇸", "🇫🇷", "🇬🇧", "🇮🇹", "🇪🇸", "🇨🇦", "🇯🇵", "🇸🇬", "🇦🇺"]):
+ name_lower = squad_name.lower()
+ if "netherlands" in name_lower or "нидерланды" in name_lower or "nl" in name_lower:
+ squad_name = f"🇳🇱 {squad_name}"
+ elif "germany" in name_lower or "германия" in name_lower or "de" in name_lower:
+ squad_name = f"🇩🇪 {squad_name}"
+ elif "usa" in name_lower or "сша" in name_lower or "america" in name_lower or "us" in name_lower:
+ squad_name = f"🇺🇸 {squad_name}"
+ else:
+ squad_name = f"🌐 {squad_name}"
+
+ countries.append({
+ "uuid": squad["uuid"],
+ "name": squad_name,
+ "price_kopeks": 0,
+ "is_available": True
+ })
+
+ await cache.set(cache_key_value, countries, 300)
+ return countries
+
+ except Exception as e:
+ logger.error(f"Ошибка получения списка стран: {e}")
+ fallback_countries = [
+ {"uuid": "default-free", "name": "🆓 Бесплатный сервер", "price_kopeks": 0, "is_available": True},
+ ]
+
+ await cache.set(cache_key_value, fallback_countries, 60)
+ return fallback_countries
+
+async def _get_countries_info(squad_uuids):
+ countries = await _get_available_countries()
+ return [c for c in countries if c['uuid'] in squad_uuids]
+
+async def handle_add_country_to_subscription(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession,
+ state: FSMContext
+):
+ logger.info(f"🔍 handle_add_country_to_subscription вызван для {db_user.telegram_id}")
+ logger.info(f"🔍 Callback data: {callback.data}")
+
+ current_state = await state.get_state()
+ logger.info(f"🔍 Текущее состояние: {current_state}")
+
+ country_uuid = callback.data.split('_')[1]
+ data = await state.get_data()
+ logger.info(f"🔍 Данные состояния: {data}")
+
+ selected_countries = data.get('countries', [])
+ countries = await _get_available_countries(db_user.promo_group_id)
+ allowed_country_ids = {country['uuid'] for country in countries}
+
+ if country_uuid not in allowed_country_ids and country_uuid not in selected_countries:
+ await callback.answer("❌ Сервер недоступен для вашей промогруппы", show_alert=True)
+ return
+
+ if country_uuid in selected_countries:
+ selected_countries.remove(country_uuid)
+ logger.info(f"🔍 Удалена страна: {country_uuid}")
+ else:
+ selected_countries.append(country_uuid)
+ logger.info(f"🔍 Добавлена страна: {country_uuid}")
+
+ total_price = 0
+ subscription = db_user.subscription
+ period_hint_days = _get_period_hint_from_subscription(subscription)
+ servers_discount_percent = _get_addon_discount_percent_for_user(
+ db_user,
+ "servers",
+ period_hint_days,
+ )
+
+ for country in countries:
+ if not country.get('is_available', True):
+ continue
+
+ if (
+ country['uuid'] in selected_countries
+ and country['uuid'] not in subscription.connected_squads
+ ):
+ server_price = country['price_kopeks']
+ if servers_discount_percent > 0 and server_price > 0:
+ discounted_price, _ = apply_percentage_discount(
+ server_price,
+ servers_discount_percent,
+ )
+ else:
+ discounted_price = server_price
+ total_price += discounted_price
+
+ data['countries'] = selected_countries
+ data['total_price'] = total_price
+ await state.set_data(data)
+
+ logger.info(f"🔍 Новые выбранные страны: {selected_countries}")
+ logger.info(f"🔍 Общая стоимость: {total_price}")
+
+ try:
+ from app.keyboards.inline import get_manage_countries_keyboard
+ await callback.message.edit_reply_markup(
+ reply_markup=get_manage_countries_keyboard(
+ countries,
+ selected_countries,
+ subscription.connected_squads,
+ db_user.language,
+ subscription.end_date,
+ servers_discount_percent,
+ )
+ )
+ logger.info(f"✅ Клавиатура обновлена")
+ except Exception as e:
+ logger.error(f"❌ Ошибка обновления клавиатуры: {e}")
+
+ await callback.answer()
+
+async def _should_show_countries_management(user: Optional[User] = None) -> bool:
+ try:
+ promo_group_id = user.promo_group_id if user else None
+
+ promo_group = getattr(user, "promo_group", None) if user else None
+ if promo_group and getattr(promo_group, "server_squads", None):
+ allowed_servers = [
+ server
+ for server in promo_group.server_squads
+ if server.is_available and not server.is_full
+ ]
+
+ if allowed_servers:
+ if len(allowed_servers) > 1:
+ logger.debug(
+ "Промогруппа %s имеет %s доступных серверов, показываем управление странами",
+ promo_group.id,
+ len(allowed_servers),
+ )
+ return True
+
+ logger.debug(
+ "Промогруппа %s имеет всего %s доступный сервер, пропускаем шаг выбора стран",
+ promo_group.id,
+ len(allowed_servers),
+ )
+ return False
+
+ countries = await _get_available_countries(promo_group_id)
+ available_countries = [c for c in countries if c.get('is_available', True)]
+ return len(available_countries) > 1
+ except Exception as e:
+ logger.error(f"Ошибка проверки доступных серверов: {e}")
+ return True
+
+async def confirm_add_countries_to_subscription(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession,
+ state: FSMContext
+):
+ data = await state.get_data()
+ texts = get_texts(db_user.language)
+ subscription = db_user.subscription
+
+ selected_countries = data.get('countries', [])
+ current_countries = subscription.connected_squads
+
+ countries = await _get_available_countries(db_user.promo_group_id)
+ allowed_country_ids = {country['uuid'] for country in countries}
+
+ selected_countries = [
+ country_uuid
+ for country_uuid in selected_countries
+ if country_uuid in allowed_country_ids or country_uuid in current_countries
+ ]
+
+ new_countries = [c for c in selected_countries if c not in current_countries]
+ removed_countries = [c for c in current_countries if c not in selected_countries]
+
+ if not new_countries and not removed_countries:
+ await callback.answer("⚠️ Изменения не обнаружены", show_alert=True)
+ return
+
+ total_price = 0
+ new_countries_names = []
+ removed_countries_names = []
+
+ period_hint_days = _get_period_hint_from_subscription(subscription)
+ servers_discount_percent = _get_addon_discount_percent_for_user(
+ db_user,
+ "servers",
+ period_hint_days,
+ )
+ total_discount_value = 0
+
+ for country in countries:
+ if not country.get('is_available', True):
+ continue
+
+ if country['uuid'] in new_countries:
+ server_price = country['price_kopeks']
+ if servers_discount_percent > 0 and server_price > 0:
+ discounted_per_month, discount_per_month = apply_percentage_discount(
+ server_price,
+ servers_discount_percent,
+ )
+ else:
+ discounted_per_month = server_price
+ discount_per_month = 0
+
+ charged_price, charged_months = calculate_prorated_price(
+ discounted_per_month,
+ subscription.end_date,
+ )
+
+ total_price += charged_price
+ total_discount_value += discount_per_month * charged_months
+ new_countries_names.append(country['name'])
+ if country['uuid'] in removed_countries:
+ removed_countries_names.append(country['name'])
+
+ if new_countries and db_user.balance_kopeks < total_price:
+ missing_kopeks = total_price - db_user.balance_kopeks
+ message_text = texts.t(
+ "ADDON_INSUFFICIENT_FUNDS_MESSAGE",
+ (
+ "⚠️ Недостаточно средств\n\n"
+ "Стоимость услуги: {required}\n"
+ "На балансе: {balance}\n"
+ "Не хватает: {missing}\n\n"
+ "Выберите способ пополнения. Сумма подставится автоматически."
+ ),
+ ).format(
+ required=texts.format_price(total_price),
+ 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 state.clear()
+ await callback.answer()
+ return
+
+ try:
+ if new_countries and total_price > 0:
+ success = await subtract_user_balance(
+ db, db_user, total_price,
+ f"Добавление стран к подписке: {', '.join(new_countries_names)}"
+ )
+
+ if not success:
+ await callback.answer("❌ Ошибка списания средств", show_alert=True)
+ return
+
+ await create_transaction(
+ db=db,
+ user_id=db_user.id,
+ type=TransactionType.SUBSCRIPTION_PAYMENT,
+ amount_kopeks=total_price,
+ description=f"Добавление стран к подписке: {', '.join(new_countries_names)}"
+ )
+
+ subscription.connected_squads = selected_countries
+ subscription.updated_at = datetime.utcnow()
+ await db.commit()
+
+ subscription_service = SubscriptionService()
+ await subscription_service.update_remnawave_user(db, subscription)
+
+ await db.refresh(db_user)
+ await db.refresh(subscription)
+
+ success_text = "✅ Страны успешно обновлены!\n\n"
+
+ if new_countries_names:
+ success_text += f"➕ Добавлены страны:\n{chr(10).join(f'• {name}' for name in new_countries_names)}\n"
+ if total_price > 0:
+ success_text += f"💰 Списано: {texts.format_price(total_price)}"
+ if total_discount_value > 0:
+ success_text += (
+ f" (скидка {servers_discount_percent}%:"
+ f" -{texts.format_price(total_discount_value)})"
+ )
+ success_text += "\n"
+
+ if removed_countries_names:
+ success_text += f"\n➖ Отключены страны:\n{chr(10).join(f'• {name}' for name in removed_countries_names)}\n"
+ success_text += "ℹ️ Повторное подключение будет платным\n"
+
+ success_text += f"\n🌍 Активных стран: {len(selected_countries)}"
+
+ await callback.message.edit_text(
+ success_text,
+ reply_markup=get_back_keyboard(db_user.language)
+ )
+
+ logger.info(
+ f"✅ Пользователь {db_user.telegram_id} обновил страны подписки. Добавлено: {len(new_countries)}, убрано: {len(removed_countries)}")
+
+ except Exception as e:
+ logger.error(f"Ошибка обновления стран подписки: {e}")
+ await callback.message.edit_text(
+ texts.ERROR,
+ reply_markup=get_back_keyboard(db_user.language)
+ )
+
+ await state.clear()
+ await callback.answer()
diff --git a/app/handlers/subscription/devices.py b/app/handlers/subscription/devices.py
new file mode 100644
index 00000000..3ca44442
--- /dev/null
+++ b/app/handlers/subscription/devices.py
@@ -0,0 +1,1322 @@
+import base64
+import json
+import logging
+from datetime import datetime, timedelta
+from typing import Dict, List, Any, Tuple, Optional
+from urllib.parse import quote
+from aiogram import Dispatcher, types, F
+from aiogram.fsm.context import FSMContext
+from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
+from sqlalchemy.ext.asyncio import AsyncSession
+from app.config import settings, PERIOD_PRICES, get_traffic_prices
+from app.database.crud.discount_offer import (
+ get_offer_by_id,
+ mark_offer_claimed,
+)
+from app.database.crud.promo_offer_template import get_promo_offer_template_by_id
+from app.database.crud.subscription import (
+ create_trial_subscription,
+ create_paid_subscription, add_subscription_traffic, add_subscription_devices,
+ update_subscription_autopay
+)
+from app.database.crud.transaction import create_transaction
+from app.database.crud.user import subtract_user_balance
+from app.database.models import (
+ User, TransactionType, SubscriptionStatus,
+ Subscription
+)
+from app.keyboards.inline import (
+ get_subscription_keyboard, get_trial_keyboard,
+ get_subscription_period_keyboard, get_traffic_packages_keyboard,
+ get_countries_keyboard, get_devices_keyboard,
+ get_subscription_confirm_keyboard, get_autopay_keyboard,
+ get_autopay_days_keyboard, get_back_keyboard,
+ get_add_traffic_keyboard,
+ get_change_devices_keyboard, get_reset_traffic_confirm_keyboard,
+ get_manage_countries_keyboard,
+ get_device_selection_keyboard, get_connection_guide_keyboard,
+ get_app_selection_keyboard, get_specific_app_keyboard,
+ get_updated_subscription_settings_keyboard, get_insufficient_balance_keyboard,
+ get_extend_subscription_keyboard_with_prices, get_confirm_change_devices_keyboard,
+ get_devices_management_keyboard, get_device_management_help_keyboard,
+ get_happ_cryptolink_keyboard,
+ get_happ_download_platform_keyboard, get_happ_download_link_keyboard,
+ get_happ_download_button_row,
+ get_payment_methods_keyboard_with_cart,
+ get_subscription_confirm_keyboard_with_cart,
+ get_insufficient_balance_keyboard_with_cart
+)
+from app.localization.texts import get_texts
+from app.services.admin_notification_service import AdminNotificationService
+from app.services.remnawave_service import RemnaWaveService
+from app.services.subscription_checkout_service import (
+ clear_subscription_checkout_draft,
+ get_subscription_checkout_draft,
+ save_subscription_checkout_draft,
+ should_offer_checkout_resume,
+)
+from app.services.subscription_service import SubscriptionService
+from app.utils.miniapp_buttons import build_miniapp_or_callback_button
+from app.services.promo_offer_service import promo_offer_service
+from app.states import SubscriptionStates
+from app.utils.pagination import paginate_list
+from app.utils.pricing_utils import (
+ calculate_months_from_days,
+ get_remaining_months,
+ calculate_prorated_price,
+ validate_pricing_calculation,
+ format_period_description,
+ apply_percentage_discount,
+)
+from app.utils.subscription_utils import (
+ get_display_subscription_link,
+ get_happ_cryptolink_redirect_link,
+ convert_subscription_link_to_happ_scheme,
+)
+from app.utils.promo_offer import (
+ build_promo_offer_hint,
+ get_user_active_promo_discount_percent,
+)
+
+from .common import _get_addon_discount_percent_for_user, _get_period_hint_from_subscription, format_additional_section, get_apps_for_device, get_device_name, get_step_description, logger
+from .countries import _get_available_countries
+
+async def get_current_devices_detailed(db_user: User) -> dict:
+ try:
+ if not db_user.remnawave_uuid:
+ return {"count": 0, "devices": []}
+
+ from app.services.remnawave_service import RemnaWaveService
+ service = RemnaWaveService()
+
+ async with service.get_api_client() as api:
+ response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}')
+
+ if response and 'response' in response:
+ devices_info = response['response']
+ total_devices = devices_info.get('total', 0)
+ devices_list = devices_info.get('devices', [])
+
+ return {
+ "count": total_devices,
+ "devices": devices_list[:5]
+ }
+ else:
+ return {"count": 0, "devices": []}
+
+ except Exception as e:
+ logger.error(f"Ошибка получения детальной информации об устройствах: {e}")
+ return {"count": 0, "devices": []}
+
+async def get_servers_display_names(squad_uuids: List[str]) -> str:
+ if not squad_uuids:
+ return "Нет серверов"
+
+ try:
+ from app.database.database import AsyncSessionLocal
+ from app.database.crud.server_squad import get_server_squad_by_uuid
+
+ server_names = []
+
+ async with AsyncSessionLocal() as db:
+ for uuid in squad_uuids:
+ server = await get_server_squad_by_uuid(db, uuid)
+ if server:
+ server_names.append(server.display_name)
+ logger.debug(f"Найден сервер в БД: {uuid} -> {server.display_name}")
+ else:
+ logger.warning(f"Сервер с UUID {uuid} не найден в БД")
+
+ if not server_names:
+ countries = await _get_available_countries()
+ for uuid in squad_uuids:
+ for country in countries:
+ if country['uuid'] == uuid:
+ server_names.append(country['name'])
+ logger.debug(f"Найден сервер в кэше: {uuid} -> {country['name']}")
+ break
+
+ if not server_names:
+ if len(squad_uuids) == 1:
+ return "🎯 Тестовый сервер"
+ return f"{len(squad_uuids)} стран"
+
+ if len(server_names) > 6:
+ displayed = ", ".join(server_names[:6])
+ remaining = len(server_names) - 6
+ return f"{displayed} и ещё {remaining}"
+ else:
+ return ", ".join(server_names)
+
+ except Exception as e:
+ logger.error(f"Ошибка получения названий серверов: {e}")
+ if len(squad_uuids) == 1:
+ return "🎯 Тестовый сервер"
+ return f"{len(squad_uuids)} стран"
+
+async def get_current_devices_count(db_user: User) -> str:
+ try:
+ if not db_user.remnawave_uuid:
+ return "—"
+
+ from app.services.remnawave_service import RemnaWaveService
+ service = RemnaWaveService()
+
+ async with service.get_api_client() as api:
+ response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}')
+
+ if response and 'response' in response:
+ total_devices = response['response'].get('total', 0)
+ return str(total_devices)
+ else:
+ return "—"
+
+ except Exception as e:
+ logger.error(f"Ошибка получения количества устройств: {e}")
+ return "—"
+
+async def handle_change_devices(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
+):
+ texts = get_texts(db_user.language)
+ subscription = db_user.subscription
+
+ if not subscription or subscription.is_trial:
+ await callback.answer(
+ texts.t("PAID_FEATURE_ONLY", "⚠️ Эта функция доступна только для платных подписок"),
+ show_alert=True,
+ )
+ return
+
+ current_devices = subscription.device_limit
+
+ period_hint_days = _get_period_hint_from_subscription(subscription)
+ devices_discount_percent = _get_addon_discount_percent_for_user(
+ db_user,
+ "devices",
+ period_hint_days,
+ )
+
+ prompt_text = texts.t(
+ "CHANGE_DEVICES_PROMPT",
+ (
+ "📱 Изменение количества устройств\n\n"
+ "Текущий лимит: {current_devices} устройств\n"
+ "Выберите новое количество устройств:\n\n"
+ "💡 Важно:\n"
+ "• При увеличении - доплата пропорционально оставшемуся времени\n"
+ "• При уменьшении - возврат средств не производится"
+ ),
+ ).format(current_devices=current_devices)
+
+ await callback.message.edit_text(
+ prompt_text,
+ reply_markup=get_change_devices_keyboard(
+ current_devices,
+ db_user.language,
+ subscription.end_date,
+ devices_discount_percent,
+ ),
+ parse_mode="HTML"
+ )
+
+ await callback.answer()
+
+async def confirm_change_devices(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
+):
+ new_devices_count = int(callback.data.split('_')[2])
+ texts = get_texts(db_user.language)
+ subscription = db_user.subscription
+
+ current_devices = subscription.device_limit
+
+ if new_devices_count == current_devices:
+ await callback.answer(
+ texts.t("DEVICES_NO_CHANGE", "ℹ️ Количество устройств не изменилось"),
+ show_alert=True,
+ )
+ return
+
+ if settings.MAX_DEVICES_LIMIT > 0 and new_devices_count > settings.MAX_DEVICES_LIMIT:
+ await callback.answer(
+ texts.t(
+ "DEVICES_LIMIT_EXCEEDED",
+ "⚠️ Превышен максимальный лимит устройств ({limit})",
+ ).format(limit=settings.MAX_DEVICES_LIMIT),
+ show_alert=True
+ )
+ return
+
+ devices_difference = new_devices_count - current_devices
+
+ if devices_difference > 0:
+ additional_devices = devices_difference
+
+ if current_devices < settings.DEFAULT_DEVICE_LIMIT:
+ free_devices = settings.DEFAULT_DEVICE_LIMIT - current_devices
+ chargeable_devices = max(0, additional_devices - free_devices)
+ else:
+ chargeable_devices = additional_devices
+
+ devices_price_per_month = chargeable_devices * settings.PRICE_PER_DEVICE
+ months_hint = get_remaining_months(subscription.end_date)
+ period_hint_days = months_hint * 30 if months_hint > 0 else None
+ devices_discount_percent = _get_addon_discount_percent_for_user(
+ db_user,
+ "devices",
+ period_hint_days,
+ )
+ discounted_per_month, discount_per_month = apply_percentage_discount(
+ devices_price_per_month,
+ devices_discount_percent,
+ )
+ price, charged_months = calculate_prorated_price(
+ discounted_per_month,
+ subscription.end_date,
+ )
+ total_discount = discount_per_month * charged_months
+
+ if price > 0 and db_user.balance_kopeks < price:
+ missing_kopeks = price - db_user.balance_kopeks
+ required_text = f"{texts.format_price(price)} (за {charged_months} мес)"
+ message_text = texts.t(
+ "ADDON_INSUFFICIENT_FUNDS_MESSAGE",
+ (
+ "⚠️ Недостаточно средств\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.answer(
+ message_text,
+ reply_markup=get_insufficient_balance_keyboard(
+ db_user.language,
+ amount_kopeks=missing_kopeks,
+ ),
+ parse_mode="HTML",
+ )
+ await callback.answer()
+ return
+
+ action_text = texts.t(
+ "DEVICE_CHANGE_ACTION_INCREASE",
+ "увеличить до {count}",
+ ).format(count=new_devices_count)
+ if price > 0:
+ cost_text = texts.t(
+ "DEVICE_CHANGE_EXTRA_COST",
+ "Доплата: {amount} (за {months} мес)",
+ ).format(
+ amount=texts.format_price(price),
+ months=charged_months,
+ )
+ if total_discount > 0:
+ cost_text += texts.t(
+ "DEVICE_CHANGE_DISCOUNT_INFO",
+ " (скидка {percent}%: -{amount})",
+ ).format(
+ percent=devices_discount_percent,
+ amount=texts.format_price(total_discount),
+ )
+ else:
+ cost_text = texts.t("DEVICE_CHANGE_FREE", "Бесплатно")
+
+ else:
+ price = 0
+ action_text = texts.t(
+ "DEVICE_CHANGE_ACTION_DECREASE",
+ "уменьшить до {count}",
+ ).format(count=new_devices_count)
+ cost_text = texts.t("DEVICE_CHANGE_NO_REFUND", "Возврат средств не производится")
+
+ confirm_text = texts.t(
+ "DEVICE_CHANGE_CONFIRMATION",
+ (
+ "📱 Подтверждение изменения\n\n"
+ "Текущее количество: {current} устройств\n"
+ "Новое количество: {new} устройств\n\n"
+ "Действие: {action}\n"
+ "💰 {cost}\n\n"
+ "Подтвердить изменение?"
+ ),
+ ).format(
+ current=current_devices,
+ new=new_devices_count,
+ action=action_text,
+ cost=cost_text,
+ )
+
+ await callback.message.edit_text(
+ confirm_text,
+ reply_markup=get_confirm_change_devices_keyboard(new_devices_count, price, db_user.language),
+ parse_mode="HTML"
+ )
+
+ await callback.answer()
+
+async def execute_change_devices(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
+):
+ callback_parts = callback.data.split('_')
+ new_devices_count = int(callback_parts[3])
+ price = int(callback_parts[4])
+
+ texts = get_texts(db_user.language)
+ subscription = db_user.subscription
+ current_devices = subscription.device_limit
+
+ try:
+ if price > 0:
+ success = await subtract_user_balance(
+ db, db_user, price,
+ f"Изменение количества устройств с {current_devices} до {new_devices_count}"
+ )
+
+ if not success:
+ await callback.answer(
+ texts.t("PAYMENT_CHARGE_ERROR", "⚠️ Ошибка списания средств"),
+ show_alert=True,
+ )
+ return
+
+ charged_months = get_remaining_months(subscription.end_date)
+ await create_transaction(
+ db=db,
+ user_id=db_user.id,
+ type=TransactionType.SUBSCRIPTION_PAYMENT,
+ amount_kopeks=price,
+ description=f"Изменение устройств с {current_devices} до {new_devices_count} на {charged_months} мес"
+ )
+
+ subscription.device_limit = new_devices_count
+ subscription.updated_at = datetime.utcnow()
+
+ await db.commit()
+
+ subscription_service = SubscriptionService()
+ await subscription_service.update_remnawave_user(db, subscription)
+
+ await db.refresh(db_user)
+ await db.refresh(subscription)
+
+ 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, "devices", current_devices, new_devices_count, price
+ )
+ except Exception as e:
+ logger.error(f"Ошибка отправки уведомления об изменении устройств: {e}")
+
+ if new_devices_count > current_devices:
+ success_text = texts.t(
+ "DEVICE_CHANGE_INCREASE_SUCCESS",
+ "✅ Количество устройств увеличено!\n\n",
+ )
+ success_text += texts.t(
+ "DEVICE_CHANGE_RESULT_LINE",
+ "📱 Было: {old} → Стало: {new}\n",
+ ).format(old=current_devices, new=new_devices_count)
+ if price > 0:
+ success_text += texts.t(
+ "DEVICE_CHANGE_CHARGED",
+ "💰 Списано: {amount}",
+ ).format(amount=texts.format_price(price))
+ else:
+ success_text = texts.t(
+ "DEVICE_CHANGE_DECREASE_SUCCESS",
+ "✅ Количество устройств уменьшено!\n\n",
+ )
+ success_text += texts.t(
+ "DEVICE_CHANGE_RESULT_LINE",
+ "📱 Было: {old} → Стало: {new}\n",
+ ).format(old=current_devices, new=new_devices_count)
+ success_text += texts.t(
+ "DEVICE_CHANGE_NO_REFUND_INFO",
+ "ℹ️ Возврат средств не производится",
+ )
+
+ await callback.message.edit_text(
+ success_text,
+ reply_markup=get_back_keyboard(db_user.language)
+ )
+
+ logger.info(
+ f"✅ Пользователь {db_user.telegram_id} изменил количество устройств с {current_devices} на {new_devices_count}, доплата: {price / 100}₽")
+
+ except Exception as e:
+ logger.error(f"Ошибка изменения количества устройств: {e}")
+ await callback.message.edit_text(
+ texts.ERROR,
+ reply_markup=get_back_keyboard(db_user.language)
+ )
+
+ await callback.answer()
+
+async def handle_device_management(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
+):
+ texts = get_texts(db_user.language)
+ subscription = db_user.subscription
+
+ if not subscription or subscription.is_trial:
+ await callback.answer(
+ texts.t("PAID_FEATURE_ONLY", "⚠️ Эта функция доступна только для платных подписок"),
+ show_alert=True,
+ )
+ return
+
+ if not db_user.remnawave_uuid:
+ await callback.answer(
+ texts.t("DEVICE_UUID_NOT_FOUND", "❌ UUID пользователя не найден"),
+ show_alert=True,
+ )
+ return
+
+ try:
+ from app.services.remnawave_service import RemnaWaveService
+ service = RemnaWaveService()
+
+ async with service.get_api_client() as api:
+ response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}')
+
+ if response and 'response' in response:
+ devices_info = response['response']
+ total_devices = devices_info.get('total', 0)
+ devices_list = devices_info.get('devices', [])
+
+ if total_devices == 0:
+ await callback.message.edit_text(
+ texts.t("DEVICE_NONE_CONNECTED", "ℹ️ У вас нет подключенных устройств"),
+ reply_markup=get_back_keyboard(db_user.language)
+ )
+ await callback.answer()
+ return
+
+ await show_devices_page(callback, db_user, devices_list, page=1)
+ else:
+ await callback.answer(
+ texts.t(
+ "DEVICE_FETCH_INFO_ERROR",
+ "❌ Ошибка получения информации об устройствах",
+ ),
+ show_alert=True,
+ )
+
+ except Exception as e:
+ logger.error(f"Ошибка получения списка устройств: {e}")
+ await callback.answer(
+ texts.t(
+ "DEVICE_FETCH_INFO_ERROR",
+ "❌ Ошибка получения информации об устройствах",
+ ),
+ show_alert=True,
+ )
+
+ await callback.answer()
+
+async def show_devices_page(
+ callback: types.CallbackQuery,
+ db_user: User,
+ devices_list: List[dict],
+ page: int = 1
+):
+ texts = get_texts(db_user.language)
+ devices_per_page = 5
+
+ pagination = paginate_list(devices_list, page=page, per_page=devices_per_page)
+
+ devices_text = texts.t(
+ "DEVICE_MANAGEMENT_OVERVIEW",
+ (
+ "🔄 Управление устройствами\n\n"
+ "📊 Всего подключено: {total} устройств\n"
+ "📄 Страница {page} из {pages}\n\n"
+ ),
+ ).format(total=len(devices_list), page=pagination.page, pages=pagination.total_pages)
+
+ if pagination.items:
+ devices_text += texts.t(
+ "DEVICE_MANAGEMENT_CONNECTED_HEADER",
+ "Подключенные устройства:\n",
+ )
+ for i, device in enumerate(pagination.items, 1):
+ platform = device.get('platform', 'Unknown')
+ device_model = device.get('deviceModel', 'Unknown')
+ device_info = f"{platform} - {device_model}"
+
+ if len(device_info) > 35:
+ device_info = device_info[:32] + "..."
+
+ devices_text += texts.t(
+ "DEVICE_MANAGEMENT_LIST_ITEM",
+ "• {device}\n",
+ ).format(device=device_info)
+
+ devices_text += texts.t(
+ "DEVICE_MANAGEMENT_ACTIONS",
+ (
+ "\n💡 Действия:\n"
+ "• Выберите устройство для сброса\n"
+ "• Или сбросьте все устройства сразу"
+ ),
+ )
+
+ await callback.message.edit_text(
+ devices_text,
+ reply_markup=get_devices_management_keyboard(
+ pagination.items,
+ pagination,
+ db_user.language
+ ),
+ parse_mode="HTML"
+ )
+
+async def handle_devices_page(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
+):
+ page = int(callback.data.split('_')[2])
+ texts = get_texts(db_user.language)
+
+ try:
+ from app.services.remnawave_service import RemnaWaveService
+ service = RemnaWaveService()
+
+ async with service.get_api_client() as api:
+ response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}')
+
+ if response and 'response' in response:
+ devices_list = response['response'].get('devices', [])
+ await show_devices_page(callback, db_user, devices_list, page=page)
+ else:
+ await callback.answer(
+ texts.t("DEVICE_FETCH_ERROR", "❌ Ошибка получения устройств"),
+ show_alert=True,
+ )
+
+ except Exception as e:
+ logger.error(f"Ошибка перехода на страницу устройств: {e}")
+ await callback.answer(
+ texts.t("DEVICE_PAGE_LOAD_ERROR", "❌ Ошибка загрузки страницы"),
+ show_alert=True,
+ )
+
+async def handle_single_device_reset(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
+):
+ try:
+ callback_parts = callback.data.split('_')
+ if len(callback_parts) < 4:
+ logger.error(f"Некорректный формат callback_data: {callback.data}")
+ await callback.answer(
+ texts.t("DEVICE_RESET_INVALID_REQUEST", "❌ Ошибка: некорректный запрос"),
+ show_alert=True,
+ )
+ return
+
+ device_index = int(callback_parts[2])
+ page = int(callback_parts[3])
+
+ logger.info(f"🔧 Сброс устройства: index={device_index}, page={page}")
+
+ except (ValueError, IndexError) as e:
+ logger.error(f"❌ Ошибка парсинга callback_data {callback.data}: {e}")
+ await callback.answer(
+ texts.t("DEVICE_RESET_PARSE_ERROR", "❌ Ошибка обработки запроса"),
+ show_alert=True,
+ )
+ return
+
+ texts = get_texts(db_user.language)
+
+ try:
+ from app.services.remnawave_service import RemnaWaveService
+ service = RemnaWaveService()
+
+ async with service.get_api_client() as api:
+ response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}')
+
+ if response and 'response' in response:
+ devices_list = response['response'].get('devices', [])
+
+ devices_per_page = 5
+ pagination = paginate_list(devices_list, page=page, per_page=devices_per_page)
+
+ if device_index < len(pagination.items):
+ device = pagination.items[device_index]
+ device_hwid = device.get('hwid')
+
+ if device_hwid:
+ delete_data = {
+ "userUuid": db_user.remnawave_uuid,
+ "hwid": device_hwid
+ }
+
+ await api._make_request('POST', '/api/hwid/devices/delete', data=delete_data)
+
+ platform = device.get('platform', 'Unknown')
+ device_model = device.get('deviceModel', 'Unknown')
+ device_info = f"{platform} - {device_model}"
+
+ await callback.answer(
+ texts.t(
+ "DEVICE_RESET_SUCCESS",
+ "✅ Устройство {device} успешно сброшено!",
+ ).format(device=device_info),
+ show_alert=True,
+ )
+
+ updated_response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}')
+ if updated_response and 'response' in updated_response:
+ updated_devices = updated_response['response'].get('devices', [])
+
+ if updated_devices:
+ updated_pagination = paginate_list(updated_devices, page=page,
+ per_page=devices_per_page)
+ if not updated_pagination.items and page > 1:
+ page = page - 1
+
+ await show_devices_page(callback, db_user, updated_devices, page=page)
+ else:
+ await callback.message.edit_text(
+ texts.t(
+ "DEVICE_RESET_ALL_DONE",
+ "ℹ️ Все устройства сброшены",
+ ),
+ reply_markup=get_back_keyboard(db_user.language)
+ )
+
+ logger.info(f"✅ Пользователь {db_user.telegram_id} сбросил устройство {device_info}")
+ else:
+ await callback.answer(
+ texts.t(
+ "DEVICE_RESET_ID_FAILED",
+ "❌ Не удалось получить ID устройства",
+ ),
+ show_alert=True,
+ )
+ else:
+ await callback.answer(
+ texts.t("DEVICE_RESET_NOT_FOUND", "❌ Устройство не найдено"),
+ show_alert=True,
+ )
+ else:
+ await callback.answer(
+ texts.t("DEVICE_FETCH_ERROR", "❌ Ошибка получения устройств"),
+ show_alert=True,
+ )
+
+ except Exception as e:
+ logger.error(f"Ошибка сброса устройства: {e}")
+ await callback.answer(
+ texts.t("DEVICE_RESET_ERROR", "❌ Ошибка сброса устройства"),
+ show_alert=True,
+ )
+
+async def handle_all_devices_reset_from_management(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
+):
+ texts = get_texts(db_user.language)
+
+ if not db_user.remnawave_uuid:
+ await callback.answer(
+ texts.t("DEVICE_UUID_NOT_FOUND", "❌ UUID пользователя не найден"),
+ show_alert=True,
+ )
+ return
+
+ try:
+ from app.services.remnawave_service import RemnaWaveService
+ service = RemnaWaveService()
+
+ async with service.get_api_client() as api:
+ devices_response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}')
+
+ if not devices_response or 'response' not in devices_response:
+ await callback.answer(
+ texts.t(
+ "DEVICE_LIST_FETCH_ERROR",
+ "❌ Ошибка получения списка устройств",
+ ),
+ show_alert=True,
+ )
+ return
+
+ devices_list = devices_response['response'].get('devices', [])
+
+ if not devices_list:
+ await callback.answer(
+ texts.t("DEVICE_NONE_CONNECTED", "ℹ️ У вас нет подключенных устройств"),
+ show_alert=True,
+ )
+ return
+
+ logger.info(f"🔧 Найдено {len(devices_list)} устройств для сброса")
+
+ success_count = 0
+ failed_count = 0
+
+ for device in devices_list:
+ device_hwid = device.get('hwid')
+ if device_hwid:
+ try:
+ delete_data = {
+ "userUuid": db_user.remnawave_uuid,
+ "hwid": device_hwid
+ }
+
+ await api._make_request('POST', '/api/hwid/devices/delete', data=delete_data)
+ success_count += 1
+ logger.info(f"✅ Устройство {device_hwid} удалено")
+
+ except Exception as device_error:
+ failed_count += 1
+ logger.error(f"❌ Ошибка удаления устройства {device_hwid}: {device_error}")
+ else:
+ failed_count += 1
+ logger.warning(f"⚠️ У устройства нет HWID: {device}")
+
+ if success_count > 0:
+ if failed_count == 0:
+ await callback.message.edit_text(
+ texts.t(
+ "DEVICE_RESET_ALL_SUCCESS_MESSAGE",
+ (
+ "✅ Все устройства успешно сброшены!\n\n"
+ "🔄 Сброшено: {count} устройств\n"
+ "📱 Теперь вы можете заново подключить свои устройства\n\n"
+ "💡 Используйте ссылку из раздела 'Моя подписка' для повторного подключения"
+ ),
+ ).format(count=success_count),
+ reply_markup=get_back_keyboard(db_user.language),
+ parse_mode="HTML"
+ )
+ logger.info(f"✅ Пользователь {db_user.telegram_id} успешно сбросил {success_count} устройств")
+ else:
+ await callback.message.edit_text(
+ texts.t(
+ "DEVICE_RESET_PARTIAL_MESSAGE",
+ (
+ "⚠️ Частичный сброс устройств\n\n"
+ "✅ Удалено: {success} устройств\n"
+ "❌ Не удалось удалить: {failed} устройств\n\n"
+ "Попробуйте еще раз или обратитесь в поддержку."
+ ),
+ ).format(success=success_count, failed=failed_count),
+ reply_markup=get_back_keyboard(db_user.language),
+ parse_mode="HTML"
+ )
+ logger.warning(
+ f"⚠️ Частичный сброс у пользователя {db_user.telegram_id}: {success_count}/{len(devices_list)}")
+ else:
+ await callback.message.edit_text(
+ texts.t(
+ "DEVICE_RESET_ALL_FAILED_MESSAGE",
+ (
+ "❌ Не удалось сбросить устройства\n\n"
+ "Попробуйте еще раз позже или обратитесь в техподдержку.\n\n"
+ "Всего устройств: {total}"
+ ),
+ ).format(total=len(devices_list)),
+ reply_markup=get_back_keyboard(db_user.language),
+ parse_mode="HTML"
+ )
+ logger.error(f"❌ Не удалось сбросить ни одного устройства у пользователя {db_user.telegram_id}")
+
+ except Exception as e:
+ logger.error(f"Ошибка сброса всех устройств: {e}")
+ await callback.message.edit_text(
+ texts.ERROR,
+ reply_markup=get_back_keyboard(db_user.language)
+ )
+
+ await callback.answer()
+
+async def confirm_add_devices(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
+):
+ devices_count = int(callback.data.split('_')[2])
+ texts = get_texts(db_user.language)
+ subscription = db_user.subscription
+
+ resume_callback = None
+
+ new_total_devices = subscription.device_limit + devices_count
+
+ if settings.MAX_DEVICES_LIMIT > 0 and new_total_devices > settings.MAX_DEVICES_LIMIT:
+ await callback.answer(
+ f"⚠️ Превышен максимальный лимит устройств ({settings.MAX_DEVICES_LIMIT}). "
+ f"У вас: {subscription.device_limit}, добавляете: {devices_count}",
+ show_alert=True
+ )
+ return
+
+ devices_price_per_month = devices_count * settings.PRICE_PER_DEVICE
+ months_hint = get_remaining_months(subscription.end_date)
+ period_hint_days = months_hint * 30 if months_hint > 0 else None
+ devices_discount_percent = _get_addon_discount_percent_for_user(
+ db_user,
+ "devices",
+ period_hint_days,
+ )
+ discounted_per_month, discount_per_month = apply_percentage_discount(
+ devices_price_per_month,
+ devices_discount_percent,
+ )
+ price, charged_months = calculate_prorated_price(
+ discounted_per_month,
+ subscription.end_date,
+ )
+ total_discount = discount_per_month * charged_months
+
+ logger.info(
+ "Добавление %s устройств: %.2f₽/мес × %s мес = %.2f₽ (скидка %.2f₽)",
+ devices_count,
+ discounted_per_month / 100,
+ charged_months,
+ price / 100,
+ total_discount / 100,
+ )
+
+ if db_user.balance_kopeks < price:
+ missing_kopeks = price - db_user.balance_kopeks
+ required_text = f"{texts.format_price(price)} (за {charged_months} мес)"
+ message_text = texts.t(
+ "ADDON_INSUFFICIENT_FUNDS_MESSAGE",
+ (
+ "⚠️ Недостаточно средств\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,
+ resume_callback=resume_callback,
+ amount_kopeks=missing_kopeks,
+ ),
+ parse_mode="HTML",
+ )
+ await callback.answer()
+ return
+
+ try:
+ success = await subtract_user_balance(
+ db, db_user, price,
+ f"Добавление {devices_count} устройств на {charged_months} мес"
+ )
+
+ if not success:
+ await callback.answer("⚠️ Ошибка списания средств", show_alert=True)
+ return
+
+ await add_subscription_devices(db, subscription, devices_count)
+
+ subscription_service = SubscriptionService()
+ await subscription_service.update_remnawave_user(db, subscription)
+
+ await create_transaction(
+ db=db,
+ user_id=db_user.id,
+ type=TransactionType.SUBSCRIPTION_PAYMENT,
+ amount_kopeks=price,
+ description=f"Добавление {devices_count} устройств на {charged_months} мес"
+ )
+
+ await db.refresh(db_user)
+ await db.refresh(subscription)
+
+ success_text = (
+ "✅ Устройства успешно добавлены!\n\n"
+ f"📱 Добавлено: {devices_count} устройств\n"
+ f"Новый лимит: {subscription.device_limit} устройств\n"
+ )
+ success_text += f"💰 Списано: {texts.format_price(price)} (за {charged_months} мес)"
+ if total_discount > 0:
+ success_text += (
+ f" (скидка {devices_discount_percent}%:"
+ f" -{texts.format_price(total_discount)})"
+ )
+
+ await callback.message.edit_text(
+ success_text,
+ reply_markup=get_back_keyboard(db_user.language)
+ )
+
+ logger.info(f"✅ Пользователь {db_user.telegram_id} добавил {devices_count} устройств за {price / 100}₽")
+
+ except Exception as e:
+ logger.error(f"Ошибка добавления устройств: {e}")
+ await callback.message.edit_text(
+ texts.ERROR,
+ reply_markup=get_back_keyboard(db_user.language)
+ )
+
+ await callback.answer()
+
+async def handle_reset_devices(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
+):
+ await handle_device_management(callback, db_user, db)
+
+async def confirm_reset_devices(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
+):
+ await handle_device_management(callback, db_user, db)
+
+async def handle_device_guide(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
+):
+ device_type = callback.data.split('_')[2]
+ texts = get_texts(db_user.language)
+ subscription = db_user.subscription
+ subscription_link = get_display_subscription_link(subscription)
+
+ if not subscription_link:
+ await callback.answer(
+ texts.t("SUBSCRIPTION_LINK_UNAVAILABLE", "❌ Ссылка подписки недоступна"),
+ show_alert=True,
+ )
+ return
+
+ apps = get_apps_for_device(device_type, db_user.language)
+ hide_subscription_link = settings.should_hide_subscription_link()
+
+ if not apps:
+ await callback.answer(
+ texts.t("SUBSCRIPTION_DEVICE_APPS_NOT_FOUND", "❌ Приложения для этого устройства не найдены"),
+ show_alert=True,
+ )
+ return
+
+ featured_app = next((app for app in apps if app.get('isFeatured', False)), apps[0])
+ featured_app_id = featured_app.get('id')
+ other_apps = [
+ app for app in apps
+ if isinstance(app, dict) and app.get('id') and app.get('id') != featured_app_id
+ ]
+
+ other_app_names = ", ".join(
+ str(app.get('name')).strip()
+ for app in other_apps
+ if isinstance(app.get('name'), str) and app.get('name').strip()
+ )
+
+ if hide_subscription_link:
+ link_section = (
+ texts.t("SUBSCRIPTION_DEVICE_LINK_TITLE", "🔗 Ссылка подписки:")
+ + "\n"
+ + texts.t(
+ "SUBSCRIPTION_LINK_HIDDEN_NOTICE",
+ "ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе \"Моя подписка\".",
+ )
+ + "\n\n"
+ )
+ else:
+ link_section = (
+ texts.t("SUBSCRIPTION_DEVICE_LINK_TITLE", "🔗 Ссылка подписки:")
+ + f"\n{subscription_link}\n\n"
+ )
+
+ installation_description = get_step_description(featured_app, "installationStep", db_user.language)
+ add_description = get_step_description(featured_app, "addSubscriptionStep", db_user.language)
+ connect_description = get_step_description(featured_app, "connectAndUseStep", db_user.language)
+ additional_before_text = format_additional_section(
+ featured_app.get("additionalBeforeAddSubscriptionStep"),
+ texts,
+ db_user.language,
+ )
+ additional_after_text = format_additional_section(
+ featured_app.get("additionalAfterAddSubscriptionStep"),
+ texts,
+ db_user.language,
+ )
+
+ guide_text = (
+ texts.t(
+ "SUBSCRIPTION_DEVICE_GUIDE_TITLE",
+ "📱 Настройка для {device_name}",
+ ).format(device_name=get_device_name(device_type, db_user.language))
+ + "\n\n"
+ + link_section
+ + texts.t(
+ "SUBSCRIPTION_DEVICE_FEATURED_APP",
+ "📋 Рекомендуемое приложение: {app_name}",
+ ).format(app_name=featured_app.get('name', ''))
+ )
+
+ if other_app_names:
+ guide_text += "\n\n" + texts.t(
+ "SUBSCRIPTION_DEVICE_OTHER_APPS",
+ "📦 Другие приложения: {app_list}",
+ ).format(app_list=other_app_names)
+ guide_text += "\n" + texts.t(
+ "SUBSCRIPTION_DEVICE_OTHER_APPS_HINT",
+ "Нажмите кнопку \"Другие приложения\" ниже, чтобы выбрать приложение.",
+ )
+
+ guide_text += "\n\n" + texts.t("SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE", "Шаг 1 - Установка:")
+ if installation_description:
+ guide_text += f"\n{installation_description}"
+
+ if additional_before_text:
+ guide_text += f"\n\n{additional_before_text}"
+
+ guide_text += "\n\n" + texts.t("SUBSCRIPTION_DEVICE_STEP_ADD_TITLE", "Шаг 2 - Добавление подписки:")
+ if add_description:
+ guide_text += f"\n{add_description}"
+
+ guide_text += "\n\n" + texts.t("SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE", "Шаг 3 - Подключение:")
+ if connect_description:
+ guide_text += f"\n{connect_description}"
+
+ guide_text += "\n\n" + texts.t("SUBSCRIPTION_DEVICE_HOW_TO_TITLE", "💡 Как подключить:")
+ guide_text += "\n" + "\n".join(
+ [
+ texts.t(
+ "SUBSCRIPTION_DEVICE_HOW_TO_STEP1",
+ "1. Установите приложение по ссылке выше",
+ ),
+ texts.t(
+ "SUBSCRIPTION_DEVICE_HOW_TO_STEP2",
+ "2. Нажмите кнопку \"Подключиться\" ниже",
+ ),
+ texts.t(
+ "SUBSCRIPTION_DEVICE_HOW_TO_STEP3",
+ "3. Откройте приложение и вставьте ссылку",
+ ),
+ texts.t(
+ "SUBSCRIPTION_DEVICE_HOW_TO_STEP4",
+ "4. Подключитесь к серверу",
+ ),
+ ]
+ )
+
+ if additional_after_text:
+ guide_text += f"\n\n{additional_after_text}"
+
+ await callback.message.edit_text(
+ guide_text,
+ reply_markup=get_connection_guide_keyboard(
+ subscription_link,
+ featured_app,
+ device_type,
+ db_user.language,
+ has_other_apps=bool(other_apps),
+ ),
+ parse_mode="HTML"
+ )
+ await callback.answer()
+
+async def handle_app_selection(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
+):
+ device_type = callback.data.split('_')[2]
+ texts = get_texts(db_user.language)
+ subscription = db_user.subscription
+
+ apps = get_apps_for_device(device_type, db_user.language)
+
+ if not apps:
+ await callback.answer(
+ texts.t("SUBSCRIPTION_DEVICE_APPS_NOT_FOUND", "❌ Приложения для этого устройства не найдены"),
+ show_alert=True,
+ )
+ return
+
+ app_text = (
+ texts.t(
+ "SUBSCRIPTION_APPS_TITLE",
+ "📱 Приложения для {device_name}",
+ ).format(device_name=get_device_name(device_type, db_user.language))
+ + "\n\n"
+ + texts.t("SUBSCRIPTION_APPS_PROMPT", "Выберите приложение для подключения:")
+ )
+
+ await callback.message.edit_text(
+ app_text,
+ reply_markup=get_app_selection_keyboard(device_type, apps, db_user.language),
+ parse_mode="HTML"
+ )
+ await callback.answer()
+
+async def handle_specific_app_guide(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
+):
+ _, device_type, app_id = callback.data.split('_')
+ texts = get_texts(db_user.language)
+ subscription = db_user.subscription
+
+ subscription_link = get_display_subscription_link(subscription)
+
+ if not subscription_link:
+ await callback.answer(
+ texts.t("SUBSCRIPTION_LINK_UNAVAILABLE", "❌ Ссылка подписки недоступна"),
+ show_alert=True,
+ )
+ return
+
+ apps = get_apps_for_device(device_type, db_user.language)
+ app = next((a for a in apps if a['id'] == app_id), None)
+
+ if not app:
+ await callback.answer(
+ texts.t("SUBSCRIPTION_APP_NOT_FOUND", "❌ Приложение не найдено"),
+ show_alert=True,
+ )
+ return
+
+ hide_subscription_link = settings.should_hide_subscription_link()
+
+ if hide_subscription_link:
+ link_section = (
+ texts.t("SUBSCRIPTION_DEVICE_LINK_TITLE", "🔗 Ссылка подписки:")
+ + "\n"
+ + texts.t(
+ "SUBSCRIPTION_LINK_HIDDEN_NOTICE",
+ "ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе \"Моя подписка\".",
+ )
+ + "\n\n"
+ )
+ else:
+ link_section = (
+ texts.t("SUBSCRIPTION_DEVICE_LINK_TITLE", "🔗 Ссылка подписки:")
+ + f"\n{subscription_link}\n\n"
+ )
+
+ installation_description = get_step_description(app, "installationStep", db_user.language)
+ add_description = get_step_description(app, "addSubscriptionStep", db_user.language)
+ connect_description = get_step_description(app, "connectAndUseStep", db_user.language)
+ additional_before_text = format_additional_section(
+ app.get("additionalBeforeAddSubscriptionStep"),
+ texts,
+ db_user.language,
+ )
+ additional_after_text = format_additional_section(
+ app.get("additionalAfterAddSubscriptionStep"),
+ texts,
+ db_user.language,
+ )
+
+ guide_text = (
+ texts.t(
+ "SUBSCRIPTION_SPECIFIC_APP_TITLE",
+ "📱 {app_name} - {device_name}",
+ ).format(app_name=app.get('name', ''), device_name=get_device_name(device_type, db_user.language))
+ + "\n\n"
+ + link_section
+ )
+
+ guide_text += texts.t("SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE", "Шаг 1 - Установка:")
+ if installation_description:
+ guide_text += f"\n{installation_description}"
+
+ if additional_before_text:
+ guide_text += f"\n\n{additional_before_text}"
+
+ guide_text += "\n\n" + texts.t("SUBSCRIPTION_DEVICE_STEP_ADD_TITLE", "Шаг 2 - Добавление подписки:")
+ if add_description:
+ guide_text += f"\n{add_description}"
+
+ guide_text += "\n\n" + texts.t("SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE", "Шаг 3 - Подключение:")
+ if connect_description:
+ guide_text += f"\n{connect_description}"
+
+ if additional_after_text:
+ guide_text += f"\n\n{additional_after_text}"
+
+ await callback.message.edit_text(
+ guide_text,
+ reply_markup=get_specific_app_keyboard(
+ subscription_link,
+ app,
+ device_type,
+ db_user.language
+ ),
+ parse_mode="HTML"
+ )
+ await callback.answer()
+
+async def show_device_connection_help(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
+):
+ subscription = db_user.subscription
+ subscription_link = get_display_subscription_link(subscription)
+
+ if not subscription_link:
+ await callback.answer("❌ Ссылка подписки недоступна", show_alert=True)
+ return
+
+ help_text = f"""
+📱 Как подключить устройство заново
+
+После сброса устройства вам нужно:
+
+1. Получить ссылку подписки:
+📋 Скопируйте ссылку ниже или найдите её в разделе "Моя подписка"
+
+2. Настроить VPN приложение:
+• Откройте ваше VPN приложение
+• Найдите функцию "Добавить подписку" или "Import"
+• Вставьте скопированную ссылку
+
+3. Подключиться:
+• Выберите сервер
+• Нажмите "Подключить"
+
+🔗 Ваша ссылка подписки:
+{subscription_link}
+
+💡 Совет: Сохраните эту ссылку - она понадобится для подключения новых устройств
+"""
+
+ await callback.message.edit_text(
+ help_text,
+ reply_markup=get_device_management_help_keyboard(db_user.language),
+ parse_mode="HTML"
+ )
+ await callback.answer()
diff --git a/app/handlers/subscription/happ.py b/app/handlers/subscription/happ.py
new file mode 100644
index 00000000..a56e249c
--- /dev/null
+++ b/app/handlers/subscription/happ.py
@@ -0,0 +1,158 @@
+import base64
+import json
+import logging
+from datetime import datetime, timedelta
+from typing import Dict, List, Any, Tuple, Optional
+from urllib.parse import quote
+from aiogram import Dispatcher, types, F
+from aiogram.fsm.context import FSMContext
+from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
+from sqlalchemy.ext.asyncio import AsyncSession
+from app.config import settings, PERIOD_PRICES, get_traffic_prices
+from app.database.crud.discount_offer import (
+ get_offer_by_id,
+ mark_offer_claimed,
+)
+from app.database.crud.promo_offer_template import get_promo_offer_template_by_id
+from app.database.crud.subscription import (
+ create_trial_subscription,
+ create_paid_subscription, add_subscription_traffic, add_subscription_devices,
+ update_subscription_autopay
+)
+from app.database.crud.transaction import create_transaction
+from app.database.crud.user import subtract_user_balance
+from app.database.models import (
+ User, TransactionType, SubscriptionStatus,
+ Subscription
+)
+from app.keyboards.inline import (
+ get_subscription_keyboard, get_trial_keyboard,
+ get_subscription_period_keyboard, get_traffic_packages_keyboard,
+ get_countries_keyboard, get_devices_keyboard,
+ get_subscription_confirm_keyboard, get_autopay_keyboard,
+ get_autopay_days_keyboard, get_back_keyboard,
+ get_add_traffic_keyboard,
+ get_change_devices_keyboard, get_reset_traffic_confirm_keyboard,
+ get_manage_countries_keyboard,
+ get_device_selection_keyboard, get_connection_guide_keyboard,
+ get_app_selection_keyboard, get_specific_app_keyboard,
+ get_updated_subscription_settings_keyboard, get_insufficient_balance_keyboard,
+ get_extend_subscription_keyboard_with_prices, get_confirm_change_devices_keyboard,
+ get_devices_management_keyboard, get_device_management_help_keyboard,
+ get_happ_cryptolink_keyboard,
+ get_happ_download_platform_keyboard, get_happ_download_link_keyboard,
+ get_happ_download_button_row,
+ get_payment_methods_keyboard_with_cart,
+ get_subscription_confirm_keyboard_with_cart,
+ get_insufficient_balance_keyboard_with_cart
+)
+from app.localization.texts import get_texts
+from app.services.admin_notification_service import AdminNotificationService
+from app.services.remnawave_service import RemnaWaveService
+from app.services.subscription_checkout_service import (
+ clear_subscription_checkout_draft,
+ get_subscription_checkout_draft,
+ save_subscription_checkout_draft,
+ should_offer_checkout_resume,
+)
+from app.services.subscription_service import SubscriptionService
+from app.utils.miniapp_buttons import build_miniapp_or_callback_button
+from app.services.promo_offer_service import promo_offer_service
+from app.states import SubscriptionStates
+from app.utils.pagination import paginate_list
+from app.utils.pricing_utils import (
+ calculate_months_from_days,
+ get_remaining_months,
+ calculate_prorated_price,
+ validate_pricing_calculation,
+ format_period_description,
+ apply_percentage_discount,
+)
+from app.utils.subscription_utils import (
+ get_display_subscription_link,
+ get_happ_cryptolink_redirect_link,
+ convert_subscription_link_to_happ_scheme,
+)
+from app.utils.promo_offer import (
+ build_promo_offer_hint,
+ get_user_active_promo_discount_percent,
+)
+
+async def handle_happ_download_request(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
+):
+ texts = get_texts(db_user.language)
+ prompt_text = texts.t(
+ "HAPP_DOWNLOAD_PROMPT",
+ "📥 Скачать Happ\nВыберите ваше устройство:",
+ )
+
+ keyboard = get_happ_download_platform_keyboard(db_user.language)
+
+ await callback.message.answer(prompt_text, reply_markup=keyboard, parse_mode="HTML")
+ await callback.answer()
+
+async def handle_happ_download_platform_choice(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
+):
+ platform = callback.data.split('_')[-1]
+ if platform == "pc":
+ platform = "windows"
+ texts = get_texts(db_user.language)
+ link = settings.get_happ_download_link(platform)
+
+ if not link:
+ await callback.answer(
+ texts.t("HAPP_DOWNLOAD_LINK_NOT_SET", "❌ Ссылка для этого устройства не настроена"),
+ show_alert=True,
+ )
+ return
+
+ platform_names = {
+ "ios": texts.t("HAPP_PLATFORM_IOS", "🍎 iOS"),
+ "android": texts.t("HAPP_PLATFORM_ANDROID", "🤖 Android"),
+ "macos": texts.t("HAPP_PLATFORM_MACOS", "🖥️ Mac OS"),
+ "windows": texts.t("HAPP_PLATFORM_WINDOWS", "💻 Windows"),
+ }
+
+ link_text = texts.t(
+ "HAPP_DOWNLOAD_LINK_MESSAGE",
+ "⬇️ Скачайте Happ для {platform}:",
+ ).format(platform=platform_names.get(platform, platform.upper()))
+
+ keyboard = get_happ_download_link_keyboard(db_user.language, link)
+
+ await callback.message.edit_text(link_text, reply_markup=keyboard, parse_mode="HTML")
+ await callback.answer()
+
+async def handle_happ_download_close(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
+):
+ try:
+ await callback.message.delete()
+ except Exception:
+ pass
+
+ await callback.answer()
+
+async def handle_happ_download_back(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
+):
+ texts = get_texts(db_user.language)
+ prompt_text = texts.t(
+ "HAPP_DOWNLOAD_PROMPT",
+ "📥 Скачать Happ\nВыберите ваше устройство:",
+ )
+
+ keyboard = get_happ_download_platform_keyboard(db_user.language)
+
+ await callback.message.edit_text(prompt_text, reply_markup=keyboard, parse_mode="HTML")
+ await callback.answer()
diff --git a/app/handlers/subscription/links.py b/app/handlers/subscription/links.py
new file mode 100644
index 00000000..a8842030
--- /dev/null
+++ b/app/handlers/subscription/links.py
@@ -0,0 +1,354 @@
+import base64
+import json
+import logging
+from datetime import datetime, timedelta
+from typing import Dict, List, Any, Tuple, Optional
+from urllib.parse import quote
+from aiogram import Dispatcher, types, F
+from aiogram.fsm.context import FSMContext
+from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
+from sqlalchemy.ext.asyncio import AsyncSession
+from app.config import settings, PERIOD_PRICES, get_traffic_prices
+from app.database.crud.discount_offer import (
+ get_offer_by_id,
+ mark_offer_claimed,
+)
+from app.database.crud.promo_offer_template import get_promo_offer_template_by_id
+from app.database.crud.subscription import (
+ create_trial_subscription,
+ create_paid_subscription, add_subscription_traffic, add_subscription_devices,
+ update_subscription_autopay
+)
+from app.database.crud.transaction import create_transaction
+from app.database.crud.user import subtract_user_balance
+from app.database.models import (
+ User, TransactionType, SubscriptionStatus,
+ Subscription
+)
+from app.keyboards.inline import (
+ get_subscription_keyboard, get_trial_keyboard,
+ get_subscription_period_keyboard, get_traffic_packages_keyboard,
+ get_countries_keyboard, get_devices_keyboard,
+ get_subscription_confirm_keyboard, get_autopay_keyboard,
+ get_autopay_days_keyboard, get_back_keyboard,
+ get_add_traffic_keyboard,
+ get_change_devices_keyboard, get_reset_traffic_confirm_keyboard,
+ get_manage_countries_keyboard,
+ get_device_selection_keyboard, get_connection_guide_keyboard,
+ get_app_selection_keyboard, get_specific_app_keyboard,
+ get_updated_subscription_settings_keyboard, get_insufficient_balance_keyboard,
+ get_extend_subscription_keyboard_with_prices, get_confirm_change_devices_keyboard,
+ get_devices_management_keyboard, get_device_management_help_keyboard,
+ get_happ_cryptolink_keyboard,
+ get_happ_download_platform_keyboard, get_happ_download_link_keyboard,
+ get_happ_download_button_row,
+ get_payment_methods_keyboard_with_cart,
+ get_subscription_confirm_keyboard_with_cart,
+ get_insufficient_balance_keyboard_with_cart
+)
+from app.localization.texts import get_texts
+from app.services.admin_notification_service import AdminNotificationService
+from app.services.remnawave_service import RemnaWaveService
+from app.services.subscription_checkout_service import (
+ clear_subscription_checkout_draft,
+ get_subscription_checkout_draft,
+ save_subscription_checkout_draft,
+ should_offer_checkout_resume,
+)
+from app.services.subscription_service import SubscriptionService
+from app.utils.miniapp_buttons import build_miniapp_or_callback_button
+from app.services.promo_offer_service import promo_offer_service
+from app.states import SubscriptionStates
+from app.utils.pagination import paginate_list
+from app.utils.pricing_utils import (
+ calculate_months_from_days,
+ get_remaining_months,
+ calculate_prorated_price,
+ validate_pricing_calculation,
+ format_period_description,
+ apply_percentage_discount,
+)
+from app.utils.subscription_utils import (
+ get_display_subscription_link,
+ get_happ_cryptolink_redirect_link,
+ convert_subscription_link_to_happ_scheme,
+)
+from app.utils.promo_offer import (
+ build_promo_offer_hint,
+ get_user_active_promo_discount_percent,
+)
+
+async def handle_connect_subscription(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
+):
+ texts = get_texts(db_user.language)
+ subscription = db_user.subscription
+ subscription_link = get_display_subscription_link(subscription)
+ hide_subscription_link = settings.should_hide_subscription_link()
+
+ if not subscription_link:
+ await callback.answer(
+ texts.t(
+ "SUBSCRIPTION_NO_ACTIVE_LINK",
+ "⚠ У вас нет активной подписки или ссылка еще генерируется",
+ ),
+ show_alert=True,
+ )
+ return
+
+ connect_mode = settings.CONNECT_BUTTON_MODE
+
+ if connect_mode == "miniapp_subscription":
+ keyboard = InlineKeyboardMarkup(inline_keyboard=[
+ [
+ InlineKeyboardButton(
+ text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
+ web_app=types.WebAppInfo(url=subscription_link)
+ )
+ ],
+ [
+ InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")
+ ]
+ ])
+
+ await callback.message.edit_text(
+ texts.t(
+ "SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE",
+ """📱 Подключить подписку
+
+🚀 Нажмите кнопку ниже, чтобы открыть подписку в мини-приложении Telegram:""",
+ ),
+ reply_markup=keyboard,
+ parse_mode="HTML"
+ )
+
+ elif connect_mode == "miniapp_custom":
+ if not settings.MINIAPP_CUSTOM_URL:
+ await callback.answer(
+ texts.t(
+ "CUSTOM_MINIAPP_URL_NOT_SET",
+ "⚠ Кастомная ссылка для мини-приложения не настроена",
+ ),
+ show_alert=True,
+ )
+ return
+
+ keyboard = InlineKeyboardMarkup(inline_keyboard=[
+ [
+ InlineKeyboardButton(
+ text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
+ web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL)
+ )
+ ],
+ [
+ InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")
+ ]
+ ])
+
+ await callback.message.edit_text(
+ texts.t(
+ "SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE",
+ """🚀 Подключить подписку
+
+📱 Нажмите кнопку ниже, чтобы открыть приложение:""",
+ ),
+ reply_markup=keyboard,
+ parse_mode="HTML"
+ )
+
+ elif connect_mode == "link":
+ rows = [
+ [
+ InlineKeyboardButton(
+ text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
+ url=subscription_link
+ )
+ ]
+ ]
+ happ_row = get_happ_download_button_row(texts)
+ if happ_row:
+ rows.append(happ_row)
+ rows.append([
+ InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")
+ ])
+
+ keyboard = InlineKeyboardMarkup(inline_keyboard=rows)
+
+ await callback.message.edit_text(
+ texts.t(
+ "SUBSCRIPTION_CONNECT_LINK_MESSAGE",
+ """🚀 Подключить подписку",
+
+🔗 Нажмите кнопку ниже, чтобы открыть ссылку подписки:""",
+ ),
+ reply_markup=keyboard,
+ parse_mode="HTML"
+ )
+ elif connect_mode == "happ_cryptolink":
+ rows = [
+ [
+ InlineKeyboardButton(
+ text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
+ callback_data="open_subscription_link",
+ )
+ ]
+ ]
+ happ_row = get_happ_download_button_row(texts)
+ if happ_row:
+ rows.append(happ_row)
+ rows.append([
+ InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")
+ ])
+
+ keyboard = InlineKeyboardMarkup(inline_keyboard=rows)
+
+ await callback.message.edit_text(
+ texts.t(
+ "SUBSCRIPTION_CONNECT_LINK_MESSAGE",
+ """🚀 Подключить подписку",
+
+🔗 Нажмите кнопку ниже, чтобы открыть ссылку подписки:""",
+ ),
+ reply_markup=keyboard,
+ parse_mode="HTML"
+ )
+ else:
+ if hide_subscription_link:
+ device_text = texts.t(
+ "SUBSCRIPTION_CONNECT_DEVICE_MESSAGE_HIDDEN",
+ """📱 Подключить подписку
+
+ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе "Моя подписка".
+
+💡 Выберите ваше устройство для получения подробной инструкции по настройке:""",
+ )
+ else:
+ device_text = texts.t(
+ "SUBSCRIPTION_CONNECT_DEVICE_MESSAGE",
+ """📱 Подключить подписку
+
+🔗 Ссылка подписки:
+{subscription_url}
+
+💡 Выберите ваше устройство для получения подробной инструкции по настройке:""",
+ ).format(subscription_url=subscription_link)
+
+ await callback.message.edit_text(
+ device_text,
+ reply_markup=get_device_selection_keyboard(db_user.language),
+ parse_mode="HTML"
+ )
+
+ await callback.answer()
+
+async def handle_open_subscription_link(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
+):
+ texts = get_texts(db_user.language)
+ subscription = db_user.subscription
+ subscription_link = get_display_subscription_link(subscription)
+
+ if not subscription_link:
+ await callback.answer(
+ texts.t("SUBSCRIPTION_LINK_UNAVAILABLE", "❌ Ссылка подписки недоступна"),
+ show_alert=True,
+ )
+ return
+
+ if settings.is_happ_cryptolink_mode():
+ redirect_link = get_happ_cryptolink_redirect_link(subscription_link)
+ happ_scheme_link = convert_subscription_link_to_happ_scheme(subscription_link)
+ happ_message = (
+ texts.t(
+ "SUBSCRIPTION_HAPP_OPEN_TITLE",
+ "🔗 Подключение через Happ",
+ )
+ + "\n\n"
+ + texts.t(
+ "SUBSCRIPTION_HAPP_OPEN_LINK",
+ "🔓 Открыть ссылку в Happ",
+ ).format(subscription_link=happ_scheme_link)
+ + "\n\n"
+ + texts.t(
+ "SUBSCRIPTION_HAPP_OPEN_HINT",
+ "💡 Если ссылка не открывается автоматически, скопируйте её вручную:",
+ )
+ )
+
+ if redirect_link:
+ happ_message += "\n\n" + texts.t(
+ "SUBSCRIPTION_HAPP_OPEN_BUTTON_HINT",
+ "▶️ Нажмите кнопку \"Подключиться\" ниже, чтобы открыть Happ и добавить подписку автоматически.",
+ )
+
+ happ_message += "\n\n" + texts.t(
+ "SUBSCRIPTION_HAPP_CRYPTOLINK_BLOCK",
+ "{crypto_link}",
+ ).format(crypto_link=subscription_link)
+
+ keyboard = get_happ_cryptolink_keyboard(
+ subscription_link,
+ db_user.language,
+ redirect_link=redirect_link,
+ )
+
+ await callback.message.answer(
+ happ_message,
+ parse_mode="HTML",
+ disable_web_page_preview=True,
+ reply_markup=keyboard,
+ )
+ await callback.answer()
+ return
+
+ link_text = (
+ texts.t("SUBSCRIPTION_DEVICE_LINK_TITLE", "🔗 Ссылка подписки:")
+ + "\n\n"
+ + f"{subscription_link}\n\n"
+ + texts.t("SUBSCRIPTION_LINK_USAGE_TITLE", "📱 Как использовать:")
+ + "\n"
+ + "\n".join(
+ [
+ texts.t(
+ "SUBSCRIPTION_LINK_STEP1",
+ "1. Нажмите на ссылку выше чтобы её скопировать",
+ ),
+ texts.t(
+ "SUBSCRIPTION_LINK_STEP2",
+ "2. Откройте ваше VPN приложение",
+ ),
+ texts.t(
+ "SUBSCRIPTION_LINK_STEP3",
+ "3. Найдите функцию \"Добавить подписку\" или \"Import\"",
+ ),
+ texts.t(
+ "SUBSCRIPTION_LINK_STEP4",
+ "4. Вставьте скопированную ссылку",
+ ),
+ ]
+ )
+ + "\n\n"
+ + texts.t(
+ "SUBSCRIPTION_LINK_HINT",
+ "💡 Если ссылка не скопировалась, выделите её вручную и скопируйте.",
+ )
+ )
+
+ await callback.message.edit_text(
+ link_text,
+ reply_markup=InlineKeyboardMarkup(inline_keyboard=[
+ [
+ InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
+ callback_data="subscription_connect")
+ ],
+ [
+ InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")
+ ]
+ ]),
+ parse_mode="HTML"
+ )
+ await callback.answer()
diff --git a/app/handlers/subscription/notifications.py b/app/handlers/subscription/notifications.py
new file mode 100644
index 00000000..6e2e61e7
--- /dev/null
+++ b/app/handlers/subscription/notifications.py
@@ -0,0 +1,131 @@
+import base64
+import json
+import logging
+from datetime import datetime, timedelta
+from typing import Dict, List, Any, Tuple, Optional
+from urllib.parse import quote
+from aiogram import Dispatcher, types, F
+from aiogram.fsm.context import FSMContext
+from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
+from sqlalchemy.ext.asyncio import AsyncSession
+from app.config import settings, PERIOD_PRICES, get_traffic_prices
+from app.database.crud.discount_offer import (
+ get_offer_by_id,
+ mark_offer_claimed,
+)
+from app.database.crud.promo_offer_template import get_promo_offer_template_by_id
+from app.database.crud.subscription import (
+ create_trial_subscription,
+ create_paid_subscription, add_subscription_traffic, add_subscription_devices,
+ update_subscription_autopay
+)
+from app.database.crud.transaction import create_transaction
+from app.database.crud.user import subtract_user_balance
+from app.database.models import (
+ User, TransactionType, SubscriptionStatus,
+ Subscription
+)
+from app.keyboards.inline import (
+ get_subscription_keyboard, get_trial_keyboard,
+ get_subscription_period_keyboard, get_traffic_packages_keyboard,
+ get_countries_keyboard, get_devices_keyboard,
+ get_subscription_confirm_keyboard, get_autopay_keyboard,
+ get_autopay_days_keyboard, get_back_keyboard,
+ get_add_traffic_keyboard,
+ get_change_devices_keyboard, get_reset_traffic_confirm_keyboard,
+ get_manage_countries_keyboard,
+ get_device_selection_keyboard, get_connection_guide_keyboard,
+ get_app_selection_keyboard, get_specific_app_keyboard,
+ get_updated_subscription_settings_keyboard, get_insufficient_balance_keyboard,
+ get_extend_subscription_keyboard_with_prices, get_confirm_change_devices_keyboard,
+ get_devices_management_keyboard, get_device_management_help_keyboard,
+ get_happ_cryptolink_keyboard,
+ get_happ_download_platform_keyboard, get_happ_download_link_keyboard,
+ get_happ_download_button_row,
+ get_payment_methods_keyboard_with_cart,
+ get_subscription_confirm_keyboard_with_cart,
+ get_insufficient_balance_keyboard_with_cart
+)
+from app.localization.texts import get_texts
+from app.services.admin_notification_service import AdminNotificationService
+from app.services.remnawave_service import RemnaWaveService
+from app.services.subscription_checkout_service import (
+ clear_subscription_checkout_draft,
+ get_subscription_checkout_draft,
+ save_subscription_checkout_draft,
+ should_offer_checkout_resume,
+)
+from app.services.subscription_service import SubscriptionService
+from app.utils.miniapp_buttons import build_miniapp_or_callback_button
+from app.services.promo_offer_service import promo_offer_service
+from app.states import SubscriptionStates
+from app.utils.pagination import paginate_list
+from app.utils.pricing_utils import (
+ calculate_months_from_days,
+ get_remaining_months,
+ calculate_prorated_price,
+ validate_pricing_calculation,
+ format_period_description,
+ apply_percentage_discount,
+)
+from app.utils.subscription_utils import (
+ get_display_subscription_link,
+ get_happ_cryptolink_redirect_link,
+ convert_subscription_link_to_happ_scheme,
+)
+from app.utils.promo_offer import (
+ build_promo_offer_hint,
+ get_user_active_promo_discount_percent,
+)
+
+from .common import logger
+
+async def send_trial_notification(callback: types.CallbackQuery, db: AsyncSession, db_user: User,
+ subscription: Subscription):
+ try:
+ notification_service = AdminNotificationService(callback.bot)
+ await notification_service.send_trial_activation_notification(db, db_user, subscription)
+ except Exception as e:
+ logger.error(f"Ошибка отправки уведомления о триале: {e}")
+
+async def send_purchase_notification(
+ callback: types.CallbackQuery,
+ db: AsyncSession,
+ db_user: User,
+ subscription: Subscription,
+ transaction_id: int,
+ period_days: int,
+ was_trial_conversion: bool = False
+):
+ try:
+ from app.database.crud.transaction import get_transaction_by_id
+
+ transaction = await get_transaction_by_id(db, transaction_id)
+ if transaction:
+ notification_service = AdminNotificationService(callback.bot)
+ await notification_service.send_subscription_purchase_notification(
+ db, db_user, subscription, transaction, period_days, was_trial_conversion
+ )
+ except Exception as e:
+ logger.error(f"Ошибка отправки уведомления о покупке: {e}")
+
+async def send_extension_notification(
+ callback: types.CallbackQuery,
+ db: AsyncSession,
+ db_user: User,
+ subscription: Subscription,
+ transaction_id: int,
+ extended_days: int,
+ old_end_date: datetime
+):
+ try:
+ from app.database.crud.transaction import get_transaction_by_id
+
+ transaction = await get_transaction_by_id(db, transaction_id)
+ if transaction:
+ notification_service = AdminNotificationService(callback.bot)
+ await notification_service.send_subscription_extension_notification(
+ db, db_user, subscription, transaction, extended_days, old_end_date
+ )
+ except Exception as e:
+ logger.error(f"Ошибка отправки уведомления о продлении: {e}")
diff --git a/app/handlers/subscription/pricing.py b/app/handlers/subscription/pricing.py
new file mode 100644
index 00000000..b1a589f4
--- /dev/null
+++ b/app/handlers/subscription/pricing.py
@@ -0,0 +1,465 @@
+import base64
+import json
+import logging
+from datetime import datetime, timedelta
+from typing import Dict, List, Any, Tuple, Optional
+from urllib.parse import quote
+from aiogram import Dispatcher, types, F
+from aiogram.fsm.context import FSMContext
+from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
+from sqlalchemy.ext.asyncio import AsyncSession
+from app.config import settings, PERIOD_PRICES, get_traffic_prices
+from app.database.crud.discount_offer import (
+ get_offer_by_id,
+ mark_offer_claimed,
+)
+from app.database.crud.promo_offer_template import get_promo_offer_template_by_id
+from app.database.crud.subscription import (
+ create_trial_subscription,
+ create_paid_subscription, add_subscription_traffic, add_subscription_devices,
+ update_subscription_autopay
+)
+from app.database.crud.transaction import create_transaction
+from app.database.crud.user import subtract_user_balance
+from app.database.models import (
+ User, TransactionType, SubscriptionStatus,
+ Subscription
+)
+from app.keyboards.inline import (
+ get_subscription_keyboard, get_trial_keyboard,
+ get_subscription_period_keyboard, get_traffic_packages_keyboard,
+ get_countries_keyboard, get_devices_keyboard,
+ get_subscription_confirm_keyboard, get_autopay_keyboard,
+ get_autopay_days_keyboard, get_back_keyboard,
+ get_add_traffic_keyboard,
+ get_change_devices_keyboard, get_reset_traffic_confirm_keyboard,
+ get_manage_countries_keyboard,
+ get_device_selection_keyboard, get_connection_guide_keyboard,
+ get_app_selection_keyboard, get_specific_app_keyboard,
+ get_updated_subscription_settings_keyboard, get_insufficient_balance_keyboard,
+ get_extend_subscription_keyboard_with_prices, get_confirm_change_devices_keyboard,
+ get_devices_management_keyboard, get_device_management_help_keyboard,
+ get_happ_cryptolink_keyboard,
+ get_happ_download_platform_keyboard, get_happ_download_link_keyboard,
+ get_happ_download_button_row,
+ get_payment_methods_keyboard_with_cart,
+ get_subscription_confirm_keyboard_with_cart,
+ get_insufficient_balance_keyboard_with_cart
+)
+from app.localization.texts import get_texts
+from app.services.admin_notification_service import AdminNotificationService
+from app.services.remnawave_service import RemnaWaveService
+from app.services.subscription_checkout_service import (
+ clear_subscription_checkout_draft,
+ get_subscription_checkout_draft,
+ save_subscription_checkout_draft,
+ should_offer_checkout_resume,
+)
+from app.services.subscription_service import SubscriptionService
+from app.utils.miniapp_buttons import build_miniapp_or_callback_button
+from app.services.promo_offer_service import promo_offer_service
+from app.states import SubscriptionStates
+from app.utils.pagination import paginate_list
+from app.utils.pricing_utils import (
+ calculate_months_from_days,
+ get_remaining_months,
+ calculate_prorated_price,
+ validate_pricing_calculation,
+ format_period_description,
+ apply_percentage_discount,
+)
+from app.utils.subscription_utils import (
+ get_display_subscription_link,
+ get_happ_cryptolink_redirect_link,
+ convert_subscription_link_to_happ_scheme,
+)
+from app.utils.promo_offer import (
+ build_promo_offer_hint,
+ get_user_active_promo_discount_percent,
+)
+
+from .common import _apply_discount_to_monthly_component, _apply_promo_offer_discount, logger
+from .countries import _get_available_countries, _get_countries_info, get_countries_price_by_uuids_fallback
+from .devices import get_current_devices_count
+from .promo import _build_promo_group_discount_text, _get_promo_offer_hint
+
+async def _prepare_subscription_summary(
+ db_user: User,
+ data: Dict[str, Any],
+ texts,
+) -> Tuple[str, Dict[str, Any]]:
+ summary_data = dict(data)
+ countries = await _get_available_countries(db_user.promo_group_id)
+
+ months_in_period = calculate_months_from_days(summary_data['period_days'])
+ period_display = format_period_description(summary_data['period_days'], db_user.language)
+
+ base_price_original = PERIOD_PRICES[summary_data['period_days']]
+ period_discount_percent = db_user.get_promo_discount(
+ "period",
+ summary_data['period_days'],
+ )
+ base_price, base_discount_total = apply_percentage_discount(
+ base_price_original,
+ period_discount_percent,
+ )
+
+ if settings.is_traffic_fixed():
+ traffic_limit = settings.get_fixed_traffic_limit()
+ traffic_price_per_month = settings.get_traffic_price(traffic_limit)
+ final_traffic_gb = traffic_limit
+ else:
+ traffic_gb = summary_data.get('traffic_gb', 0)
+ traffic_price_per_month = settings.get_traffic_price(traffic_gb)
+ final_traffic_gb = traffic_gb
+
+ traffic_discount_percent = db_user.get_promo_discount(
+ "traffic",
+ summary_data['period_days'],
+ )
+ traffic_component = _apply_discount_to_monthly_component(
+ traffic_price_per_month,
+ traffic_discount_percent,
+ months_in_period,
+ )
+ total_traffic_price = traffic_component["total"]
+
+ countries_price_per_month = 0
+ selected_countries_names: List[str] = []
+ selected_server_prices: List[int] = []
+ server_monthly_prices: List[int] = []
+
+ selected_country_ids = set(summary_data.get('countries', []))
+ for country in countries:
+ if country['uuid'] in selected_country_ids:
+ server_price_per_month = country['price_kopeks']
+ countries_price_per_month += server_price_per_month
+ selected_countries_names.append(country['name'])
+ server_monthly_prices.append(server_price_per_month)
+
+ servers_discount_percent = db_user.get_promo_discount(
+ "servers",
+ summary_data['period_days'],
+ )
+ total_countries_price = 0
+ total_servers_discount = 0
+ discounted_servers_price_per_month = 0
+
+ for server_price_per_month in server_monthly_prices:
+ discounted_per_month, discount_per_month = apply_percentage_discount(
+ server_price_per_month,
+ servers_discount_percent,
+ )
+ total_price_for_server = discounted_per_month * months_in_period
+ total_discount_for_server = discount_per_month * months_in_period
+
+ discounted_servers_price_per_month += discounted_per_month
+ total_countries_price += total_price_for_server
+ 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)
+ 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(
+ "devices",
+ summary_data['period_days'],
+ )
+ devices_component = _apply_discount_to_monthly_component(
+ devices_price_per_month,
+ devices_discount_percent,
+ months_in_period,
+ )
+ total_devices_price = devices_component["total"]
+
+ total_price = base_price + total_traffic_price + total_countries_price + total_devices_price
+
+ discounted_monthly_additions = (
+ traffic_component["discounted_per_month"]
+ + discounted_servers_price_per_month
+ + devices_component["discounted_per_month"]
+ )
+
+ is_valid = validate_pricing_calculation(
+ base_price,
+ discounted_monthly_additions,
+ months_in_period,
+ total_price,
+ )
+
+ if not is_valid:
+ raise ValueError("Subscription price calculation validation failed")
+
+ original_total_price = total_price
+ promo_offer_component = _apply_promo_offer_discount(db_user, total_price)
+ if promo_offer_component["discount"] > 0:
+ total_price = promo_offer_component["discounted"]
+
+ summary_data['total_price'] = total_price
+ if promo_offer_component["discount"] > 0:
+ summary_data['promo_offer_discount_percent'] = promo_offer_component["percent"]
+ summary_data['promo_offer_discount_value'] = promo_offer_component["discount"]
+ summary_data['total_price_before_promo_offer'] = original_total_price
+ else:
+ summary_data.pop('promo_offer_discount_percent', None)
+ summary_data.pop('promo_offer_discount_value', None)
+ summary_data.pop('total_price_before_promo_offer', None)
+ summary_data['server_prices_for_period'] = selected_server_prices
+ summary_data['months_in_period'] = months_in_period
+ summary_data['base_price'] = base_price
+ summary_data['base_price_original'] = base_price_original
+ summary_data['base_discount_percent'] = period_discount_percent
+ summary_data['base_discount_total'] = base_discount_total
+ summary_data['final_traffic_gb'] = final_traffic_gb
+ summary_data['traffic_price_per_month'] = traffic_price_per_month
+ summary_data['traffic_discount_percent'] = traffic_component["discount_percent"]
+ summary_data['traffic_discount_total'] = traffic_component["discount_total"]
+ summary_data['traffic_discounted_price_per_month'] = traffic_component["discounted_per_month"]
+ summary_data['total_traffic_price'] = total_traffic_price
+ summary_data['servers_price_per_month'] = countries_price_per_month
+ summary_data['countries_price_per_month'] = countries_price_per_month
+ summary_data['servers_discount_percent'] = servers_discount_percent
+ summary_data['servers_discount_total'] = total_servers_discount
+ summary_data['servers_discounted_price_per_month'] = discounted_servers_price_per_month
+ summary_data['total_servers_price'] = total_countries_price
+ summary_data['total_countries_price'] = total_countries_price
+ summary_data['devices_price_per_month'] = devices_price_per_month
+ summary_data['devices_discount_percent'] = devices_component["discount_percent"]
+ summary_data['devices_discount_total'] = devices_component["discount_total"]
+ summary_data['devices_discounted_price_per_month'] = devices_component["discounted_per_month"]
+ summary_data['total_devices_price'] = total_devices_price
+ summary_data['discounted_monthly_additions'] = discounted_monthly_additions
+
+ if settings.is_traffic_fixed():
+ if final_traffic_gb == 0:
+ traffic_display = "Безлимитный"
+ else:
+ traffic_display = f"{final_traffic_gb} ГБ"
+ else:
+ if summary_data.get('traffic_gb', 0) == 0:
+ traffic_display = "Безлимитный"
+ else:
+ traffic_display = f"{summary_data.get('traffic_gb', 0)} ГБ"
+
+ if base_discount_total > 0:
+ base_line = (
+ f"- Базовый период: {subscription_url}"
+
+ return info_text
diff --git a/app/handlers/subscription/promo.py b/app/handlers/subscription/promo.py
new file mode 100644
index 00000000..ade900ef
--- /dev/null
+++ b/app/handlers/subscription/promo.py
@@ -0,0 +1,459 @@
+import base64
+import json
+import logging
+from datetime import datetime, timedelta
+from typing import Dict, List, Any, Tuple, Optional
+from urllib.parse import quote
+from aiogram import Dispatcher, types, F
+from aiogram.fsm.context import FSMContext
+from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
+from sqlalchemy.ext.asyncio import AsyncSession
+from app.config import settings, PERIOD_PRICES, get_traffic_prices
+from app.database.crud.discount_offer import (
+ get_offer_by_id,
+ mark_offer_claimed,
+)
+from app.database.crud.promo_offer_template import get_promo_offer_template_by_id
+from app.database.crud.subscription import (
+ create_trial_subscription,
+ create_paid_subscription, add_subscription_traffic, add_subscription_devices,
+ update_subscription_autopay
+)
+from app.database.crud.transaction import create_transaction
+from app.database.crud.user import subtract_user_balance
+from app.database.models import (
+ User, TransactionType, SubscriptionStatus,
+ Subscription
+)
+from app.keyboards.inline import (
+ get_subscription_keyboard, get_trial_keyboard,
+ get_subscription_period_keyboard, get_traffic_packages_keyboard,
+ get_countries_keyboard, get_devices_keyboard,
+ get_subscription_confirm_keyboard, get_autopay_keyboard,
+ get_autopay_days_keyboard, get_back_keyboard,
+ get_add_traffic_keyboard,
+ get_change_devices_keyboard, get_reset_traffic_confirm_keyboard,
+ get_manage_countries_keyboard,
+ get_device_selection_keyboard, get_connection_guide_keyboard,
+ get_app_selection_keyboard, get_specific_app_keyboard,
+ get_updated_subscription_settings_keyboard, get_insufficient_balance_keyboard,
+ get_extend_subscription_keyboard_with_prices, get_confirm_change_devices_keyboard,
+ get_devices_management_keyboard, get_device_management_help_keyboard,
+ get_happ_cryptolink_keyboard,
+ get_happ_download_platform_keyboard, get_happ_download_link_keyboard,
+ get_happ_download_button_row,
+ get_payment_methods_keyboard_with_cart,
+ get_subscription_confirm_keyboard_with_cart,
+ get_insufficient_balance_keyboard_with_cart
+)
+from app.localization.texts import get_texts
+from app.services.admin_notification_service import AdminNotificationService
+from app.services.remnawave_service import RemnaWaveService
+from app.services.subscription_checkout_service import (
+ clear_subscription_checkout_draft,
+ get_subscription_checkout_draft,
+ save_subscription_checkout_draft,
+ should_offer_checkout_resume,
+)
+from app.services.subscription_service import SubscriptionService
+from app.utils.miniapp_buttons import build_miniapp_or_callback_button
+from app.services.promo_offer_service import promo_offer_service
+from app.states import SubscriptionStates
+from app.utils.pagination import paginate_list
+from app.utils.pricing_utils import (
+ calculate_months_from_days,
+ get_remaining_months,
+ calculate_prorated_price,
+ validate_pricing_calculation,
+ format_period_description,
+ apply_percentage_discount,
+)
+from app.utils.subscription_utils import (
+ get_display_subscription_link,
+ get_happ_cryptolink_redirect_link,
+ convert_subscription_link_to_happ_scheme,
+)
+from app.utils.promo_offer import (
+ build_promo_offer_hint,
+ get_user_active_promo_discount_percent,
+)
+
+from .common import _format_text_with_placeholders
+
+async def _get_promo_offer_hint(
+ db: AsyncSession,
+ db_user: User,
+ texts,
+ percent: Optional[int] = None,
+) -> Optional[str]:
+ return await build_promo_offer_hint(db, db_user, texts, percent)
+
+def _build_promo_group_discount_text(
+ db_user: User,
+ periods: Optional[List[int]] = None,
+ texts=None,
+) -> str:
+ promo_group = getattr(db_user, "promo_group", None)
+
+ if not promo_group:
+ return ""
+
+ if texts is None:
+ texts = get_texts(db_user.language)
+
+ service_lines: List[str] = []
+
+ if promo_group.server_discount_percent > 0:
+ service_lines.append(
+ texts.PROMO_GROUP_DISCOUNT_SERVERS.format(
+ percent=promo_group.server_discount_percent
+ )
+ )
+
+ if promo_group.traffic_discount_percent > 0:
+ service_lines.append(
+ texts.PROMO_GROUP_DISCOUNT_TRAFFIC.format(
+ percent=promo_group.traffic_discount_percent
+ )
+ )
+
+ if promo_group.device_discount_percent > 0:
+ service_lines.append(
+ texts.PROMO_GROUP_DISCOUNT_DEVICES.format(
+ percent=promo_group.device_discount_percent
+ )
+ )
+
+ period_lines: List[str] = []
+
+ period_candidates: set[int] = set(periods or [])
+
+ raw_period_discounts = getattr(promo_group, "period_discounts", None)
+ if isinstance(raw_period_discounts, dict):
+ for key in raw_period_discounts.keys():
+ try:
+ period_candidates.add(int(key))
+ except (TypeError, ValueError):
+ continue
+
+ for period_days in sorted(period_candidates):
+ percent = promo_group.get_discount_percent("period", period_days)
+
+ if percent <= 0:
+ continue
+
+ period_display = format_period_description(period_days, db_user.language)
+ period_lines.append(
+ texts.PROMO_GROUP_PERIOD_DISCOUNT_ITEM.format(
+ period=period_display,
+ percent=percent,
+ )
+ )
+
+ if not service_lines and not period_lines:
+ return ""
+
+ lines: List[str] = [texts.PROMO_GROUP_DISCOUNTS_HEADER]
+
+ if service_lines:
+ lines.extend(service_lines)
+
+ if period_lines:
+ if service_lines:
+ lines.append("")
+
+ lines.append(texts.PROMO_GROUP_PERIOD_DISCOUNTS_HEADER)
+ lines.extend(period_lines)
+
+ return "\n".join(lines)
+
+async def claim_discount_offer(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession,
+):
+ texts = get_texts(db_user.language)
+
+ try:
+ offer_id = int(callback.data.split("_")[-1])
+ except (ValueError, AttributeError):
+ await callback.answer(
+ texts.get("DISCOUNT_CLAIM_NOT_FOUND", "❌ Предложение не найдено"),
+ show_alert=True,
+ )
+ return
+
+ offer = await get_offer_by_id(db, offer_id)
+ if not offer or offer.user_id != db_user.id:
+ await callback.answer(
+ texts.get("DISCOUNT_CLAIM_NOT_FOUND", "❌ Предложение не найдено"),
+ show_alert=True,
+ )
+ return
+
+ now = datetime.utcnow()
+ if offer.claimed_at is not None:
+ await callback.answer(
+ texts.get("DISCOUNT_CLAIM_ALREADY", "ℹ️ Скидка уже была активирована"),
+ show_alert=True,
+ )
+ return
+
+ if not offer.is_active or offer.expires_at <= now:
+ offer.is_active = False
+ await db.commit()
+ await callback.answer(
+ texts.get("DISCOUNT_CLAIM_EXPIRED", "⚠️ Время действия предложения истекло"),
+ show_alert=True,
+ )
+ return
+
+ effect_type = (offer.effect_type or "percent_discount").lower()
+ if effect_type == "balance_bonus":
+ effect_type = "percent_discount"
+
+ if effect_type == "test_access":
+ success, newly_added, expires_at, error_code = await promo_offer_service.grant_test_access(
+ db,
+ db_user,
+ offer,
+ )
+
+ if not success:
+ if error_code == "subscription_missing":
+ error_message = texts.get(
+ "TEST_ACCESS_NO_SUBSCRIPTION",
+ "❌ Для активации предложения необходима действующая подписка.",
+ )
+ elif error_code == "squads_missing":
+ error_message = texts.get(
+ "TEST_ACCESS_NO_SQUADS",
+ "❌ Не удалось определить список серверов для теста. Обратитесь к администратору.",
+ )
+ elif error_code == "already_connected":
+ error_message = texts.get(
+ "TEST_ACCESS_ALREADY_CONNECTED",
+ "ℹ️ Этот сервер уже подключен к вашей подписке.",
+ )
+ elif error_code == "remnawave_sync_failed":
+ error_message = texts.get(
+ "TEST_ACCESS_REMNAWAVE_ERROR",
+ "❌ Не удалось подключить серверы. Попробуйте позже или обратитесь в поддержку.",
+ )
+ else:
+ error_message = texts.get(
+ "TEST_ACCESS_UNKNOWN_ERROR",
+ "❌ Не удалось активировать предложение. Попробуйте позже.",
+ )
+ await callback.answer(error_message, show_alert=True)
+ return
+
+ await mark_offer_claimed(
+ db,
+ offer,
+ details={
+ "context": "test_access_claim",
+ "new_squads": newly_added,
+ "expires_at": expires_at.isoformat() if expires_at else None,
+ },
+ )
+
+ expires_text = expires_at.strftime("%d.%m.%Y %H:%M") if expires_at else ""
+ success_message = texts.get(
+ "TEST_ACCESS_ACTIVATED_MESSAGE",
+ "🎉 Тестовые сервера подключены! Доступ активен до {expires_at}.",
+ ).format(expires_at=expires_text)
+
+ popup_text = texts.get("TEST_ACCESS_ACTIVATED_POPUP", "✅ Доступ выдан!")
+ await callback.answer(popup_text, show_alert=True)
+ back_keyboard = InlineKeyboardMarkup(
+ inline_keyboard=[
+ [
+ InlineKeyboardButton(
+ text=texts.get("BACK_TO_MENU", "🏠 В главное меню"),
+ callback_data="back_to_menu",
+ )
+ ]
+ ]
+ )
+ await callback.message.answer(success_message, reply_markup=back_keyboard)
+ return
+
+ discount_percent = int(offer.discount_percent or 0)
+ if discount_percent <= 0:
+ await callback.answer(
+ texts.get("DISCOUNT_CLAIM_ERROR", "❌ Не удалось активировать скидку. Попробуйте позже."),
+ show_alert=True,
+ )
+ return
+
+ db_user.promo_offer_discount_percent = discount_percent
+ db_user.promo_offer_discount_source = offer.notification_type
+ db_user.updated_at = now
+
+ extra_data = offer.extra_data or {}
+ raw_duration = extra_data.get("active_discount_hours")
+ template_id = extra_data.get("template_id")
+
+ if raw_duration in (None, "") and template_id:
+ try:
+ template = await get_promo_offer_template_by_id(db, int(template_id))
+ except (ValueError, TypeError):
+ template = None
+ if template and template.active_discount_hours:
+ raw_duration = template.active_discount_hours
+
+ try:
+ duration_hours = int(raw_duration) if raw_duration is not None else None
+ except (TypeError, ValueError):
+ duration_hours = None
+
+ if duration_hours and duration_hours > 0:
+ discount_expires_at = now + timedelta(hours=duration_hours)
+ else:
+ discount_expires_at = None
+
+ db_user.promo_offer_discount_expires_at = discount_expires_at
+
+ await mark_offer_claimed(
+ db,
+ offer,
+ details={
+ "context": "discount_claim",
+ "discount_percent": discount_percent,
+ "discount_expires_at": discount_expires_at.isoformat() if discount_expires_at else None,
+ },
+ )
+ await db.refresh(db_user)
+
+ success_template = texts.get(
+ "DISCOUNT_CLAIM_SUCCESS",
+ "🎉 Скидка {percent}% активирована! Она автоматически применится при следующей оплате.",
+ )
+
+ expires_text = (
+ discount_expires_at.strftime("%d.%m.%Y %H:%M") if discount_expires_at else ""
+ )
+
+ format_values: Dict[str, Any] = {"percent": discount_percent}
+
+ if duration_hours and duration_hours > 0:
+ format_values.setdefault("hours", duration_hours)
+ format_values.setdefault("duration_hours", duration_hours)
+
+ if discount_expires_at:
+ format_values.setdefault("expires_at", expires_text)
+ format_values.setdefault("expires_at_iso", discount_expires_at.isoformat())
+ try:
+ expires_timestamp = int(discount_expires_at.timestamp())
+ except (OverflowError, OSError, ValueError):
+ expires_timestamp = None
+ if expires_timestamp:
+ format_values.setdefault("expires_at_ts", expires_timestamp)
+ remaining_hours = int((discount_expires_at - now).total_seconds() // 3600)
+ if remaining_hours > 0:
+ format_values.setdefault("expires_in_hours", remaining_hours)
+
+ amount_text = ""
+ if isinstance(extra_data, dict):
+ raw_amount_text = (
+ extra_data.get("amount_text")
+ or extra_data.get("discount_amount_text")
+ or extra_data.get("formatted_amount")
+ )
+ if isinstance(raw_amount_text, str) and raw_amount_text.strip():
+ amount_text = raw_amount_text.strip()
+ else:
+ raw_amount = extra_data.get("amount") or extra_data.get("discount_amount")
+ if isinstance(raw_amount, (int, float)):
+ amount_text = settings.format_price(int(raw_amount))
+ elif isinstance(raw_amount, str) and raw_amount.strip():
+ amount_text = raw_amount.strip()
+
+ if not amount_text:
+ for key in ("discount_amount_kopeks", "amount_kopeks", "bonus_amount_kopeks"):
+ maybe_amount = extra_data.get(key)
+ try:
+ amount_value = int(maybe_amount)
+ except (TypeError, ValueError):
+ continue
+ if amount_value > 0:
+ amount_text = settings.format_price(amount_value)
+ break
+
+ for key, value in extra_data.items():
+ if (
+ isinstance(key, str)
+ and key.isidentifier()
+ and key not in format_values
+ and isinstance(value, (str, int, float))
+ ):
+ format_values[key] = value
+
+ if not amount_text:
+ try:
+ bonus_amount = int(getattr(offer, "bonus_amount_kopeks", 0))
+ except (TypeError, ValueError):
+ bonus_amount = 0
+ if bonus_amount > 0:
+ amount_text = settings.format_price(bonus_amount)
+
+ if amount_text:
+ format_values.setdefault("amount", amount_text)
+
+ success_message = _format_text_with_placeholders(success_template, format_values)
+
+ await callback.answer("✅ Скидка активирована!", show_alert=True)
+
+ offer_type = None
+ if isinstance(extra_data, dict):
+ offer_type = extra_data.get("offer_type")
+
+ subscription = getattr(db_user, "subscription", None)
+
+ if offer_type == "purchase_discount":
+ button_text = texts.get("MENU_BUY_SUBSCRIPTION", "💎 Купить подписку")
+ button_callback = "subscription_upgrade"
+ elif offer_type == "extend_discount":
+ button_text = texts.get("SUBSCRIPTION_EXTEND", "💎 Продлить подписку")
+ button_callback = "subscription_extend"
+ else:
+ has_active_paid_subscription = bool(
+ subscription
+ and getattr(subscription, "is_active", False)
+ and not getattr(subscription, "is_trial", False)
+ )
+
+ if has_active_paid_subscription:
+ button_text = texts.get("SUBSCRIPTION_EXTEND", "💎 Продлить подписку")
+ button_callback = "subscription_extend"
+ else:
+ button_text = texts.get("MENU_BUY_SUBSCRIPTION", "💎 Купить подписку")
+ button_callback = "subscription_upgrade"
+
+ buy_keyboard = InlineKeyboardMarkup(
+ inline_keyboard=[
+ [
+ build_miniapp_or_callback_button(
+ text=button_text,
+ callback_data=button_callback,
+ )
+ ]
+ ]
+ )
+ await callback.message.answer(success_message, reply_markup=buy_keyboard)
+
+async def handle_promo_offer_close(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession,
+):
+ try:
+ await callback.message.delete()
+ except Exception:
+ try:
+ await callback.message.edit_reply_markup()
+ except Exception:
+ pass
+
+ await callback.answer()
diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py
new file mode 100644
index 00000000..c24a5d6f
--- /dev/null
+++ b/app/handlers/subscription/purchase.py
@@ -0,0 +1,2231 @@
+import base64
+import json
+import logging
+from datetime import datetime, timedelta
+from typing import Dict, List, Any, Tuple, Optional
+from urllib.parse import quote
+from aiogram import Dispatcher, types, F
+from aiogram.fsm.context import FSMContext
+from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
+from sqlalchemy.ext.asyncio import AsyncSession
+from app.config import settings, PERIOD_PRICES, get_traffic_prices
+from app.database.crud.discount_offer import (
+ get_offer_by_id,
+ mark_offer_claimed,
+)
+from app.database.crud.promo_offer_template import get_promo_offer_template_by_id
+from app.database.crud.subscription import (
+ create_trial_subscription,
+ create_paid_subscription, add_subscription_traffic, add_subscription_devices,
+ update_subscription_autopay
+)
+from app.database.crud.transaction import create_transaction
+from app.database.crud.user import subtract_user_balance
+from app.database.models import (
+ User, TransactionType, SubscriptionStatus,
+ Subscription
+)
+from app.keyboards.inline import (
+ get_subscription_keyboard, get_trial_keyboard,
+ get_subscription_period_keyboard, get_traffic_packages_keyboard,
+ get_countries_keyboard, get_devices_keyboard,
+ get_subscription_confirm_keyboard, get_autopay_keyboard,
+ get_autopay_days_keyboard, get_back_keyboard,
+ get_add_traffic_keyboard,
+ get_change_devices_keyboard, get_reset_traffic_confirm_keyboard,
+ get_manage_countries_keyboard,
+ get_device_selection_keyboard, get_connection_guide_keyboard,
+ get_app_selection_keyboard, get_specific_app_keyboard,
+ get_updated_subscription_settings_keyboard, get_insufficient_balance_keyboard,
+ get_extend_subscription_keyboard_with_prices, get_confirm_change_devices_keyboard,
+ get_devices_management_keyboard, get_device_management_help_keyboard,
+ get_happ_cryptolink_keyboard,
+ get_happ_download_platform_keyboard, get_happ_download_link_keyboard,
+ get_happ_download_button_row,
+ get_payment_methods_keyboard_with_cart,
+ get_subscription_confirm_keyboard_with_cart,
+ get_insufficient_balance_keyboard_with_cart
+)
+from app.localization.texts import get_texts
+from app.services.admin_notification_service import AdminNotificationService
+from app.services.remnawave_service import RemnaWaveService
+from app.services.subscription_checkout_service import (
+ clear_subscription_checkout_draft,
+ get_subscription_checkout_draft,
+ save_subscription_checkout_draft,
+ should_offer_checkout_resume,
+)
+from app.services.subscription_service import SubscriptionService
+from app.utils.miniapp_buttons import build_miniapp_or_callback_button
+from app.services.promo_offer_service import promo_offer_service
+from app.states import SubscriptionStates
+from app.utils.pagination import paginate_list
+from app.utils.pricing_utils import (
+ calculate_months_from_days,
+ get_remaining_months,
+ calculate_prorated_price,
+ validate_pricing_calculation,
+ format_period_description,
+ apply_percentage_discount,
+)
+from app.utils.subscription_utils import (
+ get_display_subscription_link,
+ get_happ_cryptolink_redirect_link,
+ convert_subscription_link_to_happ_scheme,
+)
+from app.utils.promo_offer import (
+ build_promo_offer_hint,
+ get_user_active_promo_discount_percent,
+)
+
+from .common import _apply_promo_offer_discount, _get_promo_offer_discount_percent, logger, update_traffic_prices
+from .autopay import handle_autopay_menu, handle_subscription_cancel, handle_subscription_config_back, set_autopay_days, show_autopay_days, toggle_autopay
+from .countries import _get_available_countries, _should_show_countries_management, apply_countries_changes, countries_continue, handle_add_countries, handle_manage_country, select_country
+from .devices import confirm_add_devices, confirm_change_devices, confirm_reset_devices, execute_change_devices, get_current_devices_count, get_servers_display_names, handle_all_devices_reset_from_management, handle_app_selection, handle_change_devices, handle_device_guide, handle_device_management, handle_devices_page, handle_reset_devices, handle_single_device_reset, handle_specific_app_guide, show_device_connection_help
+from .happ import handle_happ_download_back, handle_happ_download_close, handle_happ_download_platform_choice, handle_happ_download_request
+from .links import handle_connect_subscription, handle_open_subscription_link
+from .pricing import _build_subscription_period_prompt, _prepare_subscription_summary
+from .promo import _build_promo_group_discount_text, _get_promo_offer_hint, claim_discount_offer, handle_promo_offer_close
+from .traffic import confirm_reset_traffic, confirm_switch_traffic, execute_switch_traffic, handle_no_traffic_packages, handle_reset_traffic, handle_switch_traffic, select_traffic
+
+async def show_subscription_info(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
+):
+ await db.refresh(db_user)
+
+ texts = get_texts(db_user.language)
+ subscription = db_user.subscription
+
+ if not subscription:
+ await callback.message.edit_text(
+ texts.SUBSCRIPTION_NONE,
+ reply_markup=get_back_keyboard(db_user.language)
+ )
+ await callback.answer()
+ return
+
+ from app.database.crud.subscription import check_and_update_subscription_status
+ subscription = await check_and_update_subscription_status(db, subscription)
+
+ subscription_service = SubscriptionService()
+ await subscription_service.sync_subscription_usage(db, subscription)
+
+ await db.refresh(subscription)
+
+ current_time = datetime.utcnow()
+
+ if subscription.status == "expired" or subscription.end_date <= current_time:
+ actual_status = "expired"
+ status_display = texts.t("SUBSCRIPTION_STATUS_EXPIRED", "Истекла")
+ status_emoji = "🔴"
+ elif subscription.status == "active" and subscription.end_date > current_time:
+ if subscription.is_trial:
+ actual_status = "trial_active"
+ status_display = texts.t("SUBSCRIPTION_STATUS_TRIAL", "Тестовая")
+ status_emoji = "🎯"
+ else:
+ actual_status = "paid_active"
+ status_display = texts.t("SUBSCRIPTION_STATUS_ACTIVE", "Активна")
+ status_emoji = "💎"
+ else:
+ actual_status = "unknown"
+ status_display = texts.t("SUBSCRIPTION_STATUS_UNKNOWN", "Неизвестно")
+ status_emoji = "❓"
+
+ if subscription.end_date <= current_time:
+ days_left = 0
+ time_left_text = texts.t("SUBSCRIPTION_TIME_LEFT_EXPIRED", "истёк")
+ warning_text = ""
+ else:
+ delta = subscription.end_date - current_time
+ days_left = delta.days
+ hours_left = delta.seconds // 3600
+
+ if days_left > 1:
+ time_left_text = texts.t("SUBSCRIPTION_TIME_LEFT_DAYS", "{days} дн.").format(days=days_left)
+ warning_text = ""
+ elif days_left == 1:
+ time_left_text = texts.t("SUBSCRIPTION_TIME_LEFT_DAYS", "{days} дн.").format(days=days_left)
+ warning_text = texts.t("SUBSCRIPTION_WARNING_TOMORROW", "\n⚠️ истекает завтра!")
+ elif hours_left > 0:
+ time_left_text = texts.t("SUBSCRIPTION_TIME_LEFT_HOURS", "{hours} ч.").format(hours=hours_left)
+ warning_text = texts.t("SUBSCRIPTION_WARNING_TODAY", "\n⚠️ истекает сегодня!")
+ else:
+ minutes_left = (delta.seconds % 3600) // 60
+ time_left_text = texts.t("SUBSCRIPTION_TIME_LEFT_MINUTES", "{minutes} мин.").format(
+ minutes=minutes_left
+ )
+ warning_text = texts.t(
+ "SUBSCRIPTION_WARNING_MINUTES",
+ "\n🔴 истекает через несколько минут!",
+ )
+
+ subscription_type = (
+ texts.t("SUBSCRIPTION_TYPE_TRIAL", "Триал")
+ if subscription.is_trial
+ else texts.t("SUBSCRIPTION_TYPE_PAID", "Платная")
+ )
+
+ used_traffic = f"{subscription.traffic_used_gb:.1f}"
+ if subscription.traffic_limit_gb == 0:
+ traffic_used_display = texts.t(
+ "SUBSCRIPTION_TRAFFIC_UNLIMITED",
+ "∞ (безлимит) | Использовано: {used} ГБ",
+ ).format(used=used_traffic)
+ else:
+ traffic_used_display = texts.t(
+ "SUBSCRIPTION_TRAFFIC_LIMITED",
+ "{used} / {limit} ГБ",
+ ).format(used=used_traffic, limit=subscription.traffic_limit_gb)
+
+ devices_used_str = "—"
+ devices_list = []
+ devices_count = 0
+
+ try:
+ if db_user.remnawave_uuid:
+ from app.services.remnawave_service import RemnaWaveService
+ service = RemnaWaveService()
+
+ async with service.get_api_client() as api:
+ response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}')
+
+ 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_str = await get_current_devices_count(db_user)
+
+ servers_names = await get_servers_display_names(subscription.connected_squads)
+ servers_display = (
+ servers_names
+ if servers_names
+ else texts.t("SUBSCRIPTION_NO_SERVERS", "Нет серверов")
+ )
+
+ message = texts.t(
+ "SUBSCRIPTION_OVERVIEW_TEMPLATE",
+ """👤 {full_name}
+💰 Баланс: {balance}
+📱 Подписка: {status_emoji} {status_display}{warning}
+
+📱 Информация о подписке
+🎭 Тип: {subscription_type}
+📅 Действует до: {end_date}
+⏰ Осталось: {time_left}
+📈 Трафик: {traffic}
+🌍 Серверы: {servers}
+📱 Устройства: {devices_used} / {device_limit}""",
+ ).format(
+ full_name=db_user.full_name,
+ balance=settings.format_price(db_user.balance_kopeks),
+ status_emoji=status_emoji,
+ status_display=status_display,
+ warning=warning_text,
+ subscription_type=subscription_type,
+ end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M"),
+ time_left=time_left_text,
+ traffic=traffic_used_display,
+ servers=servers_display,
+ devices_used=devices_used_str,
+ device_limit=subscription.device_limit,
+ )
+
+ if devices_list and len(devices_list) > 0:
+ message += "\n\n" + texts.t(
+ "SUBSCRIPTION_CONNECTED_DEVICES_TITLE",
+ "📱 Подключенные устройства:\n", + ) + for device in devices_list[:5]: + platform = device.get('platform', 'Unknown') + device_model = device.get('deviceModel', 'Unknown') + device_info = f"{platform} - {device_model}" + + if len(device_info) > 35: + device_info = device_info[:32] + "..." + message += f"• {device_info}\n" + message += texts.t("SUBSCRIPTION_CONNECTED_DEVICES_FOOTER", "") + + subscription_link = get_display_subscription_link(subscription) + hide_subscription_link = settings.should_hide_subscription_link() + + if ( + subscription_link + and actual_status in ["trial_active", "paid_active"] + and not hide_subscription_link + ): + message += "\n\n" + texts.t( + "SUBSCRIPTION_CONNECT_LINK_SECTION", + "🔗 Ссылка для подключения:\n
{subscription_url}",
+ ).format(subscription_url=subscription_link)
+ message += "\n\n" + texts.t(
+ "SUBSCRIPTION_CONNECT_LINK_PROMPT",
+ "📱 Скопируйте ссылку и добавьте в ваше VPN приложение",
+ )
+
+ await callback.message.edit_text(
+ message,
+ reply_markup=get_subscription_keyboard(
+ db_user.language,
+ has_subscription=True,
+ is_trial=subscription.is_trial,
+ subscription=subscription
+ ),
+ parse_mode="HTML"
+ )
+ await callback.answer()
+
+async def show_trial_offer(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
+):
+ texts = get_texts(db_user.language)
+
+ if db_user.subscription or db_user.has_had_paid_subscription:
+ await callback.message.edit_text(
+ texts.TRIAL_ALREADY_USED,
+ reply_markup=get_back_keyboard(db_user.language)
+ )
+ await callback.answer()
+ return
+
+ trial_server_name = texts.t("TRIAL_SERVER_DEFAULT_NAME", "🎯 Тестовый сервер")
+ try:
+ from app.database.crud.server_squad import (
+ get_server_squad_by_uuid,
+ get_trial_eligible_server_squads,
+ )
+
+ trial_squads = await get_trial_eligible_server_squads(db, include_unavailable=True)
+
+ if trial_squads:
+ if len(trial_squads) == 1:
+ trial_server_name = trial_squads[0].display_name
+ else:
+ trial_server_name = texts.t(
+ "TRIAL_SERVER_RANDOM_POOL",
+ "🎲 Случайный из {count} серверов",
+ ).format(count=len(trial_squads))
+ elif settings.TRIAL_SQUAD_UUID:
+ trial_server = await get_server_squad_by_uuid(db, settings.TRIAL_SQUAD_UUID)
+ if trial_server:
+ trial_server_name = trial_server.display_name
+ else:
+ logger.warning(
+ "Триальный сервер с UUID %s не найден в БД",
+ settings.TRIAL_SQUAD_UUID,
+ )
+ else:
+ logger.warning("Не настроены сквады для выдачи триалов")
+
+ except Exception as e:
+ logger.error(f"Ошибка получения триального сервера: {e}")
+
+ trial_text = texts.TRIAL_AVAILABLE.format(
+ days=settings.TRIAL_DURATION_DAYS,
+ traffic=settings.TRIAL_TRAFFIC_LIMIT_GB,
+ devices=settings.TRIAL_DEVICE_LIMIT,
+ server_name=trial_server_name
+ )
+
+ await callback.message.edit_text(
+ trial_text,
+ reply_markup=get_trial_keyboard(db_user.language)
+ )
+ await callback.answer()
+
+async def activate_trial(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
+):
+ from app.services.admin_notification_service import AdminNotificationService
+
+ texts = get_texts(db_user.language)
+
+ if db_user.subscription or db_user.has_had_paid_subscription:
+ await callback.message.edit_text(
+ texts.TRIAL_ALREADY_USED,
+ reply_markup=get_back_keyboard(db_user.language)
+ )
+ await callback.answer()
+ return
+
+ try:
+ subscription = await create_trial_subscription(db, db_user.id)
+
+ await db.refresh(db_user)
+
+ subscription_service = SubscriptionService()
+ remnawave_user = await subscription_service.create_remnawave_user(
+ db, subscription
+ )
+
+ await db.refresh(db_user)
+
+ try:
+ notification_service = AdminNotificationService(callback.bot)
+ await notification_service.send_trial_activation_notification(db, db_user, subscription)
+ except Exception as e:
+ logger.error(f"Ошибка отправки уведомления о триале: {e}")
+
+ subscription_link = get_display_subscription_link(subscription)
+ hide_subscription_link = settings.should_hide_subscription_link()
+
+ if remnawave_user and subscription_link:
+ if settings.is_happ_cryptolink_mode():
+ trial_success_text = (
+ f"{texts.TRIAL_ACTIVATED}\n\n"
+ + texts.t(
+ "SUBSCRIPTION_HAPP_LINK_PROMPT",
+ "🔒 Ссылка на подписку создана. Нажмите кнопку \"Подключиться\" ниже, чтобы открыть её в Happ.",
+ )
+ + "\n\n"
+ + texts.t(
+ "SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT",
+ "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве",
+ )
+ )
+ elif hide_subscription_link:
+ trial_success_text = (
+ f"{texts.TRIAL_ACTIVATED}\n\n"
+ + texts.t(
+ "SUBSCRIPTION_LINK_HIDDEN_NOTICE",
+ "ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе \"Моя подписка\".",
+ )
+ + "\n\n"
+ + texts.t(
+ "SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT",
+ "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве",
+ )
+ )
+ else:
+ subscription_import_link = texts.t(
+ "SUBSCRIPTION_IMPORT_LINK_SECTION",
+ "🔗 Ваша ссылка для импорта в VPN приложение:\n{subscription_url}",
+ ).format(subscription_url=subscription_link)
+
+ trial_success_text = (
+ f"{texts.TRIAL_ACTIVATED}\n\n"
+ f"{subscription_import_link}\n\n"
+ f"{texts.t('SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT', '📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве')}"
+ )
+
+ connect_mode = settings.CONNECT_BUTTON_MODE
+
+ if connect_mode == "miniapp_subscription":
+ connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[
+ [
+ InlineKeyboardButton(
+ text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
+ web_app=types.WebAppInfo(url=subscription_link),
+ )
+ ],
+ [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
+ callback_data="back_to_menu")],
+ ])
+ elif connect_mode == "miniapp_custom":
+ if not settings.MINIAPP_CUSTOM_URL:
+ await callback.answer(
+ texts.t(
+ "CUSTOM_MINIAPP_URL_NOT_SET",
+ "⚠ Кастомная ссылка для мини-приложения не настроена",
+ ),
+ show_alert=True,
+ )
+ return
+
+ connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[
+ [
+ InlineKeyboardButton(
+ text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
+ web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL),
+ )
+ ],
+ [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
+ callback_data="back_to_menu")],
+ ])
+ elif connect_mode == "link":
+ rows = [
+ [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=subscription_link)]
+ ]
+ happ_row = get_happ_download_button_row(texts)
+ if happ_row:
+ rows.append(happ_row)
+ rows.append([
+ InlineKeyboardButton(
+ text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
+ callback_data="back_to_menu"
+ )
+ ])
+ connect_keyboard = InlineKeyboardMarkup(inline_keyboard=rows)
+ elif connect_mode == "happ_cryptolink":
+ rows = [
+ [
+ InlineKeyboardButton(
+ text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
+ callback_data="open_subscription_link",
+ )
+ ]
+ ]
+ happ_row = get_happ_download_button_row(texts)
+ if happ_row:
+ rows.append(happ_row)
+ rows.append([
+ InlineKeyboardButton(
+ text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
+ callback_data="back_to_menu"
+ )
+ ])
+ connect_keyboard = InlineKeyboardMarkup(inline_keyboard=rows)
+ else:
+ connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[
+ [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
+ callback_data="subscription_connect")],
+ [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
+ callback_data="back_to_menu")],
+ ])
+
+ await callback.message.edit_text(
+ trial_success_text,
+ reply_markup=connect_keyboard,
+ parse_mode="HTML"
+ )
+ else:
+ await callback.message.edit_text(
+ f"{texts.TRIAL_ACTIVATED}\n\n⚠️ Ссылка генерируется, попробуйте перейти в раздел 'Моя подписка' через несколько секунд.",
+ reply_markup=get_back_keyboard(db_user.language)
+ )
+
+ logger.info(f"✅ Активирована тестовая подписка для пользователя {db_user.telegram_id}")
+
+ except Exception as e:
+ logger.error(f"Ошибка активации триала: {e}")
+ await callback.message.edit_text(
+ texts.ERROR,
+ reply_markup=get_back_keyboard(db_user.language)
+ )
+
+ await callback.answer()
+
+async def start_subscription_purchase(
+ callback: types.CallbackQuery,
+ state: FSMContext,
+ db_user: User,
+ db: AsyncSession,
+):
+ texts = get_texts(db_user.language)
+
+ 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",
+ )
+
+ 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)
+
+ initial_data = {
+ 'period_days': None,
+ 'countries': [],
+ 'devices': initial_devices,
+ 'total_price': 0
+ }
+
+ if settings.is_traffic_fixed():
+ initial_data['traffic_gb'] = settings.get_fixed_traffic_limit()
+ else:
+ initial_data['traffic_gb'] = None
+
+ await state.set_data(initial_data)
+ await state.set_state(SubscriptionStates.selecting_period)
+ await callback.answer()
+
+async def save_cart_and_redirect_to_topup(
+ callback: types.CallbackQuery,
+ state: FSMContext,
+ db_user: User,
+ missing_amount: int
+):
+ texts = get_texts(db_user.language)
+ data = await state.get_data()
+
+ await state.set_state(SubscriptionStates.cart_saved_for_topup)
+ await state.update_data({
+ **data,
+ 'saved_cart': True,
+ 'missing_amount': missing_amount,
+ 'return_to_cart': True
+ })
+
+ await callback.message.edit_text(
+ f"💰 Недостаточно средств для оформления подписки\n\n"
+ f"Требуется: {texts.format_price(missing_amount)}\n"
+ f"У вас: {texts.format_price(db_user.balance_kopeks)}\n\n"
+ f"🛒 Ваша корзина сохранена!\n"
+ f"После пополнения баланса вы сможете вернуться к оформлению подписки.\n\n"
+ f"Выберите способ пополнения:",
+ reply_markup=get_payment_methods_keyboard_with_cart(
+ db_user.language,
+ missing_amount,
+ ),
+ parse_mode="HTML"
+ )
+
+async def return_to_saved_cart(
+ callback: types.CallbackQuery,
+ state: FSMContext,
+ db_user: User,
+ db: AsyncSession
+):
+ data = await state.get_data()
+ texts = get_texts(db_user.language)
+
+ if not data.get('saved_cart'):
+ await callback.answer("❌ Сохраненная корзина не найдена", show_alert=True)
+ return
+
+ total_price = data.get('total_price', 0)
+
+ if db_user.balance_kopeks < total_price:
+ missing_amount = total_price - db_user.balance_kopeks
+ await callback.message.edit_text(
+ f"❌ Все еще недостаточно средств\n\n"
+ f"Требуется: {texts.format_price(total_price)}\n"
+ f"У вас: {texts.format_price(db_user.balance_kopeks)}\n"
+ f"Не хватает: {texts.format_price(missing_amount)}",
+ reply_markup=get_insufficient_balance_keyboard_with_cart(
+ db_user.language,
+ missing_amount,
+ )
+ )
+ return
+
+ countries = await _get_available_countries(db_user.promo_group_id)
+ selected_countries_names = []
+
+ months_in_period = calculate_months_from_days(data['period_days'])
+ period_display = format_period_description(data['period_days'], db_user.language)
+
+ for country in countries:
+ if country['uuid'] in data['countries']:
+ selected_countries_names.append(country['name'])
+
+ if settings.is_traffic_fixed():
+ traffic_display = "Безлимитный" if data['traffic_gb'] == 0 else f"{data['traffic_gb']} ГБ"
+ else:
+ traffic_display = "Безлимитный" if data['traffic_gb'] == 0 else f"{data['traffic_gb']} ГБ"
+
+ summary_text = (
+ "🛒 Восстановленная корзина\n\n"
+ f"📅 Период: {period_display}\n"
+ f"📊 Трафик: {traffic_display}\n"
+ f"🌍 Страны: {', '.join(selected_countries_names)}\n"
+ f"📱 Устройства: {data['devices']}\n\n"
+ f"💎 Общая стоимость: {texts.format_price(total_price)}\n\n"
+ "Подтверждаете покупку?"
+ )
+
+ await callback.message.edit_text(
+ summary_text,
+ reply_markup=get_subscription_confirm_keyboard_with_cart(db_user.language),
+ parse_mode="HTML"
+ )
+
+ await state.set_state(SubscriptionStates.confirming_purchase)
+ await callback.answer("✅ Корзина восстановлена!")
+
+async def handle_extend_subscription(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
+):
+ texts = get_texts(db_user.language)
+ subscription = db_user.subscription
+
+ if not subscription or subscription.is_trial:
+ await callback.answer("⚠ Продление доступно только для платных подписок", show_alert=True)
+ return
+
+ subscription_service = SubscriptionService()
+
+ available_periods = settings.get_available_renewal_periods()
+ renewal_prices = {}
+ promo_offer_percent = _get_promo_offer_discount_percent(db_user)
+
+ for days in available_periods:
+ try:
+ months_in_period = calculate_months_from_days(days)
+
+ from app.config import PERIOD_PRICES
+ base_price_original = PERIOD_PRICES.get(days, 0)
+ period_discount_percent = db_user.get_promo_discount("period", days)
+ base_price, _ = apply_percentage_discount(
+ base_price_original,
+ period_discount_percent,
+ )
+
+ servers_price_per_month, _ = await subscription_service.get_countries_price_by_uuids(
+ subscription.connected_squads,
+ db,
+ promo_group_id=db_user.promo_group_id,
+ )
+ servers_discount_percent = db_user.get_promo_discount(
+ "servers",
+ days,
+ )
+ 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)
+ devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
+ devices_discount_percent = db_user.get_promo_discount(
+ "devices",
+ days,
+ )
+ devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100
+ total_devices_price = (devices_price_per_month - devices_discount_per_month) * months_in_period
+
+ traffic_price_per_month = settings.get_traffic_price(subscription.traffic_limit_gb)
+ traffic_discount_percent = db_user.get_promo_discount(
+ "traffic",
+ days,
+ )
+ traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100
+ total_traffic_price = (traffic_price_per_month - traffic_discount_per_month) * months_in_period
+
+ total_original_price = (
+ base_price_original
+ + servers_price_per_month * months_in_period
+ + devices_price_per_month * months_in_period
+ + traffic_price_per_month * months_in_period
+ )
+
+ price = base_price + total_servers_price + total_devices_price + total_traffic_price
+ promo_component = _apply_promo_offer_discount(db_user, price)
+
+ renewal_prices[days] = {
+ "final": promo_component["discounted"],
+ "original": total_original_price,
+ }
+
+ except Exception as e:
+ logger.error(f"Ошибка расчета цены для периода {days}: {e}")
+ continue
+
+ if not renewal_prices:
+ await callback.answer("⚠ Нет доступных периодов для продления", show_alert=True)
+ return
+
+ prices_text = ""
+
+ for days in available_periods:
+ if days not in renewal_prices:
+ continue
+
+ price_info = renewal_prices[days]
+
+ if isinstance(price_info, dict):
+ final_price = price_info.get("final")
+ if final_price is None:
+ final_price = price_info.get("original", 0)
+ original_price = price_info.get("original", final_price)
+ else:
+ final_price = price_info
+ original_price = final_price
+
+ has_discount = original_price > final_price
+
+ period_display = format_period_description(days, db_user.language)
+
+ if has_discount:
+ prices_text += (
+ "📅 "
+ f"{period_display} - {subscription_url}",
+ ).format(subscription_url=subscription_link)
+
+ success_text = (
+ f"{texts.SUBSCRIPTION_PURCHASED}\n\n"
+ f"{import_link_section}\n\n"
+ f"{texts.t('SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT', '📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве')}"
+ )
+
+ if discount_note:
+ success_text = f"{success_text}\n\n{discount_note}"
+
+ connect_mode = settings.CONNECT_BUTTON_MODE
+
+ if connect_mode == "miniapp_subscription":
+ connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[
+ [
+ InlineKeyboardButton(
+ text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
+ web_app=types.WebAppInfo(url=subscription_link),
+ )
+ ],
+ [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
+ callback_data="back_to_menu")],
+ ])
+ elif connect_mode == "miniapp_custom":
+ if not settings.MINIAPP_CUSTOM_URL:
+ await callback.answer(
+ texts.t(
+ "CUSTOM_MINIAPP_URL_NOT_SET",
+ "⚠ Кастомная ссылка для мини-приложения не настроена",
+ ),
+ show_alert=True,
+ )
+ return
+
+ connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[
+ [
+ InlineKeyboardButton(
+ text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
+ web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL),
+ )
+ ],
+ [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
+ callback_data="back_to_menu")],
+ ])
+ elif connect_mode == "link":
+ rows = [
+ [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=subscription_link)]
+ ]
+ happ_row = get_happ_download_button_row(texts)
+ if happ_row:
+ rows.append(happ_row)
+ rows.append([InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
+ callback_data="back_to_menu")])
+ connect_keyboard = InlineKeyboardMarkup(inline_keyboard=rows)
+ elif connect_mode == "happ_cryptolink":
+ rows = [
+ [
+ InlineKeyboardButton(
+ text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
+ callback_data="open_subscription_link",
+ )
+ ]
+ ]
+ happ_row = get_happ_download_button_row(texts)
+ if happ_row:
+ rows.append(happ_row)
+ rows.append([InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
+ callback_data="back_to_menu")])
+ connect_keyboard = InlineKeyboardMarkup(inline_keyboard=rows)
+ else:
+ connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[
+ [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
+ callback_data="subscription_connect")],
+ [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
+ callback_data="back_to_menu")],
+ ])
+
+ await callback.message.edit_text(
+ success_text,
+ reply_markup=connect_keyboard,
+ parse_mode="HTML"
+ )
+ else:
+ purchase_text = texts.SUBSCRIPTION_PURCHASED
+ if discount_note:
+ purchase_text = f"{purchase_text}\n\n{discount_note}"
+ await callback.message.edit_text(
+ texts.t(
+ "SUBSCRIPTION_LINK_GENERATING_NOTICE",
+ "{purchase_text}\n\nСсылка генерируется, перейдите в раздел 'Моя подписка' через несколько секунд.",
+ ).format(purchase_text=purchase_text),
+ reply_markup=get_back_keyboard(db_user.language)
+ )
+
+ purchase_completed = True
+ logger.info(
+ f"Пользователь {db_user.telegram_id} купил подписку на {data['period_days']} дней за {final_price / 100}₽")
+
+ except Exception as e:
+ logger.error(f"Ошибка покупки подписки: {e}")
+ await callback.message.edit_text(
+ texts.ERROR,
+ reply_markup=get_back_keyboard(db_user.language)
+ )
+
+ if purchase_completed:
+ await clear_subscription_checkout_draft(db_user.id)
+
+ await state.clear()
+ await callback.answer()
+
+async def resume_subscription_checkout(
+ callback: types.CallbackQuery,
+ state: FSMContext,
+ db_user: User,
+):
+ texts = get_texts(db_user.language)
+
+ draft = await get_subscription_checkout_draft(db_user.id)
+
+ if not draft:
+ await callback.answer(texts.NO_SAVED_SUBSCRIPTION_ORDER, show_alert=True)
+ return
+
+ try:
+ summary_text, prepared_data = await _prepare_subscription_summary(db_user, draft, texts)
+ except ValueError as exc:
+ logger.error(
+ f"Ошибка восстановления заказа подписки для пользователя {db_user.telegram_id}: {exc}"
+ )
+ await clear_subscription_checkout_draft(db_user.id)
+ await callback.answer(texts.NO_SAVED_SUBSCRIPTION_ORDER, show_alert=True)
+ return
+
+ await state.set_data(prepared_data)
+ await state.set_state(SubscriptionStates.confirming_purchase)
+ 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 callback.answer()
+
+async def create_paid_subscription_with_traffic_mode(
+ db: AsyncSession,
+ user_id: int,
+ duration_days: int,
+ device_limit: int,
+ connected_squads: List[str],
+ traffic_gb: Optional[int] = None
+):
+ from app.config import settings
+
+ if traffic_gb is None:
+ if settings.is_traffic_fixed():
+ traffic_limit_gb = settings.get_fixed_traffic_limit()
+ else:
+ traffic_limit_gb = 0
+ else:
+ traffic_limit_gb = traffic_gb
+
+ subscription = await create_paid_subscription(
+ 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,
+ )
+
+ logger.info(f"📋 Создана подписка с трафиком: {traffic_limit_gb} ГБ (режим: {settings.TRAFFIC_SELECTION_MODE})")
+
+ return subscription
+
+async def handle_subscription_settings(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
+):
+ texts = get_texts(db_user.language)
+ subscription = db_user.subscription
+
+ if not subscription or subscription.is_trial:
+ await callback.answer(
+ texts.t(
+ "SUBSCRIPTION_SETTINGS_PAID_ONLY",
+ "⚠️ Настройки доступны только для платных подписок",
+ ),
+ show_alert=True,
+ )
+ return
+
+ devices_used = await get_current_devices_count(db_user)
+
+ settings_text = texts.t(
+ "SUBSCRIPTION_SETTINGS_OVERVIEW",
+ (
+ "⚙️ Настройки подписки\n\n"
+ "📊 Текущие параметры:\n"
+ "🌐 Стран: {countries_count}\n"
+ "📈 Трафик: {traffic_used} / {traffic_limit}\n"
+ "📱 Устройства: {devices_used} / {devices_limit}\n\n"
+ "Выберите что хотите изменить:"
+ ),
+ ).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),
+ devices_used=devices_used,
+ devices_limit=subscription.device_limit,
+ )
+
+ show_countries = await _should_show_countries_management(db_user)
+
+ await callback.message.edit_text(
+ settings_text,
+ reply_markup=get_updated_subscription_settings_keyboard(db_user.language, show_countries),
+ parse_mode="HTML"
+ )
+ await callback.answer()
+
+async def clear_saved_cart(
+ callback: types.CallbackQuery,
+ state: FSMContext,
+ db_user: User,
+ db: AsyncSession
+):
+ await state.clear()
+
+ from app.handlers.menu import show_main_menu
+ await show_main_menu(callback, db_user, db)
+
+ await callback.answer("🗑️ Корзина очищена")
+
+def register_handlers(dp: Dispatcher):
+ update_traffic_prices()
+
+ dp.callback_query.register(
+ show_subscription_info,
+ F.data == "menu_subscription"
+ )
+
+ dp.callback_query.register(
+ show_trial_offer,
+ F.data == "menu_trial"
+ )
+
+ dp.callback_query.register(
+ activate_trial,
+ F.data == "trial_activate"
+ )
+
+ dp.callback_query.register(
+ start_subscription_purchase,
+ F.data.in_(["menu_buy", "subscription_upgrade"])
+ )
+
+ dp.callback_query.register(
+ handle_add_countries,
+ F.data == "subscription_add_countries"
+ )
+
+ dp.callback_query.register(
+ handle_switch_traffic,
+ F.data == "subscription_switch_traffic"
+ )
+
+ dp.callback_query.register(
+ confirm_switch_traffic,
+ F.data.startswith("switch_traffic_")
+ )
+
+ dp.callback_query.register(
+ execute_switch_traffic,
+ F.data.startswith("confirm_switch_traffic_")
+ )
+
+ dp.callback_query.register(
+ handle_change_devices,
+ F.data == "subscription_change_devices"
+ )
+
+ dp.callback_query.register(
+ confirm_change_devices,
+ F.data.startswith("change_devices_")
+ )
+
+ dp.callback_query.register(
+ execute_change_devices,
+ F.data.startswith("confirm_change_devices_")
+ )
+
+ dp.callback_query.register(
+ handle_extend_subscription,
+ F.data == "subscription_extend"
+ )
+
+ dp.callback_query.register(
+ handle_reset_traffic,
+ F.data == "subscription_reset_traffic"
+ )
+
+ dp.callback_query.register(
+ confirm_add_devices,
+ F.data.startswith("add_devices_")
+ )
+
+ dp.callback_query.register(
+ confirm_extend_subscription,
+ F.data.startswith("extend_period_")
+ )
+
+ dp.callback_query.register(
+ confirm_reset_traffic,
+ F.data == "confirm_reset_traffic"
+ )
+
+ dp.callback_query.register(
+ handle_reset_devices,
+ F.data == "subscription_reset_devices"
+ )
+
+ dp.callback_query.register(
+ confirm_reset_devices,
+ F.data == "confirm_reset_devices"
+ )
+
+ dp.callback_query.register(
+ select_period,
+ F.data.startswith("period_"),
+ SubscriptionStates.selecting_period
+ )
+
+ dp.callback_query.register(
+ select_traffic,
+ F.data.startswith("traffic_"),
+ SubscriptionStates.selecting_traffic
+ )
+
+ dp.callback_query.register(
+ select_devices,
+ F.data.startswith("devices_") & ~F.data.in_(["devices_continue"]),
+ SubscriptionStates.selecting_devices
+ )
+
+ dp.callback_query.register(
+ devices_continue,
+ F.data == "devices_continue",
+ SubscriptionStates.selecting_devices
+ )
+
+ dp.callback_query.register(
+ confirm_purchase,
+ F.data == "subscription_confirm",
+ SubscriptionStates.confirming_purchase
+ )
+
+ dp.callback_query.register(
+ resume_subscription_checkout,
+ F.data == "subscription_resume_checkout",
+ )
+
+ dp.callback_query.register(
+ return_to_saved_cart,
+ F.data == "return_to_saved_cart",
+ )
+
+ dp.callback_query.register(
+ clear_saved_cart,
+ F.data == "clear_saved_cart",
+ )
+
+ dp.callback_query.register(
+ handle_autopay_menu,
+ F.data == "subscription_autopay"
+ )
+
+ dp.callback_query.register(
+ toggle_autopay,
+ F.data.in_(["autopay_enable", "autopay_disable"])
+ )
+
+ dp.callback_query.register(
+ show_autopay_days,
+ F.data == "autopay_set_days"
+ )
+
+ dp.callback_query.register(
+ handle_subscription_config_back,
+ F.data == "subscription_config_back"
+ )
+
+ dp.callback_query.register(
+ handle_subscription_cancel,
+ F.data == "subscription_cancel"
+ )
+
+ dp.callback_query.register(
+ set_autopay_days,
+ F.data.startswith("autopay_days_")
+ )
+
+ dp.callback_query.register(
+ select_country,
+ F.data.startswith("country_"),
+ SubscriptionStates.selecting_countries
+ )
+
+ dp.callback_query.register(
+ countries_continue,
+ F.data == "countries_continue",
+ SubscriptionStates.selecting_countries
+ )
+
+ dp.callback_query.register(
+ handle_manage_country,
+ F.data.startswith("country_manage_")
+ )
+
+ dp.callback_query.register(
+ apply_countries_changes,
+ F.data == "countries_apply"
+ )
+
+ dp.callback_query.register(
+ claim_discount_offer,
+ F.data.startswith("claim_discount_")
+ )
+
+ dp.callback_query.register(
+ handle_promo_offer_close,
+ F.data == "promo_offer_close",
+ )
+
+ dp.callback_query.register(
+ handle_happ_download_request,
+ F.data == "subscription_happ_download"
+ )
+
+ dp.callback_query.register(
+ handle_happ_download_platform_choice,
+ F.data.in_([
+ "happ_download_ios",
+ "happ_download_android",
+ "happ_download_pc",
+ "happ_download_macos",
+ "happ_download_windows",
+ ])
+ )
+
+ dp.callback_query.register(
+ handle_happ_download_close,
+ F.data == "happ_download_close"
+ )
+
+ dp.callback_query.register(
+ handle_happ_download_back,
+ F.data == "happ_download_back"
+ )
+
+ dp.callback_query.register(
+ handle_connect_subscription,
+ F.data == "subscription_connect"
+ )
+
+ dp.callback_query.register(
+ handle_device_guide,
+ F.data.startswith("device_guide_")
+ )
+
+ dp.callback_query.register(
+ handle_app_selection,
+ F.data.startswith("app_list_")
+ )
+
+ dp.callback_query.register(
+ handle_specific_app_guide,
+ F.data.startswith("app_")
+ )
+
+ dp.callback_query.register(
+ handle_open_subscription_link,
+ F.data == "open_subscription_link"
+ )
+
+ dp.callback_query.register(
+ handle_subscription_settings,
+ F.data == "subscription_settings"
+ )
+
+ dp.callback_query.register(
+ handle_no_traffic_packages,
+ F.data == "no_traffic_packages"
+ )
+
+ dp.callback_query.register(
+ handle_device_management,
+ F.data == "subscription_manage_devices"
+ )
+
+ dp.callback_query.register(
+ handle_devices_page,
+ F.data.startswith("devices_page_")
+ )
+
+ dp.callback_query.register(
+ handle_single_device_reset,
+ F.data.regexp(r"^reset_device_\d+_\d+$")
+ )
+
+ dp.callback_query.register(
+ handle_all_devices_reset_from_management,
+ F.data == "reset_all_devices"
+ )
+
+ dp.callback_query.register(
+ show_device_connection_help,
+ F.data == "device_connection_help"
+ )
diff --git a/app/handlers/subscription/traffic.py b/app/handlers/subscription/traffic.py
new file mode 100644
index 00000000..6733db4b
--- /dev/null
+++ b/app/handlers/subscription/traffic.py
@@ -0,0 +1,734 @@
+import base64
+import json
+import logging
+from datetime import datetime, timedelta
+from typing import Dict, List, Any, Tuple, Optional
+from urllib.parse import quote
+from aiogram import Dispatcher, types, F
+from aiogram.fsm.context import FSMContext
+from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
+from sqlalchemy.ext.asyncio import AsyncSession
+from app.config import settings, PERIOD_PRICES, get_traffic_prices
+from app.database.crud.discount_offer import (
+ get_offer_by_id,
+ mark_offer_claimed,
+)
+from app.database.crud.promo_offer_template import get_promo_offer_template_by_id
+from app.database.crud.subscription import (
+ create_trial_subscription,
+ create_paid_subscription, add_subscription_traffic, add_subscription_devices,
+ update_subscription_autopay
+)
+from app.database.crud.transaction import create_transaction
+from app.database.crud.user import subtract_user_balance
+from app.database.models import (
+ User, TransactionType, SubscriptionStatus,
+ Subscription
+)
+from app.keyboards.inline import (
+ get_subscription_keyboard, get_trial_keyboard,
+ get_subscription_period_keyboard, get_traffic_packages_keyboard,
+ get_countries_keyboard, get_devices_keyboard,
+ get_subscription_confirm_keyboard, get_autopay_keyboard,
+ get_autopay_days_keyboard, get_back_keyboard,
+ get_add_traffic_keyboard,
+ get_change_devices_keyboard, get_reset_traffic_confirm_keyboard,
+ get_manage_countries_keyboard,
+ get_device_selection_keyboard, get_connection_guide_keyboard,
+ get_app_selection_keyboard, get_specific_app_keyboard,
+ get_updated_subscription_settings_keyboard, get_insufficient_balance_keyboard,
+ get_extend_subscription_keyboard_with_prices, get_confirm_change_devices_keyboard,
+ get_devices_management_keyboard, get_device_management_help_keyboard,
+ get_happ_cryptolink_keyboard,
+ get_happ_download_platform_keyboard, get_happ_download_link_keyboard,
+ get_happ_download_button_row,
+ get_payment_methods_keyboard_with_cart,
+ get_subscription_confirm_keyboard_with_cart,
+ get_insufficient_balance_keyboard_with_cart
+)
+from app.localization.texts import get_texts
+from app.services.admin_notification_service import AdminNotificationService
+from app.services.remnawave_service import RemnaWaveService
+from app.services.subscription_checkout_service import (
+ clear_subscription_checkout_draft,
+ get_subscription_checkout_draft,
+ save_subscription_checkout_draft,
+ should_offer_checkout_resume,
+)
+from app.services.subscription_service import SubscriptionService
+from app.utils.miniapp_buttons import build_miniapp_or_callback_button
+from app.services.promo_offer_service import promo_offer_service
+from app.states import SubscriptionStates
+from app.utils.pagination import paginate_list
+from app.utils.pricing_utils import (
+ calculate_months_from_days,
+ get_remaining_months,
+ calculate_prorated_price,
+ validate_pricing_calculation,
+ format_period_description,
+ apply_percentage_discount,
+)
+from app.utils.subscription_utils import (
+ get_display_subscription_link,
+ get_happ_cryptolink_redirect_link,
+ convert_subscription_link_to_happ_scheme,
+)
+from app.utils.promo_offer import (
+ build_promo_offer_hint,
+ get_user_active_promo_discount_percent,
+)
+
+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
+
+async def handle_add_traffic(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
+):
+ from app.config import settings
+
+ texts = get_texts(db_user.language)
+
+ if settings.is_traffic_fixed():
+ await callback.answer(
+ texts.t(
+ "TRAFFIC_FIXED_MODE",
+ "⚠️ В текущем режиме трафик фиксированный и не может быть изменен",
+ ),
+ show_alert=True,
+ )
+ return
+
+ subscription = db_user.subscription
+
+ if not subscription or subscription.is_trial:
+ await callback.answer(
+ texts.t("PAID_FEATURE_ONLY", "⚠ Эта функция доступна только для платных подписок"),
+ show_alert=True,
+ )
+ return
+
+ if subscription.traffic_limit_gb == 0:
+ await callback.answer(
+ texts.t("TRAFFIC_ALREADY_UNLIMITED", "⚠ У вас уже безлимитный трафик"),
+ show_alert=True,
+ )
+ return
+
+ current_traffic = subscription.traffic_limit_gb
+ period_hint_days = _get_period_hint_from_subscription(subscription)
+ traffic_discount_percent = _get_addon_discount_percent_for_user(
+ db_user,
+ "traffic",
+ period_hint_days,
+ )
+
+ prompt_text = texts.t(
+ "ADD_TRAFFIC_PROMPT",
+ (
+ "📈 Добавить трафик к подписке\n\n"
+ "Текущий лимит: {current_traffic}\n"
+ "Выберите дополнительный трафик:"
+ ),
+ ).format(current_traffic=texts.format_traffic(current_traffic))
+
+ await callback.message.edit_text(
+ prompt_text,
+ reply_markup=get_add_traffic_keyboard(
+ db_user.language,
+ subscription.end_date,
+ traffic_discount_percent,
+ ),
+ parse_mode="HTML"
+ )
+
+ await callback.answer()
+
+async def handle_reset_traffic(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
+):
+ from app.config import settings
+
+ if settings.is_traffic_fixed():
+ await callback.answer("⚠️ В текущем режиме трафик фиксированный и не может быть сброшен", show_alert=True)
+ return
+
+ texts = get_texts(db_user.language)
+ subscription = db_user.subscription
+
+ if not subscription or subscription.is_trial:
+ await callback.answer("⌛ Эта функция доступна только для платных подписок", show_alert=True)
+ return
+
+ if subscription.traffic_limit_gb == 0:
+ await callback.answer("⌛ У вас безлимитный трафик", show_alert=True)
+ return
+
+ reset_price = PERIOD_PRICES[30]
+
+ if db_user.balance_kopeks < reset_price:
+ await callback.answer("⌛ Недостаточно средств на балансе", show_alert=True)
+ return
+
+ await callback.message.edit_text(
+ f"🔄 Сброс трафика\n\n"
+ f"Использовано: {texts.format_traffic(subscription.traffic_used_gb)}\n"
+ f"Лимит: {texts.format_traffic(subscription.traffic_limit_gb)}\n\n"
+ f"Стоимость сброса: {texts.format_price(reset_price)}\n\n"
+ "После сброса счетчик использованного трафика станет равным 0.",
+ reply_markup=get_reset_traffic_confirm_keyboard(reset_price, db_user.language)
+ )
+
+ await callback.answer()
+
+async def confirm_reset_traffic(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
+):
+ from app.config import settings
+
+ if settings.is_traffic_fixed():
+ await callback.answer("⚠️ В текущем режиме трафик фиксированный", show_alert=True)
+ return
+
+ texts = get_texts(db_user.language)
+ subscription = db_user.subscription
+
+ reset_price = PERIOD_PRICES[30]
+
+ if db_user.balance_kopeks < reset_price:
+ missing_kopeks = reset_price - db_user.balance_kopeks
+ message_text = texts.t(
+ "ADDON_INSUFFICIENT_FUNDS_MESSAGE",
+ (
+ "⚠️ Недостаточно средств\n\n"
+ "Стоимость услуги: {required}\n"
+ "На балансе: {balance}\n"
+ "Не хватает: {missing}\n\n"
+ "Выберите способ пополнения. Сумма подставится автоматически."
+ ),
+ ).format(
+ required=texts.format_price(reset_price),
+ 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
+
+ try:
+ success = await subtract_user_balance(
+ db, db_user, reset_price,
+ "Сброс трафика"
+ )
+
+ if not success:
+ await callback.answer("⌛ Ошибка списания средств", show_alert=True)
+ return
+
+ subscription.traffic_used_gb = 0.0
+ subscription.updated_at = datetime.utcnow()
+ await db.commit()
+
+ subscription_service = SubscriptionService()
+ remnawave_service = RemnaWaveService()
+
+ user = db_user
+ if user.remnawave_uuid:
+ async with remnawave_service.get_api_client() as api:
+ await api.reset_user_traffic(user.remnawave_uuid)
+
+ await create_transaction(
+ db=db,
+ user_id=db_user.id,
+ type=TransactionType.SUBSCRIPTION_PAYMENT,
+ amount_kopeks=reset_price,
+ description="Сброс трафика"
+ )
+
+ await db.refresh(db_user)
+ await db.refresh(subscription)
+
+ await callback.message.edit_text(
+ f"✅ Трафик успешно сброшен!\n\n"
+ f"🔄 Использованный трафик обнулен\n"
+ f"📊 Лимит: {texts.format_traffic(subscription.traffic_limit_gb)}",
+ reply_markup=get_back_keyboard(db_user.language)
+ )
+
+ logger.info(f"✅ Пользователь {db_user.telegram_id} сбросил трафик")
+
+ except Exception as e:
+ logger.error(f"Ошибка сброса трафика: {e}")
+ await callback.message.edit_text(
+ texts.ERROR,
+ reply_markup=get_back_keyboard(db_user.language)
+ )
+
+ await callback.answer()
+
+async def refresh_traffic_config():
+ try:
+ from app.config import refresh_traffic_prices
+ refresh_traffic_prices()
+
+ packages = settings.get_traffic_packages()
+ enabled_count = sum(1 for pkg in packages if pkg['enabled'])
+
+ logger.info(f"🔄 Конфигурация трафика обновлена: {enabled_count} активных пакетов")
+ for pkg in packages:
+ if pkg['enabled']:
+ gb_text = "♾️ Безлимит" if pkg['gb'] == 0 else f"{pkg['gb']} ГБ"
+ logger.info(f" 📦 {gb_text}: {pkg['price'] / 100}₽")
+
+ return True
+
+ except Exception as e:
+ logger.error(f"⚠️ Ошибка обновления конфигурации трафика: {e}")
+ return False
+
+async def get_traffic_packages_info() -> str:
+ try:
+ packages = settings.get_traffic_packages()
+
+ info_lines = ["📦 Настроенные пакеты трафика:"]
+
+ enabled_packages = [pkg for pkg in packages if pkg['enabled']]
+ disabled_packages = [pkg for pkg in packages if not pkg['enabled']]
+
+ if enabled_packages:
+ info_lines.append("\n✅ Активные:")
+ for pkg in enabled_packages:
+ gb_text = "♾️ Безлимит" if pkg['gb'] == 0 else f"{pkg['gb']} ГБ"
+ info_lines.append(f" • {gb_text}: {pkg['price'] // 100}₽")
+
+ if disabled_packages:
+ info_lines.append("\n❌ Отключенные:")
+ for pkg in disabled_packages:
+ gb_text = "♾️ Безлимит" if pkg['gb'] == 0 else f"{pkg['gb']} ГБ"
+ info_lines.append(f" • {gb_text}: {pkg['price'] // 100}₽")
+
+ info_lines.append(f"\n📊 Всего пакетов: {len(packages)}")
+ info_lines.append(f"🟢 Активных: {len(enabled_packages)}")
+ info_lines.append(f"🔴 Отключенных: {len(disabled_packages)}")
+
+ return "\n".join(info_lines)
+
+ except Exception as e:
+ return f"⚠️ Ошибка получения информации: {e}"
+
+async def select_traffic(
+ callback: types.CallbackQuery,
+ state: FSMContext,
+ db_user: User
+):
+ traffic_gb = int(callback.data.split('_')[1])
+ texts = get_texts(db_user.language)
+
+ data = await state.get_data()
+ data['traffic_gb'] = traffic_gb
+
+ traffic_price = settings.get_traffic_price(traffic_gb)
+ data['total_price'] += traffic_price
+
+ await state.set_data(data)
+
+ 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)
+
+ 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()
+
+async def add_traffic(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
+):
+ if settings.is_traffic_fixed():
+ await callback.answer("⚠️ В текущем режиме трафик фиксированный", show_alert=True)
+ return
+
+ traffic_gb = int(callback.data.split('_')[2])
+ texts = get_texts(db_user.language)
+ subscription = db_user.subscription
+
+ base_price = settings.get_traffic_price(traffic_gb)
+
+ if base_price == 0 and traffic_gb != 0:
+ await callback.answer("⚠️ Цена для этого пакета не настроена", show_alert=True)
+ return
+
+ period_hint_days = _get_period_hint_from_subscription(subscription)
+ discount_result = _apply_addon_discount(
+ db_user,
+ "traffic",
+ base_price,
+ period_hint_days,
+ )
+
+ discounted_per_month = discount_result["discounted"]
+ discount_per_month = discount_result["discount"]
+ charged_months = 1
+
+ if subscription:
+ price, charged_months = calculate_prorated_price(
+ discounted_per_month,
+ subscription.end_date,
+ )
+ else:
+ price = discounted_per_month
+
+ total_discount_value = discount_per_month * charged_months
+
+ if db_user.balance_kopeks < price:
+ missing_kopeks = price - db_user.balance_kopeks
+ message_text = texts.t(
+ "ADDON_INSUFFICIENT_FUNDS_MESSAGE",
+ (
+ "⚠️ Недостаточно средств\n\n"
+ "Стоимость услуги: {required}\n"
+ "На балансе: {balance}\n"
+ "Не хватает: {missing}\n\n"
+ "Выберите способ пополнения. Сумма подставится автоматически."
+ ),
+ ).format(
+ required=texts.format_price(price),
+ 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
+
+ try:
+ success = await subtract_user_balance(
+ db,
+ db_user,
+ price,
+ f"Добавление {traffic_gb} ГБ трафика",
+ )
+
+ if not success:
+ await callback.answer("⚠️ Ошибка списания средств", show_alert=True)
+ return
+
+ if traffic_gb == 0:
+ subscription.traffic_limit_gb = 0
+ else:
+ await add_subscription_traffic(db, subscription, traffic_gb)
+
+ subscription_service = SubscriptionService()
+ await subscription_service.update_remnawave_user(db, subscription)
+
+ await create_transaction(
+ db=db,
+ user_id=db_user.id,
+ type=TransactionType.SUBSCRIPTION_PAYMENT,
+ amount_kopeks=price,
+ description=f"Добавление {traffic_gb} ГБ трафика",
+ )
+
+ await db.refresh(db_user)
+ await db.refresh(subscription)
+
+ success_text = f"✅ Трафик успешно добавлен!\n\n"
+ if traffic_gb == 0:
+ success_text += "🎉 Теперь у вас безлимитный трафик!"
+ else:
+ success_text += f"📈 Добавлено: {traffic_gb} ГБ\n"
+ success_text += f"Новый лимит: {texts.format_traffic(subscription.traffic_limit_gb)}"
+
+ if price > 0:
+ success_text += f"\n💰 Списано: {texts.format_price(price)}"
+ if total_discount_value > 0:
+ success_text += (
+ f" (скидка {discount_result['percent']}%:"
+ f" -{texts.format_price(total_discount_value)})"
+ )
+
+ await callback.message.edit_text(
+ success_text,
+ reply_markup=get_back_keyboard(db_user.language)
+ )
+
+ logger.info(f"✅ Пользователь {db_user.telegram_id} добавил {traffic_gb} ГБ трафика")
+
+ except Exception as e:
+ logger.error(f"Ошибка добавления трафика: {e}")
+ await callback.message.edit_text(
+ texts.ERROR,
+ reply_markup=get_back_keyboard(db_user.language)
+ )
+
+ await callback.answer()
+
+async def handle_no_traffic_packages(
+ callback: types.CallbackQuery,
+ db_user: User
+):
+ await callback.answer(
+ "⚠️ В данный момент нет доступных пакетов трафика. "
+ "Обратитесь в техподдержку для получения информации.",
+ show_alert=True
+ )
+
+async def handle_switch_traffic(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
+):
+ from app.config import settings
+
+ if settings.is_traffic_fixed():
+ await callback.answer("⚠️ В текущем режиме трафик фиксированный", show_alert=True)
+ return
+
+ texts = get_texts(db_user.language)
+ subscription = db_user.subscription
+
+ if not subscription or subscription.is_trial:
+ await callback.answer("⚠️ Эта функция доступна только для платных подписок", show_alert=True)
+ return
+
+ current_traffic = subscription.traffic_limit_gb
+ period_hint_days = _get_period_hint_from_subscription(subscription)
+ traffic_discount_percent = _get_addon_discount_percent_for_user(
+ db_user,
+ "traffic",
+ period_hint_days,
+ )
+
+ await callback.message.edit_text(
+ f"🔄 Переключение лимита трафика\n\n"
+ f"Текущий лимит: {texts.format_traffic(current_traffic)}\n"
+ f"Выберите новый лимит трафика:\n\n"
+ f"💡 Важно:\n"
+ f"• При увеличении - доплата за разницу\n"
+ f"• При уменьшении - возврат средств не производится",
+ reply_markup=get_traffic_switch_keyboard(
+ current_traffic,
+ db_user.language,
+ subscription.end_date,
+ traffic_discount_percent,
+ ),
+ parse_mode="HTML"
+ )
+
+ await callback.answer()
+
+async def confirm_switch_traffic(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
+):
+ new_traffic_gb = int(callback.data.split('_')[2])
+ texts = get_texts(db_user.language)
+ subscription = db_user.subscription
+
+ current_traffic = subscription.traffic_limit_gb
+
+ if new_traffic_gb == current_traffic:
+ await callback.answer("ℹ️ Лимит трафика не изменился", show_alert=True)
+ return
+
+ old_price_per_month = settings.get_traffic_price(current_traffic)
+ new_price_per_month = settings.get_traffic_price(new_traffic_gb)
+
+ months_remaining = get_remaining_months(subscription.end_date)
+ period_hint_days = months_remaining * 30 if months_remaining > 0 else None
+ traffic_discount_percent = _get_addon_discount_percent_for_user(
+ db_user,
+ "traffic",
+ period_hint_days,
+ )
+
+ discounted_old_per_month, _ = apply_percentage_discount(
+ old_price_per_month,
+ traffic_discount_percent,
+ )
+ discounted_new_per_month, _ = apply_percentage_discount(
+ new_price_per_month,
+ traffic_discount_percent,
+ )
+ price_difference_per_month = discounted_new_per_month - discounted_old_per_month
+ discount_savings_per_month = (
+ (new_price_per_month - old_price_per_month) - price_difference_per_month
+ )
+
+ if price_difference_per_month > 0:
+ total_price_difference = price_difference_per_month * months_remaining
+
+ if db_user.balance_kopeks < total_price_difference:
+ missing_kopeks = total_price_difference - db_user.balance_kopeks
+ message_text = texts.t(
+ "ADDON_INSUFFICIENT_FUNDS_MESSAGE",
+ (
+ "⚠️ Недостаточно средств\n\n"
+ "Стоимость услуги: {required}\n"
+ "На балансе: {balance}\n"
+ "Не хватает: {missing}\n\n"
+ "Выберите способ пополнения. Сумма подставится автоматически."
+ ),
+ ).format(
+ required=f"{texts.format_price(total_price_difference)} (за {months_remaining} мес)",
+ 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
+
+ action_text = f"увеличить до {texts.format_traffic(new_traffic_gb)}"
+ cost_text = f"Доплата: {texts.format_price(total_price_difference)} (за {months_remaining} мес)"
+ if discount_savings_per_month > 0:
+ total_discount_savings = discount_savings_per_month * months_remaining
+ cost_text += (
+ f" (скидка {traffic_discount_percent}%:"
+ f" -{texts.format_price(total_discount_savings)})"
+ )
+ else:
+ total_price_difference = 0
+ action_text = f"уменьшить до {texts.format_traffic(new_traffic_gb)}"
+ cost_text = "Возврат средств не производится"
+
+ confirm_text = f"🔄 Подтверждение переключения трафика\n\n"
+ confirm_text += f"Текущий лимит: {texts.format_traffic(current_traffic)}\n"
+ confirm_text += f"Новый лимит: {texts.format_traffic(new_traffic_gb)}\n\n"
+ confirm_text += f"Действие: {action_text}\n"
+ confirm_text += f"💰 {cost_text}\n\n"
+ confirm_text += "Подтвердить переключение?"
+
+ await callback.message.edit_text(
+ confirm_text,
+ reply_markup=get_confirm_switch_traffic_keyboard(new_traffic_gb, total_price_difference, db_user.language),
+ parse_mode="HTML"
+ )
+
+ await callback.answer()
+
+async def execute_switch_traffic(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
+):
+ callback_parts = callback.data.split('_')
+ new_traffic_gb = int(callback_parts[3])
+ price_difference = int(callback_parts[4])
+
+ texts = get_texts(db_user.language)
+ subscription = db_user.subscription
+ current_traffic = subscription.traffic_limit_gb
+
+ try:
+ if price_difference > 0:
+ success = await subtract_user_balance(
+ db, db_user, price_difference,
+ f"Переключение трафика с {current_traffic}GB на {new_traffic_gb}GB"
+ )
+
+ if not success:
+ await callback.answer("⚠️ Ошибка списания средств", show_alert=True)
+ return
+
+ months_remaining = get_remaining_months(subscription.end_date)
+ await create_transaction(
+ db=db,
+ user_id=db_user.id,
+ type=TransactionType.SUBSCRIPTION_PAYMENT,
+ amount_kopeks=price_difference,
+ description=f"Переключение трафика с {current_traffic}GB на {new_traffic_gb}GB на {months_remaining} мес"
+ )
+
+ subscription.traffic_limit_gb = new_traffic_gb
+ subscription.updated_at = datetime.utcnow()
+
+ await db.commit()
+
+ subscription_service = SubscriptionService()
+ await subscription_service.update_remnawave_user(db, subscription)
+
+ await db.refresh(db_user)
+ await db.refresh(subscription)
+
+ 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, "traffic", current_traffic, new_traffic_gb, price_difference
+ )
+ except Exception as e:
+ logger.error(f"Ошибка отправки уведомления об изменении трафика: {e}")
+
+ if new_traffic_gb > current_traffic:
+ success_text = f"✅ Лимит трафика увеличен!\n\n"
+ success_text += f"📊 Было: {texts.format_traffic(current_traffic)} → "
+ success_text += f"Стало: {texts.format_traffic(new_traffic_gb)}\n"
+ if price_difference > 0:
+ success_text += f"💰 Списано: {texts.format_price(price_difference)}"
+ elif new_traffic_gb < current_traffic:
+ success_text = f"✅ Лимит трафика уменьшен!\n\n"
+ success_text += f"📊 Было: {texts.format_traffic(current_traffic)} → "
+ success_text += f"Стало: {texts.format_traffic(new_traffic_gb)}\n"
+ success_text += f"ℹ️ Возврат средств не производится"
+
+ await callback.message.edit_text(
+ success_text,
+ reply_markup=get_back_keyboard(db_user.language)
+ )
+
+ logger.info(
+ f"✅ Пользователь {db_user.telegram_id} переключил трафик с {current_traffic}GB на {new_traffic_gb}GB, доплата: {price_difference / 100}₽")
+
+ except Exception as e:
+ logger.error(f"Ошибка переключения трафика: {e}")
+ await callback.message.edit_text(
+ texts.ERROR,
+ reply_markup=get_back_keyboard(db_user.language)
+ )
+
+ await callback.answer()
diff --git a/docs/project_structure_reference.md b/docs/project_structure_reference.md
index 7b1fa290..a6d4109a 100644
--- a/docs/project_structure_reference.md
+++ b/docs/project_structure_reference.md
@@ -210,9 +210,12 @@
- `app/handlers/start.py` — Python-модуль
Классы: нет
Функции: `_get_language_prompt_text`, `_get_subscription_status`, `_get_subscription_status_simple`, `_insert_random_message`, `get_referral_code_keyboard`, `register_handlers`
-- `app/handlers/subscription.py` — Python-модуль
- Классы: `_SafeFormatDict` (1 методов)
- Функции: `_format_text_with_placeholders`, `_get_addon_discount_percent_for_user`, `_apply_addon_discount`, `_get_promo_offer_discount_percent`, `_apply_promo_offer_discount`, `_get_period_hint_from_subscription`, `_apply_discount_to_monthly_component`, `_build_promo_group_discount_text`, `update_traffic_prices`, `format_traffic_display`, `validate_traffic_price`, `load_app_config`, `get_localized_value`, `get_step_description`, `format_additional_section`, `build_redirect_link`, `get_apps_for_device`, `get_device_name`, `create_deep_link`, `get_reset_devices_confirm_keyboard`, `get_traffic_switch_keyboard`, `get_confirm_switch_traffic_keyboard`, `register_handlers`
+- `app/handlers/subscription/` — пакет обработчиков подписки
+ Ключевые модули:
+ - `common.py` — вспомогательные функции форматирования, расчётов и построения клавиатур.
+ - `purchase.py` — пользовательские сценарии, регистрация обработчиков (`register_handlers`).
+ - `countries.py`, `devices.py`, `traffic.py`, `autopay.py`, `promo.py`, `happ.py`, `links.py`, `notifications.py`, `pricing.py` — тематические обработчики и сервисные утилиты.
+ Публичные функции доступны через `app.handlers.subscription` (например, `create_deep_link`, `get_servers_display_names`, `register_handlers`).
- `app/handlers/support.py` — Python-модуль
Классы: нет
Функции: `register_handlers`