mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-03-03 00:31:24 +00:00
3117 lines
125 KiB
Python
3117 lines
125 KiB
Python
"""Покупка подписки по тарифам."""
|
||
import logging
|
||
from datetime import timedelta
|
||
from typing import List, Optional
|
||
|
||
from aiogram import Dispatcher, types, F
|
||
from aiogram.fsm.context import FSMContext
|
||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||
from aiogram.exceptions import TelegramBadRequest
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.config import settings
|
||
from app.database.crud.tariff import get_tariffs_for_user, get_tariff_by_id
|
||
from app.database.crud.subscription import create_paid_subscription, get_subscription_by_user_id, extend_subscription
|
||
from app.database.crud.transaction import create_transaction
|
||
from app.database.crud.user import subtract_user_balance
|
||
from app.database.models import User, Tariff, TransactionType
|
||
from app.localization.texts import get_texts
|
||
from app.states import SubscriptionStates
|
||
from app.utils.decorators import error_handler
|
||
from app.services.subscription_service import SubscriptionService
|
||
from app.services.admin_notification_service import AdminNotificationService
|
||
from app.services.user_cart_service import user_cart_service
|
||
from app.utils.promo_offer import get_user_active_promo_discount_percent
|
||
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
def _format_traffic(gb: int) -> str:
|
||
"""Форматирует трафик."""
|
||
if gb == 0:
|
||
return "Безлимит"
|
||
return f"{gb} ГБ"
|
||
|
||
|
||
def _format_price_kopeks(kopeks: int, compact: bool = False) -> str:
|
||
"""Форматирует цену из копеек в рубли."""
|
||
rubles = kopeks / 100
|
||
if compact:
|
||
# Компактный формат - округляем до рублей
|
||
return f"{int(round(rubles))}₽"
|
||
if rubles == int(rubles):
|
||
return f"{int(rubles)} ₽"
|
||
return f"{rubles:.2f} ₽"
|
||
|
||
|
||
def _format_period(days: int) -> str:
|
||
"""Форматирует период."""
|
||
if days == 1:
|
||
return "1 день"
|
||
elif days < 5:
|
||
return f"{days} дня"
|
||
elif days < 21 or days % 10 >= 5 or days % 10 == 0:
|
||
return f"{days} дней"
|
||
elif days % 10 == 1:
|
||
return f"{days} день"
|
||
else:
|
||
return f"{days} дня"
|
||
|
||
|
||
def _apply_promo_discount(price: int, discount_percent: int) -> int:
|
||
"""Применяет скидку промогруппы к цене."""
|
||
if discount_percent <= 0:
|
||
return price
|
||
discount = int(price * discount_percent / 100)
|
||
return max(0, price - discount)
|
||
|
||
|
||
def _get_user_period_discount(db_user: User, period_days: int) -> int:
|
||
"""Получает скидку пользователя на период из промогруппы."""
|
||
promo_group = getattr(db_user, 'promo_group', None)
|
||
|
||
if promo_group:
|
||
discount = promo_group.get_discount_percent("period", period_days)
|
||
if discount > 0:
|
||
return discount
|
||
|
||
personal_discount = get_user_active_promo_discount_percent(db_user)
|
||
return personal_discount
|
||
|
||
|
||
def format_tariffs_list_text(
|
||
tariffs: List[Tariff],
|
||
db_user: Optional[User] = None,
|
||
has_period_discounts: bool = False,
|
||
) -> str:
|
||
"""Форматирует текст со списком тарифов для отображения."""
|
||
lines = ["📦 <b>Выберите тариф</b>"]
|
||
|
||
if has_period_discounts:
|
||
lines.append("🎁 <i>Скидки по периодам</i>")
|
||
|
||
lines.append("")
|
||
|
||
for tariff in tariffs:
|
||
# Трафик компактно
|
||
traffic_gb = tariff.traffic_limit_gb
|
||
traffic = "∞" if traffic_gb == 0 else f"{traffic_gb}ГБ"
|
||
|
||
# Цена
|
||
is_daily = getattr(tariff, 'is_daily', False)
|
||
price_text = ""
|
||
discount_icon = ""
|
||
|
||
if is_daily:
|
||
# Для суточных тарифов показываем цену за день
|
||
daily_price = getattr(tariff, 'daily_price_kopeks', 0)
|
||
price_text = f"🔄 {_format_price_kopeks(daily_price, compact=True)}/день"
|
||
else:
|
||
# Для периодных тарифов показываем минимальную цену
|
||
prices = tariff.period_prices or {}
|
||
if prices:
|
||
min_period = min(prices.keys(), key=int)
|
||
min_price = prices[min_period]
|
||
discount_percent = 0
|
||
if db_user:
|
||
discount_percent = _get_user_period_discount(db_user, int(min_period))
|
||
if discount_percent > 0:
|
||
min_price = _apply_promo_discount(min_price, discount_percent)
|
||
discount_icon = "🔥"
|
||
price_text = f"от {_format_price_kopeks(min_price, compact=True)}{discount_icon}"
|
||
|
||
# Компактный формат: Название — 250ГБ/10📱 от 179₽🔥
|
||
lines.append(f"<b>{tariff.name}</b> — {traffic}/{tariff.device_limit}📱 {price_text}")
|
||
|
||
# Описание тарифа если есть
|
||
if tariff.description:
|
||
lines.append(f"<i>{tariff.description}</i>")
|
||
|
||
lines.append("")
|
||
|
||
return "\n".join(lines)
|
||
|
||
|
||
def get_tariffs_keyboard(
|
||
tariffs: List[Tariff],
|
||
language: str,
|
||
) -> InlineKeyboardMarkup:
|
||
"""Создает компактную клавиатуру выбора тарифов (только названия)."""
|
||
texts = get_texts(language)
|
||
buttons = []
|
||
|
||
for tariff in tariffs:
|
||
buttons.append([
|
||
InlineKeyboardButton(
|
||
text=tariff.name,
|
||
callback_data=f"tariff_select:{tariff.id}"
|
||
)
|
||
])
|
||
|
||
buttons.append([
|
||
InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")
|
||
])
|
||
|
||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||
|
||
|
||
def get_tariff_periods_keyboard(
|
||
tariff: Tariff,
|
||
language: str,
|
||
db_user: Optional[User] = None,
|
||
) -> InlineKeyboardMarkup:
|
||
"""Создает клавиатуру выбора периода для тарифа с учетом скидок по периодам."""
|
||
texts = get_texts(language)
|
||
buttons = []
|
||
|
||
prices = tariff.period_prices or {}
|
||
for period_str in sorted(prices.keys(), key=int):
|
||
period = int(period_str)
|
||
price = prices[period_str]
|
||
|
||
# Получаем скидку для конкретного периода
|
||
discount_percent = 0
|
||
if db_user:
|
||
discount_percent = _get_user_period_discount(db_user, period)
|
||
|
||
if discount_percent > 0:
|
||
original_price = price
|
||
price = _apply_promo_discount(price, discount_percent)
|
||
price_text = f"{_format_price_kopeks(price)} 🔥−{discount_percent}%"
|
||
else:
|
||
price_text = _format_price_kopeks(price)
|
||
|
||
button_text = f"{_format_period(period)} — {price_text}"
|
||
buttons.append([
|
||
InlineKeyboardButton(
|
||
text=button_text,
|
||
callback_data=f"tariff_period:{tariff.id}:{period}"
|
||
)
|
||
])
|
||
|
||
buttons.append([
|
||
InlineKeyboardButton(text=texts.BACK, callback_data="tariff_list")
|
||
])
|
||
|
||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||
|
||
|
||
def get_tariff_periods_keyboard_with_traffic(
|
||
tariff: Tariff,
|
||
language: str,
|
||
db_user: Optional[User] = None,
|
||
) -> InlineKeyboardMarkup:
|
||
"""Клавиатура выбора периода для тарифа с кастомным трафиком (переход к настройке трафика)."""
|
||
texts = get_texts(language)
|
||
buttons = []
|
||
|
||
prices = tariff.period_prices or {}
|
||
for period_str in sorted(prices.keys(), key=int):
|
||
period = int(period_str)
|
||
price = prices[period_str]
|
||
|
||
# Получаем скидку для конкретного периода
|
||
discount_percent = 0
|
||
if db_user:
|
||
discount_percent = _get_user_period_discount(db_user, period)
|
||
|
||
if discount_percent > 0:
|
||
price = _apply_promo_discount(price, discount_percent)
|
||
price_text = f"{_format_price_kopeks(price)} 🔥−{discount_percent}%"
|
||
else:
|
||
price_text = _format_price_kopeks(price)
|
||
|
||
button_text = f"{_format_period(period)} — {price_text}"
|
||
# Используем другой callback для перехода к настройке трафика
|
||
buttons.append([
|
||
InlineKeyboardButton(
|
||
text=button_text,
|
||
callback_data=f"tariff_period_traffic:{tariff.id}:{period}"
|
||
)
|
||
])
|
||
|
||
buttons.append([
|
||
InlineKeyboardButton(text=texts.BACK, callback_data="tariff_list")
|
||
])
|
||
|
||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||
|
||
|
||
def get_tariff_confirm_keyboard(
|
||
tariff_id: int,
|
||
period: int,
|
||
language: str,
|
||
) -> InlineKeyboardMarkup:
|
||
"""Создает клавиатуру подтверждения покупки тарифа."""
|
||
texts = get_texts(language)
|
||
return InlineKeyboardMarkup(inline_keyboard=[
|
||
[
|
||
InlineKeyboardButton(
|
||
text="✅ Подтвердить покупку",
|
||
callback_data=f"tariff_confirm:{tariff_id}:{period}"
|
||
)
|
||
],
|
||
[
|
||
InlineKeyboardButton(
|
||
text=texts.BACK,
|
||
callback_data=f"tariff_select:{tariff_id}"
|
||
)
|
||
]
|
||
])
|
||
|
||
|
||
def get_tariff_insufficient_balance_keyboard(
|
||
tariff_id: int,
|
||
period: int,
|
||
language: str,
|
||
) -> InlineKeyboardMarkup:
|
||
"""Создает клавиатуру при недостаточном балансе."""
|
||
texts = get_texts(language)
|
||
return InlineKeyboardMarkup(inline_keyboard=[
|
||
[
|
||
InlineKeyboardButton(
|
||
text="💳 Пополнить баланс",
|
||
callback_data="balance_topup"
|
||
)
|
||
],
|
||
[
|
||
InlineKeyboardButton(
|
||
text=texts.BACK,
|
||
callback_data=f"tariff_select:{tariff_id}"
|
||
)
|
||
]
|
||
])
|
||
|
||
|
||
def format_tariff_info_for_user(
|
||
tariff: Tariff,
|
||
language: str,
|
||
discount_percent: int = 0,
|
||
) -> str:
|
||
"""Форматирует информацию о тарифе для пользователя."""
|
||
texts = get_texts(language)
|
||
|
||
traffic = _format_traffic(tariff.traffic_limit_gb)
|
||
|
||
text = f"""📦 <b>{tariff.name}</b>
|
||
|
||
<b>Параметры:</b>
|
||
• Трафик: {traffic}
|
||
• Устройств: {tariff.device_limit}
|
||
"""
|
||
|
||
if tariff.description:
|
||
text += f"\n📝 {tariff.description}\n"
|
||
|
||
if discount_percent > 0:
|
||
text += f"\n🎁 <b>Ваша скидка: {discount_percent}%</b>\n"
|
||
|
||
# Для суточных тарифов не показываем выбор периода
|
||
is_daily = getattr(tariff, 'is_daily', False)
|
||
if not is_daily:
|
||
text += "\nВыберите период подписки:"
|
||
|
||
return text
|
||
|
||
|
||
def get_daily_tariff_confirm_keyboard(
|
||
tariff_id: int,
|
||
language: str,
|
||
) -> InlineKeyboardMarkup:
|
||
"""Создает клавиатуру подтверждения покупки суточного тарифа."""
|
||
texts = get_texts(language)
|
||
return InlineKeyboardMarkup(inline_keyboard=[
|
||
[
|
||
InlineKeyboardButton(
|
||
text="✅ Подтвердить покупку",
|
||
callback_data=f"daily_tariff_confirm:{tariff_id}"
|
||
)
|
||
],
|
||
[
|
||
InlineKeyboardButton(
|
||
text=texts.BACK,
|
||
callback_data="tariff_list"
|
||
)
|
||
]
|
||
])
|
||
|
||
|
||
def get_daily_tariff_insufficient_balance_keyboard(
|
||
tariff_id: int,
|
||
language: str,
|
||
) -> InlineKeyboardMarkup:
|
||
"""Создает клавиатуру при недостаточном балансе для суточного тарифа."""
|
||
texts = get_texts(language)
|
||
return InlineKeyboardMarkup(inline_keyboard=[
|
||
[
|
||
InlineKeyboardButton(
|
||
text="💳 Пополнить баланс",
|
||
callback_data="balance_topup"
|
||
)
|
||
],
|
||
[
|
||
InlineKeyboardButton(
|
||
text=texts.BACK,
|
||
callback_data="tariff_list"
|
||
)
|
||
]
|
||
])
|
||
|
||
|
||
# ==================== Кастомные дни/трафик ====================
|
||
|
||
|
||
def get_custom_tariff_keyboard(
|
||
tariff_id: int,
|
||
language: str,
|
||
days: int,
|
||
traffic_gb: int,
|
||
can_custom_days: bool,
|
||
can_custom_traffic: bool,
|
||
min_days: int = 1,
|
||
max_days: int = 365,
|
||
min_traffic: int = 1,
|
||
max_traffic: int = 1000,
|
||
) -> InlineKeyboardMarkup:
|
||
"""Создает клавиатуру для настройки кастомных дней и трафика."""
|
||
texts = get_texts(language)
|
||
buttons = []
|
||
|
||
# Кнопки изменения дней
|
||
if can_custom_days:
|
||
days_row = []
|
||
# -30 / -7 / -1
|
||
if days > min_days:
|
||
if days - 30 >= min_days:
|
||
days_row.append(InlineKeyboardButton(text="-30", callback_data=f"custom_days:{tariff_id}:-30"))
|
||
if days - 7 >= min_days:
|
||
days_row.append(InlineKeyboardButton(text="-7", callback_data=f"custom_days:{tariff_id}:-7"))
|
||
days_row.append(InlineKeyboardButton(text="-1", callback_data=f"custom_days:{tariff_id}:-1"))
|
||
|
||
# Текущее значение
|
||
days_row.append(InlineKeyboardButton(text=f"📅 {days} дн.", callback_data="noop"))
|
||
|
||
# +1 / +7 / +30
|
||
if days < max_days:
|
||
days_row.append(InlineKeyboardButton(text="+1", callback_data=f"custom_days:{tariff_id}:1"))
|
||
if days + 7 <= max_days:
|
||
days_row.append(InlineKeyboardButton(text="+7", callback_data=f"custom_days:{tariff_id}:7"))
|
||
if days + 30 <= max_days:
|
||
days_row.append(InlineKeyboardButton(text="+30", callback_data=f"custom_days:{tariff_id}:30"))
|
||
|
||
if days_row:
|
||
buttons.append(days_row)
|
||
|
||
# Кнопки изменения трафика
|
||
if can_custom_traffic:
|
||
traffic_row = []
|
||
# -100 / -10 / -1
|
||
if traffic_gb > min_traffic:
|
||
if traffic_gb - 100 >= min_traffic:
|
||
traffic_row.append(InlineKeyboardButton(text="-100", callback_data=f"custom_traffic:{tariff_id}:-100"))
|
||
if traffic_gb - 10 >= min_traffic:
|
||
traffic_row.append(InlineKeyboardButton(text="-10", callback_data=f"custom_traffic:{tariff_id}:-10"))
|
||
traffic_row.append(InlineKeyboardButton(text="-1", callback_data=f"custom_traffic:{tariff_id}:-1"))
|
||
|
||
# Текущее значение
|
||
traffic_row.append(InlineKeyboardButton(text=f"📊 {traffic_gb} ГБ", callback_data="noop"))
|
||
|
||
# +1 / +10 / +100
|
||
if traffic_gb < max_traffic:
|
||
traffic_row.append(InlineKeyboardButton(text="+1", callback_data=f"custom_traffic:{tariff_id}:1"))
|
||
if traffic_gb + 10 <= max_traffic:
|
||
traffic_row.append(InlineKeyboardButton(text="+10", callback_data=f"custom_traffic:{tariff_id}:10"))
|
||
if traffic_gb + 100 <= max_traffic:
|
||
traffic_row.append(InlineKeyboardButton(text="+100", callback_data=f"custom_traffic:{tariff_id}:100"))
|
||
|
||
if traffic_row:
|
||
buttons.append(traffic_row)
|
||
|
||
# Кнопка подтверждения
|
||
buttons.append([
|
||
InlineKeyboardButton(
|
||
text="✅ Подтвердить покупку",
|
||
callback_data=f"custom_confirm:{tariff_id}"
|
||
)
|
||
])
|
||
|
||
# Кнопка назад
|
||
buttons.append([
|
||
InlineKeyboardButton(text=texts.BACK, callback_data="tariff_list")
|
||
])
|
||
|
||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||
|
||
|
||
def _calculate_custom_tariff_price(
|
||
tariff: Tariff,
|
||
days: int,
|
||
traffic_gb: int,
|
||
) -> tuple[int, int, int]:
|
||
"""
|
||
Рассчитывает цену для кастомного тарифа.
|
||
|
||
Логика (как в веб-кабинете):
|
||
1. Цена периода: из period_prices ИЛИ price_per_day * дни (если custom_days)
|
||
2. Трафик: добавляется СВЕРХУ к цене периода (если custom_traffic)
|
||
|
||
Returns:
|
||
tuple: (period_price, traffic_price, total_price)
|
||
"""
|
||
period_price = 0
|
||
traffic_price = 0
|
||
|
||
# Цена за период
|
||
if tariff.can_purchase_custom_days():
|
||
# Кастомные дни - используем price_per_day
|
||
period_price = tariff.get_price_for_custom_days(days) or 0
|
||
else:
|
||
# Фиксированные периоды - берём из period_prices
|
||
period_price = tariff.get_price_for_period(days) or 0
|
||
|
||
# Цена за трафик (добавляется сверху)
|
||
if tariff.can_purchase_custom_traffic():
|
||
traffic_price = tariff.get_price_for_custom_traffic(traffic_gb) or 0
|
||
|
||
total_price = period_price + traffic_price
|
||
return period_price, traffic_price, total_price
|
||
|
||
|
||
def format_custom_tariff_preview(
|
||
tariff: Tariff,
|
||
days: int,
|
||
traffic_gb: int,
|
||
user_balance: int,
|
||
discount_percent: int = 0,
|
||
) -> str:
|
||
"""Форматирует предпросмотр покупки с кастомными параметрами."""
|
||
period_price, traffic_price, total_price = _calculate_custom_tariff_price(
|
||
tariff, days, traffic_gb
|
||
)
|
||
|
||
# Применяем скидку
|
||
if discount_percent > 0:
|
||
total_price = _apply_promo_discount(total_price, discount_percent)
|
||
|
||
traffic_display = f"{traffic_gb} ГБ" if traffic_gb > 0 else _format_traffic(tariff.traffic_limit_gb)
|
||
|
||
text = f"""📦 <b>{tariff.name}</b>
|
||
|
||
<b>Настройте параметры:</b>
|
||
"""
|
||
|
||
if tariff.can_purchase_custom_days():
|
||
text += f"📅 Дней: <b>{days}</b> (от {tariff.min_days} до {tariff.max_days})\n"
|
||
text += f" 💰 {_format_price_kopeks(period_price)}\n"
|
||
else:
|
||
# Фиксированный период - показываем без возможности изменения
|
||
text += f"📅 Период: <b>{_format_period(days)}</b>\n"
|
||
text += f" 💰 {_format_price_kopeks(period_price)}\n"
|
||
|
||
if tariff.can_purchase_custom_traffic():
|
||
text += f"📊 Трафик: <b>{traffic_gb} ГБ</b> (от {tariff.min_traffic_gb} до {tariff.max_traffic_gb})\n"
|
||
text += f" 💰 +{_format_price_kopeks(traffic_price)}\n"
|
||
else:
|
||
text += f"📊 Трафик: {traffic_display}\n"
|
||
|
||
text += f"📱 Устройств: {tariff.device_limit}\n"
|
||
|
||
if discount_percent > 0:
|
||
text += f"\n🎁 <b>Скидка: {discount_percent}%</b>\n"
|
||
|
||
text += f"""
|
||
<b>💰 Итого: {_format_price_kopeks(total_price)}</b>
|
||
|
||
💳 Ваш баланс: {_format_price_kopeks(user_balance)}"""
|
||
|
||
if user_balance < total_price:
|
||
missing = total_price - user_balance
|
||
text += f"\n⚠️ <b>Не хватает: {_format_price_kopeks(missing)}</b>"
|
||
else:
|
||
text += f"\nПосле оплаты: {_format_price_kopeks(user_balance - total_price)}"
|
||
|
||
return text
|
||
|
||
|
||
@error_handler
|
||
async def show_tariffs_list(
|
||
callback: types.CallbackQuery,
|
||
db_user: User,
|
||
db: AsyncSession,
|
||
state: FSMContext,
|
||
):
|
||
"""Показывает список тарифов для покупки."""
|
||
texts = get_texts(db_user.language)
|
||
await state.clear()
|
||
|
||
# Получаем доступные тарифы
|
||
promo_group_id = getattr(db_user, 'promo_group_id', None)
|
||
tariffs = await get_tariffs_for_user(db, promo_group_id)
|
||
|
||
if not tariffs:
|
||
await callback.message.edit_text(
|
||
"😔 <b>Нет доступных тарифов</b>\n\n"
|
||
"К сожалению, сейчас нет тарифов для покупки.",
|
||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||
[InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")]
|
||
]),
|
||
parse_mode="HTML"
|
||
)
|
||
await callback.answer()
|
||
return
|
||
|
||
# Проверяем есть ли у пользователя скидки по периодам
|
||
promo_group = getattr(db_user, 'promo_group', None)
|
||
has_period_discounts = False
|
||
if promo_group:
|
||
period_discounts = getattr(promo_group, 'period_discounts', None)
|
||
if period_discounts and isinstance(period_discounts, dict) and len(period_discounts) > 0:
|
||
has_period_discounts = True
|
||
|
||
# Формируем текст со списком тарифов и их характеристиками
|
||
tariffs_text = format_tariffs_list_text(tariffs, db_user, has_period_discounts)
|
||
|
||
await callback.message.edit_text(
|
||
tariffs_text,
|
||
reply_markup=get_tariffs_keyboard(tariffs, db_user.language),
|
||
parse_mode="HTML"
|
||
)
|
||
|
||
await callback.answer()
|
||
|
||
|
||
@error_handler
|
||
async def select_tariff(
|
||
callback: types.CallbackQuery,
|
||
db_user: User,
|
||
db: AsyncSession,
|
||
state: FSMContext,
|
||
):
|
||
"""Обрабатывает выбор тарифа."""
|
||
tariff_id = int(callback.data.split(":")[1])
|
||
tariff = await get_tariff_by_id(db, tariff_id)
|
||
|
||
if not tariff or not tariff.is_active:
|
||
await callback.answer("Тариф недоступен", show_alert=True)
|
||
return
|
||
|
||
# Проверяем, суточный ли это тариф
|
||
is_daily = getattr(tariff, 'is_daily', False)
|
||
|
||
if is_daily:
|
||
# Для суточного тарифа показываем подтверждение без выбора периода
|
||
daily_price = getattr(tariff, 'daily_price_kopeks', 0)
|
||
user_balance = db_user.balance_kopeks or 0
|
||
traffic = _format_traffic(tariff.traffic_limit_gb)
|
||
|
||
if user_balance >= daily_price:
|
||
await callback.message.edit_text(
|
||
f"✅ <b>Подтверждение покупки</b>\n\n"
|
||
f"📦 Тариф: <b>{tariff.name}</b>\n"
|
||
f"📊 Трафик: {traffic}\n"
|
||
f"📱 Устройств: {tariff.device_limit}\n"
|
||
f"🔄 Тип: <b>Суточный</b>\n\n"
|
||
f"💰 <b>Цена: {_format_price_kopeks(daily_price)}/день</b>\n\n"
|
||
f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n\n"
|
||
f"ℹ️ Средства будут списываться автоматически раз в сутки.\n"
|
||
f"Вы можете приостановить подписку в любой момент.",
|
||
reply_markup=get_daily_tariff_confirm_keyboard(tariff_id, db_user.language),
|
||
parse_mode="HTML"
|
||
)
|
||
else:
|
||
missing = daily_price - user_balance
|
||
await callback.message.edit_text(
|
||
f"❌ <b>Недостаточно средств</b>\n\n"
|
||
f"📦 Тариф: <b>{tariff.name}</b>\n"
|
||
f"🔄 Тип: Суточный\n"
|
||
f"💰 Цена: {_format_price_kopeks(daily_price)}/день\n\n"
|
||
f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n"
|
||
f"⚠️ Не хватает: <b>{_format_price_kopeks(missing)}</b>",
|
||
reply_markup=get_daily_tariff_insufficient_balance_keyboard(tariff_id, db_user.language),
|
||
parse_mode="HTML"
|
||
)
|
||
else:
|
||
# Проверяем, есть ли кастомные дни или трафик
|
||
can_custom_days = tariff.can_purchase_custom_days()
|
||
can_custom_traffic = tariff.can_purchase_custom_traffic()
|
||
|
||
if can_custom_days:
|
||
# Кастомные дни - показываем экран с +/- для дней (и опционально трафика)
|
||
user_balance = db_user.balance_kopeks or 0
|
||
|
||
initial_days = tariff.min_days
|
||
initial_traffic = tariff.min_traffic_gb if can_custom_traffic else tariff.traffic_limit_gb
|
||
|
||
# Вычисляем скидку для начального периода
|
||
discount_percent = _get_user_period_discount(db_user, initial_days)
|
||
|
||
await state.update_data(
|
||
selected_tariff_id=tariff_id,
|
||
custom_days=initial_days,
|
||
custom_traffic_gb=initial_traffic,
|
||
period_discount_percent=discount_percent,
|
||
)
|
||
|
||
preview_text = format_custom_tariff_preview(
|
||
tariff=tariff,
|
||
days=initial_days,
|
||
traffic_gb=initial_traffic,
|
||
user_balance=user_balance,
|
||
discount_percent=discount_percent,
|
||
)
|
||
|
||
await callback.message.edit_text(
|
||
preview_text,
|
||
reply_markup=get_custom_tariff_keyboard(
|
||
tariff_id=tariff_id,
|
||
language=db_user.language,
|
||
days=initial_days,
|
||
traffic_gb=initial_traffic,
|
||
can_custom_days=can_custom_days,
|
||
can_custom_traffic=can_custom_traffic,
|
||
min_days=tariff.min_days,
|
||
max_days=tariff.max_days,
|
||
min_traffic=tariff.min_traffic_gb,
|
||
max_traffic=tariff.max_traffic_gb,
|
||
),
|
||
parse_mode="HTML"
|
||
)
|
||
elif can_custom_traffic:
|
||
# Только кастомный трафик - сначала выбираем период из period_prices
|
||
# Показываем обычный выбор периода, трафик будет на следующем шаге
|
||
await callback.message.edit_text(
|
||
format_tariff_info_for_user(tariff, db_user.language) +
|
||
"\n\n📊 <i>После выбора периода вы сможете настроить трафик</i>",
|
||
reply_markup=get_tariff_periods_keyboard_with_traffic(
|
||
tariff, db_user.language, db_user=db_user
|
||
),
|
||
parse_mode="HTML"
|
||
)
|
||
else:
|
||
# Для обычного тарифа показываем выбор периода
|
||
await callback.message.edit_text(
|
||
format_tariff_info_for_user(tariff, db_user.language),
|
||
reply_markup=get_tariff_periods_keyboard(tariff, db_user.language, db_user=db_user),
|
||
parse_mode="HTML"
|
||
)
|
||
|
||
await state.update_data(selected_tariff_id=tariff_id)
|
||
await callback.answer()
|
||
|
||
|
||
@error_handler
|
||
async def handle_custom_days_change(
|
||
callback: types.CallbackQuery,
|
||
db_user: User,
|
||
db: AsyncSession,
|
||
state: FSMContext,
|
||
):
|
||
"""Обрабатывает изменение количества дней."""
|
||
parts = callback.data.split(":")
|
||
tariff_id = int(parts[1])
|
||
delta = int(parts[2])
|
||
|
||
tariff = await get_tariff_by_id(db, tariff_id)
|
||
if not tariff or not tariff.is_active:
|
||
await callback.answer("Тариф недоступен", show_alert=True)
|
||
return
|
||
|
||
state_data = await state.get_data()
|
||
current_days = state_data.get('custom_days', tariff.min_days)
|
||
current_traffic = state_data.get('custom_traffic_gb', tariff.min_traffic_gb)
|
||
|
||
# Применяем изменение
|
||
new_days = current_days + delta
|
||
new_days = max(tariff.min_days, min(tariff.max_days, new_days))
|
||
|
||
# При изменении дней пересчитываем скидку для нового периода
|
||
discount_percent = _get_user_period_discount(db_user, new_days)
|
||
|
||
await state.update_data(custom_days=new_days, period_discount_percent=discount_percent)
|
||
|
||
user_balance = db_user.balance_kopeks or 0
|
||
|
||
preview_text = format_custom_tariff_preview(
|
||
tariff=tariff,
|
||
days=new_days,
|
||
traffic_gb=current_traffic,
|
||
user_balance=user_balance,
|
||
discount_percent=discount_percent,
|
||
)
|
||
|
||
await callback.message.edit_text(
|
||
preview_text,
|
||
reply_markup=get_custom_tariff_keyboard(
|
||
tariff_id=tariff_id,
|
||
language=db_user.language,
|
||
days=new_days,
|
||
traffic_gb=current_traffic,
|
||
can_custom_days=tariff.can_purchase_custom_days(),
|
||
can_custom_traffic=tariff.can_purchase_custom_traffic(),
|
||
min_days=tariff.min_days,
|
||
max_days=tariff.max_days,
|
||
min_traffic=tariff.min_traffic_gb,
|
||
max_traffic=tariff.max_traffic_gb,
|
||
),
|
||
parse_mode="HTML"
|
||
)
|
||
await callback.answer()
|
||
|
||
|
||
@error_handler
|
||
async def handle_custom_traffic_change(
|
||
callback: types.CallbackQuery,
|
||
db_user: User,
|
||
db: AsyncSession,
|
||
state: FSMContext,
|
||
):
|
||
"""Обрабатывает изменение количества трафика."""
|
||
parts = callback.data.split(":")
|
||
tariff_id = int(parts[1])
|
||
delta = int(parts[2])
|
||
|
||
tariff = await get_tariff_by_id(db, tariff_id)
|
||
if not tariff or not tariff.is_active:
|
||
await callback.answer("Тариф недоступен", show_alert=True)
|
||
return
|
||
|
||
state_data = await state.get_data()
|
||
current_days = state_data.get('custom_days', tariff.min_days)
|
||
current_traffic = state_data.get('custom_traffic_gb', tariff.min_traffic_gb)
|
||
discount_percent = state_data.get('period_discount_percent', 0)
|
||
|
||
# Применяем изменение
|
||
new_traffic = current_traffic + delta
|
||
new_traffic = max(tariff.min_traffic_gb, min(tariff.max_traffic_gb, new_traffic))
|
||
|
||
await state.update_data(custom_traffic_gb=new_traffic)
|
||
|
||
user_balance = db_user.balance_kopeks or 0
|
||
|
||
preview_text = format_custom_tariff_preview(
|
||
tariff=tariff,
|
||
days=current_days,
|
||
traffic_gb=new_traffic,
|
||
user_balance=user_balance,
|
||
discount_percent=discount_percent,
|
||
)
|
||
|
||
await callback.message.edit_text(
|
||
preview_text,
|
||
reply_markup=get_custom_tariff_keyboard(
|
||
tariff_id=tariff_id,
|
||
language=db_user.language,
|
||
days=current_days,
|
||
traffic_gb=new_traffic,
|
||
can_custom_days=tariff.can_purchase_custom_days(),
|
||
can_custom_traffic=tariff.can_purchase_custom_traffic(),
|
||
min_days=tariff.min_days,
|
||
max_days=tariff.max_days,
|
||
min_traffic=tariff.min_traffic_gb,
|
||
max_traffic=tariff.max_traffic_gb,
|
||
),
|
||
parse_mode="HTML"
|
||
)
|
||
await callback.answer()
|
||
|
||
|
||
@error_handler
|
||
async def handle_custom_confirm(
|
||
callback: types.CallbackQuery,
|
||
db_user: User,
|
||
db: AsyncSession,
|
||
state: FSMContext,
|
||
):
|
||
"""Подтверждает покупку тарифа с кастомными параметрами."""
|
||
tariff_id = int(callback.data.split(":")[1])
|
||
|
||
tariff = await get_tariff_by_id(db, tariff_id)
|
||
if not tariff or not tariff.is_active:
|
||
await callback.answer("Тариф недоступен", show_alert=True)
|
||
return
|
||
|
||
state_data = await state.get_data()
|
||
custom_days = state_data.get('custom_days', tariff.min_days)
|
||
custom_traffic = state_data.get('custom_traffic_gb', tariff.min_traffic_gb)
|
||
discount_percent = state_data.get('period_discount_percent', 0)
|
||
|
||
# Рассчитываем цену (используем общую функцию)
|
||
period_price, traffic_price, total_price = _calculate_custom_tariff_price(
|
||
tariff, custom_days, custom_traffic
|
||
)
|
||
|
||
# Проверяем, что цена за период валидна
|
||
if period_price == 0 and not tariff.can_purchase_custom_days():
|
||
# Период не найден в period_prices - ошибка
|
||
await callback.answer("Выбранный период недоступен для этого тарифа", show_alert=True)
|
||
return
|
||
|
||
# Применяем скидку к цене периода (не к трафику)
|
||
if discount_percent > 0:
|
||
period_price = _apply_promo_discount(period_price, discount_percent)
|
||
total_price = period_price + traffic_price
|
||
|
||
# Проверяем баланс
|
||
user_balance = db_user.balance_kopeks or 0
|
||
if user_balance < total_price:
|
||
await callback.answer("Недостаточно средств на балансе", show_alert=True)
|
||
return
|
||
|
||
texts = get_texts(db_user.language)
|
||
|
||
try:
|
||
# Списываем баланс
|
||
success = await subtract_user_balance(
|
||
db, db_user, total_price,
|
||
f"Покупка тарифа {tariff.name} на {custom_days} дней"
|
||
)
|
||
if not success:
|
||
await callback.answer("Ошибка списания баланса", show_alert=True)
|
||
return
|
||
|
||
# Получаем список серверов из тарифа
|
||
squads = tariff.allowed_squads or []
|
||
|
||
# Если allowed_squads пустой - значит "все серверы", получаем их
|
||
if not squads:
|
||
from app.database.crud.server_squad import get_all_server_squads
|
||
all_servers, _ = await get_all_server_squads(db, available_only=True)
|
||
squads = [s.squad_uuid for s in all_servers if s.squad_uuid]
|
||
|
||
# Определяем трафик
|
||
traffic_limit = custom_traffic if tariff.can_purchase_custom_traffic() else tariff.traffic_limit_gb
|
||
|
||
# Проверяем есть ли уже подписка
|
||
existing_subscription = await get_subscription_by_user_id(db, db_user.id)
|
||
|
||
if existing_subscription:
|
||
# Продлеваем существующую подписку и обновляем параметры тарифа
|
||
subscription = await extend_subscription(
|
||
db,
|
||
existing_subscription,
|
||
days=custom_days,
|
||
tariff_id=tariff.id,
|
||
traffic_limit_gb=traffic_limit,
|
||
device_limit=tariff.device_limit,
|
||
connected_squads=squads,
|
||
)
|
||
else:
|
||
# Создаем новую подписку
|
||
subscription = await create_paid_subscription(
|
||
db=db,
|
||
user_id=db_user.id,
|
||
duration_days=custom_days,
|
||
traffic_limit_gb=traffic_limit,
|
||
device_limit=tariff.device_limit,
|
||
connected_squads=squads,
|
||
tariff_id=tariff.id,
|
||
)
|
||
|
||
# Обновляем пользователя в Remnawave
|
||
try:
|
||
subscription_service = SubscriptionService()
|
||
await subscription_service.create_remnawave_user(
|
||
db,
|
||
subscription,
|
||
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
|
||
reset_reason="покупка тарифа",
|
||
)
|
||
except Exception as e:
|
||
logger.error(f"Ошибка обновления Remnawave: {e}")
|
||
|
||
# Создаем транзакцию
|
||
await create_transaction(
|
||
db,
|
||
user_id=db_user.id,
|
||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||
amount_kopeks=-total_price,
|
||
description=f"Покупка тарифа {tariff.name} на {custom_days} дней",
|
||
)
|
||
|
||
# Отправляем уведомление админу
|
||
try:
|
||
admin_notification_service = AdminNotificationService(callback.bot)
|
||
await admin_notification_service.send_subscription_purchase_notification(
|
||
db,
|
||
db_user,
|
||
subscription,
|
||
None,
|
||
custom_days,
|
||
was_trial_conversion=False,
|
||
amount_kopeks=total_price,
|
||
)
|
||
except Exception as e:
|
||
logger.error(f"Ошибка отправки уведомления админу: {e}")
|
||
|
||
# Очищаем корзину после успешной покупки
|
||
try:
|
||
await user_cart_service.delete_user_cart(db_user.id)
|
||
except Exception as e:
|
||
logger.error(f"Ошибка очистки корзины: {e}")
|
||
|
||
await state.clear()
|
||
|
||
traffic_display = _format_traffic(traffic_limit)
|
||
|
||
await callback.message.edit_text(
|
||
f"🎉 <b>Подписка успешно оформлена!</b>\n\n"
|
||
f"📦 Тариф: <b>{tariff.name}</b>\n"
|
||
f"📊 Трафик: {traffic_display}\n"
|
||
f"📱 Устройств: {tariff.device_limit}\n"
|
||
f"📅 Период: {_format_period(custom_days)}\n"
|
||
f"💰 Списано: {_format_price_kopeks(total_price)}\n\n"
|
||
f"Перейдите в раздел «Подписка» для подключения.",
|
||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||
[InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")],
|
||
[InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")]
|
||
]),
|
||
parse_mode="HTML"
|
||
)
|
||
await callback.answer("Подписка оформлена!", show_alert=True)
|
||
|
||
except Exception as e:
|
||
logger.error(f"Ошибка при покупке тарифа с кастомными параметрами: {e}", exc_info=True)
|
||
await callback.answer("Произошла ошибка при оформлении подписки", show_alert=True)
|
||
|
||
|
||
@error_handler
|
||
async def select_tariff_period_with_traffic(
|
||
callback: types.CallbackQuery,
|
||
db_user: User,
|
||
db: AsyncSession,
|
||
state: FSMContext,
|
||
):
|
||
"""Обрабатывает выбор периода для тарифа с кастомным трафиком - показывает экран настройки трафика."""
|
||
parts = callback.data.split(":")
|
||
tariff_id = int(parts[1])
|
||
period = int(parts[2])
|
||
|
||
tariff = await get_tariff_by_id(db, tariff_id)
|
||
if not tariff or not tariff.is_active:
|
||
await callback.answer("Тариф недоступен", show_alert=True)
|
||
return
|
||
|
||
if not tariff.can_purchase_custom_traffic():
|
||
await callback.answer("Кастомный трафик недоступен для этого тарифа", show_alert=True)
|
||
return
|
||
|
||
user_balance = db_user.balance_kopeks or 0
|
||
initial_traffic = tariff.min_traffic_gb
|
||
|
||
# Получаем скидку для выбранного периода
|
||
discount_percent = _get_user_period_discount(db_user, period)
|
||
|
||
# Сохраняем выбранный период и скидку в состояние
|
||
await state.update_data(
|
||
selected_tariff_id=tariff_id,
|
||
custom_days=period, # Фиксированный период из period_prices
|
||
custom_traffic_gb=initial_traffic,
|
||
period_discount_percent=discount_percent, # Сохраняем скидку
|
||
)
|
||
|
||
preview_text = format_custom_tariff_preview(
|
||
tariff=tariff,
|
||
days=period,
|
||
traffic_gb=initial_traffic,
|
||
user_balance=user_balance,
|
||
discount_percent=discount_percent, # Применяем скидку при отображении
|
||
)
|
||
|
||
await callback.message.edit_text(
|
||
preview_text,
|
||
reply_markup=get_custom_tariff_keyboard(
|
||
tariff_id=tariff_id,
|
||
language=db_user.language,
|
||
days=period,
|
||
traffic_gb=initial_traffic,
|
||
can_custom_days=False, # Период уже выбран, менять нельзя
|
||
can_custom_traffic=True,
|
||
min_days=period,
|
||
max_days=period,
|
||
min_traffic=tariff.min_traffic_gb,
|
||
max_traffic=tariff.max_traffic_gb,
|
||
),
|
||
parse_mode="HTML"
|
||
)
|
||
await callback.answer()
|
||
|
||
|
||
@error_handler
|
||
async def select_tariff_period(
|
||
callback: types.CallbackQuery,
|
||
db_user: User,
|
||
db: AsyncSession,
|
||
state: FSMContext,
|
||
):
|
||
"""Обрабатывает выбор периода для тарифа."""
|
||
parts = callback.data.split(":")
|
||
tariff_id = int(parts[1])
|
||
period = int(parts[2])
|
||
|
||
tariff = await get_tariff_by_id(db, tariff_id)
|
||
if not tariff or not tariff.is_active:
|
||
await callback.answer("Тариф недоступен", show_alert=True)
|
||
return
|
||
|
||
# Получаем скидку для выбранного периода
|
||
discount_percent = _get_user_period_discount(db_user, period)
|
||
|
||
# Получаем цену
|
||
prices = tariff.period_prices or {}
|
||
base_price = prices.get(str(period), 0)
|
||
final_price = _apply_promo_discount(base_price, discount_percent)
|
||
|
||
# Проверяем баланс
|
||
user_balance = db_user.balance_kopeks or 0
|
||
|
||
traffic = _format_traffic(tariff.traffic_limit_gb)
|
||
|
||
if user_balance >= final_price:
|
||
# Показываем подтверждение
|
||
discount_text = ""
|
||
if discount_percent > 0:
|
||
discount_text = f"\n🎁 Скидка: {discount_percent}% (-{_format_price_kopeks(base_price - final_price)})"
|
||
|
||
await callback.message.edit_text(
|
||
f"✅ <b>Подтверждение покупки</b>\n\n"
|
||
f"📦 Тариф: <b>{tariff.name}</b>\n"
|
||
f"📊 Трафик: {traffic}\n"
|
||
f"📱 Устройств: {tariff.device_limit}\n"
|
||
f"📅 Период: {_format_period(period)}\n"
|
||
f"{discount_text}\n"
|
||
f"💰 <b>Итого: {_format_price_kopeks(final_price)}</b>\n\n"
|
||
f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n"
|
||
f"После оплаты: {_format_price_kopeks(user_balance - final_price)}",
|
||
reply_markup=get_tariff_confirm_keyboard(tariff_id, period, db_user.language),
|
||
parse_mode="HTML"
|
||
)
|
||
else:
|
||
# Недостаточно средств
|
||
missing = final_price - user_balance
|
||
await callback.message.edit_text(
|
||
f"❌ <b>Недостаточно средств</b>\n\n"
|
||
f"📦 Тариф: <b>{tariff.name}</b>\n"
|
||
f"📅 Период: {_format_period(period)}\n"
|
||
f"💰 Стоимость: {_format_price_kopeks(final_price)}\n\n"
|
||
f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n"
|
||
f"⚠️ Не хватает: <b>{_format_price_kopeks(missing)}</b>",
|
||
reply_markup=get_tariff_insufficient_balance_keyboard(tariff_id, period, db_user.language),
|
||
parse_mode="HTML"
|
||
)
|
||
|
||
await state.update_data(
|
||
selected_tariff_id=tariff_id,
|
||
selected_period=period,
|
||
final_price=final_price,
|
||
tariff_discount_percent=discount_percent,
|
||
)
|
||
await callback.answer()
|
||
|
||
|
||
@error_handler
|
||
async def confirm_tariff_purchase(
|
||
callback: types.CallbackQuery,
|
||
db_user: User,
|
||
db: AsyncSession,
|
||
state: FSMContext,
|
||
):
|
||
"""Подтверждает покупку тарифа и создает подписку."""
|
||
parts = callback.data.split(":")
|
||
tariff_id = int(parts[1])
|
||
period = int(parts[2])
|
||
|
||
tariff = await get_tariff_by_id(db, tariff_id)
|
||
if not tariff or not tariff.is_active:
|
||
await callback.answer("Тариф недоступен", show_alert=True)
|
||
return
|
||
|
||
# Получаем скидку для выбранного периода
|
||
discount_percent = _get_user_period_discount(db_user, period)
|
||
|
||
# Получаем цену
|
||
prices = tariff.period_prices or {}
|
||
base_price = prices.get(str(period), 0)
|
||
final_price = _apply_promo_discount(base_price, discount_percent)
|
||
|
||
# Проверяем баланс
|
||
user_balance = db_user.balance_kopeks or 0
|
||
if user_balance < final_price:
|
||
await callback.answer("Недостаточно средств на балансе", show_alert=True)
|
||
return
|
||
|
||
texts = get_texts(db_user.language)
|
||
|
||
try:
|
||
# Списываем баланс
|
||
success = await subtract_user_balance(
|
||
db, db_user, final_price,
|
||
f"Покупка тарифа {tariff.name} на {period} дней"
|
||
)
|
||
if not success:
|
||
await callback.answer("Ошибка списания баланса", show_alert=True)
|
||
return
|
||
|
||
# Получаем список серверов из тарифа
|
||
squads = tariff.allowed_squads or []
|
||
|
||
# Если allowed_squads пустой - значит "все серверы", получаем их
|
||
if not squads:
|
||
from app.database.crud.server_squad import get_all_server_squads
|
||
all_servers, _ = await get_all_server_squads(db, available_only=True)
|
||
squads = [s.squad_uuid for s in all_servers if s.squad_uuid]
|
||
|
||
# Проверяем есть ли уже подписка
|
||
existing_subscription = await get_subscription_by_user_id(db, db_user.id)
|
||
|
||
if existing_subscription:
|
||
# Продлеваем существующую подписку и обновляем параметры тарифа
|
||
subscription = await extend_subscription(
|
||
db,
|
||
existing_subscription,
|
||
days=period,
|
||
tariff_id=tariff.id,
|
||
traffic_limit_gb=tariff.traffic_limit_gb,
|
||
device_limit=tariff.device_limit,
|
||
connected_squads=squads,
|
||
)
|
||
else:
|
||
# Создаем новую подписку
|
||
subscription = await create_paid_subscription(
|
||
db=db,
|
||
user_id=db_user.id,
|
||
duration_days=period,
|
||
traffic_limit_gb=tariff.traffic_limit_gb,
|
||
device_limit=tariff.device_limit,
|
||
connected_squads=squads,
|
||
tariff_id=tariff.id,
|
||
)
|
||
|
||
# Обновляем пользователя в Remnawave
|
||
try:
|
||
subscription_service = SubscriptionService()
|
||
await subscription_service.create_remnawave_user(
|
||
db,
|
||
subscription,
|
||
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
|
||
reset_reason="покупка тарифа",
|
||
)
|
||
except Exception as e:
|
||
logger.error(f"Ошибка обновления Remnawave: {e}")
|
||
|
||
# Создаем транзакцию
|
||
await create_transaction(
|
||
db,
|
||
user_id=db_user.id,
|
||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||
amount_kopeks=-final_price,
|
||
description=f"Покупка тарифа {tariff.name} на {period} дней",
|
||
)
|
||
|
||
# Отправляем уведомление админу
|
||
try:
|
||
admin_notification_service = AdminNotificationService(callback.bot)
|
||
await admin_notification_service.send_subscription_purchase_notification(
|
||
db,
|
||
db_user,
|
||
subscription,
|
||
None, # Транзакция отсутствует, оплата с баланса
|
||
period,
|
||
was_trial_conversion=False,
|
||
amount_kopeks=final_price,
|
||
)
|
||
except Exception as e:
|
||
logger.error(f"Ошибка отправки уведомления админу: {e}")
|
||
|
||
# Очищаем корзину после успешной покупки
|
||
try:
|
||
await user_cart_service.delete_user_cart(db_user.id)
|
||
logger.info(f"Корзина очищена после покупки тарифа для пользователя {db_user.telegram_id}")
|
||
except Exception as e:
|
||
logger.error(f"Ошибка очистки корзины: {e}")
|
||
|
||
await state.clear()
|
||
|
||
traffic = _format_traffic(tariff.traffic_limit_gb)
|
||
|
||
await callback.message.edit_text(
|
||
f"🎉 <b>Подписка успешно оформлена!</b>\n\n"
|
||
f"📦 Тариф: <b>{tariff.name}</b>\n"
|
||
f"📊 Трафик: {traffic}\n"
|
||
f"📱 Устройств: {tariff.device_limit}\n"
|
||
f"📅 Период: {_format_period(period)}\n"
|
||
f"💰 Списано: {_format_price_kopeks(final_price)}\n\n"
|
||
f"Перейдите в раздел «Подписка» для подключения.",
|
||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||
[InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")],
|
||
[InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")]
|
||
]),
|
||
parse_mode="HTML"
|
||
)
|
||
await callback.answer("Подписка оформлена!", show_alert=True)
|
||
|
||
except Exception as e:
|
||
logger.error(f"Ошибка при покупке тарифа: {e}", exc_info=True)
|
||
await callback.answer("Произошла ошибка при оформлении подписки", show_alert=True)
|
||
|
||
|
||
# ==================== Покупка суточного тарифа ====================
|
||
|
||
@error_handler
|
||
async def confirm_daily_tariff_purchase(
|
||
callback: types.CallbackQuery,
|
||
db_user: User,
|
||
db: AsyncSession,
|
||
state: FSMContext,
|
||
):
|
||
"""Подтверждает покупку суточного тарифа."""
|
||
from datetime import datetime
|
||
|
||
tariff_id = int(callback.data.split(":")[1])
|
||
tariff = await get_tariff_by_id(db, tariff_id)
|
||
|
||
if not tariff or not tariff.is_active:
|
||
await callback.answer("Тариф недоступен", show_alert=True)
|
||
return
|
||
|
||
is_daily = getattr(tariff, 'is_daily', False)
|
||
if not is_daily:
|
||
await callback.answer("Это не суточный тариф", show_alert=True)
|
||
return
|
||
|
||
daily_price = getattr(tariff, 'daily_price_kopeks', 0)
|
||
if daily_price <= 0:
|
||
await callback.answer("Некорректная цена тарифа", show_alert=True)
|
||
return
|
||
|
||
# Проверяем баланс
|
||
user_balance = db_user.balance_kopeks or 0
|
||
if user_balance < daily_price:
|
||
await callback.answer("Недостаточно средств на балансе", show_alert=True)
|
||
return
|
||
|
||
texts = get_texts(db_user.language)
|
||
|
||
try:
|
||
# Списываем первый день сразу
|
||
success = await subtract_user_balance(
|
||
db, db_user, daily_price,
|
||
f"Покупка суточного тарифа {tariff.name} (первый день)"
|
||
)
|
||
if not success:
|
||
await callback.answer("Ошибка списания баланса", show_alert=True)
|
||
return
|
||
|
||
# Получаем список серверов из тарифа
|
||
squads = tariff.allowed_squads or []
|
||
|
||
# Если allowed_squads пустой - значит "все серверы", получаем их
|
||
if not squads:
|
||
from app.database.crud.server_squad import get_all_server_squads
|
||
all_servers, _ = await get_all_server_squads(db, available_only=True)
|
||
squads = [s.squad_uuid for s in all_servers if s.squad_uuid]
|
||
|
||
# Проверяем есть ли уже подписка
|
||
existing_subscription = await get_subscription_by_user_id(db, db_user.id)
|
||
|
||
if existing_subscription:
|
||
# Обновляем существующую подписку на суточный тариф
|
||
existing_subscription.tariff_id = tariff.id
|
||
existing_subscription.traffic_limit_gb = tariff.traffic_limit_gb
|
||
existing_subscription.device_limit = tariff.device_limit
|
||
existing_subscription.connected_squads = squads
|
||
existing_subscription.status = "active"
|
||
existing_subscription.is_trial = False # Сбрасываем триальный статус
|
||
existing_subscription.is_daily_paused = False
|
||
existing_subscription.last_daily_charge_at = datetime.utcnow()
|
||
# Для суточного тарифа ставим срок на 1 день
|
||
existing_subscription.end_date = datetime.utcnow() + timedelta(days=1)
|
||
|
||
# Сбрасываем докупленный трафик при смене тарифа
|
||
from app.database.models import TrafficPurchase
|
||
from sqlalchemy import delete as sql_delete
|
||
await db.execute(sql_delete(TrafficPurchase).where(TrafficPurchase.subscription_id == existing_subscription.id))
|
||
existing_subscription.purchased_traffic_gb = 0
|
||
existing_subscription.traffic_reset_at = None
|
||
|
||
await db.commit()
|
||
await db.refresh(existing_subscription)
|
||
subscription = existing_subscription
|
||
else:
|
||
# Создаем новую подписку на 1 день
|
||
subscription = await create_paid_subscription(
|
||
db=db,
|
||
user_id=db_user.id,
|
||
duration_days=1,
|
||
traffic_limit_gb=tariff.traffic_limit_gb,
|
||
device_limit=tariff.device_limit,
|
||
connected_squads=squads,
|
||
tariff_id=tariff.id,
|
||
)
|
||
# Устанавливаем время последнего списания
|
||
subscription.last_daily_charge_at = datetime.utcnow()
|
||
subscription.is_daily_paused = False
|
||
await db.commit()
|
||
await db.refresh(subscription)
|
||
|
||
# Обновляем пользователя в Remnawave
|
||
try:
|
||
subscription_service = SubscriptionService()
|
||
await subscription_service.create_remnawave_user(
|
||
db,
|
||
subscription,
|
||
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
|
||
reset_reason="покупка суточного тарифа",
|
||
)
|
||
except Exception as e:
|
||
logger.error(f"Ошибка обновления Remnawave: {e}")
|
||
|
||
# Создаем транзакцию
|
||
await create_transaction(
|
||
db,
|
||
user_id=db_user.id,
|
||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||
amount_kopeks=-daily_price,
|
||
description=f"Покупка суточного тарифа {tariff.name} (первый день)",
|
||
)
|
||
|
||
# Отправляем уведомление админу
|
||
try:
|
||
admin_notification_service = AdminNotificationService(callback.bot)
|
||
await admin_notification_service.send_subscription_purchase_notification(
|
||
db,
|
||
db_user,
|
||
subscription,
|
||
None,
|
||
1, # 1 день
|
||
was_trial_conversion=False,
|
||
amount_kopeks=daily_price,
|
||
)
|
||
except Exception as e:
|
||
logger.error(f"Ошибка отправки уведомления админу: {e}")
|
||
|
||
# Очищаем корзину после успешной покупки
|
||
try:
|
||
await user_cart_service.delete_user_cart(db_user.id)
|
||
logger.info(f"Корзина очищена после покупки суточного тарифа для пользователя {db_user.telegram_id}")
|
||
except Exception as e:
|
||
logger.error(f"Ошибка очистки корзины: {e}")
|
||
|
||
await state.clear()
|
||
|
||
traffic = _format_traffic(tariff.traffic_limit_gb)
|
||
|
||
await callback.message.edit_text(
|
||
f"🎉 <b>Суточная подписка оформлена!</b>\n\n"
|
||
f"📦 Тариф: <b>{tariff.name}</b>\n"
|
||
f"📊 Трафик: {traffic}\n"
|
||
f"📱 Устройств: {tariff.device_limit}\n"
|
||
f"🔄 Тип: Суточный\n"
|
||
f"💰 Списано: {_format_price_kopeks(daily_price)}\n\n"
|
||
f"ℹ️ Следующее списание через 24 часа.\n"
|
||
f"Перейдите в раздел «Подписка» для подключения.",
|
||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||
[InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")],
|
||
[InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")]
|
||
]),
|
||
parse_mode="HTML"
|
||
)
|
||
await callback.answer("Подписка оформлена!", show_alert=True)
|
||
|
||
except Exception as e:
|
||
logger.error(f"Ошибка при покупке суточного тарифа: {e}", exc_info=True)
|
||
await callback.answer("Произошла ошибка при оформлении подписки", show_alert=True)
|
||
|
||
|
||
# ==================== Продление по тарифу ====================
|
||
|
||
def get_tariff_extend_keyboard(
|
||
tariff: Tariff,
|
||
language: str,
|
||
db_user: Optional[User] = None,
|
||
) -> InlineKeyboardMarkup:
|
||
"""Создает клавиатуру выбора периода для продления по тарифу с учетом скидок по периодам."""
|
||
texts = get_texts(language)
|
||
buttons = []
|
||
|
||
prices = tariff.period_prices or {}
|
||
for period_str in sorted(prices.keys(), key=int):
|
||
period = int(period_str)
|
||
price = prices[period_str]
|
||
|
||
# Получаем скидку для конкретного периода
|
||
discount_percent = 0
|
||
if db_user:
|
||
discount_percent = _get_user_period_discount(db_user, period)
|
||
|
||
if discount_percent > 0:
|
||
original_price = price
|
||
price = _apply_promo_discount(price, discount_percent)
|
||
price_text = f"{_format_price_kopeks(price)} 🔥−{discount_percent}%"
|
||
else:
|
||
price_text = _format_price_kopeks(price)
|
||
|
||
button_text = f"{_format_period(period)} — {price_text}"
|
||
buttons.append([
|
||
InlineKeyboardButton(
|
||
text=button_text,
|
||
callback_data=f"tariff_extend:{tariff.id}:{period}"
|
||
)
|
||
])
|
||
|
||
buttons.append([
|
||
InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")
|
||
])
|
||
|
||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||
|
||
|
||
def get_tariff_extend_confirm_keyboard(
|
||
tariff_id: int,
|
||
period: int,
|
||
language: str,
|
||
) -> InlineKeyboardMarkup:
|
||
"""Создает клавиатуру подтверждения продления по тарифу."""
|
||
texts = get_texts(language)
|
||
return InlineKeyboardMarkup(inline_keyboard=[
|
||
[
|
||
InlineKeyboardButton(
|
||
text="✅ Подтвердить продление",
|
||
callback_data=f"tariff_ext_confirm:{tariff_id}:{period}"
|
||
)
|
||
],
|
||
[
|
||
InlineKeyboardButton(
|
||
text=texts.BACK,
|
||
callback_data="subscription_extend"
|
||
)
|
||
]
|
||
])
|
||
|
||
|
||
async def show_tariff_extend(
|
||
callback: types.CallbackQuery,
|
||
db_user: User,
|
||
db: AsyncSession,
|
||
):
|
||
"""Показывает экран продления по текущему тарифу."""
|
||
texts = get_texts(db_user.language)
|
||
|
||
subscription = await get_subscription_by_user_id(db, db_user.id)
|
||
if not subscription or not subscription.tariff_id:
|
||
await callback.answer("Тариф не найден", show_alert=True)
|
||
return
|
||
|
||
tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||
if not tariff:
|
||
await callback.answer("Тариф не найден", show_alert=True)
|
||
return
|
||
|
||
traffic = _format_traffic(tariff.traffic_limit_gb)
|
||
|
||
# Проверяем есть ли у пользователя скидки по периодам
|
||
promo_group = getattr(db_user, 'promo_group', None)
|
||
has_period_discounts = False
|
||
if promo_group:
|
||
period_discounts = getattr(promo_group, 'period_discounts', None)
|
||
if period_discounts and isinstance(period_discounts, dict) and len(period_discounts) > 0:
|
||
has_period_discounts = True
|
||
|
||
discount_hint = ""
|
||
if has_period_discounts:
|
||
discount_hint = "\n🎁 <i>Скидки зависят от выбранного периода</i>"
|
||
|
||
await callback.message.edit_text(
|
||
f"🔄 <b>Продление подписки</b>{discount_hint}\n\n"
|
||
f"📦 Тариф: <b>{tariff.name}</b>\n"
|
||
f"📊 Трафик: {traffic}\n"
|
||
f"📱 Устройств: {tariff.device_limit}\n\n"
|
||
"Выберите период продления:",
|
||
reply_markup=get_tariff_extend_keyboard(tariff, db_user.language, db_user=db_user),
|
||
parse_mode="HTML"
|
||
)
|
||
await callback.answer()
|
||
|
||
|
||
@error_handler
|
||
async def select_tariff_extend_period(
|
||
callback: types.CallbackQuery,
|
||
db_user: User,
|
||
db: AsyncSession,
|
||
state: FSMContext,
|
||
):
|
||
"""Обрабатывает выбор периода для продления."""
|
||
texts = get_texts(db_user.language)
|
||
parts = callback.data.split(":")
|
||
tariff_id = int(parts[1])
|
||
period = int(parts[2])
|
||
|
||
tariff = await get_tariff_by_id(db, tariff_id)
|
||
if not tariff or not tariff.is_active:
|
||
await callback.answer("Тариф недоступен", show_alert=True)
|
||
return
|
||
|
||
# Получаем скидку для выбранного периода
|
||
discount_percent = _get_user_period_discount(db_user, period)
|
||
|
||
# Получаем цену
|
||
prices = tariff.period_prices or {}
|
||
base_price = prices.get(str(period), 0)
|
||
final_price = _apply_promo_discount(base_price, discount_percent)
|
||
|
||
# Проверяем баланс
|
||
user_balance = db_user.balance_kopeks or 0
|
||
|
||
traffic = _format_traffic(tariff.traffic_limit_gb)
|
||
|
||
if user_balance >= final_price:
|
||
discount_text = ""
|
||
if discount_percent > 0:
|
||
discount_text = f"\n🎁 Скидка: {discount_percent}% (-{_format_price_kopeks(base_price - final_price)})"
|
||
|
||
await callback.message.edit_text(
|
||
f"✅ <b>Подтверждение продления</b>\n\n"
|
||
f"📦 Тариф: <b>{tariff.name}</b>\n"
|
||
f"📊 Трафик: {traffic}\n"
|
||
f"📱 Устройств: {tariff.device_limit}\n"
|
||
f"📅 Период: {_format_period(period)}\n"
|
||
f"{discount_text}\n"
|
||
f"💰 <b>К оплате: {_format_price_kopeks(final_price)}</b>\n\n"
|
||
f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n"
|
||
f"После оплаты: {_format_price_kopeks(user_balance - final_price)}",
|
||
reply_markup=get_tariff_extend_confirm_keyboard(tariff_id, period, db_user.language),
|
||
parse_mode="HTML"
|
||
)
|
||
else:
|
||
missing = final_price - user_balance
|
||
await callback.message.edit_text(
|
||
f"❌ <b>Недостаточно средств</b>\n\n"
|
||
f"📦 Тариф: <b>{tariff.name}</b>\n"
|
||
f"📅 Период: {_format_period(period)}\n"
|
||
f"💰 К оплате: {_format_price_kopeks(final_price)}\n\n"
|
||
f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n"
|
||
f"⚠️ Не хватает: <b>{_format_price_kopeks(missing)}</b>",
|
||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||
[InlineKeyboardButton(text="💳 Пополнить баланс", callback_data="balance_topup")],
|
||
[InlineKeyboardButton(text=texts.BACK, callback_data="subscription_extend")]
|
||
]),
|
||
parse_mode="HTML"
|
||
)
|
||
|
||
await state.update_data(
|
||
extend_tariff_id=tariff_id,
|
||
extend_period=period,
|
||
extend_discount_percent=discount_percent,
|
||
)
|
||
await callback.answer()
|
||
|
||
|
||
@error_handler
|
||
async def confirm_tariff_extend(
|
||
callback: types.CallbackQuery,
|
||
db_user: User,
|
||
db: AsyncSession,
|
||
state: FSMContext,
|
||
):
|
||
"""Подтверждает продление по тарифу."""
|
||
parts = callback.data.split(":")
|
||
tariff_id = int(parts[1])
|
||
period = int(parts[2])
|
||
|
||
tariff = await get_tariff_by_id(db, tariff_id)
|
||
if not tariff or not tariff.is_active:
|
||
await callback.answer("Тариф недоступен", show_alert=True)
|
||
return
|
||
|
||
subscription = await get_subscription_by_user_id(db, db_user.id)
|
||
if not subscription:
|
||
await callback.answer("Подписка не найдена", show_alert=True)
|
||
return
|
||
|
||
data = await state.get_data()
|
||
discount_percent = data.get('extend_discount_percent', 0)
|
||
|
||
# Получаем цену
|
||
prices = tariff.period_prices or {}
|
||
base_price = prices.get(str(period), 0)
|
||
final_price = _apply_promo_discount(base_price, discount_percent)
|
||
|
||
# Проверяем баланс
|
||
user_balance = db_user.balance_kopeks or 0
|
||
if user_balance < final_price:
|
||
await callback.answer("Недостаточно средств на балансе", show_alert=True)
|
||
return
|
||
|
||
texts = get_texts(db_user.language)
|
||
|
||
try:
|
||
# Списываем баланс
|
||
success = await subtract_user_balance(
|
||
db, db_user, final_price,
|
||
f"Продление тарифа {tariff.name} на {period} дней"
|
||
)
|
||
if not success:
|
||
await callback.answer("Ошибка списания баланса", show_alert=True)
|
||
return
|
||
|
||
# Продлеваем подписку (параметры тарифа не меняются, только добавляется время)
|
||
subscription = await extend_subscription(
|
||
db,
|
||
subscription,
|
||
days=period,
|
||
)
|
||
|
||
# Обновляем пользователя в Remnawave
|
||
try:
|
||
subscription_service = SubscriptionService()
|
||
await subscription_service.create_remnawave_user(
|
||
db,
|
||
subscription,
|
||
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
|
||
reset_reason="продление тарифа",
|
||
)
|
||
except Exception as e:
|
||
logger.error(f"Ошибка обновления Remnawave: {e}")
|
||
|
||
# Создаем транзакцию
|
||
await create_transaction(
|
||
db,
|
||
user_id=db_user.id,
|
||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||
amount_kopeks=-final_price,
|
||
description=f"Продление тарифа {tariff.name} на {period} дней",
|
||
)
|
||
|
||
# Отправляем уведомление админу
|
||
try:
|
||
admin_notification_service = AdminNotificationService(callback.bot)
|
||
await admin_notification_service.send_subscription_purchase_notification(
|
||
db,
|
||
db_user,
|
||
subscription,
|
||
None, # Транзакция отсутствует, оплата с баланса
|
||
period,
|
||
was_trial_conversion=False,
|
||
amount_kopeks=final_price,
|
||
)
|
||
except Exception as e:
|
||
logger.error(f"Ошибка отправки уведомления админу: {e}")
|
||
|
||
# Очищаем корзину после успешной покупки
|
||
try:
|
||
await user_cart_service.delete_user_cart(db_user.id)
|
||
logger.info(f"Корзина очищена после продления тарифа для пользователя {db_user.telegram_id}")
|
||
except Exception as e:
|
||
logger.error(f"Ошибка очистки корзины: {e}")
|
||
|
||
await state.clear()
|
||
|
||
traffic = _format_traffic(tariff.traffic_limit_gb)
|
||
|
||
await callback.message.edit_text(
|
||
f"🎉 <b>Подписка успешно продлена!</b>\n\n"
|
||
f"📦 Тариф: <b>{tariff.name}</b>\n"
|
||
f"📊 Трафик: {traffic}\n"
|
||
f"📱 Устройств: {tariff.device_limit}\n"
|
||
f"📅 Добавлено: {_format_period(period)}\n"
|
||
f"💰 Списано: {_format_price_kopeks(final_price)}",
|
||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||
[InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")],
|
||
[InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")]
|
||
]),
|
||
parse_mode="HTML"
|
||
)
|
||
await callback.answer("Подписка продлена!", show_alert=True)
|
||
|
||
except Exception as e:
|
||
logger.error(f"Ошибка при продлении тарифа: {e}", exc_info=True)
|
||
await callback.answer("Произошла ошибка при продлении подписки", show_alert=True)
|
||
|
||
|
||
# ==================== Переключение тарифов ====================
|
||
|
||
def format_tariff_switch_list_text(
|
||
tariffs: List[Tariff],
|
||
current_tariff_id: Optional[int],
|
||
current_tariff_name: str,
|
||
db_user: Optional[User] = None,
|
||
has_period_discounts: bool = False,
|
||
) -> str:
|
||
"""Форматирует текст со списком тарифов для переключения."""
|
||
lines = [
|
||
"📦 <b>Смена тарифа</b>",
|
||
f"📌 Текущий: <b>{current_tariff_name}</b>",
|
||
]
|
||
|
||
if has_period_discounts:
|
||
lines.append("🎁 <i>Скидки по периодам</i>")
|
||
|
||
lines.append("")
|
||
lines.append("⚠️ Оплачивается полная стоимость.")
|
||
lines.append("")
|
||
|
||
for tariff in tariffs:
|
||
if tariff.id == current_tariff_id:
|
||
continue
|
||
|
||
traffic_gb = tariff.traffic_limit_gb
|
||
traffic = "∞" if traffic_gb == 0 else f"{traffic_gb}ГБ"
|
||
|
||
# Проверяем суточный ли тариф
|
||
is_daily = getattr(tariff, 'is_daily', False)
|
||
price_text = ""
|
||
discount_icon = ""
|
||
|
||
if is_daily:
|
||
# Для суточных тарифов показываем цену за день
|
||
daily_price = getattr(tariff, 'daily_price_kopeks', 0)
|
||
price_text = f"🔄 {_format_price_kopeks(daily_price, compact=True)}/день"
|
||
else:
|
||
prices = tariff.period_prices or {}
|
||
if prices:
|
||
min_period = min(prices.keys(), key=int)
|
||
min_price = prices[min_period]
|
||
discount_percent = 0
|
||
if db_user:
|
||
discount_percent = _get_user_period_discount(db_user, int(min_period))
|
||
if discount_percent > 0:
|
||
min_price = _apply_promo_discount(min_price, discount_percent)
|
||
discount_icon = "🔥"
|
||
price_text = f"от {_format_price_kopeks(min_price, compact=True)}{discount_icon}"
|
||
|
||
lines.append(f"<b>{tariff.name}</b> — {traffic}/{tariff.device_limit}📱 {price_text}")
|
||
|
||
if tariff.description:
|
||
lines.append(f"<i>{tariff.description}</i>")
|
||
|
||
lines.append("")
|
||
|
||
return "\n".join(lines)
|
||
|
||
|
||
def get_tariff_switch_keyboard(
|
||
tariffs: List[Tariff],
|
||
current_tariff_id: Optional[int],
|
||
language: str,
|
||
) -> InlineKeyboardMarkup:
|
||
"""Создает компактную клавиатуру выбора тарифа для переключения."""
|
||
texts = get_texts(language)
|
||
buttons = []
|
||
|
||
for tariff in tariffs:
|
||
if tariff.id == current_tariff_id:
|
||
continue
|
||
|
||
buttons.append([
|
||
InlineKeyboardButton(
|
||
text=tariff.name,
|
||
callback_data=f"tariff_sw_select:{tariff.id}"
|
||
)
|
||
])
|
||
|
||
buttons.append([
|
||
InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")
|
||
])
|
||
|
||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||
|
||
|
||
def get_tariff_switch_periods_keyboard(
|
||
tariff: Tariff,
|
||
language: str,
|
||
db_user: Optional[User] = None,
|
||
) -> InlineKeyboardMarkup:
|
||
"""Создает клавиатуру выбора периода для переключения тарифа с учетом скидок по периодам."""
|
||
texts = get_texts(language)
|
||
buttons = []
|
||
|
||
prices = tariff.period_prices or {}
|
||
for period_str in sorted(prices.keys(), key=int):
|
||
period = int(period_str)
|
||
price = prices[period_str]
|
||
|
||
# Получаем скидку для конкретного периода
|
||
discount_percent = 0
|
||
if db_user:
|
||
discount_percent = _get_user_period_discount(db_user, period)
|
||
|
||
if discount_percent > 0:
|
||
original_price = price
|
||
price = _apply_promo_discount(price, discount_percent)
|
||
price_text = f"{_format_price_kopeks(price)} 🔥−{discount_percent}%"
|
||
else:
|
||
price_text = _format_price_kopeks(price)
|
||
|
||
button_text = f"{_format_period(period)} — {price_text}"
|
||
buttons.append([
|
||
InlineKeyboardButton(
|
||
text=button_text,
|
||
callback_data=f"tariff_sw_period:{tariff.id}:{period}"
|
||
)
|
||
])
|
||
|
||
buttons.append([
|
||
InlineKeyboardButton(text=texts.BACK, callback_data="tariff_switch")
|
||
])
|
||
|
||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||
|
||
|
||
def get_tariff_switch_confirm_keyboard(
|
||
tariff_id: int,
|
||
period: int,
|
||
language: str,
|
||
) -> InlineKeyboardMarkup:
|
||
"""Создает клавиатуру подтверждения переключения тарифа."""
|
||
texts = get_texts(language)
|
||
return InlineKeyboardMarkup(inline_keyboard=[
|
||
[
|
||
InlineKeyboardButton(
|
||
text="✅ Подтвердить переключение",
|
||
callback_data=f"tariff_sw_confirm:{tariff_id}:{period}"
|
||
)
|
||
],
|
||
[
|
||
InlineKeyboardButton(
|
||
text=texts.BACK,
|
||
callback_data=f"tariff_sw_select:{tariff_id}"
|
||
)
|
||
]
|
||
])
|
||
|
||
|
||
def get_tariff_switch_insufficient_balance_keyboard(
|
||
tariff_id: int,
|
||
period: int,
|
||
language: str,
|
||
) -> InlineKeyboardMarkup:
|
||
"""Создает клавиатуру при недостаточном балансе для переключения."""
|
||
texts = get_texts(language)
|
||
return InlineKeyboardMarkup(inline_keyboard=[
|
||
[
|
||
InlineKeyboardButton(
|
||
text="💳 Пополнить баланс",
|
||
callback_data="balance_topup"
|
||
)
|
||
],
|
||
[
|
||
InlineKeyboardButton(
|
||
text=texts.BACK,
|
||
callback_data=f"tariff_sw_select:{tariff_id}"
|
||
)
|
||
]
|
||
])
|
||
|
||
|
||
@error_handler
|
||
async def show_tariff_switch_list(
|
||
callback: types.CallbackQuery,
|
||
db_user: User,
|
||
db: AsyncSession,
|
||
state: FSMContext,
|
||
):
|
||
"""Показывает список тарифов для переключения."""
|
||
texts = get_texts(db_user.language)
|
||
await state.clear()
|
||
|
||
# Проверяем наличие активной подписки
|
||
subscription = await get_subscription_by_user_id(db, db_user.id)
|
||
if not subscription:
|
||
await callback.answer("У вас нет активной подписки", show_alert=True)
|
||
return
|
||
|
||
current_tariff_id = subscription.tariff_id
|
||
|
||
# Получаем доступные тарифы
|
||
promo_group_id = getattr(db_user, 'promo_group_id', None)
|
||
tariffs = await get_tariffs_for_user(db, promo_group_id)
|
||
|
||
# Фильтруем текущий тариф
|
||
available_tariffs = [t for t in tariffs if t.id != current_tariff_id]
|
||
|
||
if not available_tariffs:
|
||
await callback.message.edit_text(
|
||
"😔 <b>Нет доступных тарифов для переключения</b>\n\n"
|
||
"Вы уже используете единственный доступный тариф.",
|
||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||
[InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")]
|
||
]),
|
||
parse_mode="HTML"
|
||
)
|
||
await callback.answer()
|
||
return
|
||
|
||
# Получаем текущий тариф для отображения
|
||
current_tariff_name = "Неизвестно"
|
||
if current_tariff_id:
|
||
current_tariff = await get_tariff_by_id(db, current_tariff_id)
|
||
if current_tariff:
|
||
current_tariff_name = current_tariff.name
|
||
|
||
# Проверяем есть ли у пользователя скидки по периодам
|
||
promo_group = getattr(db_user, 'promo_group', None)
|
||
has_period_discounts = False
|
||
if promo_group:
|
||
period_discounts = getattr(promo_group, 'period_discounts', None)
|
||
if period_discounts and isinstance(period_discounts, dict) and len(period_discounts) > 0:
|
||
has_period_discounts = True
|
||
|
||
# Формируем текст со списком тарифов
|
||
switch_text = format_tariff_switch_list_text(
|
||
tariffs, current_tariff_id, current_tariff_name, db_user, has_period_discounts
|
||
)
|
||
|
||
await callback.message.edit_text(
|
||
switch_text,
|
||
reply_markup=get_tariff_switch_keyboard(tariffs, current_tariff_id, db_user.language),
|
||
parse_mode="HTML"
|
||
)
|
||
|
||
await state.update_data(
|
||
current_tariff_id=current_tariff_id,
|
||
)
|
||
await callback.answer()
|
||
|
||
|
||
@error_handler
|
||
async def select_tariff_switch(
|
||
callback: types.CallbackQuery,
|
||
db_user: User,
|
||
db: AsyncSession,
|
||
state: FSMContext,
|
||
):
|
||
"""Обрабатывает выбор тарифа для переключения."""
|
||
tariff_id = int(callback.data.split(":")[1])
|
||
tariff = await get_tariff_by_id(db, tariff_id)
|
||
|
||
if not tariff or not tariff.is_active:
|
||
await callback.answer("Тариф недоступен", show_alert=True)
|
||
return
|
||
|
||
traffic = _format_traffic(tariff.traffic_limit_gb)
|
||
|
||
# Проверяем, суточный ли это тариф
|
||
is_daily = getattr(tariff, 'is_daily', False)
|
||
|
||
if is_daily:
|
||
# Для суточного тарифа показываем подтверждение без выбора периода
|
||
daily_price = getattr(tariff, 'daily_price_kopeks', 0)
|
||
user_balance = db_user.balance_kopeks or 0
|
||
|
||
# Проверяем текущую подписку на оставшиеся дни
|
||
current_subscription = await get_subscription_by_user_id(db, db_user.id)
|
||
days_warning = ""
|
||
if current_subscription and current_subscription.end_date:
|
||
from datetime import datetime
|
||
remaining = current_subscription.end_date - datetime.utcnow()
|
||
remaining_days = max(0, remaining.days)
|
||
if remaining_days > 1:
|
||
days_warning = f"\n\n⚠️ <b>Внимание!</b> У вас осталось {remaining_days} дн. подписки.\nПри смене на суточный тариф они будут утеряны!"
|
||
|
||
if user_balance >= daily_price:
|
||
await callback.message.edit_text(
|
||
f"✅ <b>Подтверждение смены тарифа</b>\n\n"
|
||
f"📦 Новый тариф: <b>{tariff.name}</b>\n"
|
||
f"📊 Трафик: {traffic}\n"
|
||
f"📱 Устройств: {tariff.device_limit}\n"
|
||
f"🔄 Тип: <b>Суточный</b>\n\n"
|
||
f"💰 <b>Цена: {_format_price_kopeks(daily_price)}/день</b>\n\n"
|
||
f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}"
|
||
f"{days_warning}\n\n"
|
||
f"ℹ️ Средства будут списываться автоматически раз в сутки.\n"
|
||
f"Вы можете приостановить подписку в любой момент.",
|
||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||
[InlineKeyboardButton(
|
||
text="✅ Подтвердить смену",
|
||
callback_data=f"daily_tariff_switch_confirm:{tariff_id}"
|
||
)],
|
||
[InlineKeyboardButton(
|
||
text=get_texts(db_user.language).BACK,
|
||
callback_data="tariff_switch"
|
||
)]
|
||
]),
|
||
parse_mode="HTML"
|
||
)
|
||
else:
|
||
missing = daily_price - user_balance
|
||
await callback.message.edit_text(
|
||
f"❌ <b>Недостаточно средств</b>\n\n"
|
||
f"📦 Тариф: <b>{tariff.name}</b>\n"
|
||
f"🔄 Тип: Суточный\n"
|
||
f"💰 Цена: {_format_price_kopeks(daily_price)}/день\n\n"
|
||
f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n"
|
||
f"⚠️ Не хватает: <b>{_format_price_kopeks(missing)}</b>"
|
||
f"{days_warning}",
|
||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||
[InlineKeyboardButton(
|
||
text="💳 Пополнить баланс",
|
||
callback_data="balance_topup"
|
||
)],
|
||
[InlineKeyboardButton(
|
||
text=get_texts(db_user.language).BACK,
|
||
callback_data="tariff_switch"
|
||
)]
|
||
]),
|
||
parse_mode="HTML"
|
||
)
|
||
else:
|
||
# Для обычного тарифа показываем выбор периода
|
||
info_text = f"""📦 <b>{tariff.name}</b>
|
||
|
||
<b>Параметры нового тарифа:</b>
|
||
• Трафик: {traffic}
|
||
• Устройств: {tariff.device_limit}
|
||
"""
|
||
|
||
if tariff.description:
|
||
info_text += f"\n📝 {tariff.description}\n"
|
||
|
||
info_text += "\n⚠️ Оплачивается полная стоимость тарифа.\nВыберите период:"
|
||
|
||
await callback.message.edit_text(
|
||
info_text,
|
||
reply_markup=get_tariff_switch_periods_keyboard(tariff, db_user.language, db_user=db_user),
|
||
parse_mode="HTML"
|
||
)
|
||
|
||
await state.update_data(switch_tariff_id=tariff_id)
|
||
await callback.answer()
|
||
|
||
|
||
@error_handler
|
||
async def select_tariff_switch_period(
|
||
callback: types.CallbackQuery,
|
||
db_user: User,
|
||
db: AsyncSession,
|
||
state: FSMContext,
|
||
):
|
||
"""Обрабатывает выбор периода для переключения тарифа."""
|
||
from datetime import datetime
|
||
|
||
parts = callback.data.split(":")
|
||
tariff_id = int(parts[1])
|
||
period = int(parts[2])
|
||
|
||
tariff = await get_tariff_by_id(db, tariff_id)
|
||
if not tariff or not tariff.is_active:
|
||
await callback.answer("Тариф недоступен", show_alert=True)
|
||
return
|
||
|
||
data = await state.get_data()
|
||
current_tariff_id = data.get('current_tariff_id')
|
||
|
||
# Получаем скидку для выбранного периода
|
||
discount_percent = _get_user_period_discount(db_user, period)
|
||
|
||
# Получаем цену
|
||
prices = tariff.period_prices or {}
|
||
base_price = prices.get(str(period), 0)
|
||
final_price = _apply_promo_discount(base_price, discount_percent)
|
||
|
||
# Проверяем баланс
|
||
user_balance = db_user.balance_kopeks or 0
|
||
|
||
traffic = _format_traffic(tariff.traffic_limit_gb)
|
||
|
||
# Получаем текущий тариф для отображения
|
||
current_tariff_name = "Неизвестно"
|
||
if current_tariff_id:
|
||
current_tariff = await get_tariff_by_id(db, current_tariff_id)
|
||
if current_tariff:
|
||
current_tariff_name = current_tariff.name
|
||
|
||
# Получаем текущую подписку для расчёта оставшегося времени
|
||
subscription = await get_subscription_by_user_id(db, db_user.id)
|
||
remaining_days = 0
|
||
if subscription and subscription.end_date:
|
||
remaining_days = max(0, (subscription.end_date - datetime.utcnow()).days)
|
||
|
||
# При смене тарифа устанавливается ровно оплаченный период
|
||
time_info = f"⏰ Будет установлено: {period} дней"
|
||
|
||
if user_balance >= final_price:
|
||
discount_text = ""
|
||
if discount_percent > 0:
|
||
discount_text = f"\n🎁 Скидка: {discount_percent}% (-{_format_price_kopeks(base_price - final_price)})"
|
||
|
||
await callback.message.edit_text(
|
||
f"✅ <b>Подтверждение переключения тарифа</b>\n\n"
|
||
f"📌 Текущий тариф: <b>{current_tariff_name}</b>\n"
|
||
f"📦 Новый тариф: <b>{tariff.name}</b>\n"
|
||
f"📊 Трафик: {traffic}\n"
|
||
f"📱 Устройств: {tariff.device_limit}\n"
|
||
f"{time_info}\n"
|
||
f"{discount_text}\n"
|
||
f"💰 <b>К оплате: {_format_price_kopeks(final_price)}</b>\n\n"
|
||
f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n"
|
||
f"После оплаты: {_format_price_kopeks(user_balance - final_price)}",
|
||
reply_markup=get_tariff_switch_confirm_keyboard(tariff_id, period, db_user.language),
|
||
parse_mode="HTML"
|
||
)
|
||
else:
|
||
missing = final_price - user_balance
|
||
await callback.message.edit_text(
|
||
f"❌ <b>Недостаточно средств</b>\n\n"
|
||
f"📦 Тариф: <b>{tariff.name}</b>\n"
|
||
f"📅 Период: {_format_period(period)}\n"
|
||
f"💰 К оплате: {_format_price_kopeks(final_price)}\n\n"
|
||
f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n"
|
||
f"⚠️ Не хватает: <b>{_format_price_kopeks(missing)}</b>",
|
||
reply_markup=get_tariff_switch_insufficient_balance_keyboard(tariff_id, period, db_user.language),
|
||
parse_mode="HTML"
|
||
)
|
||
|
||
await state.update_data(
|
||
switch_tariff_id=tariff_id,
|
||
switch_period=period,
|
||
switch_final_price=final_price,
|
||
)
|
||
await callback.answer()
|
||
|
||
|
||
@error_handler
|
||
async def confirm_tariff_switch(
|
||
callback: types.CallbackQuery,
|
||
db_user: User,
|
||
db: AsyncSession,
|
||
state: FSMContext,
|
||
):
|
||
"""Подтверждает переключение тарифа."""
|
||
parts = callback.data.split(":")
|
||
tariff_id = int(parts[1])
|
||
period = int(parts[2])
|
||
|
||
tariff = await get_tariff_by_id(db, tariff_id)
|
||
if not tariff or not tariff.is_active:
|
||
await callback.answer("Тариф недоступен", show_alert=True)
|
||
return
|
||
|
||
# Получаем скидку для выбранного периода
|
||
discount_percent = _get_user_period_discount(db_user, period)
|
||
|
||
# Получаем цену
|
||
prices = tariff.period_prices or {}
|
||
base_price = prices.get(str(period), 0)
|
||
final_price = _apply_promo_discount(base_price, discount_percent)
|
||
|
||
# Проверяем баланс
|
||
user_balance = db_user.balance_kopeks or 0
|
||
if user_balance < final_price:
|
||
await callback.answer("Недостаточно средств на балансе", show_alert=True)
|
||
return
|
||
|
||
# Проверяем наличие подписки
|
||
subscription = await get_subscription_by_user_id(db, db_user.id)
|
||
if not subscription:
|
||
await callback.answer("У вас нет активной подписки", show_alert=True)
|
||
return
|
||
|
||
texts = get_texts(db_user.language)
|
||
|
||
try:
|
||
# Списываем баланс
|
||
success = await subtract_user_balance(
|
||
db, db_user, final_price,
|
||
f"Смена тарифа на {tariff.name} ({period} дней)"
|
||
)
|
||
if not success:
|
||
await callback.answer("Ошибка списания баланса", show_alert=True)
|
||
return
|
||
|
||
# Получаем список серверов из тарифа
|
||
squads = tariff.allowed_squads or []
|
||
|
||
# Если allowed_squads пустой - значит "все серверы", получаем их
|
||
if not squads:
|
||
from app.database.crud.server_squad import get_all_server_squads
|
||
all_servers, _ = await get_all_server_squads(db, available_only=True)
|
||
squads = [s.squad_uuid for s in all_servers if s.squad_uuid]
|
||
|
||
# При смене тарифа пользователь получает ровно тот период, за который заплатил
|
||
# Старые дни не сохраняются - это смена тарифа, а не продление
|
||
days_for_new_tariff = period
|
||
|
||
# Обновляем подписку с новыми параметрами тарифа
|
||
subscription = await extend_subscription(
|
||
db,
|
||
subscription,
|
||
days=days_for_new_tariff, # Даем ровно оплаченный период
|
||
tariff_id=tariff.id,
|
||
traffic_limit_gb=tariff.traffic_limit_gb,
|
||
device_limit=tariff.device_limit,
|
||
connected_squads=squads,
|
||
)
|
||
|
||
# Обновляем пользователя в Remnawave
|
||
try:
|
||
subscription_service = SubscriptionService()
|
||
await subscription_service.create_remnawave_user(
|
||
db,
|
||
subscription,
|
||
reset_traffic=True,
|
||
reset_reason="переключение тарифа",
|
||
)
|
||
except Exception as e:
|
||
logger.error(f"Ошибка обновления Remnawave при переключении тарифа: {e}")
|
||
|
||
# Создаем транзакцию
|
||
await create_transaction(
|
||
db,
|
||
user_id=db_user.id,
|
||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||
amount_kopeks=-final_price,
|
||
description=f"Смена тарифа на {tariff.name}",
|
||
)
|
||
|
||
# Отправляем уведомление админу
|
||
try:
|
||
admin_notification_service = AdminNotificationService(callback.bot)
|
||
await admin_notification_service.send_subscription_purchase_notification(
|
||
db,
|
||
db_user,
|
||
subscription,
|
||
None, # Транзакция отсутствует, оплата с баланса
|
||
days_for_new_tariff, # Итоговый срок подписки
|
||
was_trial_conversion=False,
|
||
amount_kopeks=final_price,
|
||
)
|
||
except Exception as e:
|
||
logger.error(f"Ошибка отправки уведомления админу: {e}")
|
||
|
||
# Очищаем корзину после успешной покупки
|
||
try:
|
||
await user_cart_service.delete_user_cart(db_user.id)
|
||
logger.info(f"Корзина очищена после смены тарифа для пользователя {db_user.telegram_id}")
|
||
except Exception as e:
|
||
logger.error(f"Ошибка очистки корзины: {e}")
|
||
|
||
await state.clear()
|
||
|
||
traffic = _format_traffic(tariff.traffic_limit_gb)
|
||
|
||
# При смене тарифа устанавливается оплаченный период
|
||
time_info = f"📅 Период: {days_for_new_tariff} дней"
|
||
|
||
await callback.message.edit_text(
|
||
f"🎉 <b>Тариф успешно изменён!</b>\n\n"
|
||
f"📦 Новый тариф: <b>{tariff.name}</b>\n"
|
||
f"📊 Трафик: {traffic}\n"
|
||
f"📱 Устройств: {tariff.device_limit}\n"
|
||
f"💰 Списано: {_format_price_kopeks(final_price)}\n"
|
||
f"{time_info}\n\n"
|
||
f"Перейдите в раздел «Подписка» для просмотра деталей.",
|
||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||
[InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")],
|
||
[InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")]
|
||
]),
|
||
parse_mode="HTML"
|
||
)
|
||
await callback.answer("Тариф изменён!", show_alert=True)
|
||
|
||
except Exception as e:
|
||
logger.error(f"Ошибка при переключении тарифа: {e}", exc_info=True)
|
||
await callback.answer("Произошла ошибка при переключении тарифа", show_alert=True)
|
||
|
||
|
||
# ==================== Смена на суточный тариф ====================
|
||
|
||
@error_handler
|
||
async def confirm_daily_tariff_switch(
|
||
callback: types.CallbackQuery,
|
||
db_user: User,
|
||
db: AsyncSession,
|
||
state: FSMContext,
|
||
):
|
||
"""Подтверждает смену на суточный тариф."""
|
||
from datetime import datetime
|
||
|
||
tariff_id = int(callback.data.split(":")[1])
|
||
tariff = await get_tariff_by_id(db, tariff_id)
|
||
|
||
if not tariff or not tariff.is_active:
|
||
await callback.answer("Тариф недоступен", show_alert=True)
|
||
return
|
||
|
||
is_daily = getattr(tariff, 'is_daily', False)
|
||
if not is_daily:
|
||
await callback.answer("Это не суточный тариф", show_alert=True)
|
||
return
|
||
|
||
daily_price = getattr(tariff, 'daily_price_kopeks', 0)
|
||
if daily_price <= 0:
|
||
await callback.answer("Некорректная цена тарифа", show_alert=True)
|
||
return
|
||
|
||
# Проверяем баланс
|
||
user_balance = db_user.balance_kopeks or 0
|
||
if user_balance < daily_price:
|
||
await callback.answer("Недостаточно средств на балансе", show_alert=True)
|
||
return
|
||
|
||
# Проверяем наличие подписки
|
||
subscription = await get_subscription_by_user_id(db, db_user.id)
|
||
if not subscription:
|
||
await callback.answer("У вас нет активной подписки", show_alert=True)
|
||
return
|
||
|
||
texts = get_texts(db_user.language)
|
||
|
||
try:
|
||
# Списываем первый день сразу
|
||
success = await subtract_user_balance(
|
||
db, db_user, daily_price,
|
||
f"Смена на суточный тариф {tariff.name} (первый день)"
|
||
)
|
||
if not success:
|
||
await callback.answer("Ошибка списания баланса", show_alert=True)
|
||
return
|
||
|
||
# Получаем список серверов из тарифа
|
||
squads = tariff.allowed_squads or []
|
||
|
||
# Если allowed_squads пустой - значит "все серверы", получаем их
|
||
if not squads:
|
||
from app.database.crud.server_squad import get_all_server_squads
|
||
all_servers, _ = await get_all_server_squads(db, available_only=True)
|
||
squads = [s.squad_uuid for s in all_servers if s.squad_uuid]
|
||
|
||
# Обновляем подписку на суточный тариф
|
||
subscription.tariff_id = tariff.id
|
||
subscription.traffic_limit_gb = tariff.traffic_limit_gb
|
||
subscription.device_limit = tariff.device_limit
|
||
subscription.connected_squads = squads
|
||
subscription.status = "active"
|
||
subscription.is_trial = False # Сбрасываем триальный статус
|
||
subscription.is_daily_paused = False
|
||
subscription.last_daily_charge_at = datetime.utcnow()
|
||
# Для суточного тарифа ставим срок на 1 день
|
||
subscription.end_date = datetime.utcnow() + timedelta(days=1)
|
||
|
||
# Сбрасываем докупленный трафик при смене тарифа
|
||
from app.database.models import TrafficPurchase
|
||
from sqlalchemy import delete as sql_delete
|
||
await db.execute(sql_delete(TrafficPurchase).where(TrafficPurchase.subscription_id == subscription.id))
|
||
subscription.purchased_traffic_gb = 0
|
||
subscription.traffic_reset_at = None
|
||
|
||
await db.commit()
|
||
await db.refresh(subscription)
|
||
|
||
# Обновляем пользователя в Remnawave
|
||
try:
|
||
subscription_service = SubscriptionService()
|
||
await subscription_service.create_remnawave_user(
|
||
db,
|
||
subscription,
|
||
reset_traffic=True,
|
||
reset_reason="смена на суточный тариф",
|
||
)
|
||
except Exception as e:
|
||
logger.error(f"Ошибка обновления Remnawave: {e}")
|
||
|
||
# Создаем транзакцию
|
||
await create_transaction(
|
||
db,
|
||
user_id=db_user.id,
|
||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||
amount_kopeks=-daily_price,
|
||
description=f"Смена на суточный тариф {tariff.name} (первый день)",
|
||
)
|
||
|
||
# Отправляем уведомление админу
|
||
try:
|
||
admin_notification_service = AdminNotificationService(callback.bot)
|
||
await admin_notification_service.send_subscription_purchase_notification(
|
||
db,
|
||
db_user,
|
||
subscription,
|
||
None,
|
||
1, # 1 день
|
||
was_trial_conversion=False,
|
||
amount_kopeks=daily_price,
|
||
)
|
||
except Exception as e:
|
||
logger.error(f"Ошибка отправки уведомления админу: {e}")
|
||
|
||
await state.clear()
|
||
|
||
traffic = _format_traffic(tariff.traffic_limit_gb)
|
||
|
||
await callback.message.edit_text(
|
||
f"🎉 <b>Тариф успешно изменён!</b>\n\n"
|
||
f"📦 Новый тариф: <b>{tariff.name}</b>\n"
|
||
f"📊 Трафик: {traffic}\n"
|
||
f"📱 Устройств: {tariff.device_limit}\n"
|
||
f"🔄 Тип: Суточный\n"
|
||
f"💰 Списано: {_format_price_kopeks(daily_price)}\n\n"
|
||
f"ℹ️ Следующее списание через 24 часа.",
|
||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||
[InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")],
|
||
[InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")]
|
||
]),
|
||
parse_mode="HTML"
|
||
)
|
||
await callback.answer("Тариф изменён!", show_alert=True)
|
||
|
||
except Exception as e:
|
||
logger.error(f"Ошибка при смене на суточный тариф: {e}", exc_info=True)
|
||
await callback.answer("Произошла ошибка при смене тарифа", show_alert=True)
|
||
|
||
|
||
# ==================== Мгновенное переключение тарифов (без выбора периода) ====================
|
||
|
||
def _get_tariff_monthly_price(tariff: Tariff) -> int:
|
||
"""Получает месячную цену тарифа (30 дней) с fallback на пропорциональный расчёт."""
|
||
price = tariff.get_price_for_period(30)
|
||
if price is not None:
|
||
return price
|
||
|
||
# Fallback: пропорционально пересчитываем из первого доступного периода
|
||
periods = tariff.get_available_periods()
|
||
if periods:
|
||
first_period = periods[0]
|
||
first_price = tariff.get_price_for_period(first_period)
|
||
if first_price:
|
||
return int(first_price * 30 / first_period)
|
||
|
||
return 0
|
||
|
||
|
||
def _calculate_instant_switch_cost(
|
||
current_tariff: Tariff,
|
||
new_tariff: Tariff,
|
||
remaining_days: int,
|
||
db_user: Optional[User] = None,
|
||
) -> tuple[int, bool]:
|
||
"""
|
||
Рассчитывает стоимость мгновенного переключения тарифа.
|
||
|
||
Если новый тариф дороже - доплата пропорционально оставшимся дням.
|
||
Если дешевле или равен - бесплатно.
|
||
|
||
Формула: (new_monthly - current_monthly) * remaining_days / 30
|
||
Скидка применяется к обоим тарифам одинаково.
|
||
|
||
Returns:
|
||
(upgrade_cost_kopeks, is_upgrade)
|
||
"""
|
||
current_monthly = _get_tariff_monthly_price(current_tariff)
|
||
new_monthly = _get_tariff_monthly_price(new_tariff)
|
||
|
||
discount_percent = 0
|
||
if db_user:
|
||
discount_percent = _get_user_period_discount(db_user, 30)
|
||
|
||
if discount_percent > 0:
|
||
current_monthly = _apply_promo_discount(current_monthly, discount_percent)
|
||
new_monthly = _apply_promo_discount(new_monthly, discount_percent)
|
||
|
||
price_diff = new_monthly - current_monthly
|
||
|
||
if price_diff <= 0:
|
||
return 0, False
|
||
|
||
upgrade_cost = int(price_diff * remaining_days / 30)
|
||
return upgrade_cost, True
|
||
|
||
|
||
def format_instant_switch_list_text(
|
||
tariffs: List[Tariff],
|
||
current_tariff: Tariff,
|
||
remaining_days: int,
|
||
db_user: Optional[User] = None,
|
||
) -> str:
|
||
"""Форматирует текст со списком тарифов для мгновенного переключения."""
|
||
lines = [
|
||
"📦 <b>Мгновенная смена тарифа</b>",
|
||
f"📌 Текущий: <b>{current_tariff.name}</b>",
|
||
f"⏰ Осталось: <b>{remaining_days} дн.</b>",
|
||
"",
|
||
"💡 При переключении остаток дней сохраняется.",
|
||
"⬆️ Повышение тарифа = доплата за разницу",
|
||
"⬇️ Понижение = бесплатно",
|
||
"",
|
||
]
|
||
|
||
for tariff in tariffs:
|
||
if tariff.id == current_tariff.id:
|
||
continue
|
||
|
||
traffic_gb = tariff.traffic_limit_gb
|
||
traffic = "∞" if traffic_gb == 0 else f"{traffic_gb}ГБ"
|
||
|
||
# Рассчитываем стоимость переключения
|
||
cost, is_upgrade = _calculate_instant_switch_cost(
|
||
current_tariff, tariff, remaining_days, db_user
|
||
)
|
||
|
||
if is_upgrade:
|
||
cost_text = f"⬆️ +{_format_price_kopeks(cost, compact=True)}"
|
||
else:
|
||
cost_text = "⬇️ Бесплатно"
|
||
|
||
lines.append(f"<b>{tariff.name}</b> — {traffic}/{tariff.device_limit}📱 {cost_text}")
|
||
|
||
if tariff.description:
|
||
lines.append(f"<i>{tariff.description}</i>")
|
||
|
||
lines.append("")
|
||
|
||
return "\n".join(lines)
|
||
|
||
|
||
def get_instant_switch_keyboard(
|
||
tariffs: List[Tariff],
|
||
current_tariff: Tariff,
|
||
remaining_days: int,
|
||
language: str,
|
||
db_user: Optional[User] = None,
|
||
) -> InlineKeyboardMarkup:
|
||
"""Создает клавиатуру для мгновенного переключения тарифа."""
|
||
texts = get_texts(language)
|
||
buttons = []
|
||
|
||
for tariff in tariffs:
|
||
if tariff.id == current_tariff.id:
|
||
continue
|
||
|
||
# Рассчитываем стоимость
|
||
cost, is_upgrade = _calculate_instant_switch_cost(
|
||
current_tariff, tariff, remaining_days, db_user
|
||
)
|
||
|
||
if is_upgrade:
|
||
btn_text = f"{tariff.name} (+{_format_price_kopeks(cost, compact=True)})"
|
||
else:
|
||
btn_text = f"{tariff.name} (бесплатно)"
|
||
|
||
buttons.append([
|
||
InlineKeyboardButton(
|
||
text=btn_text,
|
||
callback_data=f"instant_sw_preview:{tariff.id}"
|
||
)
|
||
])
|
||
|
||
buttons.append([
|
||
InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")
|
||
])
|
||
|
||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||
|
||
|
||
def get_instant_switch_confirm_keyboard(
|
||
tariff_id: int,
|
||
language: str,
|
||
) -> InlineKeyboardMarkup:
|
||
"""Создает клавиатуру подтверждения мгновенного переключения."""
|
||
texts = get_texts(language)
|
||
return InlineKeyboardMarkup(inline_keyboard=[
|
||
[
|
||
InlineKeyboardButton(
|
||
text="✅ Подтвердить переключение",
|
||
callback_data=f"instant_sw_confirm:{tariff_id}"
|
||
)
|
||
],
|
||
[
|
||
InlineKeyboardButton(
|
||
text=texts.BACK,
|
||
callback_data="instant_switch"
|
||
)
|
||
]
|
||
])
|
||
|
||
|
||
def get_instant_switch_insufficient_balance_keyboard(
|
||
tariff_id: int,
|
||
language: str,
|
||
) -> InlineKeyboardMarkup:
|
||
"""Создает клавиатуру при недостаточном балансе для мгновенного переключения."""
|
||
texts = get_texts(language)
|
||
return InlineKeyboardMarkup(inline_keyboard=[
|
||
[
|
||
InlineKeyboardButton(
|
||
text="💳 Пополнить баланс",
|
||
callback_data="balance_topup"
|
||
)
|
||
],
|
||
[
|
||
InlineKeyboardButton(
|
||
text=texts.BACK,
|
||
callback_data="instant_switch"
|
||
)
|
||
]
|
||
])
|
||
|
||
|
||
@error_handler
|
||
async def show_instant_switch_list(
|
||
callback: types.CallbackQuery,
|
||
db_user: User,
|
||
db: AsyncSession,
|
||
state: FSMContext,
|
||
):
|
||
"""Показывает список тарифов для мгновенного переключения."""
|
||
from datetime import datetime
|
||
|
||
texts = get_texts(db_user.language)
|
||
await state.clear()
|
||
|
||
# Проверяем наличие активной подписки
|
||
subscription = await get_subscription_by_user_id(db, db_user.id)
|
||
if not subscription:
|
||
await callback.answer("У вас нет активной подписки", show_alert=True)
|
||
return
|
||
|
||
if not subscription.tariff_id:
|
||
await callback.answer("У вашей подписки нет тарифа", show_alert=True)
|
||
return
|
||
|
||
# Получаем текущий тариф
|
||
current_tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||
if not current_tariff:
|
||
await callback.answer("Текущий тариф не найден", show_alert=True)
|
||
return
|
||
|
||
# Рассчитываем оставшиеся дни
|
||
remaining_days = 0
|
||
if subscription.end_date:
|
||
remaining_days = max(0, (subscription.end_date - datetime.utcnow()).days)
|
||
|
||
if remaining_days == 0:
|
||
await callback.message.edit_text(
|
||
"❌ <b>Переключение недоступно</b>\n\n"
|
||
"У вашей подписки не осталось активных дней.\n"
|
||
"Используйте продление или покупку нового тарифа.",
|
||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||
[InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")]
|
||
]),
|
||
parse_mode="HTML"
|
||
)
|
||
await callback.answer()
|
||
return
|
||
|
||
# Получаем доступные тарифы
|
||
promo_group_id = getattr(db_user, 'promo_group_id', None)
|
||
tariffs = await get_tariffs_for_user(db, promo_group_id)
|
||
|
||
# Фильтруем текущий тариф
|
||
available_tariffs = [t for t in tariffs if t.id != current_tariff.id]
|
||
|
||
if not available_tariffs:
|
||
await callback.message.edit_text(
|
||
"😔 <b>Нет доступных тарифов для переключения</b>\n\n"
|
||
"Вы уже используете единственный доступный тариф.",
|
||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||
[InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")]
|
||
]),
|
||
parse_mode="HTML"
|
||
)
|
||
await callback.answer()
|
||
return
|
||
|
||
# Формируем текст со списком тарифов
|
||
switch_text = format_instant_switch_list_text(
|
||
tariffs, current_tariff, remaining_days, db_user
|
||
)
|
||
|
||
await callback.message.edit_text(
|
||
switch_text,
|
||
reply_markup=get_instant_switch_keyboard(
|
||
tariffs, current_tariff, remaining_days, db_user.language, db_user
|
||
),
|
||
parse_mode="HTML"
|
||
)
|
||
|
||
await state.update_data(
|
||
current_tariff_id=current_tariff.id,
|
||
remaining_days=remaining_days,
|
||
)
|
||
await callback.answer()
|
||
|
||
|
||
@error_handler
|
||
async def preview_instant_switch(
|
||
callback: types.CallbackQuery,
|
||
db_user: User,
|
||
db: AsyncSession,
|
||
state: FSMContext,
|
||
):
|
||
"""Показывает превью мгновенного переключения тарифа."""
|
||
from datetime import datetime
|
||
|
||
tariff_id = int(callback.data.split(":")[1])
|
||
new_tariff = await get_tariff_by_id(db, tariff_id)
|
||
|
||
if not new_tariff or not new_tariff.is_active:
|
||
await callback.answer("Тариф недоступен", show_alert=True)
|
||
return
|
||
|
||
# Получаем данные из состояния
|
||
data = await state.get_data()
|
||
current_tariff_id = data.get('current_tariff_id')
|
||
remaining_days = data.get('remaining_days', 0)
|
||
|
||
# Если данных нет в state, получаем заново
|
||
subscription = await get_subscription_by_user_id(db, db_user.id)
|
||
if not subscription or not subscription.tariff_id:
|
||
await callback.answer("Подписка не найдена", show_alert=True)
|
||
return
|
||
|
||
current_tariff_id = current_tariff_id or subscription.tariff_id
|
||
current_tariff = await get_tariff_by_id(db, current_tariff_id)
|
||
if not current_tariff:
|
||
await callback.answer("Текущий тариф не найден", show_alert=True)
|
||
return
|
||
|
||
if not remaining_days and subscription.end_date:
|
||
remaining_days = max(0, (subscription.end_date - datetime.utcnow()).days)
|
||
|
||
# Рассчитываем стоимость переключения
|
||
upgrade_cost, is_upgrade = _calculate_instant_switch_cost(
|
||
current_tariff, new_tariff, remaining_days, db_user
|
||
)
|
||
|
||
# Проверяем баланс
|
||
user_balance = db_user.balance_kopeks or 0
|
||
|
||
traffic = _format_traffic(new_tariff.traffic_limit_gb)
|
||
current_traffic = _format_traffic(current_tariff.traffic_limit_gb)
|
||
|
||
texts = get_texts(db_user.language)
|
||
|
||
# Проверяем, суточный ли новый тариф
|
||
is_new_daily = getattr(new_tariff, 'is_daily', False)
|
||
daily_warning = ""
|
||
if is_new_daily and remaining_days > 1:
|
||
daily_warning = texts.t(
|
||
"DAILY_SWITCH_WARNING",
|
||
f"\n\n⚠️ <b>Внимание!</b> У вас осталось {remaining_days} дн. подписки.\nПри смене на суточный тариф они будут утеряны!"
|
||
).format(days=remaining_days)
|
||
|
||
# Для суточного тарифа особая логика показа
|
||
if is_new_daily:
|
||
daily_price = getattr(new_tariff, 'daily_price_kopeks', 0)
|
||
user_balance = db_user.balance_kopeks or 0
|
||
|
||
if user_balance >= daily_price:
|
||
await callback.message.edit_text(
|
||
f"🔄 <b>Переключение на суточный тариф</b>\n\n"
|
||
f"📌 Текущий: <b>{current_tariff.name}</b>\n"
|
||
f" • Трафик: {current_traffic}\n"
|
||
f" • Устройств: {current_tariff.device_limit}\n\n"
|
||
f"📦 Новый: <b>{new_tariff.name}</b>\n"
|
||
f" • Трафик: {traffic}\n"
|
||
f" • Устройств: {new_tariff.device_limit}\n"
|
||
f" • Тип: 🔄 Суточный\n\n"
|
||
f"💰 <b>Цена: {_format_price_kopeks(daily_price)}/день</b>\n\n"
|
||
f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}"
|
||
f"{daily_warning}\n\n"
|
||
f"ℹ️ Средства будут списываться автоматически раз в сутки.",
|
||
reply_markup=get_instant_switch_confirm_keyboard(tariff_id, db_user.language),
|
||
parse_mode="HTML"
|
||
)
|
||
else:
|
||
missing = daily_price - user_balance
|
||
await callback.message.edit_text(
|
||
f"❌ <b>Недостаточно средств</b>\n\n"
|
||
f"📦 Тариф: <b>{new_tariff.name}</b>\n"
|
||
f"🔄 Тип: Суточный\n"
|
||
f"💰 Цена: {_format_price_kopeks(daily_price)}/день\n\n"
|
||
f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n"
|
||
f"⚠️ Не хватает: <b>{_format_price_kopeks(missing)}</b>"
|
||
f"{daily_warning}",
|
||
reply_markup=get_instant_switch_insufficient_balance_keyboard(tariff_id, db_user.language),
|
||
parse_mode="HTML"
|
||
)
|
||
|
||
await state.update_data(
|
||
switch_tariff_id=tariff_id,
|
||
upgrade_cost=0,
|
||
is_upgrade=False,
|
||
current_tariff_id=current_tariff_id,
|
||
remaining_days=remaining_days,
|
||
)
|
||
await callback.answer()
|
||
return
|
||
|
||
if is_upgrade:
|
||
# Upgrade - нужна доплата
|
||
if user_balance >= upgrade_cost:
|
||
await callback.message.edit_text(
|
||
f"⬆️ <b>Повышение тарифа</b>\n\n"
|
||
f"📌 Текущий: <b>{current_tariff.name}</b>\n"
|
||
f" • Трафик: {current_traffic}\n"
|
||
f" • Устройств: {current_tariff.device_limit}\n\n"
|
||
f"📦 Новый: <b>{new_tariff.name}</b>\n"
|
||
f" • Трафик: {traffic}\n"
|
||
f" • Устройств: {new_tariff.device_limit}\n\n"
|
||
f"⏰ Осталось дней: <b>{remaining_days}</b>\n"
|
||
f"💰 <b>Доплата: {_format_price_kopeks(upgrade_cost)}</b>\n\n"
|
||
f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n"
|
||
f"После оплаты: {_format_price_kopeks(user_balance - upgrade_cost)}",
|
||
reply_markup=get_instant_switch_confirm_keyboard(tariff_id, db_user.language),
|
||
parse_mode="HTML"
|
||
)
|
||
else:
|
||
missing = upgrade_cost - user_balance
|
||
await callback.message.edit_text(
|
||
f"❌ <b>Недостаточно средств</b>\n\n"
|
||
f"📦 Новый тариф: <b>{new_tariff.name}</b>\n"
|
||
f"💰 Требуется доплата: {_format_price_kopeks(upgrade_cost)}\n\n"
|
||
f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n"
|
||
f"⚠️ Не хватает: <b>{_format_price_kopeks(missing)}</b>",
|
||
reply_markup=get_instant_switch_insufficient_balance_keyboard(tariff_id, db_user.language),
|
||
parse_mode="HTML"
|
||
)
|
||
else:
|
||
# Downgrade или тот же уровень - бесплатно
|
||
await callback.message.edit_text(
|
||
f"⬇️ <b>Переключение тарифа</b>\n\n"
|
||
f"📌 Текущий: <b>{current_tariff.name}</b>\n"
|
||
f" • Трафик: {current_traffic}\n"
|
||
f" • Устройств: {current_tariff.device_limit}\n\n"
|
||
f"📦 Новый: <b>{new_tariff.name}</b>\n"
|
||
f" • Трафик: {traffic}\n"
|
||
f" • Устройств: {new_tariff.device_limit}\n\n"
|
||
f"⏰ Осталось дней: <b>{remaining_days}</b>\n"
|
||
f"💰 <b>Бесплатно</b> (понижение/равный тариф)",
|
||
reply_markup=get_instant_switch_confirm_keyboard(tariff_id, db_user.language),
|
||
parse_mode="HTML"
|
||
)
|
||
|
||
await state.update_data(
|
||
switch_tariff_id=tariff_id,
|
||
upgrade_cost=upgrade_cost,
|
||
is_upgrade=is_upgrade,
|
||
current_tariff_id=current_tariff_id,
|
||
remaining_days=remaining_days,
|
||
)
|
||
await callback.answer()
|
||
|
||
|
||
@error_handler
|
||
async def confirm_instant_switch(
|
||
callback: types.CallbackQuery,
|
||
db_user: User,
|
||
db: AsyncSession,
|
||
state: FSMContext,
|
||
):
|
||
"""Подтверждает мгновенное переключение тарифа."""
|
||
from datetime import datetime, timedelta
|
||
|
||
tariff_id = int(callback.data.split(":")[1])
|
||
new_tariff = await get_tariff_by_id(db, tariff_id)
|
||
|
||
if not new_tariff or not new_tariff.is_active:
|
||
await callback.answer("Тариф недоступен", show_alert=True)
|
||
return
|
||
|
||
# Получаем данные из состояния
|
||
data = await state.get_data()
|
||
upgrade_cost = data.get('upgrade_cost', 0)
|
||
is_upgrade = data.get('is_upgrade', False)
|
||
remaining_days = data.get('remaining_days', 0)
|
||
|
||
# Проверяем подписку
|
||
subscription = await get_subscription_by_user_id(db, db_user.id)
|
||
if not subscription:
|
||
await callback.answer("Подписка не найдена", show_alert=True)
|
||
return
|
||
|
||
# Проверяем баланс если это upgrade
|
||
user_balance = db_user.balance_kopeks or 0
|
||
if is_upgrade and user_balance < upgrade_cost:
|
||
await callback.answer("Недостаточно средств на балансе", show_alert=True)
|
||
return
|
||
|
||
texts = get_texts(db_user.language)
|
||
|
||
try:
|
||
# Списываем баланс если это upgrade
|
||
if is_upgrade and upgrade_cost > 0:
|
||
success = await subtract_user_balance(
|
||
db, db_user, upgrade_cost,
|
||
f"Переключение на тариф {new_tariff.name}"
|
||
)
|
||
if not success:
|
||
await callback.answer("Ошибка списания баланса", show_alert=True)
|
||
return
|
||
|
||
# Получаем список серверов из нового тарифа
|
||
squads = new_tariff.allowed_squads or []
|
||
|
||
# Если allowed_squads пустой - значит "все серверы", получаем их
|
||
if not squads:
|
||
from app.database.crud.server_squad import get_all_server_squads
|
||
all_servers, _ = await get_all_server_squads(db, available_only=True)
|
||
squads = [s.squad_uuid for s in all_servers if s.squad_uuid]
|
||
|
||
# Проверяем, суточный ли новый тариф
|
||
is_new_daily = getattr(new_tariff, 'is_daily', False)
|
||
|
||
# Обновляем подписку с новыми параметрами тарифа
|
||
subscription.tariff_id = new_tariff.id
|
||
subscription.traffic_limit_gb = new_tariff.traffic_limit_gb
|
||
subscription.device_limit = new_tariff.device_limit
|
||
subscription.connected_squads = squads
|
||
|
||
# Сбрасываем докупленный трафик при смене тарифа
|
||
from app.database.models import TrafficPurchase
|
||
from sqlalchemy import delete as sql_delete
|
||
await db.execute(sql_delete(TrafficPurchase).where(TrafficPurchase.subscription_id == subscription.id))
|
||
subscription.purchased_traffic_gb = 0
|
||
subscription.traffic_reset_at = None
|
||
|
||
if is_new_daily:
|
||
# Для суточного тарифа - сбрасываем на 1 день и настраиваем суточные параметры
|
||
daily_price = getattr(new_tariff, 'daily_price_kopeks', 0)
|
||
|
||
# Списываем первый день если ещё не списано (upgrade_cost был 0)
|
||
if upgrade_cost == 0 and daily_price > 0:
|
||
if user_balance >= daily_price:
|
||
await subtract_user_balance(
|
||
db, db_user, daily_price,
|
||
f"Переключение на суточный тариф {new_tariff.name} (первый день)"
|
||
)
|
||
await create_transaction(
|
||
db,
|
||
user_id=db_user.id,
|
||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||
amount_kopeks=-daily_price,
|
||
description=f"Переключение на суточный тариф {new_tariff.name} (первый день)",
|
||
)
|
||
|
||
subscription.end_date = datetime.utcnow() + timedelta(days=1)
|
||
subscription.is_trial = False
|
||
subscription.is_daily_paused = False
|
||
subscription.last_daily_charge_at = datetime.utcnow()
|
||
|
||
await db.commit()
|
||
await db.refresh(subscription)
|
||
|
||
# Обновляем пользователя в Remnawave
|
||
try:
|
||
subscription_service = SubscriptionService()
|
||
await subscription_service.create_remnawave_user(
|
||
db,
|
||
subscription,
|
||
reset_traffic=False, # Не сбрасываем трафик при переключении
|
||
reset_reason="мгновенное переключение тарифа",
|
||
)
|
||
except Exception as e:
|
||
logger.error(f"Ошибка обновления Remnawave при мгновенном переключении: {e}")
|
||
|
||
# Создаем транзакцию если была оплата
|
||
if is_upgrade and upgrade_cost > 0:
|
||
await create_transaction(
|
||
db,
|
||
user_id=db_user.id,
|
||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||
amount_kopeks=-upgrade_cost,
|
||
description=f"Переключение на тариф {new_tariff.name}",
|
||
)
|
||
|
||
# Отправляем уведомление админу
|
||
try:
|
||
admin_notification_service = AdminNotificationService(callback.bot)
|
||
await admin_notification_service.send_subscription_purchase_notification(
|
||
db,
|
||
db_user,
|
||
subscription,
|
||
None,
|
||
remaining_days,
|
||
was_trial_conversion=False,
|
||
amount_kopeks=upgrade_cost,
|
||
)
|
||
except Exception as e:
|
||
logger.error(f"Ошибка отправки уведомления админу: {e}")
|
||
|
||
await state.clear()
|
||
|
||
traffic = _format_traffic(new_tariff.traffic_limit_gb)
|
||
|
||
# Для суточного тарифа другое сообщение об успехе
|
||
if is_new_daily:
|
||
daily_price = getattr(new_tariff, 'daily_price_kopeks', 0)
|
||
await callback.message.edit_text(
|
||
f"🎉 <b>Тариф успешно изменён!</b>\n\n"
|
||
f"📦 Новый тариф: <b>{new_tariff.name}</b>\n"
|
||
f"📊 Трафик: {traffic}\n"
|
||
f"📱 Устройств: {new_tariff.device_limit}\n"
|
||
f"🔄 Тип: Суточный\n"
|
||
f"💰 Списано: {_format_price_kopeks(daily_price)}\n\n"
|
||
f"ℹ️ Следующее списание через 24 часа.",
|
||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||
[InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")],
|
||
[InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")]
|
||
]),
|
||
parse_mode="HTML"
|
||
)
|
||
else:
|
||
if is_upgrade:
|
||
cost_text = f"💰 Списано: {_format_price_kopeks(upgrade_cost)}"
|
||
else:
|
||
cost_text = "💰 Бесплатно"
|
||
|
||
await callback.message.edit_text(
|
||
f"🎉 <b>Тариф успешно изменён!</b>\n\n"
|
||
f"📦 Новый тариф: <b>{new_tariff.name}</b>\n"
|
||
f"📊 Трафик: {traffic}\n"
|
||
f"📱 Устройств: {new_tariff.device_limit}\n"
|
||
f"⏰ Осталось дней: {remaining_days}\n"
|
||
f"{cost_text}",
|
||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||
[InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")],
|
||
[InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")]
|
||
]),
|
||
parse_mode="HTML"
|
||
)
|
||
await callback.answer("Тариф изменён!", show_alert=True)
|
||
|
||
except Exception as e:
|
||
logger.error(f"Ошибка при мгновенном переключении тарифа: {e}", exc_info=True)
|
||
await callback.answer("Произошла ошибка при переключении тарифа", show_alert=True)
|
||
|
||
|
||
def register_tariff_purchase_handlers(dp: Dispatcher):
|
||
"""Регистрирует обработчики покупки по тарифам."""
|
||
# Список тарифов (для режима tariffs)
|
||
dp.callback_query.register(show_tariffs_list, F.data == "tariff_list")
|
||
dp.callback_query.register(show_tariffs_list, F.data == "buy_subscription_tariffs")
|
||
|
||
# Выбор тарифа
|
||
dp.callback_query.register(select_tariff, F.data.startswith("tariff_select:"))
|
||
|
||
# Выбор периода
|
||
dp.callback_query.register(select_tariff_period, F.data.startswith("tariff_period:"))
|
||
|
||
# Подтверждение покупки
|
||
dp.callback_query.register(confirm_tariff_purchase, F.data.startswith("tariff_confirm:"))
|
||
|
||
# Подтверждение покупки суточного тарифа
|
||
dp.callback_query.register(confirm_daily_tariff_purchase, F.data.startswith("daily_tariff_confirm:"))
|
||
|
||
# Кастомные дни/трафик
|
||
dp.callback_query.register(handle_custom_days_change, F.data.startswith("custom_days:"))
|
||
dp.callback_query.register(handle_custom_traffic_change, F.data.startswith("custom_traffic:"))
|
||
dp.callback_query.register(handle_custom_confirm, F.data.startswith("custom_confirm:"))
|
||
dp.callback_query.register(select_tariff_period_with_traffic, F.data.startswith("tariff_period_traffic:"))
|
||
|
||
# Продление по тарифу
|
||
dp.callback_query.register(select_tariff_extend_period, F.data.startswith("tariff_extend:"))
|
||
dp.callback_query.register(confirm_tariff_extend, F.data.startswith("tariff_ext_confirm:"))
|
||
|
||
# Переключение тарифов (с выбором периода)
|
||
dp.callback_query.register(show_tariff_switch_list, F.data == "tariff_switch")
|
||
dp.callback_query.register(select_tariff_switch, F.data.startswith("tariff_sw_select:"))
|
||
dp.callback_query.register(select_tariff_switch_period, F.data.startswith("tariff_sw_period:"))
|
||
dp.callback_query.register(confirm_tariff_switch, F.data.startswith("tariff_sw_confirm:"))
|
||
|
||
# Смена на суточный тариф
|
||
dp.callback_query.register(confirm_daily_tariff_switch, F.data.startswith("daily_tariff_switch_confirm:"))
|
||
|
||
# Мгновенное переключение тарифов (без выбора периода)
|
||
dp.callback_query.register(show_instant_switch_list, F.data == "instant_switch")
|
||
dp.callback_query.register(preview_instant_switch, F.data.startswith("instant_sw_preview:"))
|
||
dp.callback_query.register(confirm_instant_switch, F.data.startswith("instant_sw_confirm:"))
|