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

2232 lines
87 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
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 base64
import json
import logging
from datetime import datetime, timedelta
from typing import Dict, List, Any, Tuple, Optional
from urllib.parse import quote
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings, PERIOD_PRICES, get_traffic_prices
from app.database.crud.discount_offer import (
get_offer_by_id,
mark_offer_claimed,
)
from app.database.crud.promo_offer_template import get_promo_offer_template_by_id
from app.database.crud.subscription import (
create_trial_subscription,
create_paid_subscription, add_subscription_traffic, add_subscription_devices,
update_subscription_autopay
)
from app.database.crud.transaction import create_transaction
from app.database.crud.user import subtract_user_balance
from app.database.models import (
User, TransactionType, SubscriptionStatus,
Subscription
)
from app.keyboards.inline import (
get_subscription_keyboard, get_trial_keyboard,
get_subscription_period_keyboard, get_traffic_packages_keyboard,
get_countries_keyboard, get_devices_keyboard,
get_subscription_confirm_keyboard, get_autopay_keyboard,
get_autopay_days_keyboard, get_back_keyboard,
get_add_traffic_keyboard,
get_change_devices_keyboard, get_reset_traffic_confirm_keyboard,
get_manage_countries_keyboard,
get_device_selection_keyboard, get_connection_guide_keyboard,
get_app_selection_keyboard, get_specific_app_keyboard,
get_updated_subscription_settings_keyboard, get_insufficient_balance_keyboard,
get_extend_subscription_keyboard_with_prices, get_confirm_change_devices_keyboard,
get_devices_management_keyboard, get_device_management_help_keyboard,
get_happ_cryptolink_keyboard,
get_happ_download_platform_keyboard, get_happ_download_link_keyboard,
get_happ_download_button_row,
get_payment_methods_keyboard_with_cart,
get_subscription_confirm_keyboard_with_cart,
get_insufficient_balance_keyboard_with_cart
)
from app.localization.texts import get_texts
from app.services.admin_notification_service import AdminNotificationService
from app.services.remnawave_service import RemnaWaveService
from app.services.subscription_checkout_service import (
clear_subscription_checkout_draft,
get_subscription_checkout_draft,
save_subscription_checkout_draft,
should_offer_checkout_resume,
)
from app.services.subscription_service import SubscriptionService
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
from app.services.promo_offer_service import promo_offer_service
from app.states import SubscriptionStates
from app.utils.pagination import paginate_list
from app.utils.pricing_utils import (
calculate_months_from_days,
get_remaining_months,
calculate_prorated_price,
validate_pricing_calculation,
format_period_description,
apply_percentage_discount,
)
from app.utils.subscription_utils import (
get_display_subscription_link,
get_happ_cryptolink_redirect_link,
convert_subscription_link_to_happ_scheme,
)
from app.utils.promo_offer import (
build_promo_offer_hint,
get_user_active_promo_discount_percent,
)
from .common import _apply_promo_offer_discount, _get_promo_offer_discount_percent, logger, update_traffic_prices
from .autopay import handle_autopay_menu, handle_subscription_cancel, handle_subscription_config_back, set_autopay_days, show_autopay_days, toggle_autopay
from .countries import _get_available_countries, _should_show_countries_management, apply_countries_changes, countries_continue, handle_add_countries, handle_manage_country, select_country
from .devices import confirm_add_devices, confirm_change_devices, confirm_reset_devices, execute_change_devices, get_current_devices_count, get_servers_display_names, handle_all_devices_reset_from_management, handle_app_selection, handle_change_devices, handle_device_guide, handle_device_management, handle_devices_page, handle_reset_devices, handle_single_device_reset, handle_specific_app_guide, show_device_connection_help
from .happ import handle_happ_download_back, handle_happ_download_close, handle_happ_download_platform_choice, handle_happ_download_request
from .links import handle_connect_subscription, handle_open_subscription_link
from .pricing import _build_subscription_period_prompt, _prepare_subscription_summary
from .promo import _build_promo_group_discount_text, _get_promo_offer_hint, claim_discount_offer, handle_promo_offer_close
from .traffic import confirm_reset_traffic, confirm_switch_traffic, execute_switch_traffic, handle_no_traffic_packages, handle_reset_traffic, handle_switch_traffic, select_traffic
async def show_subscription_info(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
await db.refresh(db_user)
texts = get_texts(db_user.language)
subscription = db_user.subscription
if not subscription:
await callback.message.edit_text(
texts.SUBSCRIPTION_NONE,
reply_markup=get_back_keyboard(db_user.language)
)
await callback.answer()
return
from app.database.crud.subscription import check_and_update_subscription_status
subscription = await check_and_update_subscription_status(db, subscription)
subscription_service = SubscriptionService()
await subscription_service.sync_subscription_usage(db, subscription)
await db.refresh(subscription)
current_time = datetime.utcnow()
if subscription.status == "expired" or subscription.end_date <= current_time:
actual_status = "expired"
status_display = texts.t("SUBSCRIPTION_STATUS_EXPIRED", "Истекла")
status_emoji = "🔴"
elif subscription.status == "active" and subscription.end_date > current_time:
if subscription.is_trial:
actual_status = "trial_active"
status_display = texts.t("SUBSCRIPTION_STATUS_TRIAL", "Тестовая")
status_emoji = "🎯"
else:
actual_status = "paid_active"
status_display = texts.t("SUBSCRIPTION_STATUS_ACTIVE", "Активна")
status_emoji = "💎"
else:
actual_status = "unknown"
status_display = texts.t("SUBSCRIPTION_STATUS_UNKNOWN", "Неизвестно")
status_emoji = ""
if subscription.end_date <= current_time:
days_left = 0
time_left_text = texts.t("SUBSCRIPTION_TIME_LEFT_EXPIRED", "истёк")
warning_text = ""
else:
delta = subscription.end_date - current_time
days_left = delta.days
hours_left = delta.seconds // 3600
if days_left > 1:
time_left_text = texts.t("SUBSCRIPTION_TIME_LEFT_DAYS", "{days} дн.").format(days=days_left)
warning_text = ""
elif days_left == 1:
time_left_text = texts.t("SUBSCRIPTION_TIME_LEFT_DAYS", "{days} дн.").format(days=days_left)
warning_text = texts.t("SUBSCRIPTION_WARNING_TOMORROW", "\n⚠️ истекает завтра!")
elif hours_left > 0:
time_left_text = texts.t("SUBSCRIPTION_TIME_LEFT_HOURS", "{hours} ч.").format(hours=hours_left)
warning_text = texts.t("SUBSCRIPTION_WARNING_TODAY", "\n⚠️ истекает сегодня!")
else:
minutes_left = (delta.seconds % 3600) // 60
time_left_text = texts.t("SUBSCRIPTION_TIME_LEFT_MINUTES", "{minutes} мин.").format(
minutes=minutes_left
)
warning_text = texts.t(
"SUBSCRIPTION_WARNING_MINUTES",
"\n🔴 истекает через несколько минут!",
)
subscription_type = (
texts.t("SUBSCRIPTION_TYPE_TRIAL", "Триал")
if subscription.is_trial
else texts.t("SUBSCRIPTION_TYPE_PAID", "Платная")
)
used_traffic = f"{subscription.traffic_used_gb:.1f}"
if subscription.traffic_limit_gb == 0:
traffic_used_display = texts.t(
"SUBSCRIPTION_TRAFFIC_UNLIMITED",
"∞ (безлимит) | Использовано: {used} ГБ",
).format(used=used_traffic)
else:
traffic_used_display = texts.t(
"SUBSCRIPTION_TRAFFIC_LIMITED",
"{used} / {limit} ГБ",
).format(used=used_traffic, limit=subscription.traffic_limit_gb)
devices_used_str = ""
devices_list = []
devices_count = 0
try:
if db_user.remnawave_uuid:
from app.services.remnawave_service import RemnaWaveService
service = RemnaWaveService()
async with service.get_api_client() as api:
response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}')
if response and 'response' in response:
devices_info = response['response']
devices_count = devices_info.get('total', 0)
devices_list = devices_info.get('devices', [])
devices_used_str = str(devices_count)
logger.info(f"Найдено {devices_count} устройств для пользователя {db_user.telegram_id}")
else:
logger.warning(f"Не удалось получить информацию об устройствах для {db_user.telegram_id}")
except Exception as e:
logger.error(f"Ошибка получения устройств для отображения: {e}")
devices_used_str = await get_current_devices_count(db_user)
servers_names = await get_servers_display_names(subscription.connected_squads)
servers_display = (
servers_names
if servers_names
else texts.t("SUBSCRIPTION_NO_SERVERS", "Нет серверов")
)
message = texts.t(
"SUBSCRIPTION_OVERVIEW_TEMPLATE",
"""👤 {full_name}
💰 Баланс: {balance}
📱 Подписка: {status_emoji} {status_display}{warning}
📱 Информация о подписке
🎭 Тип: {subscription_type}
📅 Действует до: {end_date}
⏰ Осталось: {time_left}
📈 Трафик: {traffic}
🌍 Серверы: {servers}
📱 Устройства: {devices_used} / {device_limit}""",
).format(
full_name=db_user.full_name,
balance=settings.format_price(db_user.balance_kopeks),
status_emoji=status_emoji,
status_display=status_display,
warning=warning_text,
subscription_type=subscription_type,
end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M"),
time_left=time_left_text,
traffic=traffic_used_display,
servers=servers_display,
devices_used=devices_used_str,
device_limit=subscription.device_limit,
)
if devices_list and len(devices_list) > 0:
message += "\n\n" + texts.t(
"SUBSCRIPTION_CONNECTED_DEVICES_TITLE",
"<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 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_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 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 select_period(
callback: types.CallbackQuery,
state: FSMContext,
db_user: User
):
period_days = int(callback.data.split('_')[1])
texts = get_texts(db_user.language)
data = await state.get_data()
data['period_days'] = period_days
data['total_price'] = PERIOD_PRICES[period_days]
if settings.is_traffic_fixed():
fixed_traffic_price = settings.get_traffic_price(settings.get_fixed_traffic_limit())
data['total_price'] += fixed_traffic_price
data['traffic_gb'] = settings.get_fixed_traffic_limit()
await state.set_data(data)
if settings.is_traffic_selectable():
available_packages = [pkg for pkg in settings.get_traffic_packages() if pkg['enabled']]
if not available_packages:
await callback.answer("⚠️ Пакеты трафика не настроены", show_alert=True)
return
await callback.message.edit_text(
texts.SELECT_TRAFFIC,
reply_markup=get_traffic_packages_keyboard(db_user.language)
)
await state.set_state(SubscriptionStates.selecting_traffic)
else:
if await _should_show_countries_management(db_user):
countries = await _get_available_countries(db_user.promo_group_id)
await callback.message.edit_text(
texts.SELECT_COUNTRIES,
reply_markup=get_countries_keyboard(countries, [], db_user.language)
)
await state.set_state(SubscriptionStates.selecting_countries)
else:
countries = await _get_available_countries(db_user.promo_group_id)
available_countries = [c for c in countries if c.get('is_available', True)]
data['countries'] = [available_countries[0]['uuid']] if available_countries else []
await state.set_data(data)
selected_devices = data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
await callback.message.edit_text(
texts.SELECT_DEVICES,
reply_markup=get_devices_keyboard(selected_devices, db_user.language)
)
await state.set_state(SubscriptionStates.selecting_devices)
await callback.answer()
async def select_devices(
callback: types.CallbackQuery,
state: FSMContext,
db_user: User
):
if not callback.data.startswith("devices_") or callback.data == "devices_continue":
await callback.answer("❌ Некорректный запрос", show_alert=True)
return
try:
devices = int(callback.data.split('_')[1])
except (ValueError, IndexError):
await callback.answer("❌ Некорректное количество устройств", show_alert=True)
return
data = await state.get_data()
base_price = (
PERIOD_PRICES[data['period_days']] +
settings.get_traffic_price(data['traffic_gb'])
)
countries = await _get_available_countries(db_user.promo_group_id)
countries_price = sum(
c['price_kopeks'] for c in countries
if c['uuid'] in data['countries']
)
devices_price = max(0, devices - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
data['devices'] = devices
data['total_price'] = base_price + countries_price + devices_price
await state.set_data(data)
await callback.message.edit_reply_markup(
reply_markup=get_devices_keyboard(devices, db_user.language)
)
await callback.answer()
async def devices_continue(
callback: types.CallbackQuery,
state: FSMContext,
db_user: User,
db: AsyncSession
):
if not callback.data == "devices_continue":
await callback.answer("⚠️ Некорректный запрос", show_alert=True)
return
data = await state.get_data()
texts = get_texts(db_user.language)
try:
summary_text, prepared_data = await _prepare_subscription_summary(db_user, data, texts)
except ValueError:
logger.error(f"Ошибка в расчете цены подписки для пользователя {db_user.telegram_id}")
await callback.answer("Ошибка расчета цены. Обратитесь в поддержку.", show_alert=True)
return
await state.set_data(prepared_data)
await save_subscription_checkout_draft(db_user.id, prepared_data)
await callback.message.edit_text(
summary_text,
reply_markup=get_subscription_confirm_keyboard(db_user.language),
parse_mode="HTML",
)
await state.set_state(SubscriptionStates.confirming_purchase)
await callback.answer()
async def confirm_purchase(
callback: types.CallbackQuery,
state: FSMContext,
db_user: User,
db: AsyncSession
):
from app.services.admin_notification_service import AdminNotificationService
data = await state.get_data()
texts = get_texts(db_user.language)
await save_subscription_checkout_draft(db_user.id, dict(data))
resume_callback = (
"subscription_resume_checkout"
if should_offer_checkout_resume(db_user, True)
else None
)
countries = await _get_available_countries(db_user.promo_group_id)
months_in_period = data.get(
'months_in_period', calculate_months_from_days(data['period_days'])
)
base_price = data.get('base_price')
base_price_original = data.get('base_price_original')
base_discount_percent = data.get('base_discount_percent')
base_discount_total = data.get('base_discount_total')
if base_price is None:
base_price_original = PERIOD_PRICES[data['period_days']]
base_discount_percent = db_user.get_promo_discount(
"period",
data['period_days'],
)
base_price, base_discount_total = apply_percentage_discount(
base_price_original,
base_discount_percent,
)
else:
if base_price_original is None:
base_price_original = PERIOD_PRICES[data['period_days']]
if base_discount_percent is None:
base_discount_percent = db_user.get_promo_discount(
"period",
data['period_days'],
)
if base_discount_total is None:
_, base_discount_total = apply_percentage_discount(
base_price_original,
base_discount_percent,
)
server_prices = data.get('server_prices_for_period', [])
if not server_prices:
countries_price_per_month = 0
per_month_prices: List[int] = []
for country in countries:
if country['uuid'] in data['countries']:
server_price_per_month = country['price_kopeks']
countries_price_per_month += server_price_per_month
per_month_prices.append(server_price_per_month)
servers_discount_percent = db_user.get_promo_discount(
"servers",
data['period_days'],
)
total_servers_price = 0
total_servers_discount = 0
discounted_servers_price_per_month = 0
server_prices = []
for server_price_per_month in per_month_prices:
discounted_per_month, discount_per_month = apply_percentage_discount(
server_price_per_month,
servers_discount_percent,
)
total_price_for_server = discounted_per_month * months_in_period
total_discount_for_server = discount_per_month * months_in_period
discounted_servers_price_per_month += discounted_per_month
total_servers_price += total_price_for_server
total_servers_discount += total_discount_for_server
server_prices.append(total_price_for_server)
total_countries_price = total_servers_price
else:
total_countries_price = data.get('total_servers_price', sum(server_prices))
countries_price_per_month = data.get('servers_price_per_month', 0)
discounted_servers_price_per_month = data.get('servers_discounted_price_per_month', countries_price_per_month)
total_servers_discount = data.get('servers_discount_total', 0)
servers_discount_percent = data.get('servers_discount_percent', 0)
additional_devices = max(0, data['devices'] - settings.DEFAULT_DEVICE_LIMIT)
devices_price_per_month = data.get(
'devices_price_per_month', additional_devices * settings.PRICE_PER_DEVICE
)
if 'devices_discount_percent' in data:
devices_discount_percent = data.get('devices_discount_percent', 0)
discounted_devices_price_per_month = data.get(
'devices_discounted_price_per_month', devices_price_per_month
)
devices_discount_total = data.get('devices_discount_total', 0)
total_devices_price = data.get(
'total_devices_price', discounted_devices_price_per_month * months_in_period
)
else:
devices_discount_percent = db_user.get_promo_discount(
"devices",
data['period_days'],
)
discounted_devices_price_per_month, discount_per_month = apply_percentage_discount(
devices_price_per_month,
devices_discount_percent,
)
devices_discount_total = discount_per_month * months_in_period
total_devices_price = discounted_devices_price_per_month * months_in_period
if settings.is_traffic_fixed():
final_traffic_gb = settings.get_fixed_traffic_limit()
traffic_price_per_month = data.get(
'traffic_price_per_month', settings.get_traffic_price(final_traffic_gb)
)
else:
final_traffic_gb = data.get('final_traffic_gb', data.get('traffic_gb'))
traffic_price_per_month = data.get(
'traffic_price_per_month', settings.get_traffic_price(data['traffic_gb'])
)
if 'traffic_discount_percent' in data:
traffic_discount_percent = data.get('traffic_discount_percent', 0)
discounted_traffic_price_per_month = data.get(
'traffic_discounted_price_per_month', traffic_price_per_month
)
traffic_discount_total = data.get('traffic_discount_total', 0)
total_traffic_price = data.get(
'total_traffic_price', discounted_traffic_price_per_month * months_in_period
)
else:
traffic_discount_percent = db_user.get_promo_discount(
"traffic",
data['period_days'],
)
discounted_traffic_price_per_month, discount_per_month = apply_percentage_discount(
traffic_price_per_month,
traffic_discount_percent,
)
traffic_discount_total = discount_per_month * months_in_period
total_traffic_price = discounted_traffic_price_per_month * months_in_period
total_servers_price = data.get('total_servers_price', total_countries_price)
cached_total_price = data['total_price']
cached_promo_discount_value = data.get('promo_offer_discount_value', 0)
validation_total_price = data.get('total_price_before_promo_offer')
if validation_total_price is None and cached_promo_discount_value > 0:
validation_total_price = cached_total_price + cached_promo_discount_value
if validation_total_price is None:
validation_total_price = cached_total_price
current_promo_offer_percent = _get_promo_offer_discount_percent(db_user)
if current_promo_offer_percent > 0:
final_price, promo_offer_discount_value = apply_percentage_discount(
validation_total_price,
current_promo_offer_percent,
)
promo_offer_discount_percent = current_promo_offer_percent
else:
final_price = validation_total_price
promo_offer_discount_value = 0
promo_offer_discount_percent = 0
discounted_monthly_additions = data.get(
'discounted_monthly_additions',
discounted_traffic_price_per_month
+ discounted_servers_price_per_month
+ discounted_devices_price_per_month,
)
is_valid = validate_pricing_calculation(
base_price,
discounted_monthly_additions,
months_in_period,
validation_total_price,
)
if not is_valid:
logger.error(f"Ошибка в расчете цены подписки для пользователя {db_user.telegram_id}")
await callback.answer("Ошибка расчета цены. Обратитесь в поддержку.", show_alert=True)
return
logger.info(f"Расчет покупки подписки на {data['period_days']} дней ({months_in_period} мес):")
base_log = f" Период: {base_price_original / 100}"
if base_discount_total and base_discount_total > 0:
base_log += (
f"{base_price / 100}"
f" (скидка {base_discount_percent}%: -{base_discount_total / 100}₽)"
)
logger.info(base_log)
if total_traffic_price > 0:
message = (
f" Трафик: {traffic_price_per_month / 100}₽/мес × {months_in_period}"
f" = {total_traffic_price / 100}"
)
if traffic_discount_total > 0:
message += (
f" (скидка {traffic_discount_percent}%:"
f" -{traffic_discount_total / 100}₽)"
)
logger.info(message)
if total_servers_price > 0:
message = (
f" Серверы: {countries_price_per_month / 100}₽/мес × {months_in_period}"
f" = {total_servers_price / 100}"
)
if total_servers_discount > 0:
message += (
f" (скидка {servers_discount_percent}%:"
f" -{total_servers_discount / 100}₽)"
)
logger.info(message)
if total_devices_price > 0:
message = (
f" Устройства: {devices_price_per_month / 100}₽/мес × {months_in_period}"
f" = {total_devices_price / 100}"
)
if devices_discount_total > 0:
message += (
f" (скидка {devices_discount_percent}%:"
f" -{devices_discount_total / 100}₽)"
)
logger.info(message)
if promo_offer_discount_value > 0:
logger.info(
" 🎯 Промо-предложение: -%s₽ (%s%%)",
promo_offer_discount_value / 100,
promo_offer_discount_percent,
)
logger.info(f" ИТОГО: {final_price / 100}")
if db_user.balance_kopeks < final_price:
missing_kopeks = final_price - db_user.balance_kopeks
message_text = texts.t(
"ADDON_INSUFFICIENT_FUNDS_MESSAGE",
(
"⚠️ <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 create_paid_subscription_with_traffic_mode(
db: AsyncSession,
user_id: int,
duration_days: int,
device_limit: int,
connected_squads: List[str],
traffic_gb: Optional[int] = None
):
from app.config import settings
if traffic_gb is None:
if settings.is_traffic_fixed():
traffic_limit_gb = settings.get_fixed_traffic_limit()
else:
traffic_limit_gb = 0
else:
traffic_limit_gb = traffic_gb
subscription = await create_paid_subscription(
db=db,
user_id=user_id,
duration_days=duration_days,
traffic_limit_gb=traffic_limit_gb,
device_limit=device_limit,
connected_squads=connected_squads,
update_server_counters=False,
)
logger.info(f"📋 Создана подписка с трафиком: {traffic_limit_gb} ГБ (режим: {settings.TRAFFIC_SELECTION_MODE})")
return subscription
async def handle_subscription_settings(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
texts = get_texts(db_user.language)
subscription = db_user.subscription
if not subscription or subscription.is_trial:
await callback.answer(
texts.t(
"SUBSCRIPTION_SETTINGS_PAID_ONLY",
"⚠️ Настройки доступны только для платных подписок",
),
show_alert=True,
)
return
devices_used = await get_current_devices_count(db_user)
settings_text = texts.t(
"SUBSCRIPTION_SETTINGS_OVERVIEW",
(
"⚙️ <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 clear_saved_cart(
callback: types.CallbackQuery,
state: FSMContext,
db_user: User,
db: AsyncSession
):
await state.clear()
from app.handlers.menu import show_main_menu
await show_main_menu(callback, db_user, db)
await callback.answer("🗑️ Корзина очищена")
def register_handlers(dp: Dispatcher):
update_traffic_prices()
dp.callback_query.register(
show_subscription_info,
F.data == "menu_subscription"
)
dp.callback_query.register(
show_trial_offer,
F.data == "menu_trial"
)
dp.callback_query.register(
activate_trial,
F.data == "trial_activate"
)
dp.callback_query.register(
start_subscription_purchase,
F.data.in_(["menu_buy", "subscription_upgrade"])
)
dp.callback_query.register(
handle_add_countries,
F.data == "subscription_add_countries"
)
dp.callback_query.register(
handle_switch_traffic,
F.data == "subscription_switch_traffic"
)
dp.callback_query.register(
confirm_switch_traffic,
F.data.startswith("switch_traffic_")
)
dp.callback_query.register(
execute_switch_traffic,
F.data.startswith("confirm_switch_traffic_")
)
dp.callback_query.register(
handle_change_devices,
F.data == "subscription_change_devices"
)
dp.callback_query.register(
confirm_change_devices,
F.data.startswith("change_devices_")
)
dp.callback_query.register(
execute_change_devices,
F.data.startswith("confirm_change_devices_")
)
dp.callback_query.register(
handle_extend_subscription,
F.data == "subscription_extend"
)
dp.callback_query.register(
handle_reset_traffic,
F.data == "subscription_reset_traffic"
)
dp.callback_query.register(
confirm_add_devices,
F.data.startswith("add_devices_")
)
dp.callback_query.register(
confirm_extend_subscription,
F.data.startswith("extend_period_")
)
dp.callback_query.register(
confirm_reset_traffic,
F.data == "confirm_reset_traffic"
)
dp.callback_query.register(
handle_reset_devices,
F.data == "subscription_reset_devices"
)
dp.callback_query.register(
confirm_reset_devices,
F.data == "confirm_reset_devices"
)
dp.callback_query.register(
select_period,
F.data.startswith("period_"),
SubscriptionStates.selecting_period
)
dp.callback_query.register(
select_traffic,
F.data.startswith("traffic_"),
SubscriptionStates.selecting_traffic
)
dp.callback_query.register(
select_devices,
F.data.startswith("devices_") & ~F.data.in_(["devices_continue"]),
SubscriptionStates.selecting_devices
)
dp.callback_query.register(
devices_continue,
F.data == "devices_continue",
SubscriptionStates.selecting_devices
)
dp.callback_query.register(
confirm_purchase,
F.data == "subscription_confirm",
SubscriptionStates.confirming_purchase
)
dp.callback_query.register(
resume_subscription_checkout,
F.data == "subscription_resume_checkout",
)
dp.callback_query.register(
return_to_saved_cart,
F.data == "return_to_saved_cart",
)
dp.callback_query.register(
clear_saved_cart,
F.data == "clear_saved_cart",
)
dp.callback_query.register(
handle_autopay_menu,
F.data == "subscription_autopay"
)
dp.callback_query.register(
toggle_autopay,
F.data.in_(["autopay_enable", "autopay_disable"])
)
dp.callback_query.register(
show_autopay_days,
F.data == "autopay_set_days"
)
dp.callback_query.register(
handle_subscription_config_back,
F.data == "subscription_config_back"
)
dp.callback_query.register(
handle_subscription_cancel,
F.data == "subscription_cancel"
)
dp.callback_query.register(
set_autopay_days,
F.data.startswith("autopay_days_")
)
dp.callback_query.register(
select_country,
F.data.startswith("country_"),
SubscriptionStates.selecting_countries
)
dp.callback_query.register(
countries_continue,
F.data == "countries_continue",
SubscriptionStates.selecting_countries
)
dp.callback_query.register(
handle_manage_country,
F.data.startswith("country_manage_")
)
dp.callback_query.register(
apply_countries_changes,
F.data == "countries_apply"
)
dp.callback_query.register(
claim_discount_offer,
F.data.startswith("claim_discount_")
)
dp.callback_query.register(
handle_promo_offer_close,
F.data == "promo_offer_close",
)
dp.callback_query.register(
handle_happ_download_request,
F.data == "subscription_happ_download"
)
dp.callback_query.register(
handle_happ_download_platform_choice,
F.data.in_([
"happ_download_ios",
"happ_download_android",
"happ_download_pc",
"happ_download_macos",
"happ_download_windows",
])
)
dp.callback_query.register(
handle_happ_download_close,
F.data == "happ_download_close"
)
dp.callback_query.register(
handle_happ_download_back,
F.data == "happ_download_back"
)
dp.callback_query.register(
handle_connect_subscription,
F.data == "subscription_connect"
)
dp.callback_query.register(
handle_device_guide,
F.data.startswith("device_guide_")
)
dp.callback_query.register(
handle_app_selection,
F.data.startswith("app_list_")
)
dp.callback_query.register(
handle_specific_app_guide,
F.data.startswith("app_")
)
dp.callback_query.register(
handle_open_subscription_link,
F.data == "open_subscription_link"
)
dp.callback_query.register(
handle_subscription_settings,
F.data == "subscription_settings"
)
dp.callback_query.register(
handle_no_traffic_packages,
F.data == "no_traffic_packages"
)
dp.callback_query.register(
handle_device_management,
F.data == "subscription_manage_devices"
)
dp.callback_query.register(
handle_devices_page,
F.data.startswith("devices_page_")
)
dp.callback_query.register(
handle_single_device_reset,
F.data.regexp(r"^reset_device_\d+_\d+$")
)
dp.callback_query.register(
handle_all_devices_reset_from_management,
F.data == "reset_all_devices"
)
dp.callback_query.register(
show_device_connection_help,
F.data == "device_connection_help"
)