@@ -1357,6 +1357,493 @@ async def confirm_tariff_switch(
|
||||
await callback.answer("Произошла ошибка при переключении тарифа", show_alert=True)
|
||||
|
||||
|
||||
# ==================== Мгновенное переключение тарифов (без выбора периода) ====================
|
||||
|
||||
def _calculate_instant_switch_cost(
|
||||
current_tariff: Tariff,
|
||||
new_tariff: Tariff,
|
||||
remaining_days: int,
|
||||
db_user: Optional[User] = None,
|
||||
) -> tuple[int, bool]:
|
||||
"""
|
||||
Рассчитывает стоимость мгновенного переключения тарифа.
|
||||
|
||||
Если новый тариф дороже - доплата пропорционально оставшимся дням.
|
||||
Если дешевле или равен - бесплатно.
|
||||
|
||||
Returns:
|
||||
(upgrade_cost_kopeks, is_upgrade)
|
||||
"""
|
||||
# Получаем месячные цены тарифов
|
||||
current_monthly = current_tariff.get_price_for_period(30) or 0
|
||||
new_monthly = new_tariff.get_price_for_period(30) or 0
|
||||
|
||||
# Применяем скидку промогруппы если есть
|
||||
discount_percent = 0
|
||||
if db_user:
|
||||
discount_percent = _get_user_period_discount(db_user, 30)
|
||||
|
||||
if discount_percent > 0:
|
||||
new_monthly = _apply_promo_discount(new_monthly, discount_percent)
|
||||
|
||||
# Рассчитываем разницу
|
||||
price_diff = new_monthly - current_monthly
|
||||
|
||||
if price_diff <= 0:
|
||||
# Downgrade или тот же уровень - бесплатно
|
||||
return 0, False
|
||||
|
||||
# Upgrade - доплата пропорционально оставшимся дням
|
||||
upgrade_cost = int(price_diff * remaining_days / 30)
|
||||
return upgrade_cost, True
|
||||
|
||||
|
||||
def format_instant_switch_list_text(
|
||||
tariffs: List[Tariff],
|
||||
current_tariff: Tariff,
|
||||
remaining_days: int,
|
||||
db_user: Optional[User] = None,
|
||||
) -> str:
|
||||
"""Форматирует текст со списком тарифов для мгновенного переключения."""
|
||||
lines = [
|
||||
"📦 <b>Мгновенная смена тарифа</b>",
|
||||
f"📌 Текущий: <b>{current_tariff.name}</b>",
|
||||
f"⏰ Осталось: <b>{remaining_days} дн.</b>",
|
||||
"",
|
||||
"💡 При переключении остаток дней сохраняется.",
|
||||
"⬆️ Повышение тарифа = доплата за разницу",
|
||||
"⬇️ Понижение = бесплатно",
|
||||
"",
|
||||
]
|
||||
|
||||
for tariff in tariffs:
|
||||
if tariff.id == current_tariff.id:
|
||||
continue
|
||||
|
||||
traffic_gb = tariff.traffic_limit_gb
|
||||
traffic = "∞" if traffic_gb == 0 else f"{traffic_gb}ГБ"
|
||||
|
||||
# Рассчитываем стоимость переключения
|
||||
cost, is_upgrade = _calculate_instant_switch_cost(
|
||||
current_tariff, tariff, remaining_days, db_user
|
||||
)
|
||||
|
||||
if is_upgrade:
|
||||
cost_text = f"⬆️ +{_format_price_kopeks(cost, compact=True)}"
|
||||
else:
|
||||
cost_text = "⬇️ Бесплатно"
|
||||
|
||||
lines.append(f"<b>{tariff.name}</b> — {traffic}/{tariff.device_limit}📱 {cost_text}")
|
||||
|
||||
if tariff.description:
|
||||
lines.append(f"<i>{tariff.description}</i>")
|
||||
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def get_instant_switch_keyboard(
|
||||
tariffs: List[Tariff],
|
||||
current_tariff: Tariff,
|
||||
remaining_days: int,
|
||||
language: str,
|
||||
db_user: Optional[User] = None,
|
||||
) -> InlineKeyboardMarkup:
|
||||
"""Создает клавиатуру для мгновенного переключения тарифа."""
|
||||
texts = get_texts(language)
|
||||
buttons = []
|
||||
|
||||
for tariff in tariffs:
|
||||
if tariff.id == current_tariff.id:
|
||||
continue
|
||||
|
||||
# Рассчитываем стоимость
|
||||
cost, is_upgrade = _calculate_instant_switch_cost(
|
||||
current_tariff, tariff, remaining_days, db_user
|
||||
)
|
||||
|
||||
if is_upgrade:
|
||||
btn_text = f"📦 {tariff.name} (+{_format_price_kopeks(cost, compact=True)})"
|
||||
else:
|
||||
btn_text = f"📦 {tariff.name} (бесплатно)"
|
||||
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text=btn_text,
|
||||
callback_data=f"instant_sw_preview:{tariff.id}"
|
||||
)
|
||||
])
|
||||
|
||||
buttons.append([
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
|
||||
def get_instant_switch_confirm_keyboard(
|
||||
tariff_id: int,
|
||||
language: str,
|
||||
) -> InlineKeyboardMarkup:
|
||||
"""Создает клавиатуру подтверждения мгновенного переключения."""
|
||||
texts = get_texts(language)
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="✅ Подтвердить переключение",
|
||||
callback_data=f"instant_sw_confirm:{tariff_id}"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.BACK,
|
||||
callback_data="instant_switch"
|
||||
)
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_instant_switch_insufficient_balance_keyboard(
|
||||
tariff_id: int,
|
||||
language: str,
|
||||
) -> InlineKeyboardMarkup:
|
||||
"""Создает клавиатуру при недостаточном балансе для мгновенного переключения."""
|
||||
texts = get_texts(language)
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="💳 Пополнить баланс",
|
||||
callback_data="balance_topup"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.BACK,
|
||||
callback_data="instant_switch"
|
||||
)
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
@error_handler
|
||||
async def show_instant_switch_list(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""Показывает список тарифов для мгновенного переключения."""
|
||||
from datetime import datetime
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
await state.clear()
|
||||
|
||||
# Проверяем наличие активной подписки
|
||||
subscription = await get_subscription_by_user_id(db, db_user.id)
|
||||
if not subscription:
|
||||
await callback.answer("У вас нет активной подписки", show_alert=True)
|
||||
return
|
||||
|
||||
if not subscription.tariff_id:
|
||||
await callback.answer("У вашей подписки нет тарифа", show_alert=True)
|
||||
return
|
||||
|
||||
# Получаем текущий тариф
|
||||
current_tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
if not current_tariff:
|
||||
await callback.answer("Текущий тариф не найден", show_alert=True)
|
||||
return
|
||||
|
||||
# Рассчитываем оставшиеся дни
|
||||
remaining_days = 0
|
||||
if subscription.end_date:
|
||||
remaining_days = max(0, (subscription.end_date - datetime.utcnow()).days)
|
||||
|
||||
if remaining_days == 0:
|
||||
await callback.message.edit_text(
|
||||
"❌ <b>Переключение недоступно</b>\n\n"
|
||||
"У вашей подписки не осталось активных дней.\n"
|
||||
"Используйте продление или покупку нового тарифа.",
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")]
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
# Получаем доступные тарифы
|
||||
promo_group_id = getattr(db_user, 'promo_group_id', None)
|
||||
tariffs = await get_tariffs_for_user(db, promo_group_id)
|
||||
|
||||
# Фильтруем текущий тариф
|
||||
available_tariffs = [t for t in tariffs if t.id != current_tariff.id]
|
||||
|
||||
if not available_tariffs:
|
||||
await callback.message.edit_text(
|
||||
"😔 <b>Нет доступных тарифов для переключения</b>\n\n"
|
||||
"Вы уже используете единственный доступный тариф.",
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")]
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
# Формируем текст со списком тарифов
|
||||
switch_text = format_instant_switch_list_text(
|
||||
tariffs, current_tariff, remaining_days, db_user
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
switch_text,
|
||||
reply_markup=get_instant_switch_keyboard(
|
||||
tariffs, current_tariff, remaining_days, db_user.language, db_user
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await state.update_data(
|
||||
current_tariff_id=current_tariff.id,
|
||||
remaining_days=remaining_days,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def preview_instant_switch(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""Показывает превью мгновенного переключения тарифа."""
|
||||
from datetime import datetime
|
||||
|
||||
tariff_id = int(callback.data.split(":")[1])
|
||||
new_tariff = await get_tariff_by_id(db, tariff_id)
|
||||
|
||||
if not new_tariff or not new_tariff.is_active:
|
||||
await callback.answer("Тариф недоступен", show_alert=True)
|
||||
return
|
||||
|
||||
# Получаем данные из состояния
|
||||
data = await state.get_data()
|
||||
current_tariff_id = data.get('current_tariff_id')
|
||||
remaining_days = data.get('remaining_days', 0)
|
||||
|
||||
# Если данных нет в state, получаем заново
|
||||
subscription = await get_subscription_by_user_id(db, db_user.id)
|
||||
if not subscription or not subscription.tariff_id:
|
||||
await callback.answer("Подписка не найдена", show_alert=True)
|
||||
return
|
||||
|
||||
current_tariff_id = current_tariff_id or subscription.tariff_id
|
||||
current_tariff = await get_tariff_by_id(db, current_tariff_id)
|
||||
if not current_tariff:
|
||||
await callback.answer("Текущий тариф не найден", show_alert=True)
|
||||
return
|
||||
|
||||
if not remaining_days and subscription.end_date:
|
||||
remaining_days = max(0, (subscription.end_date - datetime.utcnow()).days)
|
||||
|
||||
# Рассчитываем стоимость переключения
|
||||
upgrade_cost, is_upgrade = _calculate_instant_switch_cost(
|
||||
current_tariff, new_tariff, remaining_days, db_user
|
||||
)
|
||||
|
||||
# Проверяем баланс
|
||||
user_balance = db_user.balance_kopeks or 0
|
||||
|
||||
traffic = _format_traffic(new_tariff.traffic_limit_gb)
|
||||
current_traffic = _format_traffic(current_tariff.traffic_limit_gb)
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if is_upgrade:
|
||||
# Upgrade - нужна доплата
|
||||
if user_balance >= upgrade_cost:
|
||||
await callback.message.edit_text(
|
||||
f"⬆️ <b>Повышение тарифа</b>\n\n"
|
||||
f"📌 Текущий: <b>{current_tariff.name}</b>\n"
|
||||
f" • Трафик: {current_traffic}\n"
|
||||
f" • Устройств: {current_tariff.device_limit}\n\n"
|
||||
f"📦 Новый: <b>{new_tariff.name}</b>\n"
|
||||
f" • Трафик: {traffic}\n"
|
||||
f" • Устройств: {new_tariff.device_limit}\n\n"
|
||||
f"⏰ Осталось дней: <b>{remaining_days}</b>\n"
|
||||
f"💰 <b>Доплата: {_format_price_kopeks(upgrade_cost)}</b>\n\n"
|
||||
f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n"
|
||||
f"После оплаты: {_format_price_kopeks(user_balance - upgrade_cost)}",
|
||||
reply_markup=get_instant_switch_confirm_keyboard(tariff_id, db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
missing = upgrade_cost - user_balance
|
||||
await callback.message.edit_text(
|
||||
f"❌ <b>Недостаточно средств</b>\n\n"
|
||||
f"📦 Новый тариф: <b>{new_tariff.name}</b>\n"
|
||||
f"💰 Требуется доплата: {_format_price_kopeks(upgrade_cost)}\n\n"
|
||||
f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n"
|
||||
f"⚠️ Не хватает: <b>{_format_price_kopeks(missing)}</b>",
|
||||
reply_markup=get_instant_switch_insufficient_balance_keyboard(tariff_id, db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
# Downgrade или тот же уровень - бесплатно
|
||||
await callback.message.edit_text(
|
||||
f"⬇️ <b>Переключение тарифа</b>\n\n"
|
||||
f"📌 Текущий: <b>{current_tariff.name}</b>\n"
|
||||
f" • Трафик: {current_traffic}\n"
|
||||
f" • Устройств: {current_tariff.device_limit}\n\n"
|
||||
f"📦 Новый: <b>{new_tariff.name}</b>\n"
|
||||
f" • Трафик: {traffic}\n"
|
||||
f" • Устройств: {new_tariff.device_limit}\n\n"
|
||||
f"⏰ Осталось дней: <b>{remaining_days}</b>\n"
|
||||
f"💰 <b>Бесплатно</b> (понижение/равный тариф)",
|
||||
reply_markup=get_instant_switch_confirm_keyboard(tariff_id, db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await state.update_data(
|
||||
switch_tariff_id=tariff_id,
|
||||
upgrade_cost=upgrade_cost,
|
||||
is_upgrade=is_upgrade,
|
||||
current_tariff_id=current_tariff_id,
|
||||
remaining_days=remaining_days,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def confirm_instant_switch(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""Подтверждает мгновенное переключение тарифа."""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
tariff_id = int(callback.data.split(":")[1])
|
||||
new_tariff = await get_tariff_by_id(db, tariff_id)
|
||||
|
||||
if not new_tariff or not new_tariff.is_active:
|
||||
await callback.answer("Тариф недоступен", show_alert=True)
|
||||
return
|
||||
|
||||
# Получаем данные из состояния
|
||||
data = await state.get_data()
|
||||
upgrade_cost = data.get('upgrade_cost', 0)
|
||||
is_upgrade = data.get('is_upgrade', False)
|
||||
remaining_days = data.get('remaining_days', 0)
|
||||
|
||||
# Проверяем подписку
|
||||
subscription = await get_subscription_by_user_id(db, db_user.id)
|
||||
if not subscription:
|
||||
await callback.answer("Подписка не найдена", show_alert=True)
|
||||
return
|
||||
|
||||
# Проверяем баланс если это upgrade
|
||||
user_balance = db_user.balance_kopeks or 0
|
||||
if is_upgrade and user_balance < upgrade_cost:
|
||||
await callback.answer("Недостаточно средств на балансе", show_alert=True)
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
try:
|
||||
# Списываем баланс если это upgrade
|
||||
if is_upgrade and upgrade_cost > 0:
|
||||
success = await subtract_user_balance(
|
||||
db, db_user, upgrade_cost,
|
||||
f"Переключение на тариф {new_tariff.name}"
|
||||
)
|
||||
if not success:
|
||||
await callback.answer("Ошибка списания баланса", show_alert=True)
|
||||
return
|
||||
|
||||
# Получаем список серверов из нового тарифа
|
||||
squads = new_tariff.allowed_squads or []
|
||||
|
||||
# Обновляем подписку с новыми параметрами тарифа
|
||||
# НЕ меняем end_date - только параметры тарифа
|
||||
subscription.tariff_id = new_tariff.id
|
||||
subscription.traffic_limit_gb = new_tariff.traffic_limit_gb
|
||||
subscription.device_limit = new_tariff.device_limit
|
||||
subscription.connected_squads = squads
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
# Обновляем пользователя в Remnawave
|
||||
try:
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.create_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=False, # Не сбрасываем трафик при переключении
|
||||
reset_reason="мгновенное переключение тарифа",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обновления Remnawave при мгновенном переключении: {e}")
|
||||
|
||||
# Создаем транзакцию если была оплата
|
||||
if is_upgrade and upgrade_cost > 0:
|
||||
await create_transaction(
|
||||
db,
|
||||
user_id=db_user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=-upgrade_cost,
|
||||
description=f"Переключение на тариф {new_tariff.name}",
|
||||
)
|
||||
|
||||
# Отправляем уведомление админу
|
||||
try:
|
||||
admin_notification_service = AdminNotificationService(callback.bot)
|
||||
await admin_notification_service.send_subscription_purchase_notification(
|
||||
db,
|
||||
db_user,
|
||||
subscription,
|
||||
None,
|
||||
remaining_days,
|
||||
was_trial_conversion=False,
|
||||
amount_kopeks=upgrade_cost,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления админу: {e}")
|
||||
|
||||
await state.clear()
|
||||
|
||||
traffic = _format_traffic(new_tariff.traffic_limit_gb)
|
||||
|
||||
if is_upgrade:
|
||||
cost_text = f"💰 Списано: {_format_price_kopeks(upgrade_cost)}"
|
||||
else:
|
||||
cost_text = "💰 Бесплатно"
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"🎉 <b>Тариф успешно изменён!</b>\n\n"
|
||||
f"📦 Новый тариф: <b>{new_tariff.name}</b>\n"
|
||||
f"📊 Трафик: {traffic}\n"
|
||||
f"📱 Устройств: {new_tariff.device_limit}\n"
|
||||
f"⏰ Осталось дней: {remaining_days}\n"
|
||||
f"{cost_text}",
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")],
|
||||
[InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")]
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer("Тариф изменён!", show_alert=True)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при мгновенном переключении тарифа: {e}", exc_info=True)
|
||||
await callback.answer("Произошла ошибка при переключении тарифа", show_alert=True)
|
||||
|
||||
|
||||
def register_tariff_purchase_handlers(dp: Dispatcher):
|
||||
"""Регистрирует обработчики покупки по тарифам."""
|
||||
# Список тарифов (для режима tariffs)
|
||||
@@ -1376,8 +1863,13 @@ def register_tariff_purchase_handlers(dp: Dispatcher):
|
||||
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(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:"))
|
||||
|
||||
@@ -1000,7 +1000,7 @@ def get_subscription_keyboard(
|
||||
settings_row.append(
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("CHANGE_TARIFF_BUTTON", "📦 Тариф"),
|
||||
callback_data="tariff_switch"
|
||||
callback_data="instant_switch"
|
||||
)
|
||||
)
|
||||
keyboard.append(settings_row)
|
||||
|
||||
@@ -6543,6 +6543,271 @@ async def purchase_tariff_endpoint(
|
||||
)
|
||||
|
||||
|
||||
def _calculate_tariff_switch_cost(
|
||||
current_tariff,
|
||||
new_tariff,
|
||||
remaining_days: int,
|
||||
promo_group=None,
|
||||
) -> tuple[int, bool]:
|
||||
"""
|
||||
Рассчитывает стоимость переключения тарифа.
|
||||
|
||||
Returns:
|
||||
(cost_kopeks, is_upgrade) - стоимость доплаты и флаг апгрейда
|
||||
"""
|
||||
# Берём месячную цену (30 дней) как базу
|
||||
current_monthly = current_tariff.get_price_for_period(30) or 0
|
||||
new_monthly = new_tariff.get_price_for_period(30) or 0
|
||||
|
||||
# Применяем скидку промогруппы
|
||||
if promo_group:
|
||||
raw_discounts = getattr(promo_group, 'period_discounts', None) or {}
|
||||
for k, v in raw_discounts.items():
|
||||
try:
|
||||
if int(k) == 30:
|
||||
discount = max(0, min(100, int(v)))
|
||||
current_monthly = int(current_monthly * (100 - discount) / 100)
|
||||
new_monthly = int(new_monthly * (100 - discount) / 100)
|
||||
break
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
price_diff = new_monthly - current_monthly
|
||||
|
||||
if price_diff <= 0:
|
||||
# Даунгрейд или равная цена - бесплатно
|
||||
return 0, False
|
||||
|
||||
# Апгрейд - рассчитываем доплату пропорционально оставшимся дням
|
||||
upgrade_cost = int(price_diff * remaining_days / 30)
|
||||
return upgrade_cost, True
|
||||
|
||||
|
||||
@router.post("/subscription/tariff/switch/preview")
|
||||
async def preview_tariff_switch_endpoint(
|
||||
payload: MiniAppTariffSwitchRequest,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
"""Предпросмотр переключения тарифа - показывает стоимость."""
|
||||
from app.webapi.schemas.miniapp import MiniAppTariffSwitchRequest, MiniAppTariffSwitchPreviewResponse
|
||||
from datetime import datetime
|
||||
|
||||
user = await _authorize_miniapp_user(payload.init_data, db)
|
||||
|
||||
if not settings.is_tariffs_mode():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"code": "tariffs_mode_disabled", "message": "Tariffs mode is not enabled"},
|
||||
)
|
||||
|
||||
subscription = getattr(user, "subscription", None)
|
||||
if not subscription or not subscription.tariff_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"code": "no_subscription", "message": "No active subscription with tariff"},
|
||||
)
|
||||
|
||||
if subscription.status not in ("active", "trial"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"code": "subscription_inactive", "message": "Subscription is not active"},
|
||||
)
|
||||
|
||||
current_tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
new_tariff = await get_tariff_by_id(db, payload.tariff_id)
|
||||
|
||||
if not new_tariff or not new_tariff.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"code": "tariff_not_found", "message": "Tariff not found or inactive"},
|
||||
)
|
||||
|
||||
if subscription.tariff_id == payload.tariff_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"code": "same_tariff", "message": "Already on this tariff"},
|
||||
)
|
||||
|
||||
# Проверяем доступность тарифа для пользователя
|
||||
promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else getattr(user, "promo_group", None)
|
||||
promo_group_id = promo_group.id if promo_group else None
|
||||
if not new_tariff.is_available_for_promo_group(promo_group_id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail={"code": "tariff_not_available", "message": "Tariff not available for your promo group"},
|
||||
)
|
||||
|
||||
# Рассчитываем оставшиеся дни
|
||||
remaining_days = 0
|
||||
if subscription.end_date and subscription.end_date > datetime.utcnow():
|
||||
delta = subscription.end_date - datetime.utcnow()
|
||||
remaining_days = max(0, delta.days)
|
||||
|
||||
# Рассчитываем стоимость переключения
|
||||
upgrade_cost, is_upgrade = _calculate_tariff_switch_cost(
|
||||
current_tariff, new_tariff, remaining_days, promo_group
|
||||
)
|
||||
|
||||
balance = user.balance_kopeks or 0
|
||||
has_enough = balance >= upgrade_cost
|
||||
missing = max(0, upgrade_cost - balance) if not has_enough else 0
|
||||
|
||||
return MiniAppTariffSwitchPreviewResponse(
|
||||
can_switch=has_enough,
|
||||
current_tariff_id=current_tariff.id if current_tariff else None,
|
||||
current_tariff_name=current_tariff.name if current_tariff else None,
|
||||
new_tariff_id=new_tariff.id,
|
||||
new_tariff_name=new_tariff.name,
|
||||
remaining_days=remaining_days,
|
||||
upgrade_cost_kopeks=upgrade_cost,
|
||||
upgrade_cost_label=settings.format_price(upgrade_cost) if upgrade_cost > 0 else "Бесплатно",
|
||||
balance_kopeks=balance,
|
||||
balance_label=settings.format_price(balance),
|
||||
has_enough_balance=has_enough,
|
||||
missing_amount_kopeks=missing,
|
||||
missing_amount_label=settings.format_price(missing) if missing > 0 else "",
|
||||
is_upgrade=is_upgrade,
|
||||
message=None,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/subscription/tariff/switch")
|
||||
async def switch_tariff_endpoint(
|
||||
payload: MiniAppTariffSwitchRequest,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
"""Переключение тарифа без изменения даты окончания."""
|
||||
from app.webapi.schemas.miniapp import MiniAppTariffSwitchRequest, MiniAppTariffSwitchResponse
|
||||
from datetime import datetime
|
||||
|
||||
user = await _authorize_miniapp_user(payload.init_data, db)
|
||||
|
||||
if not settings.is_tariffs_mode():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"code": "tariffs_mode_disabled", "message": "Tariffs mode is not enabled"},
|
||||
)
|
||||
|
||||
subscription = getattr(user, "subscription", None)
|
||||
if not subscription or not subscription.tariff_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"code": "no_subscription", "message": "No active subscription with tariff"},
|
||||
)
|
||||
|
||||
if subscription.status not in ("active", "trial"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"code": "subscription_inactive", "message": "Subscription is not active"},
|
||||
)
|
||||
|
||||
current_tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
new_tariff = await get_tariff_by_id(db, payload.tariff_id)
|
||||
|
||||
if not new_tariff or not new_tariff.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"code": "tariff_not_found", "message": "Tariff not found or inactive"},
|
||||
)
|
||||
|
||||
if subscription.tariff_id == payload.tariff_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"code": "same_tariff", "message": "Already on this tariff"},
|
||||
)
|
||||
|
||||
# Проверяем доступность тарифа
|
||||
promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else getattr(user, "promo_group", None)
|
||||
promo_group_id = promo_group.id if promo_group else None
|
||||
if not new_tariff.is_available_for_promo_group(promo_group_id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail={"code": "tariff_not_available", "message": "Tariff not available"},
|
||||
)
|
||||
|
||||
# Рассчитываем оставшиеся дни
|
||||
remaining_days = 0
|
||||
if subscription.end_date and subscription.end_date > datetime.utcnow():
|
||||
delta = subscription.end_date - datetime.utcnow()
|
||||
remaining_days = max(0, delta.days)
|
||||
|
||||
# Рассчитываем стоимость
|
||||
upgrade_cost, is_upgrade = _calculate_tariff_switch_cost(
|
||||
current_tariff, new_tariff, remaining_days, promo_group
|
||||
)
|
||||
|
||||
# Списываем доплату если апгрейд
|
||||
if upgrade_cost > 0:
|
||||
if user.balance_kopeks < upgrade_cost:
|
||||
missing = upgrade_cost - user.balance_kopeks
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||
detail={
|
||||
"code": "insufficient_funds",
|
||||
"message": f"Недостаточно средств. Не хватает {settings.format_price(missing)}",
|
||||
"missing_amount": missing,
|
||||
},
|
||||
)
|
||||
|
||||
description = f"Переход на тариф '{new_tariff.name}' (доплата за {remaining_days} дней)"
|
||||
success = await subtract_user_balance(db, user, upgrade_cost, description)
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail={"code": "balance_error", "message": "Failed to charge balance"},
|
||||
)
|
||||
|
||||
# Записываем транзакцию
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=upgrade_cost,
|
||||
description=description,
|
||||
)
|
||||
|
||||
# Обновляем подписку - меняем тариф без изменения даты
|
||||
subscription.tariff_id = new_tariff.id
|
||||
subscription.traffic_limit_gb = new_tariff.traffic_limit_gb
|
||||
subscription.device_limit = new_tariff.device_limit
|
||||
subscription.connected_squads = new_tariff.allowed_squads or []
|
||||
# Сбрасываем докупленный трафик при смене тарифа
|
||||
subscription.purchased_traffic_gb = 0
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
await db.refresh(user)
|
||||
|
||||
# Синхронизируем с RemnaWave
|
||||
try:
|
||||
service = SubscriptionService()
|
||||
await service.update_remnawave_user(db, subscription)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка синхронизации с RemnaWave при смене тарифа: {e}")
|
||||
|
||||
lang = getattr(user, "language", settings.DEFAULT_LANGUAGE)
|
||||
if upgrade_cost > 0:
|
||||
if lang == "ru":
|
||||
message = f"Тариф изменён на '{new_tariff.name}'. Списано {settings.format_price(upgrade_cost)}"
|
||||
else:
|
||||
message = f"Switched to '{new_tariff.name}'. Charged {settings.format_price(upgrade_cost)}"
|
||||
else:
|
||||
if lang == "ru":
|
||||
message = f"Тариф изменён на '{new_tariff.name}'"
|
||||
else:
|
||||
message = f"Switched to '{new_tariff.name}'"
|
||||
|
||||
return MiniAppTariffSwitchResponse(
|
||||
success=True,
|
||||
message=message,
|
||||
tariff_id=new_tariff.id,
|
||||
tariff_name=new_tariff.name,
|
||||
charged_kopeks=upgrade_cost,
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
balance_label=settings.format_price(user.balance_kopeks),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/subscription/traffic-topup")
|
||||
async def purchase_traffic_topup_endpoint(
|
||||
payload: MiniAppTrafficTopupRequest,
|
||||
|
||||
@@ -607,6 +607,42 @@ class MiniAppTariffPurchaseResponse(BaseModel):
|
||||
balance_label: Optional[str] = None
|
||||
|
||||
|
||||
class MiniAppTariffSwitchRequest(BaseModel):
|
||||
"""Запрос на переключение тарифа (без выбора периода)."""
|
||||
init_data: str = Field(..., alias="initData")
|
||||
tariff_id: int = Field(..., alias="tariffId")
|
||||
|
||||
|
||||
class MiniAppTariffSwitchPreviewResponse(BaseModel):
|
||||
"""Предпросмотр переключения тарифа."""
|
||||
can_switch: bool = True
|
||||
current_tariff_id: Optional[int] = None
|
||||
current_tariff_name: Optional[str] = None
|
||||
new_tariff_id: int
|
||||
new_tariff_name: str
|
||||
remaining_days: int = 0
|
||||
upgrade_cost_kopeks: int = 0 # 0 если даунгрейд или равная цена
|
||||
upgrade_cost_label: str = ""
|
||||
balance_kopeks: int = 0
|
||||
balance_label: str = ""
|
||||
has_enough_balance: bool = True
|
||||
missing_amount_kopeks: int = 0
|
||||
missing_amount_label: str = ""
|
||||
is_upgrade: bool = False # True если новый тариф дороже
|
||||
message: Optional[str] = None
|
||||
|
||||
|
||||
class MiniAppTariffSwitchResponse(BaseModel):
|
||||
"""Ответ на переключение тарифа."""
|
||||
success: bool = True
|
||||
message: Optional[str] = None
|
||||
tariff_id: int
|
||||
tariff_name: str
|
||||
charged_kopeks: int = 0
|
||||
balance_kopeks: int = 0
|
||||
balance_label: str = ""
|
||||
|
||||
|
||||
class MiniAppSubscriptionResponse(BaseModel):
|
||||
success: bool = True
|
||||
subscription_id: Optional[int] = None
|
||||
|
||||
@@ -816,6 +816,285 @@
|
||||
background: linear-gradient(135deg, rgba(34, 197, 94, 0.2), rgba(34, 197, 94, 0.08));
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Instant Tariff Switch Styles
|
||||
============================================ */
|
||||
.instant-switch-current-info {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.instant-switch-current-tariff,
|
||||
.instant-switch-remaining {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.instant-switch-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.instant-switch-value {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.instant-switch-hint {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
background: linear-gradient(135deg, rgba(59, 130, 246, 0.08) 0%, rgba(59, 130, 246, 0.04) 100%);
|
||||
border: 1px solid rgba(59, 130, 246, 0.15);
|
||||
border-radius: 10px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.instant-switch-hint-icon {
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.instant-switch-hint-text {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Tariff item in instant switch */
|
||||
.instant-switch-tariff-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.instant-switch-tariff-item:hover {
|
||||
border-color: var(--primary);
|
||||
background: rgba(var(--primary-rgb), 0.04);
|
||||
}
|
||||
|
||||
.instant-switch-tariff-item.current {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.instant-switch-tariff-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.instant-switch-tariff-name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.instant-switch-tariff-details {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.instant-switch-tariff-cost {
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.instant-switch-cost-badge {
|
||||
display: inline-block;
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.instant-switch-cost-badge.upgrade {
|
||||
background: linear-gradient(135deg, rgba(249, 115, 22, 0.15) 0%, rgba(249, 115, 22, 0.08) 100%);
|
||||
color: #f97316;
|
||||
border: 1px solid rgba(249, 115, 22, 0.25);
|
||||
}
|
||||
|
||||
.instant-switch-cost-badge.free {
|
||||
background: linear-gradient(135deg, rgba(34, 197, 94, 0.15) 0%, rgba(34, 197, 94, 0.08) 100%);
|
||||
color: #22c55e;
|
||||
border: 1px solid rgba(34, 197, 94, 0.25);
|
||||
}
|
||||
|
||||
/* Confirmation panel */
|
||||
.instant-switch-confirm {
|
||||
margin-top: 20px;
|
||||
padding: 20px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 16px;
|
||||
border: 2px solid var(--primary);
|
||||
animation: slideUp 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.instant-switch-confirm-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.instant-switch-confirm-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.instant-switch-confirm-close {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: var(--bg-tertiary, var(--bg-primary));
|
||||
color: var(--text-secondary);
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.instant-switch-confirm-close:hover {
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.instant-switch-compare {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.instant-switch-compare-item {
|
||||
text-align: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.instant-switch-compare-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.instant-switch-compare-value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.instant-switch-compare-arrow {
|
||||
font-size: 20px;
|
||||
color: var(--primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.instant-switch-cost {
|
||||
text-align: center;
|
||||
padding: 12px;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.instant-switch-cost-label {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.instant-switch-cost-value {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--primary);
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.instant-switch-cost-value.free {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.instant-switch-balance {
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.instant-switch-insufficient {
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: #ef4444;
|
||||
padding: 10px;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.instant-switch-confirm-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.instant-switch-confirm-actions .btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.instant-switch-confirm-actions .btn-secondary {
|
||||
background: var(--bg-tertiary, var(--bg-primary));
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .instant-switch-hint {
|
||||
background: linear-gradient(135deg, rgba(59, 130, 246, 0.12) 0%, rgba(59, 130, 246, 0.06) 100%);
|
||||
border-color: rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .instant-switch-cost-badge.upgrade {
|
||||
background: linear-gradient(135deg, rgba(249, 115, 22, 0.2) 0%, rgba(249, 115, 22, 0.1) 100%);
|
||||
color: #fb923c;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .instant-switch-cost-badge.free {
|
||||
background: linear-gradient(135deg, rgba(34, 197, 94, 0.2) 0%, rgba(34, 197, 94, 0.1) 100%);
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.subscription-settings-toggle.active .tariff-name-badge {
|
||||
background: linear-gradient(135deg, rgba(var(--primary-rgb), 0.25), rgba(var(--primary-rgb), 0.12));
|
||||
box-shadow: 0 2px 6px rgba(var(--primary-rgb), 0.15);
|
||||
@@ -5543,6 +5822,96 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Instant Tariff Switch Section (для пользователей с подпиской) -->
|
||||
<div class="card expandable hidden" id="instantSwitchCard">
|
||||
<div class="card-header">
|
||||
<div class="card-title">
|
||||
<svg class="card-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"/>
|
||||
</svg>
|
||||
<span data-i18n="instant_switch.title">Сменить тариф</span>
|
||||
</div>
|
||||
<svg class="expand-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div id="instantSwitchContent">
|
||||
<div class="subscription-settings-loading" id="instantSwitchLoading">
|
||||
<div class="subscription-settings-loading-line"></div>
|
||||
<div class="subscription-settings-loading-line" style="width: 70%;"></div>
|
||||
</div>
|
||||
<div class="subscription-settings-error hidden" id="instantSwitchError">
|
||||
<div id="instantSwitchErrorText">Не удалось загрузить тарифы</div>
|
||||
<button class="subscription-settings-retry" id="instantSwitchRetry" type="button">Повторить</button>
|
||||
</div>
|
||||
<div id="instantSwitchBody" class="hidden">
|
||||
<!-- Текущий тариф и остаток дней -->
|
||||
<div class="instant-switch-current-info" id="instantSwitchCurrentInfo">
|
||||
<div class="instant-switch-current-tariff">
|
||||
<div class="instant-switch-label">Текущий тариф</div>
|
||||
<div class="instant-switch-value" id="instantSwitchCurrentName">—</div>
|
||||
</div>
|
||||
<div class="instant-switch-remaining">
|
||||
<div class="instant-switch-label">Осталось</div>
|
||||
<div class="instant-switch-value" id="instantSwitchRemainingDays">— дн.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Пояснение -->
|
||||
<div class="instant-switch-hint">
|
||||
<div class="instant-switch-hint-icon">💡</div>
|
||||
<div class="instant-switch-hint-text">
|
||||
При смене тарифа ваш остаток дней сохраняется.
|
||||
Повышение тарифа — доплата за разницу, понижение — бесплатно.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Список тарифов для переключения -->
|
||||
<div class="subscription-renewal-section" style="margin-top: 16px;">
|
||||
<div class="subscription-renewal-section-title">Выберите новый тариф</div>
|
||||
<div id="instantSwitchList" class="subscription-renewal-options"></div>
|
||||
</div>
|
||||
|
||||
<!-- Подтверждение переключения -->
|
||||
<div id="instantSwitchConfirm" class="instant-switch-confirm hidden">
|
||||
<div class="instant-switch-confirm-header">
|
||||
<div class="instant-switch-confirm-title">Подтверждение</div>
|
||||
<button class="instant-switch-confirm-close" id="instantSwitchConfirmClose" type="button">×</button>
|
||||
</div>
|
||||
<div class="instant-switch-confirm-body">
|
||||
<div class="instant-switch-compare">
|
||||
<div class="instant-switch-compare-item">
|
||||
<div class="instant-switch-compare-label">Текущий</div>
|
||||
<div class="instant-switch-compare-value" id="instantSwitchFromTariff">—</div>
|
||||
</div>
|
||||
<div class="instant-switch-compare-arrow">→</div>
|
||||
<div class="instant-switch-compare-item">
|
||||
<div class="instant-switch-compare-label">Новый</div>
|
||||
<div class="instant-switch-compare-value" id="instantSwitchToTariff">—</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="instant-switch-cost" id="instantSwitchCost">
|
||||
<span class="instant-switch-cost-label">Стоимость:</span>
|
||||
<span class="instant-switch-cost-value" id="instantSwitchCostValue">Бесплатно</span>
|
||||
</div>
|
||||
<div class="instant-switch-balance" id="instantSwitchBalance">
|
||||
Ваш баланс: <span id="instantSwitchBalanceValue">—</span>
|
||||
</div>
|
||||
<div class="instant-switch-insufficient hidden" id="instantSwitchInsufficient">
|
||||
⚠️ Недостаточно средств. Не хватает: <span id="instantSwitchMissing">—</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="instant-switch-confirm-actions">
|
||||
<button class="btn btn-secondary" id="instantSwitchCancelBtn" type="button">Отмена</button>
|
||||
<button class="btn btn-primary" id="instantSwitchConfirmBtn" type="button">Подтвердить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Subscription Settings -->
|
||||
<div class="card expandable subscription-settings-card hidden" id="subscriptionSettingsCard">
|
||||
<div class="card-header">
|
||||
@@ -6369,6 +6738,20 @@
|
||||
'tariffs.select': 'Select tariff',
|
||||
'tariffs.current': 'Current tariff',
|
||||
'tariffs.no_tariffs': 'No tariffs available',
|
||||
'instant_switch.title': 'Switch tariff',
|
||||
'instant_switch.current': 'Current tariff',
|
||||
'instant_switch.remaining': 'Remaining',
|
||||
'instant_switch.hint': 'When switching, your remaining days are preserved. Upgrade = pay the difference, downgrade = free.',
|
||||
'instant_switch.select': 'Select new tariff',
|
||||
'instant_switch.confirm': 'Confirmation',
|
||||
'instant_switch.from': 'Current',
|
||||
'instant_switch.to': 'New',
|
||||
'instant_switch.cost': 'Cost:',
|
||||
'instant_switch.free': 'Free',
|
||||
'instant_switch.balance': 'Your balance:',
|
||||
'instant_switch.insufficient': 'Insufficient funds. Missing:',
|
||||
'instant_switch.cancel': 'Cancel',
|
||||
'instant_switch.confirm_btn': 'Confirm',
|
||||
'card.referral.title': 'Referral Program',
|
||||
'card.history.title': 'Transaction History',
|
||||
'card.servers.title': 'Connected Servers',
|
||||
@@ -6817,6 +7200,20 @@
|
||||
'tariffs.select': 'Выбрать тариф',
|
||||
'tariffs.current': 'Текущий тариф',
|
||||
'tariffs.no_tariffs': 'Нет доступных тарифов',
|
||||
'instant_switch.title': 'Сменить тариф',
|
||||
'instant_switch.current': 'Текущий тариф',
|
||||
'instant_switch.remaining': 'Осталось',
|
||||
'instant_switch.hint': 'При смене тарифа остаток дней сохраняется. Повышение = доплата, понижение = бесплатно.',
|
||||
'instant_switch.select': 'Выберите новый тариф',
|
||||
'instant_switch.confirm': 'Подтверждение',
|
||||
'instant_switch.from': 'Текущий',
|
||||
'instant_switch.to': 'Новый',
|
||||
'instant_switch.cost': 'Стоимость:',
|
||||
'instant_switch.free': 'Бесплатно',
|
||||
'instant_switch.balance': 'Ваш баланс:',
|
||||
'instant_switch.insufficient': 'Недостаточно средств. Не хватает:',
|
||||
'instant_switch.cancel': 'Отмена',
|
||||
'instant_switch.confirm_btn': 'Подтвердить',
|
||||
'card.referral.title': 'Реферальная программа',
|
||||
'card.history.title': 'История операций',
|
||||
'card.servers.title': 'Подключённые серверы',
|
||||
@@ -20233,12 +20630,282 @@
|
||||
document.getElementById('tariffsRetry')?.addEventListener('click', loadTariffs);
|
||||
document.getElementById('tariffsSelectBtn')?.addEventListener('click', purchaseTariff);
|
||||
|
||||
// ============================================
|
||||
// Instant Tariff Switch
|
||||
// ============================================
|
||||
let instantSwitchData = null;
|
||||
let instantSwitchSelectedTariff = null;
|
||||
let instantSwitchPreviewData = null;
|
||||
|
||||
async function loadInstantSwitch() {
|
||||
if (!isTariffsMode()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const card = document.getElementById('instantSwitchCard');
|
||||
const loading = document.getElementById('instantSwitchLoading');
|
||||
const error = document.getElementById('instantSwitchError');
|
||||
const body = document.getElementById('instantSwitchBody');
|
||||
|
||||
// Показываем карточку только если есть активная подписка с тарифом
|
||||
const hasActiveSubscription = userData?.subscription_status === 'active' || userData?.subscriptionStatus === 'active';
|
||||
const currentTariff = userData?.current_tariff ?? userData?.currentTariff;
|
||||
|
||||
if (!hasActiveSubscription || !currentTariff) {
|
||||
card?.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
card?.classList.remove('hidden');
|
||||
loading?.classList.remove('hidden');
|
||||
error?.classList.add('hidden');
|
||||
body?.classList.add('hidden');
|
||||
|
||||
try {
|
||||
const initData = tg.initData || '';
|
||||
const response = await fetch('/miniapp/subscription/tariffs', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ initData })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load tariffs');
|
||||
}
|
||||
|
||||
instantSwitchData = await response.json();
|
||||
renderInstantSwitch();
|
||||
} catch (err) {
|
||||
console.error('Failed to load instant switch:', err);
|
||||
loading?.classList.add('hidden');
|
||||
error?.classList.remove('hidden');
|
||||
document.getElementById('instantSwitchErrorText').textContent =
|
||||
err.message || 'Не удалось загрузить тарифы';
|
||||
}
|
||||
}
|
||||
|
||||
function renderInstantSwitch() {
|
||||
const loading = document.getElementById('instantSwitchLoading');
|
||||
const body = document.getElementById('instantSwitchBody');
|
||||
const list = document.getElementById('instantSwitchList');
|
||||
const currentNameEl = document.getElementById('instantSwitchCurrentName');
|
||||
const remainingEl = document.getElementById('instantSwitchRemainingDays');
|
||||
const confirmPanel = document.getElementById('instantSwitchConfirm');
|
||||
|
||||
loading?.classList.add('hidden');
|
||||
body?.classList.remove('hidden');
|
||||
confirmPanel?.classList.add('hidden');
|
||||
|
||||
// Текущий тариф и остаток дней
|
||||
const currentTariff = instantSwitchData?.current_tariff || instantSwitchData?.currentTariff;
|
||||
if (currentTariff && currentNameEl) {
|
||||
currentNameEl.textContent = currentTariff.name;
|
||||
}
|
||||
|
||||
// Остаток дней из userData
|
||||
const daysLeft = userData?.days_left ?? userData?.daysLeft ?? 0;
|
||||
if (remainingEl) {
|
||||
remainingEl.textContent = `${daysLeft} дн.`;
|
||||
}
|
||||
|
||||
// Список тарифов
|
||||
if (!list) return;
|
||||
list.innerHTML = '';
|
||||
|
||||
const tariffs = instantSwitchData?.tariffs || [];
|
||||
const currentTariffId = currentTariff?.id;
|
||||
const availableTariffs = tariffs.filter(t => t.id !== currentTariffId);
|
||||
|
||||
if (availableTariffs.length === 0) {
|
||||
list.innerHTML = '<div style="text-align: center; color: var(--text-secondary); padding: 20px;">Нет других доступных тарифов</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Рассчитываем стоимость для каждого тарифа
|
||||
const currentMonthly = getMonthlyPrice(currentTariff);
|
||||
|
||||
availableTariffs.forEach(tariff => {
|
||||
const newMonthly = getMonthlyPrice(tariff);
|
||||
const priceDiff = newMonthly - currentMonthly;
|
||||
const isUpgrade = priceDiff > 0;
|
||||
const upgradeCost = isUpgrade ? Math.round(priceDiff * daysLeft / 30) : 0;
|
||||
|
||||
const trafficLabel = tariff.traffic_limit_label || tariff.trafficLimitLabel
|
||||
|| ((tariff.traffic_limit_gb || tariff.trafficLimitGb) === 0 ? '∞' : (tariff.traffic_limit_gb || tariff.trafficLimitGb) + ' ГБ');
|
||||
const deviceLimit = tariff.device_limit || tariff.deviceLimit || 1;
|
||||
|
||||
const div = document.createElement('div');
|
||||
div.className = 'instant-switch-tariff-item';
|
||||
div.innerHTML = `
|
||||
<div class="instant-switch-tariff-info">
|
||||
<div class="instant-switch-tariff-name">${escapeHtml(tariff.name)}</div>
|
||||
<div class="instant-switch-tariff-details">
|
||||
<span>📱 ${deviceLimit}</span>
|
||||
<span>📊 ${trafficLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="instant-switch-tariff-cost">
|
||||
<span class="instant-switch-cost-badge ${isUpgrade ? 'upgrade' : 'free'}">
|
||||
${isUpgrade ? '+' + formatPriceFromKopeks(upgradeCost, instantSwitchData?.currency || 'RUB') : 'Бесплатно'}
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
div.addEventListener('click', () => previewInstantSwitch(tariff));
|
||||
list.appendChild(div);
|
||||
});
|
||||
}
|
||||
|
||||
function getMonthlyPrice(tariff) {
|
||||
if (!tariff) return 0;
|
||||
const periods = tariff.periods || [];
|
||||
const period30 = periods.find(p => (p.days || p.period_days || p.periodDays) === 30);
|
||||
if (period30) {
|
||||
return period30.price_kopeks || period30.priceKopeks || period30.final_price || period30.finalPrice || 0;
|
||||
}
|
||||
// Fallback - пропорционально пересчитываем
|
||||
if (periods.length > 0) {
|
||||
const firstPeriod = periods[0];
|
||||
const days = firstPeriod.days || firstPeriod.period_days || firstPeriod.periodDays || 30;
|
||||
const price = firstPeriod.price_kopeks || firstPeriod.priceKopeks || 0;
|
||||
return Math.round(price * 30 / days);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function previewInstantSwitch(tariff) {
|
||||
instantSwitchSelectedTariff = tariff;
|
||||
|
||||
const confirmPanel = document.getElementById('instantSwitchConfirm');
|
||||
const fromTariffEl = document.getElementById('instantSwitchFromTariff');
|
||||
const toTariffEl = document.getElementById('instantSwitchToTariff');
|
||||
const costValueEl = document.getElementById('instantSwitchCostValue');
|
||||
const balanceValueEl = document.getElementById('instantSwitchBalanceValue');
|
||||
const insufficientEl = document.getElementById('instantSwitchInsufficient');
|
||||
const missingEl = document.getElementById('instantSwitchMissing');
|
||||
const confirmBtn = document.getElementById('instantSwitchConfirmBtn');
|
||||
|
||||
// Показываем панель подтверждения
|
||||
confirmPanel?.classList.remove('hidden');
|
||||
|
||||
// Заполняем сравнение
|
||||
const currentTariff = instantSwitchData?.current_tariff || instantSwitchData?.currentTariff;
|
||||
if (fromTariffEl) fromTariffEl.textContent = currentTariff?.name || '—';
|
||||
if (toTariffEl) toTariffEl.textContent = tariff.name;
|
||||
|
||||
// Запрашиваем превью с сервера
|
||||
try {
|
||||
const initData = tg.initData || '';
|
||||
const response = await fetch('/miniapp/subscription/tariff/switch/preview', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ initData, tariffId: tariff.id })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error?.detail?.message || 'Ошибка');
|
||||
}
|
||||
|
||||
instantSwitchPreviewData = await response.json();
|
||||
|
||||
// Стоимость
|
||||
if (costValueEl) {
|
||||
if (instantSwitchPreviewData.is_upgrade || instantSwitchPreviewData.isUpgrade) {
|
||||
costValueEl.textContent = instantSwitchPreviewData.upgrade_cost_label || instantSwitchPreviewData.upgradeCostLabel || '—';
|
||||
costValueEl.classList.remove('free');
|
||||
} else {
|
||||
costValueEl.textContent = 'Бесплатно';
|
||||
costValueEl.classList.add('free');
|
||||
}
|
||||
}
|
||||
|
||||
// Баланс
|
||||
if (balanceValueEl) {
|
||||
balanceValueEl.textContent = instantSwitchPreviewData.balance_label || instantSwitchPreviewData.balanceLabel || '—';
|
||||
}
|
||||
|
||||
// Недостаточно средств
|
||||
const hasEnough = instantSwitchPreviewData.has_enough_balance ?? instantSwitchPreviewData.hasEnoughBalance ?? true;
|
||||
if (!hasEnough) {
|
||||
insufficientEl?.classList.remove('hidden');
|
||||
if (missingEl) {
|
||||
missingEl.textContent = instantSwitchPreviewData.missing_amount_label || instantSwitchPreviewData.missingAmountLabel || '—';
|
||||
}
|
||||
if (confirmBtn) confirmBtn.disabled = true;
|
||||
} else {
|
||||
insufficientEl?.classList.add('hidden');
|
||||
if (confirmBtn) confirmBtn.disabled = false;
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error('Preview failed:', err);
|
||||
showPopup(err.message || 'Не удалось получить информацию', 'Ошибка');
|
||||
confirmPanel?.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function closeInstantSwitchConfirm() {
|
||||
const confirmPanel = document.getElementById('instantSwitchConfirm');
|
||||
confirmPanel?.classList.add('hidden');
|
||||
instantSwitchSelectedTariff = null;
|
||||
instantSwitchPreviewData = null;
|
||||
}
|
||||
|
||||
async function confirmInstantSwitch() {
|
||||
if (!instantSwitchSelectedTariff) return;
|
||||
|
||||
const confirmBtn = document.getElementById('instantSwitchConfirmBtn');
|
||||
const cancelBtn = document.getElementById('instantSwitchCancelBtn');
|
||||
|
||||
if (confirmBtn) {
|
||||
confirmBtn.disabled = true;
|
||||
confirmBtn.textContent = 'Обработка...';
|
||||
}
|
||||
if (cancelBtn) cancelBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const initData = tg.initData || '';
|
||||
const response = await fetch('/miniapp/subscription/tariff/switch', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ initData, tariffId: instantSwitchSelectedTariff.id })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result?.detail?.message || result?.message || 'Ошибка переключения');
|
||||
}
|
||||
|
||||
showPopup(result.message || 'Тариф успешно изменён!', 'Успех');
|
||||
closeInstantSwitchConfirm();
|
||||
await refreshSubscriptionData();
|
||||
} catch (err) {
|
||||
console.error('Switch failed:', err);
|
||||
showPopup(err.message || 'Не удалось сменить тариф', 'Ошибка');
|
||||
} finally {
|
||||
if (confirmBtn) {
|
||||
confirmBtn.disabled = false;
|
||||
confirmBtn.textContent = 'Подтвердить';
|
||||
}
|
||||
if (cancelBtn) cancelBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Event listeners для instant switch
|
||||
document.getElementById('instantSwitchRetry')?.addEventListener('click', loadInstantSwitch);
|
||||
document.getElementById('instantSwitchConfirmClose')?.addEventListener('click', closeInstantSwitchConfirm);
|
||||
document.getElementById('instantSwitchCancelBtn')?.addEventListener('click', closeInstantSwitchConfirm);
|
||||
document.getElementById('instantSwitchConfirmBtn')?.addEventListener('click', confirmInstantSwitch);
|
||||
|
||||
// Загружаем тарифы после загрузки данных подписки
|
||||
const originalApplySubscriptionData = applySubscriptionData;
|
||||
applySubscriptionData = function(payload) {
|
||||
const result = originalApplySubscriptionData(payload);
|
||||
if (isTariffsMode()) {
|
||||
loadTariffs();
|
||||
loadInstantSwitch();
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user