diff --git a/app/handlers/subscription/tariff_purchase.py b/app/handlers/subscription/tariff_purchase.py
index 322228c4..bb7a9a41 100644
--- a/app/handlers/subscription/tariff_purchase.py
+++ b/app/handlers/subscription/tariff_purchase.py
@@ -1,5 +1,6 @@
"""Покупка подписки по тарифам."""
import logging
+from datetime import timedelta
from typing import List, Optional
from aiogram import Dispatcher, types, F
@@ -98,19 +99,27 @@ def format_tariffs_list_text(
traffic = "∞" if traffic_gb == 0 else f"{traffic_gb}ГБ"
# Цена
- prices = tariff.period_prices or {}
+ is_daily = getattr(tariff, 'is_daily', False)
price_text = ""
discount_icon = ""
- if prices:
- min_period = min(prices.keys(), key=int)
- min_price = prices[min_period]
- discount_percent = 0
- if db_user:
- discount_percent = _get_user_period_discount(db_user, int(min_period))
- if discount_percent > 0:
- min_price = _apply_promo_discount(min_price, discount_percent)
- discount_icon = "🔥"
- price_text = f"от {_format_price_kopeks(min_price, compact=True)}{discount_icon}"
+
+ if is_daily:
+ # Для суточных тарифов показываем цену за день
+ daily_price = getattr(tariff, 'daily_price_kopeks', 0)
+ price_text = f"🔄 {_format_price_kopeks(daily_price, compact=True)}/день"
+ else:
+ # Для периодных тарифов показываем минимальную цену
+ prices = tariff.period_prices or {}
+ if prices:
+ min_period = min(prices.keys(), key=int)
+ min_price = prices[min_period]
+ discount_percent = 0
+ if db_user:
+ discount_percent = _get_user_period_discount(db_user, int(min_period))
+ if discount_percent > 0:
+ min_price = _apply_promo_discount(min_price, discount_percent)
+ discount_icon = "🔥"
+ price_text = f"от {_format_price_kopeks(min_price, compact=True)}{discount_icon}"
# Компактный формат: Название — 250ГБ/10📱 от 179₽🔥
lines.append(f"{tariff.name} — {traffic}/{tariff.device_limit}📱 {price_text}")
@@ -257,11 +266,58 @@ def format_tariff_info_for_user(
if discount_percent > 0:
text += f"\n🎁 Ваша скидка: {discount_percent}%\n"
- text += "\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"
+ )
+ ]
+ ])
+
+
@error_handler
async def show_tariffs_list(
callback: types.CallbackQuery,
@@ -324,11 +380,48 @@ async def select_tariff(
await callback.answer("Тариф недоступен", show_alert=True)
return
- 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"
- )
+ # Проверяем, суточный ли это тариф
+ is_daily = getattr(tariff, 'is_daily', False)
+
+ if is_daily:
+ # Для суточного тарифа показываем подтверждение без выбора периода
+ daily_price = getattr(tariff, 'daily_price_kopeks', 0)
+ user_balance = db_user.balance_kopeks or 0
+ traffic = _format_traffic(tariff.traffic_limit_gb)
+
+ if user_balance >= daily_price:
+ await callback.message.edit_text(
+ f"✅ Подтверждение покупки\n\n"
+ f"📦 Тариф: {tariff.name}\n"
+ f"📊 Трафик: {traffic}\n"
+ f"📱 Устройств: {tariff.device_limit}\n"
+ f"🔄 Тип: Суточный\n\n"
+ f"💰 Цена: {_format_price_kopeks(daily_price)}/день\n\n"
+ f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n\n"
+ f"ℹ️ Средства будут списываться автоматически раз в сутки.\n"
+ f"Вы можете приостановить подписку в любой момент.",
+ reply_markup=get_daily_tariff_confirm_keyboard(tariff_id, db_user.language),
+ parse_mode="HTML"
+ )
+ else:
+ missing = daily_price - user_balance
+ await callback.message.edit_text(
+ f"❌ Недостаточно средств\n\n"
+ f"📦 Тариф: {tariff.name}\n"
+ f"🔄 Тип: Суточный\n"
+ f"💰 Цена: {_format_price_kopeks(daily_price)}/день\n\n"
+ f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n"
+ f"⚠️ Не хватает: {_format_price_kopeks(missing)}",
+ reply_markup=get_daily_tariff_insufficient_balance_keyboard(tariff_id, db_user.language),
+ 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()
@@ -546,6 +639,160 @@ async def confirm_tariff_purchase(
await callback.answer("Произошла ошибка при оформлении подписки", show_alert=True)
+# ==================== Покупка суточного тарифа ====================
+
+@error_handler
+async def confirm_daily_tariff_purchase(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession,
+ state: FSMContext,
+):
+ """Подтверждает покупку суточного тарифа."""
+ from datetime import datetime
+
+ tariff_id = int(callback.data.split(":")[1])
+ tariff = await get_tariff_by_id(db, tariff_id)
+
+ if not tariff or not tariff.is_active:
+ await callback.answer("Тариф недоступен", show_alert=True)
+ return
+
+ is_daily = getattr(tariff, 'is_daily', False)
+ if not is_daily:
+ await callback.answer("Это не суточный тариф", show_alert=True)
+ return
+
+ daily_price = getattr(tariff, 'daily_price_kopeks', 0)
+ if daily_price <= 0:
+ await callback.answer("Некорректная цена тарифа", show_alert=True)
+ return
+
+ # Проверяем баланс
+ user_balance = db_user.balance_kopeks or 0
+ if user_balance < daily_price:
+ await callback.answer("Недостаточно средств на балансе", show_alert=True)
+ return
+
+ texts = get_texts(db_user.language)
+
+ try:
+ # Списываем первый день сразу
+ success = await subtract_user_balance(
+ db, db_user, daily_price,
+ f"Покупка суточного тарифа {tariff.name} (первый день)"
+ )
+ if not success:
+ await callback.answer("Ошибка списания баланса", show_alert=True)
+ return
+
+ # Получаем список серверов из тарифа
+ squads = tariff.allowed_squads or []
+
+ # Проверяем есть ли уже подписка
+ existing_subscription = await get_subscription_by_user_id(db, db_user.id)
+
+ if existing_subscription:
+ # Обновляем существующую подписку на суточный тариф
+ existing_subscription.tariff_id = tariff.id
+ existing_subscription.traffic_limit_gb = tariff.traffic_limit_gb
+ existing_subscription.device_limit = tariff.device_limit
+ existing_subscription.connected_squads = squads
+ existing_subscription.status = "active"
+ existing_subscription.is_daily_paused = False
+ existing_subscription.last_daily_charge_at = datetime.utcnow()
+ # Для суточного тарифа ставим далёкую дату окончания
+ existing_subscription.end_date = datetime.utcnow() + timedelta(days=365 * 10)
+
+ await db.commit()
+ await db.refresh(existing_subscription)
+ subscription = existing_subscription
+ else:
+ # Создаем новую подписку
+ subscription = await create_paid_subscription(
+ db=db,
+ user_id=db_user.id,
+ duration_days=365 * 10, # Суточные подписки не имеют фиксированного срока
+ traffic_limit_gb=tariff.traffic_limit_gb,
+ device_limit=tariff.device_limit,
+ connected_squads=squads,
+ tariff_id=tariff.id,
+ )
+ # Устанавливаем время последнего списания
+ subscription.last_daily_charge_at = datetime.utcnow()
+ subscription.is_daily_paused = False
+ await db.commit()
+ await db.refresh(subscription)
+
+ # Обновляем пользователя в Remnawave
+ try:
+ subscription_service = SubscriptionService()
+ await subscription_service.create_remnawave_user(
+ db,
+ subscription,
+ reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
+ reset_reason="покупка суточного тарифа",
+ )
+ except Exception as e:
+ logger.error(f"Ошибка обновления Remnawave: {e}")
+
+ # Создаем транзакцию
+ await create_transaction(
+ db,
+ user_id=db_user.id,
+ type=TransactionType.SUBSCRIPTION_PAYMENT,
+ amount_kopeks=-daily_price,
+ description=f"Покупка суточного тарифа {tariff.name} (первый день)",
+ )
+
+ # Отправляем уведомление админу
+ try:
+ admin_notification_service = AdminNotificationService(callback.bot)
+ await admin_notification_service.send_subscription_purchase_notification(
+ db,
+ db_user,
+ subscription,
+ None,
+ 1, # 1 день
+ was_trial_conversion=False,
+ amount_kopeks=daily_price,
+ )
+ except Exception as e:
+ logger.error(f"Ошибка отправки уведомления админу: {e}")
+
+ # Очищаем корзину после успешной покупки
+ try:
+ await user_cart_service.delete_user_cart(db_user.id)
+ logger.info(f"Корзина очищена после покупки суточного тарифа для пользователя {db_user.telegram_id}")
+ except Exception as e:
+ logger.error(f"Ошибка очистки корзины: {e}")
+
+ await state.clear()
+
+ traffic = _format_traffic(tariff.traffic_limit_gb)
+
+ await callback.message.edit_text(
+ f"🎉 Суточная подписка оформлена!\n\n"
+ f"📦 Тариф: {tariff.name}\n"
+ f"📊 Трафик: {traffic}\n"
+ f"📱 Устройств: {tariff.device_limit}\n"
+ f"🔄 Тип: Суточный\n"
+ f"💰 Списано: {_format_price_kopeks(daily_price)}\n\n"
+ f"ℹ️ Следующее списание через 24 часа.\n"
+ f"Перейдите в раздел «Подписка» для подключения.",
+ reply_markup=InlineKeyboardMarkup(inline_keyboard=[
+ [InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")],
+ [InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")]
+ ]),
+ parse_mode="HTML"
+ )
+ await callback.answer("Подписка оформлена!", show_alert=True)
+
+ except Exception as e:
+ logger.error(f"Ошибка при покупке суточного тарифа: {e}", exc_info=True)
+ await callback.answer("Произошла ошибка при оформлении подписки", show_alert=True)
+
+
# ==================== Продление по тарифу ====================
def get_tariff_extend_keyboard(
@@ -880,19 +1127,27 @@ def format_tariff_switch_list_text(
traffic_gb = tariff.traffic_limit_gb
traffic = "∞" if traffic_gb == 0 else f"{traffic_gb}ГБ"
- prices = tariff.period_prices or {}
+ # Проверяем суточный ли тариф
+ is_daily = getattr(tariff, 'is_daily', False)
price_text = ""
discount_icon = ""
- if prices:
- min_period = min(prices.keys(), key=int)
- min_price = prices[min_period]
- discount_percent = 0
- if db_user:
- discount_percent = _get_user_period_discount(db_user, int(min_period))
- if discount_percent > 0:
- min_price = _apply_promo_discount(min_price, discount_percent)
- discount_icon = "🔥"
- price_text = f"от {_format_price_kopeks(min_price, compact=True)}{discount_icon}"
+
+ if is_daily:
+ # Для суточных тарифов показываем цену за день
+ daily_price = getattr(tariff, 'daily_price_kopeks', 0)
+ price_text = f"🔄 {_format_price_kopeks(daily_price, compact=True)}/день"
+ else:
+ prices = tariff.period_prices or {}
+ if prices:
+ min_period = min(prices.keys(), key=int)
+ min_price = prices[min_period]
+ discount_percent = 0
+ if db_user:
+ discount_percent = _get_user_period_discount(db_user, int(min_period))
+ if discount_percent > 0:
+ min_price = _apply_promo_discount(min_price, discount_percent)
+ discount_icon = "🔥"
+ price_text = f"от {_format_price_kopeks(min_price, compact=True)}{discount_icon}"
lines.append(f"{tariff.name} — {traffic}/{tariff.device_limit}📱 {price_text}")
@@ -1105,23 +1360,77 @@ async def select_tariff_switch(
traffic = _format_traffic(tariff.traffic_limit_gb)
- info_text = f"""📦 {tariff.name}
+ # Проверяем, суточный ли это тариф
+ is_daily = getattr(tariff, 'is_daily', False)
+
+ if is_daily:
+ # Для суточного тарифа показываем подтверждение без выбора периода
+ daily_price = getattr(tariff, 'daily_price_kopeks', 0)
+ user_balance = db_user.balance_kopeks or 0
+
+ if user_balance >= daily_price:
+ await callback.message.edit_text(
+ f"✅ Подтверждение смены тарифа\n\n"
+ f"📦 Новый тариф: {tariff.name}\n"
+ f"📊 Трафик: {traffic}\n"
+ f"📱 Устройств: {tariff.device_limit}\n"
+ f"🔄 Тип: Суточный\n\n"
+ f"💰 Цена: {_format_price_kopeks(daily_price)}/день\n\n"
+ f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\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"❌ Недостаточно средств\n\n"
+ f"📦 Тариф: {tariff.name}\n"
+ f"🔄 Тип: Суточный\n"
+ f"💰 Цена: {_format_price_kopeks(daily_price)}/день\n\n"
+ f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n"
+ f"⚠️ Не хватает: {_format_price_kopeks(missing)}",
+ 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"""📦 {tariff.name}
Параметры нового тарифа:
• Трафик: {traffic}
• Устройств: {tariff.device_limit}
"""
- if tariff.description:
- info_text += f"\n📝 {tariff.description}\n"
+ if tariff.description:
+ info_text += f"\n📝 {tariff.description}\n"
- info_text += "\n⚠️ Оплачивается полная стоимость тарифа.\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 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()
@@ -1356,6 +1665,137 @@ async def confirm_tariff_switch(
await callback.answer("Произошла ошибка при переключении тарифа", show_alert=True)
+# ==================== Смена на суточный тариф ====================
+
+@error_handler
+async def confirm_daily_tariff_switch(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession,
+ state: FSMContext,
+):
+ """Подтверждает смену на суточный тариф."""
+ from datetime import datetime
+
+ tariff_id = int(callback.data.split(":")[1])
+ tariff = await get_tariff_by_id(db, tariff_id)
+
+ if not tariff or not tariff.is_active:
+ await callback.answer("Тариф недоступен", show_alert=True)
+ return
+
+ is_daily = getattr(tariff, 'is_daily', False)
+ if not is_daily:
+ await callback.answer("Это не суточный тариф", show_alert=True)
+ return
+
+ daily_price = getattr(tariff, 'daily_price_kopeks', 0)
+ if daily_price <= 0:
+ await callback.answer("Некорректная цена тарифа", show_alert=True)
+ return
+
+ # Проверяем баланс
+ user_balance = db_user.balance_kopeks or 0
+ if user_balance < daily_price:
+ await callback.answer("Недостаточно средств на балансе", show_alert=True)
+ return
+
+ # Проверяем наличие подписки
+ subscription = await get_subscription_by_user_id(db, db_user.id)
+ if not subscription:
+ await callback.answer("У вас нет активной подписки", show_alert=True)
+ return
+
+ texts = get_texts(db_user.language)
+
+ try:
+ # Списываем первый день сразу
+ success = await subtract_user_balance(
+ db, db_user, daily_price,
+ f"Смена на суточный тариф {tariff.name} (первый день)"
+ )
+ if not success:
+ await callback.answer("Ошибка списания баланса", show_alert=True)
+ return
+
+ # Получаем список серверов из тарифа
+ squads = tariff.allowed_squads or []
+
+ # Обновляем подписку на суточный тариф
+ subscription.tariff_id = tariff.id
+ subscription.traffic_limit_gb = tariff.traffic_limit_gb
+ subscription.device_limit = tariff.device_limit
+ subscription.connected_squads = squads
+ subscription.status = "active"
+ subscription.is_daily_paused = False
+ subscription.last_daily_charge_at = datetime.utcnow()
+ # Для суточного тарифа ставим далёкую дату окончания
+ subscription.end_date = datetime.utcnow() + timedelta(days=365 * 10)
+
+ await db.commit()
+ await db.refresh(subscription)
+
+ # Обновляем пользователя в Remnawave
+ try:
+ subscription_service = SubscriptionService()
+ await subscription_service.create_remnawave_user(
+ db,
+ subscription,
+ reset_traffic=True,
+ reset_reason="смена на суточный тариф",
+ )
+ except Exception as e:
+ logger.error(f"Ошибка обновления Remnawave: {e}")
+
+ # Создаем транзакцию
+ await create_transaction(
+ db,
+ user_id=db_user.id,
+ type=TransactionType.SUBSCRIPTION_PAYMENT,
+ amount_kopeks=-daily_price,
+ description=f"Смена на суточный тариф {tariff.name} (первый день)",
+ )
+
+ # Отправляем уведомление админу
+ try:
+ admin_notification_service = AdminNotificationService(callback.bot)
+ await admin_notification_service.send_subscription_purchase_notification(
+ db,
+ db_user,
+ subscription,
+ None,
+ 1, # 1 день
+ was_trial_conversion=False,
+ amount_kopeks=daily_price,
+ )
+ except Exception as e:
+ logger.error(f"Ошибка отправки уведомления админу: {e}")
+
+ await state.clear()
+
+ traffic = _format_traffic(tariff.traffic_limit_gb)
+
+ await callback.message.edit_text(
+ f"🎉 Тариф успешно изменён!\n\n"
+ f"📦 Новый тариф: {tariff.name}\n"
+ f"📊 Трафик: {traffic}\n"
+ f"📱 Устройств: {tariff.device_limit}\n"
+ f"🔄 Тип: Суточный\n"
+ f"💰 Списано: {_format_price_kopeks(daily_price)}\n\n"
+ f"ℹ️ Следующее списание через 24 часа.",
+ reply_markup=InlineKeyboardMarkup(inline_keyboard=[
+ [InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")],
+ [InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")]
+ ]),
+ parse_mode="HTML"
+ )
+ await callback.answer("Тариф изменён!", show_alert=True)
+
+ except Exception as e:
+ logger.error(f"Ошибка при смене на суточный тариф: {e}", exc_info=True)
+ await callback.answer("Произошла ошибка при смене тарифа", show_alert=True)
+
+
# ==================== Мгновенное переключение тарифов (без выбора периода) ====================
def _get_tariff_monthly_price(tariff: Tariff) -> int:
@@ -1874,6 +2314,9 @@ def register_tariff_purchase_handlers(dp: Dispatcher):
# Подтверждение покупки
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(select_tariff_extend_period, F.data.startswith("tariff_extend:"))
dp.callback_query.register(confirm_tariff_extend, F.data.startswith("tariff_ext_confirm:"))
@@ -1884,6 +2327,9 @@ def register_tariff_purchase_handlers(dp: Dispatcher):
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:"))