Implement custom days and traffic handling in subscription purchase flow

- Added new states for selecting custom days and traffic in the subscription process.
- Enhanced the tariff purchase handler to support custom days and traffic adjustments.
- Introduced new functions for formatting and displaying custom tariff previews.
- Updated the ban notification service to include a new notification type for WiFi bans.
- Modified API routes and schemas to accommodate the new notification type and its parameters.
This commit is contained in:
PEDZEO
2026-01-14 06:37:59 +03:00
parent a686333603
commit 84abf529de
7 changed files with 613 additions and 11 deletions

View File

@@ -3190,12 +3190,27 @@ async def handle_toggle_daily_subscription_pause(
"DAILY_SUBSCRIPTION_RESUMED",
"▶️ Подписка возобновлена!"
)
# Синхронизируем с Remnawave - активируем пользователя
try:
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=False,
reset_reason=None,
)
logger.info(f"✅ Синхронизировано с Remnawave после возобновления суточной подписки {subscription.id}")
except Exception as e:
logger.error(f"Ошибка синхронизации с Remnawave при возобновлении: {e}")
else:
# Была активна, теперь на паузе
message = texts.t(
"DAILY_SUBSCRIPTION_PAUSED",
"⏸️ Подписка приостановлена!"
)
# При паузе можно отключить пользователя в Remnawave (опционально)
# Пока оставляем активным, т.к. пауза - это только остановка списания
await callback.answer(message, show_alert=True)

View File

