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

735 lines
28 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 base64
import json
import logging
from datetime import datetime, timedelta
from typing import Dict, List, Any, Tuple, Optional
from urllib.parse import quote
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings, PERIOD_PRICES, get_traffic_prices
from app.database.crud.discount_offer import (
get_offer_by_id,
mark_offer_claimed,
)
from app.database.crud.promo_offer_template import get_promo_offer_template_by_id
from app.database.crud.subscription import (
create_trial_subscription,
create_paid_subscription, add_subscription_traffic, add_subscription_devices,
update_subscription_autopay
)
from app.database.crud.transaction import create_transaction
from app.database.crud.user import subtract_user_balance
from app.database.models import (
User, TransactionType, SubscriptionStatus,
Subscription
)
from app.keyboards.inline import (
get_subscription_keyboard, get_trial_keyboard,
get_subscription_period_keyboard, get_traffic_packages_keyboard,
get_countries_keyboard, get_devices_keyboard,
get_subscription_confirm_keyboard, get_autopay_keyboard,
get_autopay_days_keyboard, get_back_keyboard,
get_add_traffic_keyboard,
get_change_devices_keyboard, get_reset_traffic_confirm_keyboard,
get_manage_countries_keyboard,
get_device_selection_keyboard, get_connection_guide_keyboard,
get_app_selection_keyboard, get_specific_app_keyboard,
get_updated_subscription_settings_keyboard, get_insufficient_balance_keyboard,
get_extend_subscription_keyboard_with_prices, get_confirm_change_devices_keyboard,
get_devices_management_keyboard, get_device_management_help_keyboard,
get_happ_cryptolink_keyboard,
get_happ_download_platform_keyboard, get_happ_download_link_keyboard,
get_happ_download_button_row,
get_payment_methods_keyboard_with_cart,
get_subscription_confirm_keyboard_with_cart,
get_insufficient_balance_keyboard_with_cart
)
from app.localization.texts import get_texts
from app.services.admin_notification_service import AdminNotificationService
from app.services.remnawave_service import RemnaWaveService
from app.services.subscription_checkout_service import (
clear_subscription_checkout_draft,
get_subscription_checkout_draft,
save_subscription_checkout_draft,
should_offer_checkout_resume,
)
from app.services.subscription_service import SubscriptionService
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
from app.services.promo_offer_service import promo_offer_service
from app.states import SubscriptionStates
from app.utils.pagination import paginate_list
from app.utils.pricing_utils import (
calculate_months_from_days,
get_remaining_months,
calculate_prorated_price,
validate_pricing_calculation,
format_period_description,
apply_percentage_discount,
)
from app.utils.subscription_utils import (
get_display_subscription_link,
get_happ_cryptolink_redirect_link,
convert_subscription_link_to_happ_scheme,
)
from app.utils.promo_offer import (
build_promo_offer_hint,
get_user_active_promo_discount_percent,
)
from .common import _apply_addon_discount, _get_addon_discount_percent_for_user, _get_period_hint_from_subscription, get_confirm_switch_traffic_keyboard, get_traffic_switch_keyboard, logger
from .countries import _get_available_countries, _should_show_countries_management
async def handle_add_traffic(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
from app.config import settings
texts = get_texts(db_user.language)
if settings.is_traffic_fixed():
await callback.answer(
texts.t(
"TRAFFIC_FIXED_MODE",
"⚠️ В текущем режиме трафик фиксированный и не может быть изменен",
),
show_alert=True,
)
return
subscription = db_user.subscription
if not subscription or subscription.is_trial:
await callback.answer(
texts.t("PAID_FEATURE_ONLY", "⚠ Эта функция доступна только для платных подписок"),
show_alert=True,
)
return
if subscription.traffic_limit_gb == 0:
await callback.answer(
texts.t("TRAFFIC_ALREADY_UNLIMITED", "У вас уже безлимитный трафик"),
show_alert=True,
)
return
current_traffic = subscription.traffic_limit_gb
period_hint_days = _get_period_hint_from_subscription(subscription)
traffic_discount_percent = _get_addon_discount_percent_for_user(
db_user,
"traffic",
period_hint_days,
)
prompt_text = texts.t(
"ADD_TRAFFIC_PROMPT",
(
"📈 <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_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()
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 refresh_traffic_config():
try:
from app.config import refresh_traffic_prices
refresh_traffic_prices()
packages = settings.get_traffic_packages()
enabled_count = sum(1 for pkg in packages if pkg['enabled'])
logger.info(f"🔄 Конфигурация трафика обновлена: {enabled_count} активных пакетов")
for pkg in packages:
if pkg['enabled']:
gb_text = "♾️ Безлимит" if pkg['gb'] == 0 else f"{pkg['gb']} ГБ"
logger.info(f" 📦 {gb_text}: {pkg['price'] / 100}")
return True
except Exception as e:
logger.error(f"⚠️ Ошибка обновления конфигурации трафика: {e}")
return False
async def get_traffic_packages_info() -> str:
try:
packages = settings.get_traffic_packages()
info_lines = ["📦 Настроенные пакеты трафика:"]
enabled_packages = [pkg for pkg in packages if pkg['enabled']]
disabled_packages = [pkg for pkg in packages if not pkg['enabled']]
if enabled_packages:
info_lines.append("\n✅ Активные:")
for pkg in enabled_packages:
gb_text = "♾️ Безлимит" if pkg['gb'] == 0 else f"{pkg['gb']} ГБ"
info_lines.append(f"{gb_text}: {pkg['price'] // 100}")
if disabled_packages:
info_lines.append("\n❌ Отключенные:")
for pkg in disabled_packages:
gb_text = "♾️ Безлимит" if pkg['gb'] == 0 else f"{pkg['gb']} ГБ"
info_lines.append(f"{gb_text}: {pkg['price'] // 100}")
info_lines.append(f"\n📊 Всего пакетов: {len(packages)}")
info_lines.append(f"🟢 Активных: {len(enabled_packages)}")
info_lines.append(f"🔴 Отключенных: {len(disabled_packages)}")
return "\n".join(info_lines)
except Exception as e:
return f"⚠️ Ошибка получения информации: {e}"
async def select_traffic(
callback: types.CallbackQuery,
state: FSMContext,
db_user: User
):
traffic_gb = int(callback.data.split('_')[1])
texts = get_texts(db_user.language)
data = await state.get_data()
data['traffic_gb'] = traffic_gb
traffic_price = settings.get_traffic_price(traffic_gb)
data['total_price'] += traffic_price
await state.set_data(data)
if await _should_show_countries_management(db_user):
countries = await _get_available_countries(db_user.promo_group_id)
await callback.message.edit_text(
texts.SELECT_COUNTRIES,
reply_markup=get_countries_keyboard(countries, [], db_user.language)
)
await state.set_state(SubscriptionStates.selecting_countries)
else:
countries = await _get_available_countries(db_user.promo_group_id)
available_countries = [c for c in countries if c.get('is_available', True)]
data['countries'] = [available_countries[0]['uuid']] if available_countries else []
await state.set_data(data)
selected_devices = data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
await callback.message.edit_text(
texts.SELECT_DEVICES,
reply_markup=get_devices_keyboard(selected_devices, db_user.language)
)
await state.set_state(SubscriptionStates.selecting_devices)
await callback.answer()
async def add_traffic(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
if settings.is_traffic_fixed():
await callback.answer("⚠️ В текущем режиме трафик фиксированный", show_alert=True)
return
traffic_gb = int(callback.data.split('_')[2])
texts = get_texts(db_user.language)
subscription = db_user.subscription
base_price = settings.get_traffic_price(traffic_gb)
if base_price == 0 and traffic_gb != 0:
await callback.answer("⚠️ Цена для этого пакета не настроена", show_alert=True)
return
period_hint_days = _get_period_hint_from_subscription(subscription)
discount_result = _apply_addon_discount(
db_user,
"traffic",
base_price,
period_hint_days,
)
discounted_per_month = discount_result["discounted"]
discount_per_month = discount_result["discount"]
charged_months = 1
if subscription:
price, charged_months = calculate_prorated_price(
discounted_per_month,
subscription.end_date,
)
else:
price = discounted_per_month
total_discount_value = discount_per_month * charged_months
if db_user.balance_kopeks < price:
missing_kopeks = price - db_user.balance_kopeks
message_text = texts.t(
"ADDON_INSUFFICIENT_FUNDS_MESSAGE",
(
"⚠️ <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 handle_no_traffic_packages(
callback: types.CallbackQuery,
db_user: User
):
await callback.answer(
"⚠️ В данный момент нет доступных пакетов трафика. "
"Обратитесь в техподдержку для получения информации.",
show_alert=True
)
async def handle_switch_traffic(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
from app.config import settings
if settings.is_traffic_fixed():
await callback.answer("⚠️ В текущем режиме трафик фиксированный", show_alert=True)
return
texts = get_texts(db_user.language)
subscription = db_user.subscription
if not subscription or subscription.is_trial:
await callback.answer("⚠️ Эта функция доступна только для платных подписок", show_alert=True)
return
current_traffic = subscription.traffic_limit_gb
period_hint_days = _get_period_hint_from_subscription(subscription)
traffic_discount_percent = _get_addon_discount_percent_for_user(
db_user,
"traffic",
period_hint_days,
)
await callback.message.edit_text(
f"🔄 <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 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()