From d082e8a61cf9074c263a71fdb80cd43df4016d5d Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 13 Oct 2025 21:21:26 +0300 Subject: [PATCH] Refactor subscription handlers into modular package --- app/handlers/subscription.py | 6838 -------------------- app/handlers/subscription/__init__.py | 211 + app/handlers/subscription/autopay.py | 255 + app/handlers/subscription/common.py | 487 ++ app/handlers/subscription/countries.py | 958 +++ app/handlers/subscription/devices.py | 1322 ++++ app/handlers/subscription/happ.py | 158 + app/handlers/subscription/links.py | 354 + app/handlers/subscription/notifications.py | 131 + app/handlers/subscription/pricing.py | 465 ++ app/handlers/subscription/promo.py | 459 ++ app/handlers/subscription/purchase.py | 2231 +++++++ app/handlers/subscription/traffic.py | 734 +++ docs/project_structure_reference.md | 9 +- 14 files changed, 7771 insertions(+), 6841 deletions(-) delete mode 100644 app/handlers/subscription.py create mode 100644 app/handlers/subscription/__init__.py create mode 100644 app/handlers/subscription/autopay.py create mode 100644 app/handlers/subscription/common.py create mode 100644 app/handlers/subscription/countries.py create mode 100644 app/handlers/subscription/devices.py create mode 100644 app/handlers/subscription/happ.py create mode 100644 app/handlers/subscription/links.py create mode 100644 app/handlers/subscription/notifications.py create mode 100644 app/handlers/subscription/pricing.py create mode 100644 app/handlers/subscription/promo.py create mode 100644 app/handlers/subscription/purchase.py create mode 100644 app/handlers/subscription/traffic.py 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} - {texts.format_price(original_price)} " - f"{texts.format_price(final_price)}\n" - ) - else: - prices_text += ( - "📅 " - f"{period_display} - {texts.format_price(final_price)}\n" - ) - - promo_discounts_text = _build_promo_group_discount_text( - db_user, - available_periods, - texts=texts, - ) - - message_text = ( - "⏰ Продление подписки\n\n" - f"Осталось дней: {subscription.days_left}\n\n" - f"Ваша текущая конфигурация:\n" - f"🌍 Серверов: {len(subscription.connected_squads)}\n" - f"📊 Трафик: {texts.format_traffic(subscription.traffic_limit_gb)}\n" - f"📱 Устройств: {subscription.device_limit}\n\n" - f"Выберите период продления:\n" - f"{prices_text.rstrip()}\n\n" - ) - - if promo_discounts_text: - message_text += f"{promo_discounts_text}\n\n" - - promo_offer_hint = await _get_promo_offer_hint( - db, - db_user, - texts, - promo_offer_percent, - ) - if promo_offer_hint: - message_text += f"{promo_offer_hint}\n\n" - - message_text += "💡 Цена включает все ваши текущие серверы и настройки" - - await callback.message.edit_text( - message_text, - reply_markup=get_extend_subscription_keyboard_with_prices(db_user.language, renewal_prices), - 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() - - -def update_traffic_prices(): - from app.config import refresh_traffic_prices - refresh_traffic_prices() - logger.info("🔄 TRAFFIC_PRICES обновлены из конфигурации") - - -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 confirm_extend_subscription( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): - from app.services.admin_notification_service import AdminNotificationService - - days = int(callback.data.split('_')[2]) - texts = get_texts(db_user.language) - subscription = db_user.subscription - - if not subscription: - await callback.answer("⚠ У вас нет активной подписки", show_alert=True) - return - - months_in_period = calculate_months_from_days(days) - old_end_date = subscription.end_date - server_uuid_prices: Dict[str, int] = {} - - try: - 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, base_discount_total = apply_percentage_discount( - base_price_original, - period_discount_percent, - ) - - subscription_service = SubscriptionService() - servers_price_per_month, per_server_monthly_prices = 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, - ) - total_servers_price = 0 - total_servers_discount = 0 - - for squad_uuid, server_monthly_price in zip(subscription.connected_squads, per_server_monthly_prices): - discount_per_month = server_monthly_price * servers_discount_percent // 100 - discounted_per_month = server_monthly_price - discount_per_month - total_servers_price += discounted_per_month * months_in_period - total_servers_discount += discount_per_month * months_in_period - server_uuid_prices[squad_uuid] = discounted_per_month * months_in_period - - discounted_servers_price_per_month = servers_price_per_month - ( - servers_price_per_month * servers_discount_percent // 100 - ) - - 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 - discounted_devices_price_per_month = devices_price_per_month - devices_discount_per_month - total_devices_price = discounted_devices_price_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 - discounted_traffic_price_per_month = traffic_price_per_month - traffic_discount_per_month - total_traffic_price = discounted_traffic_price_per_month * months_in_period - - price = base_price + total_servers_price + total_devices_price + total_traffic_price - original_price = price - promo_component = _apply_promo_offer_discount(db_user, price) - if promo_component["discount"] > 0: - price = promo_component["discounted"] - - monthly_additions = ( - discounted_servers_price_per_month - + discounted_devices_price_per_month - + discounted_traffic_price_per_month - ) - is_valid = validate_pricing_calculation(base_price, monthly_additions, months_in_period, original_price) - - if not is_valid: - logger.error(f"Ошибка в расчете цены продления для пользователя {db_user.telegram_id}") - await callback.answer("Ошибка расчета цены. Обратитесь в поддержку.", show_alert=True) - return - - logger.info(f"💰 Расчет продления подписки {subscription.id} на {days} дней ({months_in_period} мес):") - base_log = f" 📅 Период {days} дней: {base_price_original / 100}₽" - if base_discount_total > 0: - base_log += ( - f" → {base_price / 100}₽" - f" (скидка {period_discount_percent}%: -{base_discount_total / 100}₽)" - ) - logger.info(base_log) - if total_servers_price > 0: - logger.info( - f" 🌐 Серверы: {servers_price_per_month / 100}₽/мес × {months_in_period}" - f" = {total_servers_price / 100}₽" - + ( - f" (скидка {servers_discount_percent}%:" - f" -{total_servers_discount / 100}₽)" - if total_servers_discount > 0 - else "" - ) - ) - if total_devices_price > 0: - logger.info( - f" 📱 Устройства: {devices_price_per_month / 100}₽/мес × {months_in_period}" - f" = {total_devices_price / 100}₽" - + ( - f" (скидка {devices_discount_percent}%:" - f" -{devices_discount_per_month * months_in_period / 100}₽)" - if devices_discount_percent > 0 and devices_discount_per_month > 0 - else "" - ) - ) - if total_traffic_price > 0: - logger.info( - f" 📊 Трафик: {traffic_price_per_month / 100}₽/мес × {months_in_period}" - f" = {total_traffic_price / 100}₽" - + ( - f" (скидка {traffic_discount_percent}%:" - f" -{traffic_discount_per_month * months_in_period / 100}₽)" - if traffic_discount_percent > 0 and traffic_discount_per_month > 0 - else "" - ) - ) - if promo_component["discount"] > 0: - logger.info( - " 🎯 Промо-предложение: -%s₽ (%s%%)", - promo_component["discount"] / 100, - promo_component["percent"], - ) - logger.info(f" 💎 ИТОГО: {price / 100}₽") - - except Exception as e: - logger.error(f"⚠ ОШИБКА РАСЧЕТА ЦЕНЫ: {e}") - await callback.answer("⚠ Ошибка расчета стоимости", show_alert=True) - return - - if db_user.balance_kopeks < price: - missing_kopeks = price - db_user.balance_kopeks - required_text = texts.format_price(price) - 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, - amount_kopeks=missing_kopeks, - ), - parse_mode="HTML", - ) - await callback.answer() - return - - try: - success = await subtract_user_balance( - db, - db_user, - price, - f"Продление подписки на {days} дней", - consume_promo_offer=promo_component["discount"] > 0, - ) - - if not success: - await callback.answer("⚠ Ошибка списания средств", show_alert=True) - return - - current_time = datetime.utcnow() - - if subscription.end_date > current_time: - new_end_date = subscription.end_date + timedelta(days=days) - else: - new_end_date = current_time + timedelta(days=days) - - subscription.end_date = new_end_date - - subscription.status = SubscriptionStatus.ACTIVE.value - subscription.updated_at = current_time - - await db.commit() - await db.refresh(subscription) - await db.refresh(db_user) - - # ensure freshly loaded values are available even if SQLAlchemy expires - # attributes on subsequent access - refreshed_end_date = subscription.end_date - refreshed_balance = db_user.balance_kopeks - - from app.database.crud.server_squad import get_server_ids_by_uuids - from app.database.crud.subscription import add_subscription_servers - - server_ids = await get_server_ids_by_uuids(db, subscription.connected_squads) - if server_ids: - from sqlalchemy import select - from app.database.models import ServerSquad - - result = await db.execute( - select(ServerSquad.id, ServerSquad.squad_uuid).where(ServerSquad.id.in_(server_ids)) - ) - id_to_uuid = {row.id: row.squad_uuid for row in result} - default_price = total_servers_price // len(server_ids) if server_ids else 0 - server_prices_for_period = [ - server_uuid_prices.get(id_to_uuid.get(server_id, ""), default_price) - for server_id in server_ids - ] - await add_subscription_servers(db, subscription, server_ids, server_prices_for_period) - - try: - remnawave_result = await subscription_service.update_remnawave_user( - db, - subscription, - reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT, - reset_reason="продление подписки", - ) - if remnawave_result: - logger.info("✅ RemnaWave обновлен успешно") - else: - logger.error("⚠ ОШИБКА ОБНОВЛЕНИЯ REMNAWAVE") - except Exception as e: - logger.error(f"⚠ ИСКЛЮЧЕНИЕ ПРИ ОБНОВЛЕНИИ REMNAWAVE: {e}") - - transaction = await create_transaction( - db=db, - user_id=db_user.id, - type=TransactionType.SUBSCRIPTION_PAYMENT, - amount_kopeks=price, - description=f"Продление подписки на {days} дней ({months_in_period} мес)" - ) - - try: - notification_service = AdminNotificationService(callback.bot) - await notification_service.send_subscription_extension_notification( - db, - db_user, - subscription, - transaction, - days, - old_end_date, - new_end_date=refreshed_end_date, - balance_after=refreshed_balance, - ) - except Exception as e: - logger.error(f"Ошибка отправки уведомления о продлении: {e}") - - success_message = ( - "✅ Подписка успешно продлена!\n\n" - f"⏰ Добавлено: {days} дней\n" - f"Действует до: {refreshed_end_date.strftime('%d.%m.%Y %H:%M')}\n\n" - f"💰 Списано: {texts.format_price(price)}" - ) - - if promo_component["discount"] > 0: - success_message += ( - f" (включая доп. скидку {promo_component['percent']}%:" - f" -{texts.format_price(promo_component['discount'])})" - ) - - await callback.message.edit_text( - success_message, - reply_markup=get_back_keyboard(db_user.language) - ) - - logger.info(f"✅ Пользователь {db_user.telegram_id} продлил подписку на {days} дней за {price / 100}₽") - - except Exception as e: - logger.error(f"⚠ КРИТИЧЕСКАЯ ОШИБКА ПРОДЛЕНИЯ: {e}") - import traceback - logger.error(f"TRACEBACK: {traceback.format_exc()}") - - await callback.message.edit_text( - "⚠ Произошла ошибка при продлении подписки. Обратитесь в поддержку.", - reply_markup=get_back_keyboard(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 select_period( - callback: types.CallbackQuery, - state: FSMContext, - db_user: User -): - period_days = int(callback.data.split('_')[1]) - texts = get_texts(db_user.language) - - data = await state.get_data() - data['period_days'] = period_days - data['total_price'] = PERIOD_PRICES[period_days] - - if settings.is_traffic_fixed(): - fixed_traffic_price = settings.get_traffic_price(settings.get_fixed_traffic_limit()) - data['total_price'] += fixed_traffic_price - data['traffic_gb'] = settings.get_fixed_traffic_limit() - - await state.set_data(data) - - if settings.is_traffic_selectable(): - available_packages = [pkg for pkg in settings.get_traffic_packages() if pkg['enabled']] - - if not available_packages: - await callback.answer("⚠️ Пакеты трафика не настроены", show_alert=True) - return - - 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: - 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 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 get_subscription_info_text(subscription, texts, db_user, db: AsyncSession): - devices_used = await get_current_devices_count(db_user) - countries_info = await _get_countries_info(subscription.connected_squads) - countries_text = ", ".join([c['name'] for c in countries_info]) if countries_info else "Нет" - - subscription_url = getattr(subscription, 'subscription_url', None) or "Генерируется..." - - if subscription.is_trial: - status_text = "🎁 Тестовая" - type_text = "Триал" - else: - if subscription.is_active: - status_text = "✅ Оплачена" - else: - status_text = "⌛ Истекла" - type_text = "Платная подписка" - - if subscription.traffic_limit_gb == 0: - if settings.is_traffic_fixed(): - traffic_text = "∞ Безлимитный" - else: - traffic_text = "∞ Безлимитный" - else: - if settings.is_traffic_fixed(): - traffic_text = f"{subscription.traffic_limit_gb} ГБ" - else: - traffic_text = f"{subscription.traffic_limit_gb} ГБ" - - subscription_cost = await get_subscription_cost(subscription, db) - - info_text = texts.SUBSCRIPTION_INFO.format( - status=status_text, - type=type_text, - end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M"), - days_left=max(0, subscription.days_left), - traffic_used=texts.format_traffic(subscription.traffic_used_gb), - traffic_limit=traffic_text, - countries_count=len(subscription.connected_squads), - devices_used=devices_used, - devices_limit=subscription.device_limit, - autopay_status="✅ Включен" if subscription.autopay_enabled else "⌛ Выключен" - ) - - if subscription_cost > 0: - info_text += f"\n💰 Стоимость подписки в месяц: {texts.format_price(subscription_cost)}" - - if ( - subscription_url - and subscription_url != "Генерируется..." - and not settings.should_hide_subscription_link() - ): - info_text += f"\n\n🔗 Ваша ссылка для импорта в VPN приложениe:\n{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"- Базовый период: {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 + +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 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 get_subscription_info_text(subscription, texts, db_user, db: AsyncSession): + devices_used = await get_current_devices_count(db_user) + countries_info = await _get_countries_info(subscription.connected_squads) + countries_text = ", ".join([c['name'] for c in countries_info]) if countries_info else "Нет" + + subscription_url = getattr(subscription, 'subscription_url', None) or "Генерируется..." + + if subscription.is_trial: + status_text = "🎁 Тестовая" + type_text = "Триал" + else: + if subscription.is_active: + status_text = "✅ Оплачена" + else: + status_text = "⌛ Истекла" + type_text = "Платная подписка" + + if subscription.traffic_limit_gb == 0: + if settings.is_traffic_fixed(): + traffic_text = "∞ Безлимитный" + else: + traffic_text = "∞ Безлимитный" + else: + if settings.is_traffic_fixed(): + traffic_text = f"{subscription.traffic_limit_gb} ГБ" + else: + traffic_text = f"{subscription.traffic_limit_gb} ГБ" + + subscription_cost = await get_subscription_cost(subscription, db) + + info_text = texts.SUBSCRIPTION_INFO.format( + status=status_text, + type=type_text, + end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M"), + days_left=max(0, subscription.days_left), + traffic_used=texts.format_traffic(subscription.traffic_used_gb), + traffic_limit=traffic_text, + countries_count=len(subscription.connected_squads), + devices_used=devices_used, + devices_limit=subscription.device_limit, + autopay_status="✅ Включен" if subscription.autopay_enabled else "⌛ Выключен" + ) + + if subscription_cost > 0: + info_text += f"\n💰 Стоимость подписки в месяц: {texts.format_price(subscription_cost)}" + + if ( + subscription_url + and subscription_url != "Генерируется..." + and not settings.should_hide_subscription_link() + ): + info_text += f"\n\n🔗 Ваша ссылка для импорта в VPN приложениe:\n{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} - {texts.format_price(original_price)} " + f"{texts.format_price(final_price)}\n" + ) + else: + prices_text += ( + "📅 " + f"{period_display} - {texts.format_price(final_price)}\n" + ) + + promo_discounts_text = _build_promo_group_discount_text( + db_user, + available_periods, + texts=texts, + ) + + message_text = ( + "⏰ Продление подписки\n\n" + f"Осталось дней: {subscription.days_left}\n\n" + f"Ваша текущая конфигурация:\n" + f"🌍 Серверов: {len(subscription.connected_squads)}\n" + f"📊 Трафик: {texts.format_traffic(subscription.traffic_limit_gb)}\n" + f"📱 Устройств: {subscription.device_limit}\n\n" + f"Выберите период продления:\n" + f"{prices_text.rstrip()}\n\n" + ) + + if promo_discounts_text: + message_text += f"{promo_discounts_text}\n\n" + + promo_offer_hint = await _get_promo_offer_hint( + db, + db_user, + texts, + promo_offer_percent, + ) + if promo_offer_hint: + message_text += f"{promo_offer_hint}\n\n" + + message_text += "💡 Цена включает все ваши текущие серверы и настройки" + + await callback.message.edit_text( + message_text, + reply_markup=get_extend_subscription_keyboard_with_prices(db_user.language, renewal_prices), + parse_mode="HTML" + ) + + await callback.answer() + +async def confirm_extend_subscription( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + from app.services.admin_notification_service import AdminNotificationService + + days = int(callback.data.split('_')[2]) + texts = get_texts(db_user.language) + subscription = db_user.subscription + + if not subscription: + await callback.answer("⚠ У вас нет активной подписки", show_alert=True) + return + + months_in_period = calculate_months_from_days(days) + old_end_date = subscription.end_date + server_uuid_prices: Dict[str, int] = {} + + try: + 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, base_discount_total = apply_percentage_discount( + base_price_original, + period_discount_percent, + ) + + subscription_service = SubscriptionService() + servers_price_per_month, per_server_monthly_prices = 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, + ) + total_servers_price = 0 + total_servers_discount = 0 + + for squad_uuid, server_monthly_price in zip(subscription.connected_squads, per_server_monthly_prices): + discount_per_month = server_monthly_price * servers_discount_percent // 100 + discounted_per_month = server_monthly_price - discount_per_month + total_servers_price += discounted_per_month * months_in_period + total_servers_discount += discount_per_month * months_in_period + server_uuid_prices[squad_uuid] = discounted_per_month * months_in_period + + discounted_servers_price_per_month = servers_price_per_month - ( + servers_price_per_month * servers_discount_percent // 100 + ) + + 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 + discounted_devices_price_per_month = devices_price_per_month - devices_discount_per_month + total_devices_price = discounted_devices_price_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 + discounted_traffic_price_per_month = traffic_price_per_month - traffic_discount_per_month + total_traffic_price = discounted_traffic_price_per_month * months_in_period + + price = base_price + total_servers_price + total_devices_price + total_traffic_price + original_price = price + promo_component = _apply_promo_offer_discount(db_user, price) + if promo_component["discount"] > 0: + price = promo_component["discounted"] + + monthly_additions = ( + discounted_servers_price_per_month + + discounted_devices_price_per_month + + discounted_traffic_price_per_month + ) + is_valid = validate_pricing_calculation(base_price, monthly_additions, months_in_period, original_price) + + if not is_valid: + logger.error(f"Ошибка в расчете цены продления для пользователя {db_user.telegram_id}") + await callback.answer("Ошибка расчета цены. Обратитесь в поддержку.", show_alert=True) + return + + logger.info(f"💰 Расчет продления подписки {subscription.id} на {days} дней ({months_in_period} мес):") + base_log = f" 📅 Период {days} дней: {base_price_original / 100}₽" + if base_discount_total > 0: + base_log += ( + f" → {base_price / 100}₽" + f" (скидка {period_discount_percent}%: -{base_discount_total / 100}₽)" + ) + logger.info(base_log) + if total_servers_price > 0: + logger.info( + f" 🌐 Серверы: {servers_price_per_month / 100}₽/мес × {months_in_period}" + f" = {total_servers_price / 100}₽" + + ( + f" (скидка {servers_discount_percent}%:" + f" -{total_servers_discount / 100}₽)" + if total_servers_discount > 0 + else "" + ) + ) + if total_devices_price > 0: + logger.info( + f" 📱 Устройства: {devices_price_per_month / 100}₽/мес × {months_in_period}" + f" = {total_devices_price / 100}₽" + + ( + f" (скидка {devices_discount_percent}%:" + f" -{devices_discount_per_month * months_in_period / 100}₽)" + if devices_discount_percent > 0 and devices_discount_per_month > 0 + else "" + ) + ) + if total_traffic_price > 0: + logger.info( + f" 📊 Трафик: {traffic_price_per_month / 100}₽/мес × {months_in_period}" + f" = {total_traffic_price / 100}₽" + + ( + f" (скидка {traffic_discount_percent}%:" + f" -{traffic_discount_per_month * months_in_period / 100}₽)" + if traffic_discount_percent > 0 and traffic_discount_per_month > 0 + else "" + ) + ) + if promo_component["discount"] > 0: + logger.info( + " 🎯 Промо-предложение: -%s₽ (%s%%)", + promo_component["discount"] / 100, + promo_component["percent"], + ) + logger.info(f" 💎 ИТОГО: {price / 100}₽") + + except Exception as e: + logger.error(f"⚠ ОШИБКА РАСЧЕТА ЦЕНЫ: {e}") + await callback.answer("⚠ Ошибка расчета стоимости", show_alert=True) + return + + if db_user.balance_kopeks < price: + missing_kopeks = price - db_user.balance_kopeks + required_text = texts.format_price(price) + 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, + amount_kopeks=missing_kopeks, + ), + parse_mode="HTML", + ) + await callback.answer() + return + + try: + success = await subtract_user_balance( + db, + db_user, + price, + f"Продление подписки на {days} дней", + consume_promo_offer=promo_component["discount"] > 0, + ) + + if not success: + await callback.answer("⚠ Ошибка списания средств", show_alert=True) + return + + current_time = datetime.utcnow() + + if subscription.end_date > current_time: + new_end_date = subscription.end_date + timedelta(days=days) + else: + new_end_date = current_time + timedelta(days=days) + + subscription.end_date = new_end_date + + subscription.status = SubscriptionStatus.ACTIVE.value + subscription.updated_at = current_time + + await db.commit() + await db.refresh(subscription) + await db.refresh(db_user) + + # ensure freshly loaded values are available even if SQLAlchemy expires + # attributes on subsequent access + refreshed_end_date = subscription.end_date + refreshed_balance = db_user.balance_kopeks + + from app.database.crud.server_squad import get_server_ids_by_uuids + from app.database.crud.subscription import add_subscription_servers + + server_ids = await get_server_ids_by_uuids(db, subscription.connected_squads) + if server_ids: + from sqlalchemy import select + from app.database.models import ServerSquad + + result = await db.execute( + select(ServerSquad.id, ServerSquad.squad_uuid).where(ServerSquad.id.in_(server_ids)) + ) + id_to_uuid = {row.id: row.squad_uuid for row in result} + default_price = total_servers_price // len(server_ids) if server_ids else 0 + server_prices_for_period = [ + server_uuid_prices.get(id_to_uuid.get(server_id, ""), default_price) + for server_id in server_ids + ] + await add_subscription_servers(db, subscription, server_ids, server_prices_for_period) + + try: + remnawave_result = await subscription_service.update_remnawave_user( + db, + subscription, + reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT, + reset_reason="продление подписки", + ) + if remnawave_result: + logger.info("✅ RemnaWave обновлен успешно") + else: + logger.error("⚠ ОШИБКА ОБНОВЛЕНИЯ REMNAWAVE") + except Exception as e: + logger.error(f"⚠ ИСКЛЮЧЕНИЕ ПРИ ОБНОВЛЕНИИ REMNAWAVE: {e}") + + transaction = await create_transaction( + db=db, + user_id=db_user.id, + type=TransactionType.SUBSCRIPTION_PAYMENT, + amount_kopeks=price, + description=f"Продление подписки на {days} дней ({months_in_period} мес)" + ) + + try: + notification_service = AdminNotificationService(callback.bot) + await notification_service.send_subscription_extension_notification( + db, + db_user, + subscription, + transaction, + days, + old_end_date, + new_end_date=refreshed_end_date, + balance_after=refreshed_balance, + ) + except Exception as e: + logger.error(f"Ошибка отправки уведомления о продлении: {e}") + + success_message = ( + "✅ Подписка успешно продлена!\n\n" + f"⏰ Добавлено: {days} дней\n" + f"Действует до: {refreshed_end_date.strftime('%d.%m.%Y %H:%M')}\n\n" + f"💰 Списано: {texts.format_price(price)}" + ) + + if promo_component["discount"] > 0: + success_message += ( + f" (включая доп. скидку {promo_component['percent']}%:" + f" -{texts.format_price(promo_component['discount'])})" + ) + + await callback.message.edit_text( + success_message, + reply_markup=get_back_keyboard(db_user.language) + ) + + logger.info(f"✅ Пользователь {db_user.telegram_id} продлил подписку на {days} дней за {price / 100}₽") + + except Exception as e: + logger.error(f"⚠ КРИТИЧЕСКАЯ ОШИБКА ПРОДЛЕНИЯ: {e}") + import traceback + logger.error(f"TRACEBACK: {traceback.format_exc()}") + + await callback.message.edit_text( + "⚠ Произошла ошибка при продлении подписки. Обратитесь в поддержку.", + reply_markup=get_back_keyboard(db_user.language) + ) + + await callback.answer() + +async def select_period( + callback: types.CallbackQuery, + state: FSMContext, + db_user: User +): + period_days = int(callback.data.split('_')[1]) + texts = get_texts(db_user.language) + + data = await state.get_data() + data['period_days'] = period_days + data['total_price'] = PERIOD_PRICES[period_days] + + if settings.is_traffic_fixed(): + fixed_traffic_price = settings.get_traffic_price(settings.get_fixed_traffic_limit()) + data['total_price'] += fixed_traffic_price + data['traffic_gb'] = settings.get_fixed_traffic_limit() + + await state.set_data(data) + + if settings.is_traffic_selectable(): + available_packages = [pkg for pkg in settings.get_traffic_packages() if pkg['enabled']] + + if not available_packages: + await callback.answer("⚠️ Пакеты трафика не настроены", show_alert=True) + return + + 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: + 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_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 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`