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:"))