@@ -318,6 +318,151 @@ def get_daily_tariff_insufficient_balance_keyboard(
])
# ==================== Кастомные дни/трафик ====================
def get_custom_tariff_keyboard(
tariff_id: int,
language: str,
days: int,
traffic_gb: int,
can_custom_days: bool,
can_custom_traffic: bool,
min_days: int = 1,
max_days: int = 365,
min_traffic: int = 1,
max_traffic: int = 1000,
) -> InlineKeyboardMarkup:
"""Создает клавиатуру для настройки кастомных дней и трафика."""
texts = get_texts(language)
buttons = []
# Кнопки изменения дней
if can_custom_days:
days_row = []
# -30 / -7 / -1
if days > min_days:
if days - 30 >= min_days:
days_row.append(InlineKeyboardButton(text="-30", callback_data=f"custom_days:{tariff_id}:-30"))
if days - 7 >= min_days:
days_row.append(InlineKeyboardButton(text="-7", callback_data=f"custom_days:{tariff_id}:-7"))
days_row.append(InlineKeyboardButton(text="-1", callback_data=f"custom_days:{tariff_id}:-1"))
# Текущее значение
days_row.append(InlineKeyboardButton(text=f"📅 {days} дн.", callback_data="noop"))
# +1 / +7 / +30
if days < max_days:
days_row.append(InlineKeyboardButton(text="+1", callback_data=f"custom_days:{tariff_id}:1"))
if days + 7 <= max_days:
days_row.append(InlineKeyboardButton(text="+7", callback_data=f"custom_days:{tariff_id}:7"))
if days + 30 <= max_days:
days_row.append(InlineKeyboardButton(text="+30", callback_data=f"custom_days:{tariff_id}:30"))
if days_row:
buttons.append(days_row)
# Кнопки изменения трафика
if can_custom_traffic:
traffic_row = []
# -100 / -10 / -1
if traffic_gb > min_traffic:
if traffic_gb - 100 >= min_traffic:
traffic_row.append(InlineKeyboardButton(text="-100", callback_data=f"custom_traffic:{tariff_id}:-100"))
if traffic_gb - 10 >= min_traffic:
traffic_row.append(InlineKeyboardButton(text="-10", callback_data=f"custom_traffic:{tariff_id}:-10"))
traffic_row.append(InlineKeyboardButton(text="-1", callback_data=f"custom_traffic:{tariff_id}:-1"))
# Текущее значение
traffic_row.append(InlineKeyboardButton(text=f"📊 {traffic_gb} ГБ", callback_data="noop"))
# +1 / +10 / +100
if traffic_gb < max_traffic:
traffic_row.append(InlineKeyboardButton(text="+1", callback_data=f"custom_traffic:{tariff_id}:1"))
if traffic_gb + 10 <= max_traffic:
traffic_row.append(InlineKeyboardButton(text="+10", callback_data=f"custom_traffic:{tariff_id}:10"))
if traffic_gb + 100 <= max_traffic:
traffic_row.append(InlineKeyboardButton(text="+100", callback_data=f"custom_traffic:{tariff_id}:100"))
if traffic_row:
buttons.append(traffic_row)
# Кнопка подтверждения
buttons.append([
InlineKeyboardButton(
text="✅ Подтвердить покупку",
callback_data=f"custom_confirm:{tariff_id}"
)
])
# Кнопка назад
buttons.append([
InlineKeyboardButton(text=texts.BACK, callback_data="tariff_list")
])
return InlineKeyboardMarkup(inline_keyboard=buttons)
def format_custom_tariff_preview(
tariff: Tariff,
days: int,
traffic_gb: int,
user_balance: int,
discount_percent: int = 0,
) -> str:
"""Форматирует предпросмотр покупки с кастомными параметрами."""
# Рассчитываем цену
days_price = 0
traffic_price = 0
if tariff.can_purchase_custom_days():
days_price = tariff.get_price_for_custom_days(days) or 0
if tariff.can_purchase_custom_traffic():
traffic_price = tariff.get_price_for_custom_traffic(traffic_gb) or 0
total_price = days_price + traffic_price
# Применяем скидку
if discount_percent > 0:
total_price = _apply_promo_discount(total_price, discount_percent)
traffic_display = f"{traffic_gb} ГБ" if traffic_gb > 0 else _format_traffic(tariff.traffic_limit_gb)
text = f"""📦 <b>{tariff.name}</b>
<b>Настройте параметры:</b>
"""
if tariff.can_purchase_custom_days():
text += f"📅 Дней: <b>{days}</b> (от {tariff.min_days} до {tariff.max_days})\n"
text += f" 💰 {_format_price_kopeks(days_price)}\n"
if tariff.can_purchase_custom_traffic():
text += f"📊 Трафик: <b>{traffic_gb} ГБ</b> (от {tariff.min_traffic_gb} до {tariff.max_traffic_gb})\n"
text += f" 💰 {_format_price_kopeks(traffic_price)}\n"
else:
text += f"📊 Трафик: {traffic_display}\n"
text += f"📱 Устройств: {tariff.device_limit}\n"
if discount_percent > 0:
text += f"\n🎁 <b>Скидка: {discount_percent}%</b>\n"
text += f"""
<b>💰 Итого: {_format_price_kopeks(total_price)}</b>
💳 Ваш баланс: {_format_price_kopeks(user_balance)}"""
if user_balance < total_price:
missing = total_price - user_balance
text += f"\n⚠️ <b>Не хватает: {_format_price_kopeks(missing)}</b>"
else:
text += f"\nПосле оплаты: {_format_price_kopeks(user_balance - total_price)}"
return text
@error_handler
async def show_tariffs_list(
callback: types.CallbackQuery,
@@ -416,17 +561,317 @@ async def select_tariff(
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"
)
# Проверяем, есть ли кастомные дни или трафик
can_custom_days = tariff.can_purchase_custom_days()
can_custom_traffic = tariff.can_purchase_custom_traffic()
if can_custom_days or can_custom_traffic:
# Показываем экран настройки кастомных параметров
user_balance = db_user.balance_kopeks or 0
# Начальные значения - минимальные
initial_days = tariff.min_days if can_custom_days else 30
initial_traffic = tariff.min_traffic_gb if can_custom_traffic else tariff.traffic_limit_gb
# Сохраняем в состояние
await state.update_data(
selected_tariff_id=tariff_id,
custom_days=initial_days,
custom_traffic_gb=initial_traffic,
)
preview_text = format_custom_tariff_preview(
tariff=tariff,
days=initial_days,
traffic_gb=initial_traffic,
user_balance=user_balance,
)
await callback.message.edit_text(
preview_text,
reply_markup=get_custom_tariff_keyboard(
tariff_id=tariff_id,
language=db_user.language,
days=initial_days,
traffic_gb=initial_traffic,
can_custom_days=can_custom_days,
can_custom_traffic=can_custom_traffic,
min_days=tariff.min_days,
max_days=tariff.max_days,
min_traffic=tariff.min_traffic_gb,
max_traffic=tariff.max_traffic_gb,
),
parse_mode="HTML"
)
else:
# Для обычного тарифа показываем выбор периода
await callback.message.edit_text(
format_tariff_info_for_user(tariff, db_user.language),
reply_markup=get_tariff_periods_keyboard(tariff, db_user.language, db_user=db_user),
parse_mode="HTML"
)
await state.update_data(selected_tariff_id=tariff_id)
await callback.answer()
@error_handler
async def handle_custom_days_change(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
"""Обрабатывает изменение количества дней."""
parts = callback.data.split(":")
tariff_id = int(parts[1])
delta = int(parts[2])
tariff = await get_tariff_by_id(db, tariff_id)
if not tariff or not tariff.is_active:
await callback.answer("Тариф недоступен", show_alert=True)
return
state_data = await state.get_data()
current_days = state_data.get('custom_days', tariff.min_days)
current_traffic = state_data.get('custom_traffic_gb', tariff.min_traffic_gb)
# Применяем изменение
new_days = current_days + delta
new_days = max(tariff.min_days, min(tariff.max_days, new_days))
await state.update_data(custom_days=new_days)
user_balance = db_user.balance_kopeks or 0
preview_text = format_custom_tariff_preview(
tariff=tariff,
days=new_days,
traffic_gb=current_traffic,
user_balance=user_balance,
)
await callback.message.edit_text(
preview_text,
reply_markup=get_custom_tariff_keyboard(
tariff_id=tariff_id,
language=db_user.language,
days=new_days,
traffic_gb=current_traffic,
can_custom_days=tariff.can_purchase_custom_days(),
can_custom_traffic=tariff.can_purchase_custom_traffic(),
min_days=tariff.min_days,
max_days=tariff.max_days,
min_traffic=tariff.min_traffic_gb,
max_traffic=tariff.max_traffic_gb,
),
parse_mode="HTML"
)
await callback.answer()
@error_handler
async def handle_custom_traffic_change(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
"""Обрабатывает изменение количества трафика."""
parts = callback.data.split(":")
tariff_id = int(parts[1])
delta = int(parts[2])
tariff = await get_tariff_by_id(db, tariff_id)
if not tariff or not tariff.is_active:
await callback.answer("Тариф недоступен", show_alert=True)
return
state_data = await state.get_data()
current_days = state_data.get('custom_days', tariff.min_days)
current_traffic = state_data.get('custom_traffic_gb', tariff.min_traffic_gb)
# Применяем изменение
new_traffic = current_traffic + delta
new_traffic = max(tariff.min_traffic_gb, min(tariff.max_traffic_gb, new_traffic))
await state.update_data(custom_traffic_gb=new_traffic)
user_balance = db_user.balance_kopeks or 0
preview_text = format_custom_tariff_preview(
tariff=tariff,
days=current_days,
traffic_gb=new_traffic,
user_balance=user_balance,
)
await callback.message.edit_text(
preview_text,
reply_markup=get_custom_tariff_keyboard(
tariff_id=tariff_id,
language=db_user.language,
days=current_days,
traffic_gb=new_traffic,
can_custom_days=tariff.can_purchase_custom_days(),
can_custom_traffic=tariff.can_purchase_custom_traffic(),
min_days=tariff.min_days,
max_days=tariff.max_days,
min_traffic=tariff.min_traffic_gb,
max_traffic=tariff.max_traffic_gb,
),
parse_mode="HTML"
)
await callback.answer()
@error_handler
async def handle_custom_confirm(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
"""Подтверждает покупку тарифа с кастомными параметрами."""
tariff_id = int(callback.data.split(":")[1])
tariff = await get_tariff_by_id(db, tariff_id)
if not tariff or not tariff.is_active:
await callback.answer("Тариф недоступен", show_alert=True)
return
state_data = await state.get_data()
custom_days = state_data.get('custom_days', tariff.min_days)
custom_traffic = state_data.get('custom_traffic_gb', tariff.min_traffic_gb)
# Рассчитываем цену
days_price = tariff.get_price_for_custom_days(custom_days) or 0
traffic_price = tariff.get_price_for_custom_traffic(custom_traffic) or 0
total_price = days_price + traffic_price
# Проверяем баланс
user_balance = db_user.balance_kopeks or 0
if user_balance < total_price:
await callback.answer("Недостаточно средств на балансе", show_alert=True)
return
texts = get_texts(db_user.language)
try:
# Списываем баланс
success = await subtract_user_balance(
db, db_user, total_price,
f"Покупка тарифа {tariff.name} на {custom_days} дней"
)
if not success:
await callback.answer("Ошибка списания баланса", show_alert=True)
return
# Получаем список серверов из тарифа
squads = tariff.allowed_squads or []
# Если allowed_squads пустой - значит "все серверы", получаем их
if not squads:
from app.database.crud.server_squad import get_all_server_squads
all_servers, _ = await get_all_server_squads(db, available_only=True)
squads = [s.squad_uuid for s in all_servers if s.squad_uuid]
# Определяем трафик
traffic_limit = custom_traffic if tariff.can_purchase_custom_traffic() else tariff.traffic_limit_gb
# Проверяем есть ли уже подписка
existing_subscription = await get_subscription_by_user_id(db, db_user.id)
if existing_subscription:
# Продлеваем существующую подписку и обновляем параметры тарифа
subscription = await extend_subscription(
db,
existing_subscription,
days=custom_days,
tariff_id=tariff.id,
traffic_limit_gb=traffic_limit,
device_limit=tariff.device_limit,
connected_squads=squads,
)
else:
# Создаем новую подписку
subscription = await create_paid_subscription(
db=db,
user_id=db_user.id,
duration_days=custom_days,
traffic_limit_gb=traffic_limit,
device_limit=tariff.device_limit,
connected_squads=squads,
tariff_id=tariff.id,
)
# Обновляем пользователя в Remnawave
try:
subscription_service = SubscriptionService()
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
reset_reason="покупка тарифа",
)
except Exception as e:
logger.error(f"Ошибка обновления Remnawave: {e}")
# Создаем транзакцию
await create_transaction(
db,
user_id=db_user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=-total_price,
description=f"Покупка тарифа {tariff.name} на {custom_days} дней",
)
# Отправляем уведомление админу
try:
admin_notification_service = AdminNotificationService(callback.bot)
await admin_notification_service.send_subscription_purchase_notification(
db,
db_user,
subscription,
None,
custom_days,
was_trial_conversion=False,
amount_kopeks=total_price,
)
except Exception as e:
logger.error(f"Ошибка отправки уведомления админу: {e}")
# Очищаем корзину после успешной покупки
try:
await user_cart_service.delete_user_cart(db_user.id)
except Exception as e:
logger.error(f"Ошибка очистки корзины: {e}")
await state.clear()
traffic_display = _format_traffic(traffic_limit)
await callback.message.edit_text(
f"🎉 <b>Подписка успешно оформлена!</b>\n\n"
f"📦 Тариф: <b>{tariff.name}</b>\n"
f"📊 Трафик: {traffic_display}\n"
f"📱 Устройств: {tariff.device_limit}\n"
f"📅 Период: {_format_period(custom_days)}\n"
f"💰 Списано: {_format_price_kopeks(total_price)}\n\n"
f"Перейдите в раздел «Подписка» для подключения.",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")],
[InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")]
]),
parse_mode="HTML"
)
await callback.answer("Подписка оформлена!", show_alert=True)
except Exception as e:
logger.error(f"Ошибка при покупке тарифа с кастомными параметрами: {e}", exc_info=True)
await callback.answer("Произошла ошибка при оформлении подписки", show_alert=True)
@error_handler
async def select_tariff_period(
callback: types.CallbackQuery,
@@ -2460,6 +2905,11 @@ def register_tariff_purchase_handlers(dp: Dispatcher):
# Подтверждение покупки суточного тарифа
dp.callback_query.register(confirm_daily_tariff_purchase, F.data.startswith("daily_tariff_confirm:"))
# Кастомные дни/трафик
dp.callback_query.register(handle_custom_days_change, F.data.startswith("custom_days:"))
dp.callback_query.register(handle_custom_traffic_change, F.data.startswith("custom_traffic:"))
dp.callback_query.register(handle_custom_confirm, F.data.startswith("custom_confirm:"))
# Продление по тарифу
dp.callback_query.register(select_tariff_extend_period, F.data.startswith("tariff_extend:"))
dp.callback_query.register(confirm_tariff_extend, F.data.startswith("tariff_ext_confirm:"))

View File

@@ -229,6 +229,65 @@ class BanNotificationService:
)
return False, f"Ошибка Telegram API: {str(e)}", user.telegram_id
async def send_network_wifi_notification(
self,
db: AsyncSession,
user_identifier: str,
username: str,
ban_minutes: int,
network_type: Optional[str] = None,
node_name: Optional[str] = None
) -> Tuple[bool, str, Optional[int]]:
"""
Отправить уведомление о блокировке за использование WiFi сети
Returns:
(success, message, telegram_id)
"""
if not self._bot:
return False, "Бот не инициализирован", None
# Находим пользователя
user = await self._find_user_by_identifier(db, user_identifier)
if not user:
logger.warning(f"Пользователь {user_identifier} не найден в базе данных")
return False, f"Пользователь не найден: {user_identifier}", None
# Формируем сообщение
network_info = f"Тип сети: <b>{network_type}</b>\n" if network_type else ""
node_info = f"Сервер: <b>{node_name}</b>\n" if node_name else ""
message_text = (
"📶 <b>Блокировка за использование WiFi сети</b>\n\n"
f"Ваш аккаунт временно заблокирован из-за использования WiFi сети.\n\n"
f"{network_info}"
f"{node_info}"
f"⏱ Блокировка на: <b>{ban_minutes} мин</b>\n\n"
f" Использование VPN через WiFi запрещено правилами сервиса.\n"
f"Пожалуйста, используйте мобильную сеть для подключения к VPN.\n\n"
f"После окончания блокировки ваш доступ будет восстановлен автоматически."
)
# Отправляем сообщение
try:
await self._bot.send_message(
chat_id=user.telegram_id,
text=message_text,
parse_mode="HTML"
)
logger.info(
f"Уведомление о WiFi бане отправлено пользователю {username} "
f"(telegram_id: {user.telegram_id})"
)
return True, "Уведомление отправлено", user.telegram_id
except TelegramAPIError as e:
logger.error(
f"Ошибка отправки WiFi уведомления пользователю {username} "
f"(telegram_id: {user.telegram_id}): {e}"
)
return False, f"Ошибка Telegram API: {str(e)}", user.telegram_id
# Глобальный экземпляр сервиса
ban_notification_service = BanNotificationService()

View File

@@ -1893,7 +1893,60 @@ class RemnaWaveService:
except Exception as e:
logger.error(f"Ошибка получения статистики трафика для пользователя {telegram_id}: {e}")
return None
async def get_telegram_id_by_email(self, user_identifier: str) -> Optional[int]:
"""
Получить telegram_id пользователя по email или username из панели RemnaWave.
Args:
user_identifier: Email или username пользователя
Returns:
telegram_id если найден, иначе None
"""
if not self.is_configured:
logger.warning("RemnaWave API не настроен для поиска пользователя")
return None
try:
async with self.get_api_client() as api:
# Сначала пробуем найти по username (часто username == email)
try:
user = await api.get_user_by_username(user_identifier)
if user and user.telegram_id:
logger.info(
f"Найден пользователь по username '{user_identifier}': "
f"telegram_id={user.telegram_id}"
)
return user.telegram_id
except Exception as e:
logger.debug(f"Пользователь не найден по username '{user_identifier}': {e}")
# Если не нашли по username, ищем по email среди всех пользователей
try:
all_users_response = await api.get_all_users(start=0, size=10000)
users_list = all_users_response.get('users', [])
for panel_user in users_list:
panel_email = panel_user.email if hasattr(panel_user, 'email') else None
if panel_email and panel_email.lower() == user_identifier.lower():
panel_telegram_id = panel_user.telegram_id if hasattr(panel_user, 'telegram_id') else None
if panel_telegram_id:
logger.info(
f"Найден пользователь по email '{user_identifier}': "
f"telegram_id={panel_telegram_id}"
)
return panel_telegram_id
except Exception as e:
logger.warning(f"Ошибка поиска пользователя по email '{user_identifier}': {e}")
logger.warning(f"Пользователь с идентификатором '{user_identifier}' не найден в панели")
return None
except Exception as e:
logger.error(f"Ошибка получения telegram_id для '{user_identifier}': {e}")
return None
async def test_api_connection(self) -> Dict[str, Any]:
if not self.is_configured:
return {

View File

@@ -12,17 +12,22 @@ class SubscriptionStates(StatesGroup):
selecting_countries = State()
selecting_devices = State()
confirming_purchase = State()
adding_countries = State()
adding_traffic = State()
adding_devices = State()
extending_subscription = State()
confirming_traffic_reset = State()
cart_saved_for_topup = State()
# Состояния для простой подписки
waiting_for_simple_subscription_payment_method = State()
# Состояния для кастомных дней/трафика при покупке тарифа
selecting_custom_days = State()
selecting_custom_traffic = State()
confirming_custom_purchase = State()
class BalanceStates(StatesGroup):
waiting_for_amount = State()
waiting_for_pal24_method = State()

View File

@@ -87,6 +87,22 @@ async def send_ban_notification(
warning_message=request.warning_message,
)
elif request.notification_type == "network_wifi":
if request.ban_minutes is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Для типа 'network_wifi' требуется поле: ban_minutes"
)
success, message, telegram_id = await ban_notification_service.send_network_wifi_notification(
db=db,
user_identifier=request.user_identifier,
username=request.username,
ban_minutes=request.ban_minutes,
network_type=request.network_type,
node_name=request.node_name,
)
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,

View File

@@ -7,8 +7,8 @@ from pydantic import BaseModel, Field
class BanNotificationRequest(BaseModel):
"""Запрос на отправку уведомления о бане пользователю"""
notification_type: Literal["punishment", "enabled", "warning"] = Field(
description="Тип уведомления: punishment (бан), enabled (разбан), warning (предупреждение)"
notification_type: Literal["punishment", "enabled", "warning", "network_wifi"] = Field(
description="Тип уведомления: punishment (бан за устройства), enabled (разбан), warning (предупреждение), network_wifi (бан за WiFi)"
)
user_identifier: str = Field(
description="Email или user_id пользователя из Remnawave Panel"
@@ -25,6 +25,10 @@ class BanNotificationRequest(BaseModel):
# Данные для warning
warning_message: Optional[str] = Field(None, description="Текст предупреждения")
# Данные для network_wifi
network_type: Optional[str] = Field(None, description="Тип сети (WiFi/Mobile)")
node_name: Optional[str] = Field(None, description="Название ноды")
class Config:
json_schema_extra = {
"example": {