Files
remnawave-bedolaga-telegram…/app/handlers/subscription.py

6646 lines
246 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import json
import logging
from datetime import datetime, timedelta
from typing import Dict, List, Any, Tuple, Optional
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.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"- Базовый период: <s>{texts.format_price(base_price_original)}</s> "
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 = (
"📋 <b>Сводка заказа</b>\n\n"
f"📅 <b>Период:</b> {period_display}\n"
f"📊 <b>Трафик:</b> {traffic_display}\n"
f"🌍 <b>Страны:</b> {', '.join(selected_countries_names)}\n"
f"📱 <b>Устройства:</b> {devices_selected}\n\n"
"💰 <b>Детализация стоимости:</b>\n"
f"{details_text}\n\n"
f"💎 <b>Общая стоимость:</b> {texts.format_price(total_price)}\n\n"
"Подтверждаете покупку?"
)
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",
"<blockquote>📱 <b>Подключенные устройства:</b>\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", "</blockquote>")
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",
"🔗 <b>Ссылка для подключения:</b>\n<code>{subscription_url}</code>",
).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",
"🔗 <b>Ваша ссылка для импорта в VPN приложение:</b>\n<code>{subscription_url}</code>",
).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",
(
"🌍 <b>Управление странами подписки</b>\n\n"
"📋 <b>Текущие страны ({current_count}):</b>\n"
"{current_list}\n\n"
"💡 <b>Инструкция:</b>\n"
"✅ - страна подключена\n"
" - будет добавлена (платно)\n"
" - будет отключена (бесплатно)\n"
"⚪ - не выбрана\n\n"
"⚠️ <b>Важно:</b> Повторное подключение отключенных стран будет платным!"
),
).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",
(
"⚠️ <b>Недостаточно средств</b>\n\n"
"Стоимость услуги: {required}\n"
"На балансе: {balance}\n"
"Не хватает: {missing}\n\n"
"Выберите способ пополнения. Сумма подставится автоматически."
),
).format(
required=required_text,
balance=texts.format_price(db_user.balance_kopeks),
missing=texts.format_price(missing_kopeks),
)
await callback.message.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",
"✅ <b>Страны успешно обновлены!</b>\n\n",
)
if added_names:
success_text += texts.t(
"COUNTRY_CHANGES_ADDED_HEADER",
" <b>Добавлены страны:</b>\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",
" <b>Отключены страны:</b>\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",
"🌐 <b>Активных стран:</b> {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",
(
"📈 <b>Добавить трафик к подписке</b>\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",
(
"📱 <b>Изменение количества устройств</b>\n\n"
"Текущий лимит: {current_devices} устройств\n"
"Выберите новое количество устройств:\n\n"
"💡 <b>Важно:</b>\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",
(
"⚠️ <b>Недостаточно средств</b>\n\n"
"Стоимость услуги: {required}\n"
"На балансе: {balance}\n"
"Не хватает: {missing}\n\n"
"Выберите способ пополнения. Сумма подставится автоматически."
),
).format(
required=required_text,
balance=texts.format_price(db_user.balance_kopeks),
missing=texts.format_price(missing_kopeks),
)
await callback.message.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",
(
"📱 <b>Подтверждение изменения</b>\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",
(
"🔄 <b>Управление устройствами</b>\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",
"<b>Подключенные устройства:</b>\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💡 <b>Действия:</b>\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",
(
"✅ <b>Все устройства успешно сброшены!</b>\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",
(
"⚠️ <b>Частичный сброс устройств</b>\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",
(
"❌ <b>Не удалось сбросить устройства</b>\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} - <s>{texts.format_price(original_price)}</s> "
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"<b>Ваша текущая конфигурация:</b>\n"
f"🌍 Серверов: {len(subscription.connected_squads)}\n"
f"📊 Трафик: {texts.format_traffic(subscription.traffic_limit_gb)}\n"
f"📱 Устройств: {subscription.device_limit}\n\n"
f"<b>Выберите период продления:</b>\n"
f"{prices_text.rstrip()}\n\n"
)
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 += "💡 <i>Цена включает все ваши текущие серверы и настройки</i>"
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"🔄 <b>Сброс трафика</b>\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",
(
"⚠️ <b>Недостаточно средств</b>\n\n"
"Стоимость услуги: {required}\n"
"На балансе: {balance}\n"
"Не хватает: {missing}\n\n"
"Выберите способ пополнения. Сумма подставится автоматически."
),
).format(
required=required_text,
balance=texts.format_price(db_user.balance_kopeks),
missing=texts.format_price(missing_kopeks),
)
await callback.message.edit_text(
message_text,
reply_markup=get_insufficient_balance_keyboard(
db_user.language,
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",
(
"⚠️ <b>Недостаточно средств</b>\n\n"
"Стоимость услуги: {required}\n"
"На балансе: {balance}\n"
"Не хватает: {missing}\n\n"
"Выберите способ пополнения. Сумма подставится автоматически."
),
).format(
required=required_text,
balance=texts.format_price(db_user.balance_kopeks),
missing=texts.format_price(missing_kopeks),
)
await callback.message.edit_text(
message_text,
reply_markup=get_insufficient_balance_keyboard(
db_user.language,
amount_kopeks=missing_kopeks,
),
parse_mode="HTML",
)
await callback.answer()
return
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",
(
"⚠️ <b>Недостаточно средств</b>\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💰 <b>Стоимость подписки в месяц:</b> {texts.format_price(subscription_cost)}"
if (
subscription_url
and subscription_url != "Генерируется..."
and not settings.should_hide_subscription_link()
):
info_text += f"\n\n🔗 <b>Ваша ссылка для импорта в VPN приложениe:</b>\n<code>{subscription_url}</code>"
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",
(
"⚠️ <b>Недостаточно средств</b>\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",
(
"⚠️ <b>Недостаточно средств</b>\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",
"🔗 <b>Ваша ссылка для импорта в VPN приложение:</b>\\n<code>{subscription_url}</code>",
).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",
(
"⚠️ <b>Недостаточно средств</b>\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",
(
"⚙️ <b>Настройки подписки</b>\n\n"
"📊 <b>Текущие параметры:</b>\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",
(
"💳 <b>Автоплатеж</b>\n\n"
"📊 <b>Статус:</b> {status}\n"
"⏰ <b>Списание за:</b> {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",
(
"⚠️ <b>Недостаточно средств</b>\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",
"📥 <b>Скачать Happ</b>\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",
"📥 <b>Скачать Happ</b>\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",
"""📱 <b>Подключить подписку</b>
🚀 Нажмите кнопку ниже, чтобы открыть подписку в мини-приложении 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",
"""🚀 <b>Подключить подписку</b>
📱 Нажмите кнопку ниже, чтобы открыть приложение:""",
),
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",
"""🚀 <b>Подключить подписку</b>",
🔗 Нажмите кнопку ниже, чтобы открыть ссылку подписки:""",
),
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",
"""🚀 <b>Подключить подписку</b>",
🔗 Нажмите кнопку ниже, чтобы открыть ссылку подписки:""",
),
reply_markup=keyboard,
parse_mode="HTML"
)
else:
if hide_subscription_link:
device_text = texts.t(
"SUBSCRIPTION_CONNECT_DEVICE_MESSAGE_HIDDEN",
"""📱 <b>Подключить подписку</b>
Ссылка подписки доступна по кнопкам ниже или в разделе "Моя подписка".
💡 <b>Выберите ваше устройство</b> для получения подробной инструкции по настройке:""",
)
else:
device_text = texts.t(
"SUBSCRIPTION_CONNECT_DEVICE_MESSAGE",
"""📱 <b>Подключить подписку</b>
🔗 <b>Ссылка подписки:</b>
<code>{subscription_url}</code>
💡 <b>Выберите ваше устройство</b> для получения подробной инструкции по настройке:""",
).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=[
[
InlineKeyboardButton(
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])
if hide_subscription_link:
link_section = (
texts.t("SUBSCRIPTION_DEVICE_LINK_TITLE", "🔗 <b>Ссылка подписки:</b>")
+ "\n"
+ texts.t(
"SUBSCRIPTION_LINK_HIDDEN_NOTICE",
" Ссылка подписки доступна по кнопкам ниже или в разделе \"Моя подписка\".",
)
+ "\n\n"
)
else:
link_section = (
texts.t("SUBSCRIPTION_DEVICE_LINK_TITLE", "🔗 <b>Ссылка подписки:</b>")
+ f"\n<code>{subscription_link}</code>\n\n"
)
guide_text = (
texts.t(
"SUBSCRIPTION_DEVICE_GUIDE_TITLE",
"📱 <b>Настройка для {device_name}</b>",
).format(device_name=get_device_name(device_type, db_user.language))
+ "\n\n"
+ link_section
+ texts.t(
"SUBSCRIPTION_DEVICE_FEATURED_APP",
"📋 <b>Рекомендуемое приложение:</b> {app_name}",
).format(app_name=featured_app['name'])
+ "\n\n"
+ texts.t("SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE", "<b>Шаг 1 - Установка:</b>")
+ f"\n{featured_app['installationStep']['description'][db_user.language]}\n\n"
+ texts.t("SUBSCRIPTION_DEVICE_STEP_ADD_TITLE", "<b>Шаг 2 - Добавление подписки:</b>")
+ f"\n{featured_app['addSubscriptionStep']['description'][db_user.language]}\n\n"
+ texts.t("SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE", "<b>Шаг 3 - Подключение:</b>")
+ f"\n{featured_app['connectAndUseStep']['description'][db_user.language]}\n\n"
+ texts.t("SUBSCRIPTION_DEVICE_HOW_TO_TITLE", "💡 <b>Как подключить:</b>")
+ "\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. Подключитесь к серверу",
),
]
)
)
await callback.message.edit_text(
guide_text,
reply_markup=get_connection_guide_keyboard(
subscription_link,
featured_app,
db_user.language
),
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",
"📱 <b>Приложения для {device_name}</b>",
).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", "🔗 <b>Ссылка подписки:</b>")
+ "\n"
+ texts.t(
"SUBSCRIPTION_LINK_HIDDEN_NOTICE",
" Ссылка подписки доступна по кнопкам ниже или в разделе \"Моя подписка\".",
)
+ "\n\n"
)
else:
link_section = (
texts.t("SUBSCRIPTION_DEVICE_LINK_TITLE", "🔗 <b>Ссылка подписки:</b>")
+ f"\n<code>{subscription_link}</code>\n\n"
)
guide_text = (
texts.t(
"SUBSCRIPTION_SPECIFIC_APP_TITLE",
"📱 <b>{app_name} - {device_name}</b>",
).format(app_name=app['name'], device_name=get_device_name(device_type, db_user.language))
+ "\n\n"
+ link_section
+ texts.t("SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE", "<b>Шаг 1 - Установка:</b>")
+ f"\n{app['installationStep']['description'][db_user.language]}\n\n"
+ texts.t("SUBSCRIPTION_DEVICE_STEP_ADD_TITLE", "<b>Шаг 2 - Добавление подписки:</b>")
+ f"\n{app['addSubscriptionStep']['description'][db_user.language]}\n\n"
+ texts.t("SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE", "<b>Шаг 3 - Подключение:</b>")
+ f"\n{app['connectAndUseStep']['description'][db_user.language]}"
)
if 'additionalAfterAddSubscriptionStep' in app:
additional = app['additionalAfterAddSubscriptionStep']
guide_text += (
"\n\n"
+ texts.t(
"SUBSCRIPTION_ADDITIONAL_STEP_TITLE",
"<b>{title}:</b>",
).format(title=additional['title'][db_user.language])
+ f"\n{additional['description'][db_user.language]}"
)
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",
"🔗 <b>Подключение через Happ</b>",
)
+ "\n\n"
+ texts.t(
"SUBSCRIPTION_HAPP_OPEN_LINK",
"<a href=\"{subscription_link}\">🔓 Открыть ссылку в Happ</a>",
).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",
"<blockquote expandable><code>{crypto_link}</code></blockquote>",
).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", "🔗 <b>Ссылка подписки:</b>")
+ "\n\n"
+ f"<code>{subscription_link}</code>\n\n"
+ texts.t("SUBSCRIPTION_LINK_USAGE_TITLE", "📱 <b>Как использовать:</b>")
+ "\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:
return json.load(f)
except Exception as e:
logger.error(f"Ошибка загрузки конфига приложений: {e}")
return {}
def get_apps_for_device(device_type: str, language: str = "ru") -> List[Dict[str, Any]]:
config = load_app_config()['platforms']
device_mapping = {
'ios': 'ios',
'android': 'android',
'windows': 'windows',
'mac': 'macos',
'tv': 'androidTV'
}
config_key = device_mapping.get(device_type, device_type)
return config.get(config_key, [])
def get_device_name(device_type: str, language: str = "ru") -> str:
if language == "en":
names = {
'ios': 'iPhone/iPad',
'android': 'Android',
'windows': 'Windows',
'mac': 'macOS',
'tv': 'Android TV'
}
else:
names = {
'ios': 'iPhone/iPad',
'android': 'Android',
'windows': 'Windows',
'mac': 'macOS',
'tv': 'Android TV'
}
return names.get(device_type, device_type)
def create_deep_link(app: Dict[str, Any], subscription_url: str) -> str:
return 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"""
📱 <b>Как подключить устройство заново</b>
После сброса устройства вам нужно:
<b>1. Получить ссылку подписки:</b>
📋 Скопируйте ссылку ниже или найдите её в разделе "Моя подписка"
<b>2. Настроить VPN приложение:</b>
• Откройте ваше VPN приложение
• Найдите функцию "Добавить подписку" или "Import"
• Вставьте скопированную ссылку
<b>3. Подключиться:</b>
• Выберите сервер
• Нажмите "Подключить"
<b>🔗 Ваша ссылка подписки:</b>
<code>{subscription_link}</code>
💡 <b>Совет:</b> Сохраните эту ссылку - она понадобится для подключения новых устройств
"""
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"🔄 <b>Переключение лимита трафика</b>\n\n"
f"Текущий лимит: {texts.format_traffic(current_traffic)}\n"
f"Выберите новый лимит трафика:\n\n"
f"💡 <b>Важно:</b>\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",
(
"⚠️ <b>Недостаточно средств</b>\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"🔄 <b>Подтверждение переключения трафика</b>\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"
)