9de34900a2
Bot uses default HTML parse mode — all messages are HTML-parsed by Telegram. Added html.escape() to all user-controlled and admin-controlled strings before interpolation into HTML messages to prevent injection and parse errors. 49 files, ~250+ injection points fixed: - user.full_name, first_name across all handlers and services - tariff.name/description in purchase flow, admin panel, auto-purchase service - campaign.name, start_parameter in admin and user-facing handlers - group.name, promo_group.name across promo management - contest.title, prize_text, leaderboard names (including public channels) - transaction.description (contains raw user.full_name from referral service) - restriction_reason across all balance and subscription handlers - ticket.title, message_text, poll.title, poll.description - welcome text template placeholders (first_name, username) - maintenance reason, admin_name, selected_prize.display_name New helpers in app/utils/formatting.py: - safe_html_name() for escaping display names - user_html_link() replacing 15+ duplicated inline link patterns
3662 lines
157 KiB
Python
3662 lines
157 KiB
Python
"""Покупка подписки по тарифам."""
|
||
|
||
import html
|
||
from datetime import UTC, datetime, timedelta
|
||
|
||
import structlog
|
||
from aiogram import Dispatcher, F, types
|
||
from aiogram.fsm.context import FSMContext
|
||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.config import settings
|
||
from app.database.crud.subscription import create_paid_subscription, extend_subscription, get_subscription_by_user_id
|
||
from app.database.crud.tariff import get_tariff_by_id, get_tariffs_for_user
|
||
from app.database.crud.transaction import create_transaction
|
||
from app.database.crud.user import subtract_user_balance
|
||
from app.database.models import Tariff, TransactionType, User
|
||
from app.localization.texts import get_texts
|
||
from app.services.admin_notification_service import AdminNotificationService
|
||
from app.services.subscription_service import SubscriptionService
|
||
from app.services.user_cart_service import user_cart_service
|
||
from app.utils.decorators import error_handler
|
||
from app.utils.formatting import format_period, format_price_kopeks, format_traffic
|
||
from app.utils.promo_offer import get_user_active_promo_discount_percent
|
||
|
||
|
||
logger = structlog.get_logger(__name__)
|
||
|
||
|
||
def _apply_promo_discount(price: int, group_pct: int, offer_pct: int = 0) -> int:
|
||
"""Применяет стекинг скидок к цене (sequential floor division, как PricingEngine)."""
|
||
from app.services.pricing_engine import PricingEngine
|
||
|
||
final, _, _ = PricingEngine.apply_stacked_discounts(price, group_pct, offer_pct)
|
||
return final
|
||
|
||
|
||
def _get_user_period_discount(db_user: User, period_days: int) -> tuple[int, int, int]:
|
||
"""Получает скидку пользователя на период из промогруппы + промо-оффер.
|
||
|
||
Returns:
|
||
(group_pct, offer_pct, display_combined_pct) — отдельные проценты для
|
||
корректного расчёта цены и комбинированный процент для отображения в UI.
|
||
"""
|
||
promo_group = db_user.get_primary_promo_group()
|
||
group_discount = promo_group.get_discount_percent('period', period_days) if promo_group else 0
|
||
personal_discount = get_user_active_promo_discount_percent(db_user)
|
||
|
||
if group_discount <= 0 and personal_discount <= 0:
|
||
return 0, 0, 0
|
||
|
||
# Комбинированный процент для отображения
|
||
remaining = (100 - group_discount) * (100 - personal_discount)
|
||
display_combined = 100 - remaining // 100
|
||
|
||
return group_discount, personal_discount, display_combined
|
||
|
||
|
||
def format_tariffs_list_text(
|
||
tariffs: list[Tariff],
|
||
db_user: User | None = 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)
|
||
if db_user:
|
||
group_pct, offer_pct, daily_discount = _get_user_period_discount(db_user, 1)
|
||
if daily_discount > 0:
|
||
daily_price = _apply_promo_discount(daily_price, group_pct, offer_pct)
|
||
discount_icon = '🔥'
|
||
price_text = f'🔄 {format_price_kopeks(daily_price, compact=True)}/день{discount_icon}'
|
||
else:
|
||
# Для периодных тарифов показываем минимальную цену
|
||
prices = tariff.period_prices or {}
|
||
if prices:
|
||
min_period = min(prices.keys(), key=int)
|
||
min_price = prices[min_period]
|
||
group_pct, offer_pct, discount_percent = 0, 0, 0
|
||
if db_user:
|
||
group_pct, offer_pct, discount_percent = _get_user_period_discount(db_user, int(min_period))
|
||
if discount_percent > 0:
|
||
min_price = _apply_promo_discount(min_price, group_pct, offer_pct)
|
||
discount_icon = '🔥'
|
||
price_text = f'от {format_price_kopeks(min_price, compact=True)}{discount_icon}'
|
||
|
||
# Компактный формат: Название — 250 ГБ / 10 📱 от 179₽🔥
|
||
lines.append(f'<b>{html.escape(tariff.name)}</b> — {traffic} / {tariff.device_limit} 📱 {price_text}')
|
||
|
||
# Описание тарифа если есть
|
||
if tariff.description:
|
||
lines.append(f'<i>{html.escape(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: User | None = 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]
|
||
|
||
# Получаем скидку для конкретного периода
|
||
group_pct, offer_pct, discount_percent = 0, 0, 0
|
||
if db_user:
|
||
group_pct, offer_pct, discount_percent = _get_user_period_discount(db_user, period)
|
||
|
||
if discount_percent > 0:
|
||
price = _apply_promo_discount(price, group_pct, offer_pct)
|
||
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: User | None = 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]
|
||
|
||
# Получаем скидку для конкретного периода
|
||
group_pct, offer_pct, discount_percent = 0, 0, 0
|
||
if db_user:
|
||
group_pct, offer_pct, discount_percent = _get_user_period_discount(db_user, period)
|
||
|
||
if discount_percent > 0:
|
||
price = _apply_promo_discount(price, group_pct, offer_pct)
|
||
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:
|
||
"""Форматирует информацию о тарифе для пользователя."""
|
||
get_texts(language)
|
||
|
||
traffic = format_traffic(tariff.traffic_limit_gb)
|
||
|
||
text = f"""📦 <b>{html.escape(tariff.name)}</b>
|
||
|
||
<b>Параметры:</b>
|
||
• Трафик: {traffic}
|
||
• Устройств: {tariff.device_limit}
|
||
"""
|
||
|
||
if tariff.description:
|
||
text += f'\n📝 {html.escape(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
|
||
|
||
|
||
async def format_custom_tariff_preview(
|
||
tariff: Tariff,
|
||
days: int,
|
||
traffic_gb: int,
|
||
user_balance: int,
|
||
db_user: User | None = None,
|
||
discount_percent: int = 0,
|
||
group_pct: int = 0,
|
||
offer_pct: int = 0,
|
||
) -> str:
|
||
"""Форматирует предпросмотр покупки с кастомными параметрами.
|
||
|
||
Uses PricingEngine when db_user is provided for accurate per-category discounts
|
||
(period, traffic addon). Falls back to manual calculation otherwise.
|
||
"""
|
||
if db_user is not None:
|
||
# Use PricingEngine — single source of truth for all discounts
|
||
from app.services.pricing_engine import pricing_engine
|
||
|
||
result = await pricing_engine.calculate_tariff_purchase_price(
|
||
tariff,
|
||
days,
|
||
device_limit=tariff.device_limit,
|
||
custom_traffic_gb=traffic_gb if tariff.can_purchase_custom_traffic() else None,
|
||
user=db_user,
|
||
)
|
||
period_price = result.base_price
|
||
traffic_price = result.traffic_price
|
||
total_price = result.final_total
|
||
has_discount = result.promo_group_discount > 0 or result.promo_offer_discount > 0
|
||
else:
|
||
# Fallback: raw prices without discounts
|
||
period_price, traffic_price, total_price = _calculate_custom_tariff_price(tariff, days, traffic_gb)
|
||
has_discount = discount_percent > 0
|
||
if has_discount:
|
||
total_price = _apply_promo_discount(total_price, group_pct, offer_pct)
|
||
|
||
traffic_display = f'{traffic_gb} ГБ' if traffic_gb > 0 else format_traffic(tariff.traffic_limit_gb)
|
||
|
||
text = f"""📦 <b>{html.escape(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 has_discount:
|
||
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 = db_user.get_primary_promo_group() if hasattr(db_user, 'get_primary_promo_group') else None
|
||
if promo_group is None:
|
||
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:
|
||
# Для суточного тарифа показываем подтверждение без выбора периода
|
||
raw_daily_price = getattr(tariff, 'daily_price_kopeks', 0)
|
||
group_pct, offer_pct, daily_discount = _get_user_period_discount(db_user, 1)
|
||
daily_price = (
|
||
_apply_promo_discount(raw_daily_price, group_pct, offer_pct) if daily_discount > 0 else raw_daily_price
|
||
)
|
||
discount_text = f'\n💎 Скидка: {daily_discount}%' if daily_discount > 0 else ''
|
||
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>{html.escape(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>'
|
||
f'{discount_text}\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
|
||
|
||
# Сохраняем данные корзины для автопокупки суточного тарифа
|
||
cart_data = {
|
||
'cart_mode': 'daily_tariff_purchase',
|
||
'tariff_id': tariff_id,
|
||
'is_daily': True,
|
||
'daily_price_kopeks': daily_price,
|
||
'total_price': daily_price,
|
||
'user_id': db_user.id,
|
||
'saved_cart': True,
|
||
'missing_amount': missing,
|
||
'return_to_cart': True,
|
||
'description': f'Покупка суточного тарифа {tariff.name}',
|
||
'traffic_limit_gb': tariff.traffic_limit_gb,
|
||
'device_limit': tariff.device_limit,
|
||
'allowed_squads': tariff.allowed_squads or [],
|
||
}
|
||
await user_cart_service.save_user_cart(db_user.id, cart_data)
|
||
|
||
await callback.message.edit_text(
|
||
f'❌ <b>Недостаточно средств</b>\n\n'
|
||
f'📦 Тариф: <b>{html.escape(tariff.name)}</b>\n'
|
||
f'🔄 Тип: Суточный\n'
|
||
f'💰 Цена: {format_price_kopeks(daily_price)}/день'
|
||
f'{discount_text}\n\n'
|
||
f'💳 Ваш баланс: {format_price_kopeks(user_balance)}\n'
|
||
f'⚠️ Не хватает: <b>{format_price_kopeks(missing)}</b>\n\n'
|
||
f'🛒 <i>Корзина сохранена! После пополнения баланса подписка будет оформлена автоматически.</i>',
|
||
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
|
||
|
||
# Вычисляем скидку для начального периода
|
||
group_pct, offer_pct, 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,
|
||
period_group_pct=group_pct,
|
||
period_offer_pct=offer_pct,
|
||
)
|
||
|
||
preview_text = await format_custom_tariff_preview(
|
||
tariff=tariff,
|
||
days=initial_days,
|
||
traffic_gb=initial_traffic,
|
||
user_balance=user_balance,
|
||
db_user=db_user,
|
||
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))
|
||
|
||
# При изменении дней пересчитываем скидку для нового периода
|
||
group_pct, offer_pct, discount_percent = _get_user_period_discount(db_user, new_days)
|
||
|
||
await state.update_data(
|
||
custom_days=new_days,
|
||
period_discount_percent=discount_percent,
|
||
period_group_pct=group_pct,
|
||
period_offer_pct=offer_pct,
|
||
)
|
||
|
||
user_balance = db_user.balance_kopeks or 0
|
||
|
||
preview_text = await format_custom_tariff_preview(
|
||
tariff=tariff,
|
||
days=new_days,
|
||
traffic_gb=current_traffic,
|
||
user_balance=user_balance,
|
||
db_user=db_user,
|
||
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 = await format_custom_tariff_preview(
|
||
tariff=tariff,
|
||
days=current_days,
|
||
traffic_gb=new_traffic,
|
||
user_balance=user_balance,
|
||
db_user=db_user,
|
||
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
|
||
|
||
# Lock user BEFORE price computation to prevent TOCTOU on promo offer
|
||
from app.database.crud.user import lock_user_for_pricing
|
||
|
||
db_user = await lock_user_for_pricing(db, db_user.id)
|
||
|
||
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)
|
||
|
||
# Calculate price via PricingEngine (single source of truth for all discounts)
|
||
from app.services.pricing_engine import pricing_engine
|
||
|
||
result = await pricing_engine.calculate_tariff_purchase_price(
|
||
tariff,
|
||
custom_days,
|
||
device_limit=tariff.device_limit,
|
||
custom_traffic_gb=custom_traffic if tariff.can_purchase_custom_traffic() else None,
|
||
user=db_user,
|
||
)
|
||
total_price = result.final_total
|
||
|
||
# Проверяем, что цена за период валидна
|
||
if result.base_price == 0 and not tariff.can_purchase_custom_days():
|
||
await callback.answer('Выбранный период недоступен для этого тарифа', show_alert=True)
|
||
return
|
||
|
||
# Проверяем баланс (user already locked, balance is fresh)
|
||
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)
|
||
|
||
# Save promo offer state before deduction (for restore on failure)
|
||
consume_promo = result.promo_offer_discount > 0
|
||
saved_promo_percent = int(getattr(db_user, 'promo_offer_discount_percent', 0) or 0) if consume_promo else 0
|
||
saved_promo_source = getattr(db_user, 'promo_offer_discount_source', None) if consume_promo else None
|
||
saved_promo_expires = getattr(db_user, 'promo_offer_discount_expires_at', None) if consume_promo else None
|
||
|
||
try:
|
||
# Списываем баланс
|
||
success = await subtract_user_balance(
|
||
db,
|
||
db_user,
|
||
total_price,
|
||
f'Покупка тарифа {tariff.name} на {custom_days} дней',
|
||
consume_promo_offer=consume_promo,
|
||
mark_as_paid_subscription=True,
|
||
)
|
||
if not success:
|
||
await callback.answer('Ошибка списания баланса', show_alert=True)
|
||
return
|
||
except Exception as e:
|
||
logger.error('Ошибка списания баланса при покупке кастомного тарифа', error=e, exc_info=True)
|
||
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)
|
||
|
||
try:
|
||
if existing_subscription:
|
||
# Продлеваем существующую подписку и обновляем параметры тарифа
|
||
# Сохраняем докупленные устройства при продлении того же тарифа
|
||
if existing_subscription.tariff_id == tariff.id:
|
||
effective_device_limit = max(tariff.device_limit or 0, existing_subscription.device_limit or 0)
|
||
else:
|
||
effective_device_limit = tariff.device_limit
|
||
subscription = await extend_subscription(
|
||
db,
|
||
existing_subscription,
|
||
days=custom_days,
|
||
tariff_id=tariff.id,
|
||
traffic_limit_gb=traffic_limit,
|
||
device_limit=effective_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,
|
||
)
|
||
except Exception as e:
|
||
logger.error('Ошибка создания/продления подписки при покупке кастомного тарифа', error=e, exc_info=True)
|
||
await db.rollback()
|
||
# Compensating refund: balance was already committed by subtract_user_balance
|
||
try:
|
||
from app.database.crud.user import add_user_balance
|
||
|
||
await add_user_balance(
|
||
db,
|
||
db_user,
|
||
total_price,
|
||
'Возврат: ошибка покупки кастомного тарифа',
|
||
create_transaction=True,
|
||
transaction_type=TransactionType.REFUND,
|
||
)
|
||
# Restore promo offer if consumed
|
||
if consume_promo and saved_promo_percent > 0:
|
||
db_user.promo_offer_discount_percent = saved_promo_percent
|
||
db_user.promo_offer_discount_source = saved_promo_source
|
||
db_user.promo_offer_discount_expires_at = saved_promo_expires
|
||
await db.commit()
|
||
except Exception as refund_error:
|
||
logger.critical(
|
||
'CRITICAL: не удалось вернуть средства после ошибки покупки кастомного тарифа',
|
||
user_id=db_user.id,
|
||
price_kopeks=total_price,
|
||
refund_error=refund_error,
|
||
)
|
||
await callback.answer('Произошла ошибка при оформлении подписки', show_alert=True)
|
||
return
|
||
|
||
try:
|
||
# Обновляем пользователя в Remnawave
|
||
# При покупке тарифа ВСЕГДА сбрасываем трафик в панели
|
||
try:
|
||
subscription_service = SubscriptionService()
|
||
await subscription_service.create_remnawave_user(
|
||
db,
|
||
subscription,
|
||
reset_traffic=True,
|
||
reset_reason='покупка тарифа',
|
||
)
|
||
except Exception as e:
|
||
logger.error('Ошибка обновления Remnawave', error=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,
|
||
purchase_type='renewal' if existing_subscription else 'first_purchase',
|
||
)
|
||
except Exception as e:
|
||
logger.error('Ошибка отправки уведомления админу', error=e)
|
||
|
||
# Очищаем корзину после успешной покупки
|
||
try:
|
||
await user_cart_service.delete_user_cart(db_user.id)
|
||
except Exception as e:
|
||
logger.error('Ошибка очистки корзины', error=e)
|
||
|
||
await state.clear()
|
||
|
||
traffic_display = format_traffic(traffic_limit)
|
||
|
||
await callback.message.edit_text(
|
||
f'🎉 <b>Подписка успешно оформлена!</b>\n\n'
|
||
f'📦 Тариф: <b>{html.escape(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('Ошибка при покупке тарифа с кастомными параметрами', error=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
|
||
|
||
# Получаем скидку для выбранного периода
|
||
group_pct, offer_pct, 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,
|
||
period_group_pct=group_pct,
|
||
period_offer_pct=offer_pct,
|
||
)
|
||
|
||
preview_text = await format_custom_tariff_preview(
|
||
tariff=tariff,
|
||
days=period,
|
||
traffic_gb=initial_traffic,
|
||
user_balance=user_balance,
|
||
db_user=db_user,
|
||
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
|
||
|
||
# Получаем скидку для выбранного периода
|
||
group_pct, offer_pct, 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, group_pct, offer_pct)
|
||
|
||
# Проверяем баланс
|
||
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>{html.escape(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
|
||
|
||
# Сохраняем данные корзины для автопокупки после пополнения
|
||
cart_data = {
|
||
'cart_mode': 'tariff_purchase',
|
||
'tariff_id': tariff_id,
|
||
'period_days': period,
|
||
'total_price': final_price,
|
||
'user_id': db_user.id,
|
||
'saved_cart': True,
|
||
'missing_amount': missing,
|
||
'return_to_cart': True,
|
||
'description': f'Покупка тарифа {tariff.name} на {period} дней',
|
||
'traffic_limit_gb': tariff.traffic_limit_gb,
|
||
'device_limit': tariff.device_limit,
|
||
'allowed_squads': tariff.allowed_squads or [],
|
||
'discount_percent': discount_percent,
|
||
}
|
||
await user_cart_service.save_user_cart(db_user.id, cart_data)
|
||
|
||
await callback.message.edit_text(
|
||
f'❌ <b>Недостаточно средств</b>\n\n'
|
||
f'📦 Тариф: <b>{html.escape(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>\n\n'
|
||
f'🛒 <i>Корзина сохранена! После пополнения баланса подписка будет оформлена автоматически.</i>',
|
||
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
|
||
|
||
# Lock user BEFORE price computation to prevent TOCTOU on promo offer
|
||
from app.database.crud.user import lock_user_for_pricing
|
||
|
||
db_user = await lock_user_for_pricing(db, db_user.id)
|
||
|
||
# Calculate price via PricingEngine (single source of truth)
|
||
from app.services.pricing_engine import pricing_engine
|
||
|
||
existing_sub = await get_subscription_by_user_id(db, db_user.id)
|
||
device_limit = None
|
||
if existing_sub and existing_sub.tariff_id == tariff.id:
|
||
device_limit = existing_sub.device_limit
|
||
|
||
result = await pricing_engine.calculate_tariff_purchase_price(
|
||
tariff,
|
||
period,
|
||
device_limit=device_limit,
|
||
user=db_user,
|
||
)
|
||
final_price = result.final_total
|
||
|
||
# Проверяем баланс (user already locked, balance is fresh)
|
||
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)
|
||
|
||
# Списываем баланс
|
||
consume_promo = result.promo_offer_discount > 0
|
||
# Save promo offer state before deduction (for restore on failure)
|
||
saved_promo_percent = int(getattr(db_user, 'promo_offer_discount_percent', 0) or 0) if consume_promo else 0
|
||
saved_promo_source = getattr(db_user, 'promo_offer_discount_source', None) if consume_promo else None
|
||
saved_promo_expires = getattr(db_user, 'promo_offer_discount_expires_at', None) if consume_promo else None
|
||
try:
|
||
success = await subtract_user_balance(
|
||
db,
|
||
db_user,
|
||
final_price,
|
||
f'Покупка тарифа {tariff.name} на {period} дней',
|
||
consume_promo_offer=consume_promo,
|
||
mark_as_paid_subscription=True,
|
||
)
|
||
if not success:
|
||
await callback.answer('Ошибка списания баланса', show_alert=True)
|
||
return
|
||
except Exception as e:
|
||
logger.error('Ошибка списания баланса при покупке тарифа', error=e, exc_info=True)
|
||
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]
|
||
|
||
# Reuse existing_sub fetched above for device pricing
|
||
existing_subscription = existing_sub
|
||
|
||
try:
|
||
if existing_subscription:
|
||
# Продлеваем существующую подписку и обновляем параметры тарифа
|
||
# Сохраняем докупленные устройства при продлении того же тарифа
|
||
if existing_subscription.tariff_id == tariff.id:
|
||
effective_device_limit = max(tariff.device_limit or 0, existing_subscription.device_limit or 0)
|
||
else:
|
||
effective_device_limit = tariff.device_limit
|
||
subscription = await extend_subscription(
|
||
db,
|
||
existing_subscription,
|
||
days=period,
|
||
tariff_id=tariff.id,
|
||
traffic_limit_gb=tariff.traffic_limit_gb,
|
||
device_limit=effective_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,
|
||
)
|
||
except Exception as e:
|
||
logger.error('Ошибка создания/продления подписки при покупке тарифа', error=e, exc_info=True)
|
||
await db.rollback()
|
||
# Compensating refund: balance was already committed by subtract_user_balance
|
||
try:
|
||
from app.database.crud.user import add_user_balance
|
||
|
||
await add_user_balance(
|
||
db,
|
||
db_user,
|
||
final_price,
|
||
'Возврат: ошибка покупки тарифа',
|
||
create_transaction=True,
|
||
transaction_type=TransactionType.REFUND,
|
||
)
|
||
# Restore promo offer if consumed
|
||
if consume_promo and saved_promo_percent > 0:
|
||
db_user.promo_offer_discount_percent = saved_promo_percent
|
||
db_user.promo_offer_discount_source = saved_promo_source
|
||
db_user.promo_offer_discount_expires_at = saved_promo_expires
|
||
await db.commit()
|
||
except Exception as refund_error:
|
||
logger.critical(
|
||
'CRITICAL: не удалось вернуть средства после ошибки покупки тарифа',
|
||
user_id=db_user.id,
|
||
price_kopeks=final_price,
|
||
refund_error=refund_error,
|
||
)
|
||
await callback.answer('Произошла ошибка при оформлении подписки', show_alert=True)
|
||
return
|
||
|
||
# Обновляем пользователя в Remnawave
|
||
# При покупке тарифа ВСЕГДА сбрасываем трафик в панели
|
||
try:
|
||
subscription_service = SubscriptionService()
|
||
await subscription_service.create_remnawave_user(
|
||
db,
|
||
subscription,
|
||
reset_traffic=True,
|
||
reset_reason='покупка тарифа',
|
||
)
|
||
except Exception as e:
|
||
logger.error('Ошибка обновления Remnawave', error=e)
|
||
|
||
# Создаем транзакцию
|
||
try:
|
||
await create_transaction(
|
||
db,
|
||
user_id=db_user.id,
|
||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||
amount_kopeks=final_price,
|
||
description=f'Покупка тарифа {tariff.name} на {period} дней',
|
||
)
|
||
except Exception as e:
|
||
logger.error('Ошибка создания транзакции', error=e)
|
||
|
||
# Отправляем уведомление админу
|
||
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,
|
||
purchase_type='renewal' if existing_subscription else 'first_purchase',
|
||
)
|
||
except Exception as e:
|
||
logger.error('Ошибка отправки уведомления админу', error=e)
|
||
|
||
# Очищаем корзину после успешной покупки
|
||
try:
|
||
await user_cart_service.delete_user_cart(db_user.id)
|
||
logger.info('Корзина очищена после покупки тарифа для пользователя', telegram_id=db_user.telegram_id)
|
||
except Exception as e:
|
||
logger.error('Ошибка очистки корзины', error=e)
|
||
|
||
await state.clear()
|
||
|
||
traffic = format_traffic(tariff.traffic_limit_gb)
|
||
|
||
await callback.message.edit_text(
|
||
f'🎉 <b>Подписка успешно оформлена!</b>\n\n'
|
||
f'📦 Тариф: <b>{html.escape(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)
|
||
|
||
|
||
# ==================== Покупка суточного тарифа ====================
|
||
|
||
|
||
@error_handler
|
||
async def confirm_daily_tariff_purchase(
|
||
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 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
|
||
|
||
# Lock user BEFORE price computation to prevent TOCTOU on promo offer
|
||
from app.database.crud.user import lock_user_for_pricing
|
||
|
||
db_user = await lock_user_for_pricing(db, db_user.id)
|
||
|
||
# Apply group + promo-offer discounts via PricingEngine (single source of truth)
|
||
from app.services.pricing_engine import pricing_engine
|
||
|
||
pricing_result = await pricing_engine.calculate_tariff_purchase_price(
|
||
tariff,
|
||
period_days=1,
|
||
device_limit=tariff.device_limit,
|
||
user=db_user,
|
||
)
|
||
final_daily_price = pricing_result.final_total
|
||
consume_promo = pricing_result.breakdown.get('offer_discount_pct', 0) > 0
|
||
|
||
# Проверяем баланс (user already locked, balance is fresh)
|
||
user_balance = db_user.balance_kopeks or 0
|
||
if user_balance < final_daily_price:
|
||
await callback.answer('Недостаточно средств на балансе', show_alert=True)
|
||
return
|
||
|
||
texts = get_texts(db_user.language)
|
||
|
||
try:
|
||
# Списываем первый день сразу
|
||
success = await subtract_user_balance(
|
||
db,
|
||
db_user,
|
||
final_daily_price,
|
||
f'Покупка суточного тарифа {tariff.name} (первый день)',
|
||
consume_promo_offer=consume_promo,
|
||
mark_as_paid_subscription=True,
|
||
)
|
||
if not success:
|
||
await callback.answer('Ошибка списания баланса', show_alert=True)
|
||
return
|
||
except Exception as e:
|
||
logger.error('Ошибка списания баланса при покупке суточного тарифа', error=e, exc_info=True)
|
||
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)
|
||
|
||
try:
|
||
if existing_subscription:
|
||
# Обновляем существующую подписку на суточный тариф
|
||
# Сбрасываем лимит устройств на базу нового тарифа (докупленные не переносятся)
|
||
from app.database.crud.subscription import calc_device_limit_on_tariff_switch
|
||
|
||
old_tariff = (
|
||
await get_tariff_by_id(db, existing_subscription.tariff_id) if existing_subscription.tariff_id else None
|
||
)
|
||
existing_subscription.tariff_id = tariff.id
|
||
existing_subscription.traffic_limit_gb = tariff.traffic_limit_gb
|
||
existing_subscription.device_limit = calc_device_limit_on_tariff_switch(
|
||
current_device_limit=existing_subscription.device_limit,
|
||
old_tariff_device_limit=old_tariff.device_limit if old_tariff else None,
|
||
new_tariff_device_limit=tariff.device_limit,
|
||
max_device_limit=getattr(tariff, 'max_device_limit', None),
|
||
)
|
||
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.now(UTC)
|
||
# Для суточного тарифа ставим срок на 1 день
|
||
existing_subscription.end_date = datetime.now(UTC) + timedelta(days=1)
|
||
|
||
# Сбрасываем докупленный трафик при смене тарифа
|
||
from sqlalchemy import delete as sql_delete
|
||
|
||
from app.database.models import TrafficPurchase
|
||
|
||
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.now(UTC)
|
||
subscription.is_daily_paused = False
|
||
await db.commit()
|
||
await db.refresh(subscription)
|
||
except Exception as e:
|
||
logger.error('Ошибка создания/продления подписки при покупке суточного тарифа', error=e, exc_info=True)
|
||
await db.rollback()
|
||
# Compensating refund: balance was already committed by subtract_user_balance
|
||
try:
|
||
from app.database.crud.user import add_user_balance
|
||
|
||
await add_user_balance(
|
||
db,
|
||
db_user,
|
||
final_daily_price,
|
||
'Возврат: ошибка покупки суточного тарифа',
|
||
create_transaction=True,
|
||
transaction_type=TransactionType.REFUND,
|
||
)
|
||
except Exception as refund_error:
|
||
logger.critical(
|
||
'CRITICAL: не удалось вернуть средства после ошибки покупки суточного тарифа',
|
||
user_id=db_user.id,
|
||
price_kopeks=final_daily_price,
|
||
refund_error=refund_error,
|
||
)
|
||
await callback.answer('Произошла ошибка при оформлении подписки', show_alert=True)
|
||
return
|
||
|
||
# Обновляем пользователя в Remnawave
|
||
# При покупке тарифа ВСЕГДА сбрасываем трафик в панели
|
||
try:
|
||
subscription_service = SubscriptionService()
|
||
await subscription_service.create_remnawave_user(
|
||
db,
|
||
subscription,
|
||
reset_traffic=True,
|
||
reset_reason='покупка суточного тарифа',
|
||
)
|
||
except Exception as e:
|
||
logger.error('Ошибка обновления Remnawave', error=e)
|
||
|
||
# Создаем транзакцию
|
||
await create_transaction(
|
||
db,
|
||
user_id=db_user.id,
|
||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||
amount_kopeks=final_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=final_daily_price,
|
||
purchase_type='renewal' if existing_subscription else 'first_purchase',
|
||
)
|
||
except Exception as e:
|
||
logger.error('Ошибка отправки уведомления админу', error=e)
|
||
|
||
# Очищаем корзину после успешной покупки
|
||
try:
|
||
await user_cart_service.delete_user_cart(db_user.id)
|
||
logger.info('Корзина очищена после покупки суточного тарифа для пользователя', telegram_id=db_user.telegram_id)
|
||
except Exception as e:
|
||
logger.error('Ошибка очистки корзины', error=e)
|
||
|
||
await state.clear()
|
||
|
||
traffic = format_traffic(tariff.traffic_limit_gb)
|
||
|
||
await callback.message.edit_text(
|
||
f'🎉 <b>Суточная подписка оформлена!</b>\n\n'
|
||
f'📦 Тариф: <b>{html.escape(tariff.name)}</b>\n'
|
||
f'📊 Трафик: {traffic}\n'
|
||
f'📱 Устройств: {tariff.device_limit}\n'
|
||
f'🔄 Тип: Суточный\n'
|
||
f'💰 Списано: {format_price_kopeks(final_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)
|
||
|
||
|
||
# ==================== Продление по тарифу ====================
|
||
|
||
|
||
def _calc_extra_devices_cost(tariff: Tariff, subscription_device_limit: int, period_days: int) -> int:
|
||
"""Рассчитывает стоимость дополнительных устройств сверх тарифа для периода."""
|
||
additional = max(0, subscription_device_limit - (tariff.device_limit or 1))
|
||
if additional <= 0:
|
||
return 0
|
||
device_price = getattr(tariff, 'device_price_kopeks', None) or 0
|
||
if device_price <= 0:
|
||
return 0
|
||
months = max(1, round(period_days / 30))
|
||
return additional * device_price * months
|
||
|
||
|
||
def get_tariff_extend_keyboard(
|
||
tariff: Tariff,
|
||
language: str,
|
||
db_user: User | None = None,
|
||
subscription_device_limit: int | None = None,
|
||
) -> InlineKeyboardMarkup:
|
||
"""Создает клавиатуру выбора периода для продления по тарифу с учетом скидок по периодам."""
|
||
from app.services.pricing_engine import PricingEngine
|
||
|
||
texts = get_texts(language)
|
||
buttons = []
|
||
|
||
promo_group = PricingEngine.resolve_promo_group(db_user) if db_user else None
|
||
|
||
prices = tariff.period_prices or {}
|
||
for period_str in sorted(prices.keys(), key=int):
|
||
period = int(period_str)
|
||
base_price = prices[period_str]
|
||
|
||
# Стоимость дополнительных устройств
|
||
devices_cost = 0
|
||
if subscription_device_limit is not None:
|
||
devices_cost = _calc_extra_devices_cost(tariff, subscription_device_limit, period)
|
||
|
||
# Per-category group discounts (period + devices separately, like PricingEngine)
|
||
period_pct = promo_group.get_discount_percent('period', period) if promo_group else 0
|
||
devices_pct = promo_group.get_discount_percent('devices', period) if promo_group else 0
|
||
offer_pct = get_user_active_promo_discount_percent(db_user) if db_user else 0
|
||
|
||
discounted_base = PricingEngine.apply_discount(base_price, period_pct)
|
||
discounted_devices = PricingEngine.apply_discount(devices_cost, devices_pct)
|
||
subtotal = discounted_base + discounted_devices
|
||
price = PricingEngine.apply_discount(subtotal, offer_pct)
|
||
|
||
# Combined display discount
|
||
total_original = base_price + devices_cost
|
||
has_discount = price < total_original and total_original > 0
|
||
if has_discount:
|
||
combined_pct = round((1 - price / total_original) * 100)
|
||
price_text = f'{format_price_kopeks(price)} 🔥−{combined_pct}%'
|
||
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,
|
||
):
|
||
"""Показывает экран продления по текущему тарифу."""
|
||
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 = db_user.get_primary_promo_group() if hasattr(db_user, 'get_primary_promo_group') else None
|
||
if promo_group is None:
|
||
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>'
|
||
|
||
actual_device_limit = subscription.device_limit or tariff.device_limit
|
||
|
||
await callback.message.edit_text(
|
||
f'🔄 <b>Продление подписки</b>{discount_hint}\n\n'
|
||
f'📦 Тариф: <b>{html.escape(tariff.name)}</b>\n'
|
||
f'📊 Трафик: {traffic}\n'
|
||
f'📱 Устройств: {actual_device_limit}\n\n'
|
||
'Выберите период продления:',
|
||
reply_markup=get_tariff_extend_keyboard(
|
||
tariff, db_user.language, db_user=db_user, subscription_device_limit=actual_device_limit
|
||
),
|
||
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])
|
||
|
||
# Кнопка «Назад» шлёт tariff_extend:{id} без периода — показываем экран выбора периода
|
||
if len(parts) < 3:
|
||
await show_tariff_extend(callback, db_user, db)
|
||
return
|
||
|
||
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)
|
||
actual_device_limit = (subscription.device_limit if subscription else None) or tariff.device_limit
|
||
|
||
# Calculate price via PricingEngine (per-category discounts: period + devices)
|
||
from app.services.pricing_engine import pricing_engine
|
||
|
||
result = await pricing_engine.calculate_tariff_purchase_price(
|
||
tariff,
|
||
period,
|
||
device_limit=actual_device_limit,
|
||
user=db_user,
|
||
)
|
||
final_price = result.final_total
|
||
original_price = result.original_total
|
||
total_discount = result.promo_group_discount + result.promo_offer_discount
|
||
discount_percent = (
|
||
round((1 - final_price / original_price) * 100) if original_price > 0 and total_discount > 0 else 0
|
||
)
|
||
|
||
# Проверяем баланс
|
||
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(total_discount)})'
|
||
|
||
await callback.message.edit_text(
|
||
f'✅ <b>Подтверждение продления</b>\n\n'
|
||
f'📦 Тариф: <b>{html.escape(tariff.name)}</b>\n'
|
||
f'📊 Трафик: {traffic}\n'
|
||
f'📱 Устройств: {actual_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
|
||
|
||
# Сохраняем данные корзины для автопокупки после пополнения
|
||
cart_data = {
|
||
'cart_mode': 'extend',
|
||
'tariff_id': tariff_id,
|
||
'subscription_id': subscription.id if subscription else None,
|
||
'period_days': period,
|
||
'total_price': final_price,
|
||
'user_id': db_user.id,
|
||
'saved_cart': True,
|
||
'missing_amount': missing,
|
||
'return_to_cart': True,
|
||
'description': f'Продление тарифа {tariff.name} на {period} дней',
|
||
'traffic_limit_gb': tariff.traffic_limit_gb,
|
||
'device_limit': actual_device_limit,
|
||
'allowed_squads': tariff.allowed_squads or [],
|
||
'discount_percent': discount_percent,
|
||
}
|
||
await user_cart_service.save_user_cart(db_user.id, cart_data)
|
||
|
||
await callback.message.edit_text(
|
||
f'❌ <b>Недостаточно средств</b>\n\n'
|
||
f'📦 Тариф: <b>{html.escape(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>\n\n'
|
||
f'🛒 <i>Корзина сохранена! После пополнения баланса подписка будет продлена автоматически.</i>',
|
||
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
|
||
|
||
actual_device_limit = subscription.device_limit or tariff.device_limit
|
||
|
||
from app.database.crud.user import lock_user_for_pricing
|
||
|
||
db_user = await lock_user_for_pricing(db, db_user.id)
|
||
|
||
# Calculate price via PricingEngine (handles per-category discounts: period + devices)
|
||
from app.services.pricing_engine import pricing_engine
|
||
|
||
result = await pricing_engine.calculate_tariff_purchase_price(
|
||
tariff,
|
||
period,
|
||
device_limit=actual_device_limit,
|
||
user=db_user,
|
||
)
|
||
final_price = result.final_total
|
||
consume_promo = result.promo_offer_discount > 0
|
||
|
||
# Проверяем баланс
|
||
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} дней',
|
||
consume_promo_offer=consume_promo,
|
||
mark_as_paid_subscription=True,
|
||
)
|
||
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('Ошибка обновления Remnawave', error=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,
|
||
purchase_type='renewal',
|
||
)
|
||
except Exception as e:
|
||
logger.error('Ошибка отправки уведомления админу', error=e)
|
||
|
||
# Очищаем корзину после успешной покупки
|
||
try:
|
||
await user_cart_service.delete_user_cart(db_user.id)
|
||
logger.info('Корзина очищена после продления тарифа для пользователя', telegram_id=db_user.telegram_id)
|
||
except Exception as e:
|
||
logger.error('Ошибка очистки корзины', error=e)
|
||
|
||
await state.clear()
|
||
|
||
traffic = format_traffic(tariff.traffic_limit_gb)
|
||
|
||
await callback.message.edit_text(
|
||
f'🎉 <b>Подписка успешно продлена!</b>\n\n'
|
||
f'📦 Тариф: <b>{html.escape(tariff.name)}</b>\n'
|
||
f'📊 Трафик: {traffic}\n'
|
||
f'📱 Устройств: {actual_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('Ошибка при продлении тарифа', error=e, exc_info=True)
|
||
await callback.answer('Произошла ошибка при продлении подписки', show_alert=True)
|
||
|
||
|
||
# ==================== Переключение тарифов ====================
|
||
|
||
|
||
def format_tariff_switch_list_text(
|
||
tariffs: list[Tariff],
|
||
current_tariff_id: int | None,
|
||
current_tariff_name: str,
|
||
db_user: User | None = 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)
|
||
if db_user:
|
||
group_pct, offer_pct, daily_discount = _get_user_period_discount(db_user, 1)
|
||
if daily_discount > 0:
|
||
daily_price = _apply_promo_discount(daily_price, group_pct, offer_pct)
|
||
discount_icon = '🔥'
|
||
price_text = f'🔄 {format_price_kopeks(daily_price, compact=True)}/день{discount_icon}'
|
||
else:
|
||
prices = tariff.period_prices or {}
|
||
if prices:
|
||
min_period = min(prices.keys(), key=int)
|
||
min_price = prices[min_period]
|
||
group_pct, offer_pct, discount_percent = 0, 0, 0
|
||
if db_user:
|
||
group_pct, offer_pct, discount_percent = _get_user_period_discount(db_user, int(min_period))
|
||
if discount_percent > 0:
|
||
min_price = _apply_promo_discount(min_price, group_pct, offer_pct)
|
||
discount_icon = '🔥'
|
||
price_text = f'от {format_price_kopeks(min_price, compact=True)}{discount_icon}'
|
||
|
||
lines.append(f'<b>{html.escape(tariff.name)}</b> — {traffic} / {tariff.device_limit} 📱 {price_text}')
|
||
|
||
if tariff.description:
|
||
lines.append(f'<i>{html.escape(tariff.description)}</i>')
|
||
|
||
lines.append('')
|
||
|
||
return '\n'.join(lines)
|
||
|
||
|
||
def get_tariff_switch_keyboard(
|
||
tariffs: list[Tariff],
|
||
current_tariff_id: int | None,
|
||
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: User | None = 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]
|
||
|
||
# Получаем скидку для конкретного периода
|
||
group_pct, offer_pct, discount_percent = 0, 0, 0
|
||
if db_user:
|
||
group_pct, offer_pct, discount_percent = _get_user_period_discount(db_user, period)
|
||
|
||
if discount_percent > 0:
|
||
price = _apply_promo_discount(price, group_pct, offer_pct)
|
||
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 = html.escape(current_tariff.name)
|
||
|
||
# Проверяем есть ли у пользователя скидки по периодам
|
||
promo_group = db_user.get_primary_promo_group() if hasattr(db_user, 'get_primary_promo_group') else None
|
||
if promo_group is None:
|
||
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:
|
||
# Для суточного тарифа показываем подтверждение без выбора периода
|
||
raw_daily_price = getattr(tariff, 'daily_price_kopeks', 0)
|
||
group_pct, offer_pct, daily_discount = _get_user_period_discount(db_user, 1)
|
||
daily_price = (
|
||
_apply_promo_discount(raw_daily_price, group_pct, offer_pct) if daily_discount > 0 else raw_daily_price
|
||
)
|
||
discount_text = f'\n💎 Скидка: {daily_discount}%' if daily_discount > 0 else ''
|
||
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:
|
||
remaining = current_subscription.end_date - datetime.now(UTC)
|
||
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>{html.escape(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>'
|
||
f'{discount_text}\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>{html.escape(tariff.name)}</b>\n'
|
||
f'🔄 Тип: Суточный\n'
|
||
f'💰 Цена: {format_price_kopeks(daily_price)}/день'
|
||
f'{discount_text}\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>{html.escape(tariff.name)}</b>
|
||
|
||
<b>Параметры нового тарифа:</b>
|
||
• Трафик: {traffic}
|
||
• Устройств: {tariff.device_limit}
|
||
"""
|
||
|
||
if tariff.description:
|
||
info_text += f'\n📝 {html.escape(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,
|
||
):
|
||
"""Обрабатывает выбор периода для переключения тарифа."""
|
||
|
||
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')
|
||
|
||
# Calculate price via PricingEngine (per-category discounts: period + devices for new tariff)
|
||
from app.services.pricing_engine import pricing_engine
|
||
|
||
result = await pricing_engine.calculate_tariff_purchase_price(
|
||
tariff,
|
||
period,
|
||
device_limit=tariff.device_limit or 0,
|
||
user=db_user,
|
||
)
|
||
final_price = result.final_total
|
||
original_price = result.original_total
|
||
total_discount = result.promo_group_discount + result.promo_offer_discount
|
||
discount_percent = (
|
||
round((1 - final_price / original_price) * 100) if original_price > 0 and total_discount > 0 else 0
|
||
)
|
||
|
||
# Проверяем баланс
|
||
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 = html.escape(current_tariff.name)
|
||
|
||
# Получаем текущую подписку для расчёта оставшегося времени
|
||
subscription = await get_subscription_by_user_id(db, db_user.id)
|
||
if subscription and subscription.end_date:
|
||
max(0, (subscription.end_date - datetime.now(UTC)).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(total_discount)})'
|
||
|
||
await callback.message.edit_text(
|
||
f'✅ <b>Подтверждение переключения тарифа</b>\n\n'
|
||
f'📌 Текущий тариф: <b>{current_tariff_name}</b>\n'
|
||
f'📦 Новый тариф: <b>{html.escape(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>{html.escape(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
|
||
|
||
from app.database.crud.user import lock_user_for_pricing
|
||
|
||
db_user = await lock_user_for_pricing(db, db_user.id)
|
||
|
||
# Проверяем наличие подписки (need device_limit for pricing)
|
||
subscription = await get_subscription_by_user_id(db, db_user.id)
|
||
if not subscription:
|
||
await callback.answer('У вас нет активной подписки', show_alert=True)
|
||
return
|
||
|
||
# Calculate price via PricingEngine (handles per-category discounts + extra devices)
|
||
from app.services.pricing_engine import pricing_engine
|
||
|
||
effective_device_limit = (
|
||
subscription.device_limit if subscription.tariff_id == tariff.id else (tariff.device_limit or 0)
|
||
)
|
||
result = await pricing_engine.calculate_tariff_purchase_price(
|
||
tariff,
|
||
period,
|
||
device_limit=effective_device_limit,
|
||
user=db_user,
|
||
)
|
||
final_price = result.final_total
|
||
consume_promo = result.promo_offer_discount > 0
|
||
|
||
# Проверяем баланс
|
||
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} дней)',
|
||
consume_promo_offer=consume_promo,
|
||
mark_as_paid_subscription=True,
|
||
)
|
||
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]
|
||
|
||
# При смене тарифа пользователь получает оплаченный период + оставшиеся дни
|
||
# (остаток добавляется в extend_subscription автоматически)
|
||
days_for_new_tariff = period
|
||
|
||
# Обновляем подписку с новыми параметрами тарифа
|
||
# Сохраняем докупленные устройства при продлении того же тарифа
|
||
if subscription.tariff_id == tariff.id:
|
||
effective_device_limit = max(tariff.device_limit or 0, subscription.device_limit or 0)
|
||
else:
|
||
effective_device_limit = tariff.device_limit
|
||
subscription = await extend_subscription(
|
||
db,
|
||
subscription,
|
||
days=days_for_new_tariff, # Даем ровно оплаченный период
|
||
tariff_id=tariff.id,
|
||
traffic_limit_gb=tariff.traffic_limit_gb,
|
||
device_limit=effective_device_limit,
|
||
connected_squads=squads,
|
||
)
|
||
|
||
# Обновляем пользователя в Remnawave
|
||
try:
|
||
subscription_service = SubscriptionService()
|
||
await subscription_service.create_remnawave_user(
|
||
db,
|
||
subscription,
|
||
reset_traffic=settings.RESET_TRAFFIC_ON_TARIFF_SWITCH,
|
||
reset_reason='переключение тарифа',
|
||
)
|
||
except Exception as e:
|
||
logger.error('Ошибка обновления Remnawave при переключении тарифа', error=e)
|
||
|
||
# Гарантированный сброс устройств при смене тарифа
|
||
await db.refresh(db_user)
|
||
if db_user.remnawave_uuid:
|
||
try:
|
||
from app.services.remnawave_service import RemnaWaveService
|
||
|
||
service = RemnaWaveService()
|
||
async with service.get_api_client() as api:
|
||
await api.reset_user_devices(db_user.remnawave_uuid)
|
||
logger.info('🔧 Сброшены устройства при смене тарифа для user_id', db_user_id=db_user.id)
|
||
except Exception as e:
|
||
logger.error('Ошибка сброса устройств при смене тарифа', error=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,
|
||
purchase_type='tariff_switch',
|
||
)
|
||
except Exception as e:
|
||
logger.error('Ошибка отправки уведомления админу', error=e)
|
||
|
||
# Очищаем корзину после успешной покупки
|
||
try:
|
||
await user_cart_service.delete_user_cart(db_user.id)
|
||
logger.info('Корзина очищена после смены тарифа для пользователя', telegram_id=db_user.telegram_id)
|
||
except Exception as e:
|
||
logger.error('Ошибка очистки корзины', error=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>{html.escape(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('Ошибка при переключении тарифа', error=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,
|
||
):
|
||
"""Подтверждает смену на суточный тариф."""
|
||
|
||
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
|
||
|
||
# Lock user BEFORE price computation to prevent TOCTOU on promo offer
|
||
from app.database.crud.user import lock_user_for_pricing
|
||
|
||
db_user = await lock_user_for_pricing(db, db_user.id)
|
||
|
||
# Apply group + promo-offer discounts via PricingEngine (single source of truth)
|
||
from app.services.pricing_engine import pricing_engine
|
||
|
||
pricing_result = await pricing_engine.calculate_tariff_purchase_price(
|
||
tariff,
|
||
period_days=1,
|
||
device_limit=tariff.device_limit,
|
||
user=db_user,
|
||
)
|
||
final_daily_price = pricing_result.final_total
|
||
consume_promo = pricing_result.breakdown.get('offer_discount_pct', 0) > 0
|
||
|
||
# Проверяем баланс (user already locked, balance is fresh)
|
||
user_balance = db_user.balance_kopeks or 0
|
||
if user_balance < final_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,
|
||
final_daily_price,
|
||
f'Смена на суточный тариф {tariff.name} (первый день)',
|
||
consume_promo_offer=consume_promo,
|
||
mark_as_paid_subscription=True,
|
||
)
|
||
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]
|
||
|
||
# Обновляем подписку на суточный тариф
|
||
# Сбрасываем лимит устройств на базу нового тарифа (докупленные не переносятся)
|
||
from app.database.crud.subscription import calc_device_limit_on_tariff_switch
|
||
|
||
old_tariff = await get_tariff_by_id(db, subscription.tariff_id) if subscription.tariff_id else None
|
||
subscription.tariff_id = tariff.id
|
||
subscription.traffic_limit_gb = tariff.traffic_limit_gb
|
||
subscription.device_limit = calc_device_limit_on_tariff_switch(
|
||
current_device_limit=subscription.device_limit,
|
||
old_tariff_device_limit=old_tariff.device_limit if old_tariff else None,
|
||
new_tariff_device_limit=tariff.device_limit,
|
||
max_device_limit=getattr(tariff, 'max_device_limit', None),
|
||
)
|
||
subscription.connected_squads = squads
|
||
subscription.status = 'active'
|
||
subscription.is_trial = False # Сбрасываем триальный статус
|
||
subscription.is_daily_paused = False
|
||
subscription.last_daily_charge_at = datetime.now(UTC)
|
||
# Для суточного тарифа ставим срок на 1 день
|
||
subscription.end_date = datetime.now(UTC) + timedelta(days=1)
|
||
|
||
# Сбрасываем докупленный трафик при смене тарифа
|
||
from sqlalchemy import delete as sql_delete
|
||
|
||
from app.database.models import TrafficPurchase
|
||
|
||
await db.execute(sql_delete(TrafficPurchase).where(TrafficPurchase.subscription_id == subscription.id))
|
||
subscription.purchased_traffic_gb = 0
|
||
subscription.traffic_reset_at = None
|
||
|
||
if settings.RESET_TRAFFIC_ON_TARIFF_SWITCH:
|
||
subscription.traffic_used_gb = 0.0
|
||
|
||
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_TARIFF_SWITCH,
|
||
reset_reason='смена на суточный тариф',
|
||
)
|
||
except Exception as e:
|
||
logger.error('Ошибка обновления Remnawave', error=e)
|
||
|
||
# Гарантированный сброс устройств при смене тарифа
|
||
await db.refresh(db_user)
|
||
if db_user.remnawave_uuid:
|
||
try:
|
||
from app.services.remnawave_service import RemnaWaveService
|
||
|
||
service = RemnaWaveService()
|
||
async with service.get_api_client() as api:
|
||
await api.reset_user_devices(db_user.remnawave_uuid)
|
||
logger.info('🔧 Сброшены устройства при смене на суточный тариф для user_id', db_user_id=db_user.id)
|
||
except Exception as e:
|
||
logger.error('Ошибка сброса устройств при смене тарифа', error=e)
|
||
|
||
# Создаем транзакцию
|
||
await create_transaction(
|
||
db,
|
||
user_id=db_user.id,
|
||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||
amount_kopeks=final_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=final_daily_price,
|
||
purchase_type='tariff_switch',
|
||
)
|
||
except Exception as e:
|
||
logger.error('Ошибка отправки уведомления админу', error=e)
|
||
|
||
await state.clear()
|
||
|
||
traffic = format_traffic(tariff.traffic_limit_gb)
|
||
|
||
await callback.message.edit_text(
|
||
f'🎉 <b>Тариф успешно изменён!</b>\n\n'
|
||
f'📦 Новый тариф: <b>{html.escape(tariff.name)}</b>\n'
|
||
f'📊 Трафик: {traffic}\n'
|
||
f'📱 Устройств: {tariff.device_limit}\n'
|
||
f'🔄 Тип: Суточный\n'
|
||
f'💰 Списано: {format_price_kopeks(final_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('Ошибка при смене на суточный тариф', error=e, exc_info=True)
|
||
await db.rollback()
|
||
# Compensating refund: balance was already committed by subtract_user_balance
|
||
try:
|
||
from app.database.crud.user import add_user_balance
|
||
|
||
await add_user_balance(
|
||
db,
|
||
db_user,
|
||
final_daily_price,
|
||
'Возврат: ошибка смены на суточный тариф',
|
||
create_transaction=True,
|
||
transaction_type=TransactionType.REFUND,
|
||
)
|
||
except Exception as refund_error:
|
||
logger.critical(
|
||
'CRITICAL: не удалось вернуть средства после ошибки смены на суточный тариф',
|
||
user_id=db_user.id,
|
||
price_kopeks=final_daily_price,
|
||
refund_error=refund_error,
|
||
)
|
||
await callback.answer('Произошла ошибка при смене тарифа', show_alert=True)
|
||
|
||
|
||
# ==================== Мгновенное переключение тарифов (без выбора периода) ====================
|
||
|
||
|
||
def _calculate_instant_switch_cost(
|
||
current_tariff: Tariff,
|
||
new_tariff: Tariff,
|
||
remaining_days: int,
|
||
db_user: User | None = None,
|
||
) -> tuple[int, bool]:
|
||
"""Рассчитывает стоимость мгновенного переключения тарифа.
|
||
|
||
Делегирует расчёт в PricingEngine.calculate_tariff_switch_cost().
|
||
Returns:
|
||
(upgrade_cost_kopeks, is_upgrade)
|
||
"""
|
||
from app.services.pricing_engine import pricing_engine
|
||
|
||
result = pricing_engine.calculate_tariff_switch_cost(
|
||
current_tariff,
|
||
new_tariff,
|
||
remaining_days,
|
||
user=db_user,
|
||
)
|
||
return result.upgrade_cost, result.is_upgrade
|
||
|
||
|
||
def format_instant_switch_list_text(
|
||
tariffs: list[Tariff],
|
||
current_tariff: Tariff,
|
||
remaining_days: int,
|
||
db_user: User | None = None,
|
||
) -> str:
|
||
"""Форматирует текст со списком тарифов для мгновенного переключения."""
|
||
lines = [
|
||
'📦 <b>Мгновенная смена тарифа</b>',
|
||
f'📌 Текущий: <b>{html.escape(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>{html.escape(tariff.name)}</b> — {traffic} / {tariff.device_limit} 📱 {cost_text}')
|
||
|
||
if tariff.description:
|
||
lines.append(f'<i>{html.escape(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: User | None = 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,
|
||
):
|
||
"""Показывает список тарифов для мгновенного переключения."""
|
||
|
||
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
|
||
|
||
# Рассчитываем оставшиеся дни
|
||
now = datetime.now(UTC)
|
||
remaining_days = 0
|
||
if subscription.end_date:
|
||
remaining_days = max(0, (subscription.end_date - now).days)
|
||
|
||
if not subscription.end_date or subscription.end_date <= now:
|
||
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,
|
||
):
|
||
"""Показывает превью мгновенного переключения тарифа."""
|
||
|
||
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.now(UTC)).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:
|
||
raw_daily_price = getattr(new_tariff, 'daily_price_kopeks', 0)
|
||
# Применяем групповую скидку + promo-offer для отображения
|
||
daily_group_pct, daily_offer_pct, daily_discount = _get_user_period_discount(db_user, 1)
|
||
daily_price = (
|
||
_apply_promo_discount(raw_daily_price, daily_group_pct, daily_offer_pct)
|
||
if daily_discount > 0
|
||
else raw_daily_price
|
||
)
|
||
discount_text = f'\n💎 Скидка: {daily_discount}%' if daily_discount > 0 else ''
|
||
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>{html.escape(current_tariff.name)}</b>\n'
|
||
f' • Трафик: {current_traffic}\n'
|
||
f' • Устройств: {current_tariff.device_limit}\n\n'
|
||
f'📦 Новый: <b>{html.escape(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>'
|
||
f'{discount_text}\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>{html.escape(new_tariff.name)}</b>\n'
|
||
f'🔄 Тип: Суточный\n'
|
||
f'💰 Цена: {format_price_kopeks(daily_price)}/день'
|
||
f'{discount_text}\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>{html.escape(current_tariff.name)}</b>\n'
|
||
f' • Трафик: {current_traffic}\n'
|
||
f' • Устройств: {current_tariff.device_limit}\n\n'
|
||
f'📦 Новый: <b>{html.escape(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>{html.escape(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>{html.escape(current_tariff.name)}</b>\n'
|
||
f' • Трафик: {current_traffic}\n'
|
||
f' • Устройств: {current_tariff.device_limit}\n\n'
|
||
f'📦 Новый: <b>{html.escape(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,
|
||
):
|
||
"""Подтверждает мгновенное переключение тарифа."""
|
||
|
||
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
|
||
|
||
# Проверяем подписку
|
||
subscription = await get_subscription_by_user_id(db, db_user.id)
|
||
if not subscription:
|
||
await callback.answer('Подписка не найдена', show_alert=True)
|
||
return
|
||
|
||
from app.database.crud.user import lock_user_for_pricing
|
||
|
||
db_user = await lock_user_for_pricing(db, db_user.id)
|
||
|
||
# Recompute upgrade_cost under lock (FSM-stored value may be stale)
|
||
current_tariff = await get_tariff_by_id(db, subscription.tariff_id) if subscription.tariff_id else None
|
||
if not current_tariff:
|
||
await callback.answer('Текущий тариф не найден', show_alert=True)
|
||
return
|
||
remaining_days = max(0, (subscription.end_date - datetime.now(UTC)).days) if subscription.end_date else 0
|
||
|
||
# Use full TariffSwitchResult to access offer_discount_pct for consume_promo_offer flag
|
||
from app.services.pricing_engine import pricing_engine
|
||
|
||
switch_result = pricing_engine.calculate_tariff_switch_cost(
|
||
current_tariff,
|
||
new_tariff,
|
||
remaining_days,
|
||
user=db_user,
|
||
)
|
||
upgrade_cost = switch_result.upgrade_cost
|
||
is_upgrade = switch_result.is_upgrade
|
||
consume_promo = switch_result.offer_discount_pct > 0
|
||
|
||
# Проверяем баланс если это upgrade (use locked user's fresh balance)
|
||
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
|
||
# upgrade_cost includes both group + offer discounts from PricingEngine
|
||
if is_upgrade and upgrade_cost > 0:
|
||
success = await subtract_user_balance(
|
||
db,
|
||
db_user,
|
||
upgrade_cost,
|
||
f'Переключение на тариф {new_tariff.name}',
|
||
consume_promo_offer=consume_promo,
|
||
mark_as_paid_subscription=True,
|
||
)
|
||
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)
|
||
|
||
# Обновляем подписку с новыми параметрами тарифа
|
||
# Сбрасываем лимит устройств на базу нового тарифа (докупленные не переносятся)
|
||
from app.database.crud.subscription import calc_device_limit_on_tariff_switch
|
||
|
||
old_tariff = await get_tariff_by_id(db, subscription.tariff_id) if subscription.tariff_id else None
|
||
subscription.tariff_id = new_tariff.id
|
||
subscription.traffic_limit_gb = new_tariff.traffic_limit_gb
|
||
subscription.device_limit = calc_device_limit_on_tariff_switch(
|
||
current_device_limit=subscription.device_limit,
|
||
old_tariff_device_limit=old_tariff.device_limit if old_tariff else None,
|
||
new_tariff_device_limit=new_tariff.device_limit,
|
||
max_device_limit=getattr(new_tariff, 'max_device_limit', None),
|
||
)
|
||
subscription.connected_squads = squads
|
||
|
||
# Сбрасываем докупленный трафик при смене тарифа
|
||
from sqlalchemy import delete as sql_delete
|
||
|
||
from app.database.models import TrafficPurchase
|
||
|
||
await db.execute(sql_delete(TrafficPurchase).where(TrafficPurchase.subscription_id == subscription.id))
|
||
subscription.purchased_traffic_gb = 0
|
||
subscription.traffic_reset_at = None
|
||
|
||
if settings.RESET_TRAFFIC_ON_TARIFF_SWITCH:
|
||
subscription.traffic_used_gb = 0.0
|
||
|
||
if is_new_daily:
|
||
# Для суточного тарифа - сбрасываем на 1 день и настраиваем суточные параметры
|
||
# Apply group + promo-offer discounts via PricingEngine (single source of truth)
|
||
daily_pricing = await pricing_engine.calculate_tariff_purchase_price(
|
||
new_tariff,
|
||
period_days=1,
|
||
device_limit=new_tariff.device_limit,
|
||
user=db_user,
|
||
)
|
||
daily_price = daily_pricing.final_total
|
||
consume_promo_for_daily = daily_pricing.breakdown.get('offer_discount_pct', 0) > 0
|
||
|
||
# Списываем первый день если ещё не списано (upgrade_cost был 0)
|
||
if upgrade_cost == 0 and daily_price > 0:
|
||
if user_balance >= daily_price:
|
||
success = await subtract_user_balance(
|
||
db,
|
||
db_user,
|
||
daily_price,
|
||
f'Переключение на суточный тариф {new_tariff.name} (первый день)',
|
||
consume_promo_offer=consume_promo_for_daily,
|
||
mark_as_paid_subscription=True,
|
||
)
|
||
if not success:
|
||
await callback.answer('❌ Недостаточно средств', show_alert=True)
|
||
return
|
||
await create_transaction(
|
||
db,
|
||
user_id=db_user.id,
|
||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||
amount_kopeks=daily_price,
|
||
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,
|
||
1,
|
||
was_trial_conversion=False,
|
||
amount_kopeks=daily_price,
|
||
purchase_type='tariff_switch',
|
||
)
|
||
except Exception as e:
|
||
logger.error('Ошибка отправки уведомления админу', error=e)
|
||
|
||
subscription.end_date = datetime.now(UTC) + timedelta(days=1)
|
||
subscription.is_trial = False
|
||
subscription.is_daily_paused = False
|
||
subscription.last_daily_charge_at = datetime.now(UTC)
|
||
|
||
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_TARIFF_SWITCH,
|
||
reset_reason='мгновенное переключение тарифа',
|
||
)
|
||
except Exception as e:
|
||
logger.error('Ошибка обновления Remnawave при мгновенном переключении', error=e)
|
||
|
||
# Гарантированный сброс устройств при смене тарифа
|
||
await db.refresh(db_user)
|
||
if db_user.remnawave_uuid:
|
||
try:
|
||
from app.services.remnawave_service import RemnaWaveService
|
||
|
||
service = RemnaWaveService()
|
||
async with service.get_api_client() as api:
|
||
await api.reset_user_devices(db_user.remnawave_uuid)
|
||
logger.info(
|
||
'🔧 Сброшены устройства при мгновенном переключении тарифа для user_id', db_user_id=db_user.id
|
||
)
|
||
except Exception as e:
|
||
logger.error('Ошибка сброса устройств при переключении тарифа', error=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,
|
||
purchase_type='tariff_switch',
|
||
)
|
||
except Exception as e:
|
||
logger.error('Ошибка отправки уведомления админу', error=e)
|
||
|
||
await state.clear()
|
||
|
||
traffic = format_traffic(new_tariff.traffic_limit_gb)
|
||
|
||
# Для суточного тарифа другое сообщение об успехе
|
||
if is_new_daily:
|
||
await callback.message.edit_text(
|
||
f'🎉 <b>Тариф успешно изменён!</b>\n\n'
|
||
f'📦 Новый тариф: <b>{html.escape(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>{html.escape(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('Ошибка при мгновенном переключении тарифа', error=e, exc_info=True)
|
||
await callback.answer('Произошла ошибка при переключении тарифа', show_alert=True)
|
||
|
||
|
||
async def return_to_saved_tariff_cart(
|
||
callback: types.CallbackQuery,
|
||
state: FSMContext,
|
||
db_user: User,
|
||
db: AsyncSession,
|
||
cart_data: dict,
|
||
):
|
||
"""Восстанавливает сохраненную корзину тарифа после пополнения баланса."""
|
||
texts = get_texts(db_user.language)
|
||
cart_mode = cart_data.get('cart_mode')
|
||
tariff_id = cart_data.get('tariff_id')
|
||
|
||
if not tariff_id:
|
||
await callback.answer('❌ Данные корзины повреждены', show_alert=True)
|
||
return
|
||
|
||
tariff = await get_tariff_by_id(db, tariff_id)
|
||
if not tariff or not tariff.is_active:
|
||
await callback.answer('❌ Тариф больше недоступен', show_alert=True)
|
||
# Очищаем корзину
|
||
await user_cart_service.delete_user_cart(db_user.id)
|
||
return
|
||
|
||
total_price = cart_data.get('total_price', 0)
|
||
user_balance = db_user.balance_kopeks or 0
|
||
traffic = format_traffic(tariff.traffic_limit_gb)
|
||
|
||
# Проверяем баланс
|
||
if user_balance < total_price:
|
||
missing = total_price - user_balance
|
||
|
||
if cart_mode == 'daily_tariff_purchase':
|
||
await callback.message.edit_text(
|
||
f'❌ <b>Все еще недостаточно средств</b>\n\n'
|
||
f'📦 Тариф: <b>{html.escape(tariff.name)}</b>\n'
|
||
f'🔄 Тип: Суточный\n'
|
||
f'💰 Стоимость: {format_price_kopeks(total_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',
|
||
)
|
||
elif cart_mode == 'extend':
|
||
period = cart_data.get('period_days', 30)
|
||
await callback.message.edit_text(
|
||
f'❌ <b>Все еще недостаточно средств</b>\n\n'
|
||
f'📦 Тариф: <b>{html.escape(tariff.name)}</b>\n'
|
||
f'📅 Период: {format_period(period)}\n'
|
||
f'💰 Стоимость: {format_price_kopeks(total_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',
|
||
)
|
||
else: # tariff_purchase
|
||
period = cart_data.get('period_days', 30)
|
||
await callback.message.edit_text(
|
||
f'❌ <b>Все еще недостаточно средств</b>\n\n'
|
||
f'📦 Тариф: <b>{html.escape(tariff.name)}</b>\n'
|
||
f'📅 Период: {format_period(period)}\n'
|
||
f'💰 Стоимость: {format_price_kopeks(total_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 callback.answer()
|
||
return
|
||
|
||
# Баланс достаточен - показываем подтверждение
|
||
discount_percent = cart_data.get('discount_percent', 0)
|
||
|
||
if cart_mode == 'daily_tariff_purchase':
|
||
daily_price = cart_data.get('daily_price_kopeks', total_price)
|
||
|
||
await callback.message.edit_text(
|
||
f'✅ <b>Подтверждение покупки</b>\n\n'
|
||
f'📦 Тариф: <b>{html.escape(tariff.name)}</b>\n'
|
||
f'📊 Трафик: {traffic}\n'
|
||
f'📱 Устройств: {tariff.device_limit}\n'
|
||
f'🔄 Тип: Суточный\n'
|
||
f'💰 <b>Стоимость в день: {format_price_kopeks(daily_price)}</b>\n\n'
|
||
f'💳 Ваш баланс: {format_price_kopeks(user_balance)}\n'
|
||
f'После оплаты: {format_price_kopeks(user_balance - daily_price)}',
|
||
reply_markup=get_daily_tariff_confirm_keyboard(tariff_id, db_user.language),
|
||
parse_mode='HTML',
|
||
)
|
||
elif cart_mode == 'extend':
|
||
period = cart_data.get('period_days', 30)
|
||
|
||
discount_text = ''
|
||
if discount_percent > 0:
|
||
original_price = int(total_price / (1 - discount_percent / 100))
|
||
discount_text = f'\n🎁 Скидка: {discount_percent}% (-{format_price_kopeks(original_price - total_price)})'
|
||
|
||
await callback.message.edit_text(
|
||
f'✅ <b>Подтверждение продления</b>\n\n'
|
||
f'📦 Тариф: <b>{html.escape(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(total_price)}</b>\n\n'
|
||
f'💳 Ваш баланс: {format_price_kopeks(user_balance)}\n'
|
||
f'После оплаты: {format_price_kopeks(user_balance - total_price)}',
|
||
reply_markup=InlineKeyboardMarkup(
|
||
inline_keyboard=[
|
||
[
|
||
InlineKeyboardButton(
|
||
text='✅ Подтвердить продление', callback_data=f'tariff_ext_confirm:{tariff_id}:{period}'
|
||
)
|
||
],
|
||
[InlineKeyboardButton(text=texts.BACK, callback_data=f'tariff_extend:{tariff_id}')],
|
||
]
|
||
),
|
||
parse_mode='HTML',
|
||
)
|
||
else: # tariff_purchase
|
||
period = cart_data.get('period_days', 30)
|
||
|
||
discount_text = ''
|
||
if discount_percent > 0:
|
||
original_price = int(total_price / (1 - discount_percent / 100))
|
||
discount_text = f'\n🎁 Скидка: {discount_percent}% (-{format_price_kopeks(original_price - total_price)})'
|
||
|
||
await callback.message.edit_text(
|
||
f'✅ <b>Подтверждение покупки</b>\n\n'
|
||
f'📦 Тариф: <b>{html.escape(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(total_price)}</b>\n\n'
|
||
f'💳 Ваш баланс: {format_price_kopeks(user_balance)}\n'
|
||
f'После оплаты: {format_price_kopeks(user_balance - total_price)}',
|
||
reply_markup=get_tariff_confirm_keyboard(tariff_id, period, db_user.language),
|
||
parse_mode='HTML',
|
||
)
|
||
|
||
await callback.answer('✅ Корзина восстановлена!')
|
||
|
||
|
||
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:'))
|