From d9ce09b3bf74a9dd14993b9fa91c4f89316e637d Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 8 Jan 2026 23:14:40 +0300 Subject: [PATCH 01/84] Update ru.json --- app/localization/locales/ru.json | 1 + 1 file changed, 1 insertion(+) diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index 14866b55..718672ef 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -204,6 +204,7 @@ "ADMIN_MAIN_SETTINGS": "⚙️ Настройки", "ADMIN_MAIN_SUPPORT": "🛟 Поддержка", "ADMIN_MAIN_SYSTEM": "🛠️ Система", + "ADMIN_MAIN_TARIFFS": "📦 Тарифы", "ADMIN_MAIN_TRIALS": "🧪 Триалы", "ADMIN_MAIN_PAYMENTS": "💳 Пополнения", "ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Юзеры/Подписки", From 879650c2e49d787f6b27f16aac7dae0883beea28 Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 9 Jan 2026 13:11:40 +0300 Subject: [PATCH 02/84] Update webhook_server.py --- app/external/webhook_server.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/app/external/webhook_server.py b/app/external/webhook_server.py index 0a57abb7..c8ad3210 100644 --- a/app/external/webhook_server.py +++ b/app/external/webhook_server.py @@ -39,6 +39,23 @@ class WebhookServer: if settings.is_freekassa_enabled(): self.app.router.add_post(settings.FREEKASSA_WEBHOOK_PATH, self._freekassa_webhook_handler) + else: + # Диагностика почему Freekassa не включена + if settings.FREEKASSA_ENABLED: + missing = [] + if settings.FREEKASSA_SHOP_ID is None: + missing.append("FREEKASSA_SHOP_ID") + if settings.FREEKASSA_API_KEY is None: + missing.append("FREEKASSA_API_KEY") + if settings.FREEKASSA_SECRET_WORD_1 is None: + missing.append("FREEKASSA_SECRET_WORD_1") + if settings.FREEKASSA_SECRET_WORD_2 is None: + missing.append("FREEKASSA_SECRET_WORD_2") + if missing: + logger.warning( + f"Freekassa ENABLED=true, но webhook не зарегистрирован. " + f"Отсутствуют параметры: {', '.join(missing)}" + ) self.app.router.add_get('/health', self._health_check) From 4e6d5b47167028d59f982c71ca257a750a2032b5 Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 9 Jan 2026 19:30:53 +0300 Subject: [PATCH 03/84] Update subscription.py --- app/database/crud/subscription.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index 10dc78a5..76ec15b1 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -380,6 +380,11 @@ async def extend_subscription( subscription.tariff_id = tariff_id logger.info(f"📦 Обновлен тариф подписки: {old_tariff_id} → {tariff_id}") + # При покупке тарифа сбрасываем триальный статус + if subscription.is_trial: + subscription.is_trial = False + logger.info(f"🎓 Подписка {subscription.id} конвертирована из триала в платную") + if traffic_limit_gb is not None: old_traffic = subscription.traffic_limit_gb subscription.traffic_limit_gb = traffic_limit_gb From cd0a25abec1fe7ae6243c94bedc0d2683e8e0dd9 Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 10 Jan 2026 18:17:10 +0300 Subject: [PATCH 04/84] Update users.py --- app/handlers/admin/users.py | 389 +++++++++++++++++++++++++++++++++++- 1 file changed, 387 insertions(+), 2 deletions(-) diff --git a/app/handlers/admin/users.py b/app/handlers/admin/users.py index 8eb315ad..6d347b28 100644 --- a/app/handlers/admin/users.py +++ b/app/handlers/admin/users.py @@ -1063,12 +1063,16 @@ async def _render_user_subscription_overview( ) ]) - # Кнопка смены тарифа в режиме тарифов + # Кнопки тарифов в режиме тарифов if settings.is_tariffs_mode(): keyboard.append([ types.InlineKeyboardButton( text="📦 Сменить тариф", callback_data=f"admin_sub_change_tariff_{user_id}" + ), + types.InlineKeyboardButton( + text="💳 Купить тариф", + callback_data=f"admin_tariff_buy_{user_id}" ) ]) @@ -4983,6 +4987,366 @@ async def admin_buy_subscription_execute( await db.rollback() +# ==================== Покупка тарифа администратором ==================== + +@admin_required +@error_handler +async def admin_buy_tariff( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + """Показывает список тарифов для покупки админом.""" + user_id = int(callback.data.split('_')[-1]) + + user_service = UserService() + profile = await user_service.get_user_profile(db, user_id) + + if not profile: + await callback.answer("❌ Пользователь не найден", show_alert=True) + return + + target_user = profile["user"] + + # Получаем доступные тарифы + from app.database.crud.tariff import get_tariffs_for_user + tariffs = await get_tariffs_for_user(db, target_user) + + if not tariffs: + await callback.message.edit_text( + "❌ Нет доступных тарифов\n\n" + "Создайте тарифы в разделе управления тарифами.", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_user_subscription_{user_id}")] + ]) + ) + await callback.answer() + return + + target_user_link = f'{target_user.full_name}' + text = f"💳 Покупка тарифа для пользователя\n\n" + text += f"👤 {target_user_link} (ID: {target_user.telegram_id})\n" + text += f"💰 Баланс: {settings.format_price(target_user.balance_kopeks)}\n\n" + text += "📦 Выберите тариф:\n\n" + + for tariff in tariffs: + traffic = "♾️" if tariff.traffic_limit_gb == 0 else f"{tariff.traffic_limit_gb} ГБ" + prices = tariff.period_prices or {} + min_price = min(prices.values()) if prices else 0 + text += f"{tariff.name} — {traffic}/{tariff.device_limit}📱 от {settings.format_price(min_price)}\n" + + keyboard = [] + for tariff in tariffs: + keyboard.append([ + types.InlineKeyboardButton( + text=f"📦 {tariff.name}", + callback_data=f"admin_tariff_buy_select_{user_id}_{tariff.id}" + ) + ]) + + keyboard.append([ + types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_user_subscription_{user_id}") + ]) + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), + parse_mode="HTML" + ) + await callback.answer() + + +@admin_required +@error_handler +async def admin_buy_tariff_period( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + """Показывает выбор периода для тарифа.""" + parts = callback.data.split('_') + user_id = int(parts[4]) + tariff_id = int(parts[5]) + + user_service = UserService() + profile = await user_service.get_user_profile(db, user_id) + + if not profile: + await callback.answer("❌ Пользователь не найден", show_alert=True) + return + + target_user = profile["user"] + + from app.database.crud.tariff import get_tariff_by_id + tariff = await get_tariff_by_id(db, tariff_id) + + if not tariff or not tariff.is_active: + await callback.answer("❌ Тариф недоступен", show_alert=True) + return + + target_user_link = f'{target_user.full_name}' + traffic = "♾️ Безлимит" if tariff.traffic_limit_gb == 0 else f"{tariff.traffic_limit_gb} ГБ" + + text = f"💳 Покупка тарифа для пользователя\n\n" + text += f"👤 {target_user_link} (ID: {target_user.telegram_id})\n" + text += f"💰 Баланс: {settings.format_price(target_user.balance_kopeks)}\n\n" + text += f"📦 Тариф: {tariff.name}\n" + text += f"📊 Трафик: {traffic}\n" + text += f"📱 Устройств: {tariff.device_limit}\n" + text += f"🌐 Серверов: {len(tariff.allowed_squads) if tariff.allowed_squads else 0}\n\n" + text += "Выберите период:" + + prices = tariff.period_prices or {} + keyboard = [] + + for period_str, price in sorted(prices.items(), key=lambda x: int(x[0])): + period = int(period_str) + keyboard.append([ + types.InlineKeyboardButton( + text=f"{period} дней — {settings.format_price(price)}", + callback_data=f"admin_tariff_buy_confirm_{user_id}_{tariff_id}_{period}_{price}" + ) + ]) + + keyboard.append([ + types.InlineKeyboardButton(text="⬅️ К тарифам", callback_data=f"admin_tariff_buy_{user_id}") + ]) + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), + parse_mode="HTML" + ) + await callback.answer() + + +@admin_required +@error_handler +async def admin_buy_tariff_confirm( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + """Подтверждение покупки тарифа.""" + parts = callback.data.split('_') + user_id = int(parts[4]) + tariff_id = int(parts[5]) + period = int(parts[6]) + price_kopeks = int(parts[7]) + + user_service = UserService() + profile = await user_service.get_user_profile(db, user_id) + + if not profile: + await callback.answer("❌ Пользователь не найден", show_alert=True) + return + + target_user = profile["user"] + + from app.database.crud.tariff import get_tariff_by_id + tariff = await get_tariff_by_id(db, tariff_id) + + if not tariff or not tariff.is_active: + await callback.answer("❌ Тариф недоступен", show_alert=True) + return + + # Проверяем баланс + if target_user.balance_kopeks < price_kopeks: + missing = price_kopeks - target_user.balance_kopeks + await callback.message.edit_text( + f"❌ Недостаточно средств\n\n" + f"💰 Баланс: {settings.format_price(target_user.balance_kopeks)}\n" + f"💳 Стоимость: {settings.format_price(price_kopeks)}\n" + f"📉 Не хватает: {settings.format_price(missing)}\n\n" + f"Пополните баланс пользователя перед покупкой.", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_tariff_buy_select_{user_id}_{tariff_id}")] + ]), + parse_mode="HTML" + ) + await callback.answer() + return + + target_user_link = f'{target_user.full_name}' + traffic = "♾️ Безлимит" if tariff.traffic_limit_gb == 0 else f"{tariff.traffic_limit_gb} ГБ" + + text = f"💳 Подтверждение покупки тарифа\n\n" + text += f"👤 {target_user_link} (ID: {target_user.telegram_id})\n" + text += f"💰 Баланс: {settings.format_price(target_user.balance_kopeks)}\n\n" + text += f"📦 Тариф: {tariff.name}\n" + text += f"📊 Трафик: {traffic}\n" + text += f"📱 Устройств: {tariff.device_limit}\n" + text += f"📅 Период: {period} дней\n" + text += f"💰 Стоимость: {settings.format_price(price_kopeks)}\n\n" + text += "Подтвердить покупку?" + + keyboard = [ + [ + types.InlineKeyboardButton( + text="✅ Подтвердить", + callback_data=f"admin_tariff_buy_exec_{user_id}_{tariff_id}_{period}_{price_kopeks}" + ) + ], + [ + types.InlineKeyboardButton( + text="❌ Отмена", + callback_data=f"admin_tariff_buy_select_{user_id}_{tariff_id}" + ) + ] + ] + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), + parse_mode="HTML" + ) + await callback.answer() + + +@admin_required +@error_handler +async def admin_buy_tariff_execute( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + """Выполняет покупку тарифа для пользователя.""" + parts = callback.data.split('_') + user_id = int(parts[4]) + tariff_id = int(parts[5]) + period = int(parts[6]) + price_kopeks = int(parts[7]) + + user_service = UserService() + profile = await user_service.get_user_profile(db, user_id) + + if not profile: + await callback.answer("❌ Пользователь не найден", show_alert=True) + return + + target_user = profile["user"] + + from app.database.crud.tariff import get_tariff_by_id + tariff = await get_tariff_by_id(db, tariff_id) + + if not tariff or not tariff.is_active: + await callback.answer("❌ Тариф недоступен", show_alert=True) + return + + # Проверяем баланс ещё раз + if target_user.balance_kopeks < price_kopeks: + await callback.answer("❌ Недостаточно средств на балансе", show_alert=True) + return + + try: + from app.database.crud.user import subtract_user_balance + from app.database.crud.subscription import extend_subscription, create_paid_subscription, get_subscription_by_user_id + from app.database.crud.transaction import create_transaction + from app.services.subscription_service import SubscriptionService + + # Списываем баланс + success = await subtract_user_balance( + db, target_user, price_kopeks, + f"Покупка тарифа {tariff.name} на {period} дней (администратор)" + ) + + if not success: + await callback.answer("❌ Ошибка списания средств", show_alert=True) + return + + # Получаем серверы из тарифа + squads = tariff.allowed_squads or [] + + # Проверяем есть ли подписка + existing_subscription = await get_subscription_by_user_id(db, target_user.id) + + if existing_subscription: + # Продлеваем существующую подписку + subscription = await extend_subscription( + db, + existing_subscription, + days=period, + tariff_id=tariff.id, + traffic_limit_gb=tariff.traffic_limit_gb, + device_limit=tariff.device_limit, + connected_squads=squads, + ) + else: + # Создаем новую подписку + subscription = await create_paid_subscription( + db=db, + user_id=target_user.id, + duration_days=period, + traffic_limit_gb=tariff.traffic_limit_gb, + 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=target_user.id, + type=TransactionType.SUBSCRIPTION_PAYMENT, + amount_kopeks=-price_kopeks, + description=f"Покупка тарифа {tariff.name} на {period} дней (администратор)", + ) + + target_user_link = f'{target_user.full_name}' + traffic = "♾️ Безлимит" if tariff.traffic_limit_gb == 0 else f"{tariff.traffic_limit_gb} ГБ" + + await callback.message.edit_text( + f"✅ Тариф успешно куплен!\n\n" + f"👤 {target_user_link} (ID: {target_user.telegram_id})\n" + f"📦 Тариф: {tariff.name}\n" + f"📊 Трафик: {traffic}\n" + f"📱 Устройств: {tariff.device_limit}\n" + f"📅 Период: {period} дней\n" + f"💰 Списано: {settings.format_price(price_kopeks)}\n" + f"📅 Действует до: {format_datetime(subscription.end_date)}", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")] + ]), + parse_mode="HTML" + ) + + # Уведомляем пользователя + try: + if callback.bot: + await callback.bot.send_message( + chat_id=target_user.telegram_id, + text=f"💳 Администратор оформил вам тариф\n\n" + f"📦 Тариф: {tariff.name}\n" + f"📊 Трафик: {traffic}\n" + f"📱 Устройств: {tariff.device_limit}\n" + f"📅 Период: {period} дней\n" + f"💰 Списано с баланса: {settings.format_price(price_kopeks)}\n" + f"📅 Действует до: {format_datetime(subscription.end_date)}", + parse_mode="HTML" + ) + except Exception as e: + logger.error(f"Ошибка отправки уведомления пользователю: {e}") + + await callback.answer("✅ Тариф куплен!", show_alert=True) + + except Exception as e: + logger.error(f"Ошибка покупки тарифа администратором: {e}", exc_info=True) + await callback.answer("❌ Ошибка при покупке тарифа", show_alert=True) + await db.rollback() + + @admin_required @error_handler async def change_subscription_type_confirm( @@ -5673,7 +6037,28 @@ def register_handlers(dp: Dispatcher): admin_buy_subscription_execute, F.data.startswith("admin_buy_sub_execute_") ) - + + # Регистрация обработчиков для покупки тарифа администратором + dp.callback_query.register( + admin_buy_tariff, + F.data.startswith("admin_tariff_buy_") & ~F.data.startswith("admin_tariff_buy_select_") & ~F.data.startswith("admin_tariff_buy_confirm_") & ~F.data.startswith("admin_tariff_buy_exec_") + ) + + dp.callback_query.register( + admin_buy_tariff_period, + F.data.startswith("admin_tariff_buy_select_") + ) + + dp.callback_query.register( + admin_buy_tariff_confirm, + F.data.startswith("admin_tariff_buy_confirm_") + ) + + dp.callback_query.register( + admin_buy_tariff_execute, + F.data.startswith("admin_tariff_buy_exec_") + ) + # Регистрация обработчиков для фильтрации пользователей dp.callback_query.register( show_users_filters, From bc7470b39d20c91cbcdc8bf24ae199abdc92e2a5 Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 10 Jan 2026 18:20:35 +0300 Subject: [PATCH 05/84] Update subscription.py --- app/database/crud/subscription.py | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index 76ec15b1..5eada17c 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -316,11 +316,22 @@ async def extend_subscription( current_time = datetime.utcnow() logger.info(f"🔄 Продление подписки {subscription.id} на {days} дней") - logger.info(f"📊 Текущие параметры: статус={subscription.status}, окончание={subscription.end_date}") + logger.info(f"📊 Текущие параметры: статус={subscription.status}, окончание={subscription.end_date}, тариф={subscription.tariff_id}") + + # Определяем, происходит ли СМЕНА тарифа (а не продление того же) + is_tariff_change = ( + tariff_id is not None + and subscription.tariff_id is not None + and tariff_id != subscription.tariff_id + ) + + if is_tariff_change: + logger.info(f"🔄 Обнаружена СМЕНА тарифа: {subscription.tariff_id} → {tariff_id}") # НОВОЕ: Вычисляем бонусные дни от триала ДО изменения end_date + # Бонусные дни НЕ начисляются при смене тарифа bonus_days = 0 - if subscription.is_trial and settings.TRIAL_ADD_REMAINING_DAYS_TO_PAID: + if not is_tariff_change and subscription.is_trial and settings.TRIAL_ADD_REMAINING_DAYS_TO_PAID: # Вычисляем остаток триала if subscription.end_date and subscription.end_date > current_time: remaining = subscription.end_date - current_time @@ -342,6 +353,11 @@ async def extend_subscription( abs(days), subscription.end_date, ) + elif is_tariff_change: + # При СМЕНЕ тарифа срок начинается с текущей даты + subscription.end_date = current_time + timedelta(days=total_days) + subscription.start_date = current_time + logger.info(f"📅 СМЕНА тарифа: срок начинается с текущей даты + {total_days} дней") elif subscription.end_date > current_time: subscription.end_date = subscription.end_date + timedelta(days=total_days) logger.info(f"📅 Подписка активна, добавляем {total_days} дней ({days} + {bonus_days} бонус) к текущей дате окончания") From 3685af771e3ebfc39645e690c1a0305207a39a60 Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 10 Jan 2026 18:28:48 +0300 Subject: [PATCH 06/84] Update miniapp.py --- app/webapi/routes/miniapp.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index 2f6acade..d5859200 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -6214,7 +6214,7 @@ async def purchase_tariff_endpoint( subscription = await create_paid_subscription( db=db, user_id=user.id, - days=payload.period_days, + duration_days=payload.period_days, traffic_limit_gb=tariff.traffic_limit_gb, device_limit=tariff.device_limit, connected_squads=tariff.allowed_squads or [], From af5401a393555d71805b06b0bc3241732beed505 Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 10 Jan 2026 18:38:20 +0300 Subject: [PATCH 07/84] Update freekassa_service.py --- app/services/freekassa_service.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/services/freekassa_service.py b/app/services/freekassa_service.py index 6a94d91b..64f63c07 100644 --- a/app/services/freekassa_service.py +++ b/app/services/freekassa_service.py @@ -206,10 +206,12 @@ class FreekassaService: data = await response.json() - if response.status != 200 or data.get("type") == "error": + # Проверяем на ошибку - API может вернуть error или type=error + error_msg = data.get("error") or data.get("message") + if response.status != 200 or data.get("type") == "error" or error_msg: logger.error(f"Freekassa create_order error: {data}") raise Exception( - f"Freekassa API error: {data.get('message', 'Unknown error')}" + f"Freekassa API error: {error_msg or 'Unknown error'}" ) return data From fc9552a1bf3dbee9466c76595cb62104ff2ff11e Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 10 Jan 2026 18:38:58 +0300 Subject: [PATCH 08/84] Add files via upload --- app/localization/locales/en.json | 1 + app/localization/locales/ru.json | 3 +++ app/localization/locales/ua.json | 1 + app/localization/locales/zh.json | 1 + 4 files changed, 6 insertions(+) diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index 494b82e8..4b3e0e71 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -201,6 +201,7 @@ "ADMIN_MAIN_SETTINGS": "⚙️ Settings", "ADMIN_MAIN_SUPPORT": "🛟 Support", "ADMIN_MAIN_SYSTEM": "🛠️ System", + "ADMIN_MAIN_TARIFFS": "📦 Tariffs", "ADMIN_MAIN_TRIALS": "🧪 Trials", "ADMIN_MAIN_PAYMENTS": "💳 Top-ups", "ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Users / Subscriptions", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index 718672ef..9a1cfa53 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -906,6 +906,7 @@ "AUTOPAY_SUCCESS": "\n✅ Автоплатеж выполнен\n\nВаша подписка автоматически продлена на {days} дней.\nСписано с баланса: {amount}\n", "AUTOPAY_TOGGLE_SUCCESS": "✅ Автоплатеж {status}!", "BACK": "⬅️ Назад", + "BACK_BUTTON": "◀️ Назад", "BACK_TO_MAIN_MENU_BUTTON": "⬅️ В главное меню", "BACK_TO_MENU": "🏠 В главное меню", "BACK_TO_OPEN_TICKETS": "🔴 Открытые тикеты", @@ -1049,6 +1050,7 @@ "FAQ_PAGE_NOT_AVAILABLE": "Эта страница FAQ недоступна.", "FAQ_PAGE_TITLE": "{title}", "FAQ_PAGE_UNTITLED": "Без названия", + "FREEKASSA_ENTER_AMOUNT": "💳 Пополнение через {name}\n\nВведите сумму пополнения в рублях.\n\nМинимум: {min_amount}\nМаксимум: {max_amount}", "GO_TO_BALANCE_TOP_UP": "💳 Перейти к пополнению баланса", "HAPP_DOWNLOAD_BUTTON": "⬇️ Скачать Happ", "HAPP_DOWNLOAD_LINK_MESSAGE": "⬇️ Скачайте Happ для {platform}:", @@ -1158,6 +1160,7 @@ "PAYMENT_CARD_YOOKASSA": "💳 Банковская карта (YooKassa)", "PAYMENT_PLATEGA": "💳 Platega", "PAYMENT_CHARGE_ERROR": "⚠️ Ошибка списания средств", + "PAYMENT_CREATE_ERROR": "❌ Не удалось создать платёж. Попробуйте позже.", "PAYMENT_CRYPTOBOT": "🪙 Криптовалюта (CryptoBot)", "PAYMENT_HELEKET": "🪙 Криптовалюта (Heleket)", "HELEKET_PAYMENT_ALREADY_PAID": "✅ Платёж уже зачислен", diff --git a/app/localization/locales/ua.json b/app/localization/locales/ua.json index 5639c0b7..baa7ef9a 100644 --- a/app/localization/locales/ua.json +++ b/app/localization/locales/ua.json @@ -131,6 +131,7 @@ "ADMIN_MAIN_SETTINGS": "⚙️ Налаштування", "ADMIN_MAIN_SUPPORT": "🛟 Підтримка", "ADMIN_MAIN_SYSTEM": "🛠️ Система", + "ADMIN_MAIN_TARIFFS": "📦 Тарифи", "ADMIN_MAIN_TRIALS": "🧪 Тріали", "ADMIN_MAIN_PAYMENTS": "💳 Поповнення", "ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Юзери/Підписки", diff --git a/app/localization/locales/zh.json b/app/localization/locales/zh.json index 279fcbca..8b7f7c27 100644 --- a/app/localization/locales/zh.json +++ b/app/localization/locales/zh.json @@ -130,6 +130,7 @@ "ADMIN_MAIN_SETTINGS":"⚙️设置", "ADMIN_MAIN_SUPPORT":"🛟支持", "ADMIN_MAIN_SYSTEM":"🛠️系统", +"ADMIN_MAIN_TARIFFS":"📦套餐", "ADMIN_MAIN_TRIALS":"🧪试用", "ADMIN_MAIN_PAYMENTS":"💳充值", "ADMIN_MAIN_USERS_SUBSCRIPTIONS":"👥用户/订阅", From 4d62fa946b1e4f2f44ec861082cd7c6d3a21c3b9 Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 10 Jan 2026 19:40:56 +0300 Subject: [PATCH 09/84] Update freekassa_service.py --- app/services/freekassa_service.py | 66 ++++++++++++++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/app/services/freekassa_service.py b/app/services/freekassa_service.py index 64f63c07..65cb855b 100644 --- a/app/services/freekassa_service.py +++ b/app/services/freekassa_service.py @@ -4,6 +4,7 @@ import hashlib import hmac import time import logging +import asyncio from typing import Optional, Dict, Any, Set import aiohttp @@ -12,6 +13,10 @@ from app.config import settings logger = logging.getLogger(__name__) +# Кэш для публичного IP +_cached_public_ip: Optional[str] = None +_ip_fetch_lock = asyncio.Lock() + # IP-адреса Freekassa для проверки webhook FREEKASSA_IPS: Set[str] = { "168.119.157.136", @@ -22,6 +27,62 @@ FREEKASSA_IPS: Set[str] = { API_BASE_URL = "https://api.fk.life/v1" +# Сервисы для определения публичного IP (в порядке приоритета) +IP_SERVICES = [ + "https://api.ipify.org", + "https://ifconfig.me/ip", + "https://icanhazip.com", + "https://ipinfo.io/ip", +] + + +async def get_public_ip() -> str: + """ + Получает публичный IP сервера. + 1. Сначала проверяет переменную окружения SERVER_PUBLIC_IP + 2. Если нет - запрашивает через внешние сервисы и кэширует + """ + global _cached_public_ip + + # Проверяем переменную окружения + env_ip = getattr(settings, 'SERVER_PUBLIC_IP', None) + if env_ip: + return env_ip + + # Возвращаем кэшированный IP если есть + if _cached_public_ip: + return _cached_public_ip + + async with _ip_fetch_lock: + # Повторная проверка после получения блокировки + if _cached_public_ip: + return _cached_public_ip + + # Пробуем получить IP от внешних сервисов + async with aiohttp.ClientSession() as session: + for service_url in IP_SERVICES: + try: + async with session.get( + service_url, + timeout=aiohttp.ClientTimeout(total=5) + ) as response: + if response.status == 200: + ip = (await response.text()).strip() + # Простая валидация IPv4 + if ip and len(ip.split('.')) == 4: + _cached_public_ip = ip + logger.info(f"Определён публичный IP сервера: {ip}") + return ip + except Exception as e: + logger.debug(f"Не удалось получить IP от {service_url}: {e}") + continue + + # Fallback на известный рабочий IP если ничего не получилось + fallback_ip = "185.92.183.173" + logger.warning(f"Не удалось определить публичный IP, используем fallback: {fallback_ip}") + _cached_public_ip = fallback_ip + return fallback_ip + class FreekassaService: """Сервис для работы с API Freekassa.""" @@ -177,13 +238,16 @@ class FreekassaService: # Используем payment_system_id из настроек, если не передан явно ps_id = payment_system_id or settings.FREEKASSA_PAYMENT_SYSTEM_ID or 1 + # Определяем публичный IP сервера (127.0.0.1 отклоняется API) + server_ip = ip or await get_public_ip() + params = { "shopId": self.shop_id, "nonce": int(time.time_ns()), # Наносекунды для уникальности "paymentId": str(order_id), "i": ps_id, "email": email or "user@example.com", - "ip": ip or "127.0.0.1", + "ip": server_ip, "amount": final_amount, "currency": currency, } From 011f4428e29c2d9102a97a276d7da68930c725d2 Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 10 Jan 2026 19:41:18 +0300 Subject: [PATCH 10/84] Update config.py --- app/config.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/config.py b/app/config.py index 58ff2239..b97779ff 100644 --- a/app/config.py +++ b/app/config.py @@ -447,6 +447,8 @@ class Settings(BaseSettings): FREEKASSA_PAYMENT_SYSTEM_ID: Optional[int] = None # Использовать API для создания заказов (нужно для NSPK СБП) FREEKASSA_USE_API: bool = False + # Публичный IP сервера для Freekassa API (если не задан - определяется автоматически) + SERVER_PUBLIC_IP: Optional[str] = None MAIN_MENU_MODE: str = "default" CONNECT_BUTTON_MODE: str = "guide" From 57e4ccea280a32db9e95ecca279f352f06f2e8e9 Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 10 Jan 2026 20:29:22 +0300 Subject: [PATCH 11/84] Update states.py --- app/states.py | 1 + 1 file changed, 1 insertion(+) diff --git a/app/states.py b/app/states.py index 33d74fd4..6cbfd071 100644 --- a/app/states.py +++ b/app/states.py @@ -180,6 +180,7 @@ class AdminStates(StatesGroup): editing_tariff_trial_days = State() editing_tariff_squads = State() editing_tariff_promo_groups = State() + editing_tariff_traffic_topup_packages = State() class SupportStates(StatesGroup): From e66944e2bfc80c0f083c55166d1c57c7f3543a7b Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 10 Jan 2026 20:29:53 +0300 Subject: [PATCH 12/84] Update inline.py --- app/keyboards/inline.py | 76 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index ef0ed9db..b94c866d 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -1795,7 +1795,81 @@ def get_add_traffic_keyboard( ]) return InlineKeyboardMarkup(inline_keyboard=buttons) - + + +def get_add_traffic_keyboard_from_tariff( + language: str, + packages: dict, # {gb: price_kopeks} + subscription_end_date: datetime = None, + discount_percent: int = 0, +) -> InlineKeyboardMarkup: + """ + Клавиатура для докупки трафика из настроек тарифа. + + Args: + language: Язык интерфейса + packages: Словарь {ГБ: цена_в_копейках} из тарифа + subscription_end_date: Дата окончания подписки для расчета цены + discount_percent: Процент скидки + """ + from app.utils.pricing_utils import get_remaining_months + + texts = get_texts(language) + + months_multiplier = 1 + period_text = "" + if subscription_end_date: + months_multiplier = get_remaining_months(subscription_end_date) + if months_multiplier > 1: + period_text = f" (за {months_multiplier} мес)" + + if not packages: + return InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton( + text=texts.t("NO_TRAFFIC_PACKAGES", "❌ Нет доступных пакетов"), + callback_data="no_traffic_packages" + )], + [InlineKeyboardButton( + text=texts.BACK, + callback_data="menu_subscription" + )] + ]) + + buttons = [] + + # Сортируем пакеты по размеру + sorted_packages = sorted(packages.items(), key=lambda x: x[0]) + + for gb, price_per_month in sorted_packages: + discounted_per_month, discount_per_month = apply_percentage_discount( + price_per_month, + discount_percent, + ) + total_price = discounted_per_month * months_multiplier + total_discount = discount_per_month * months_multiplier + + if language == "ru": + text = f"📊 +{gb} ГБ трафика - {total_price // 100} ₽{period_text}" + else: + text = f"📊 +{gb} GB traffic - {total_price // 100} ₽{period_text}" + + if discount_percent > 0 and total_discount > 0: + text += f" (скидка {discount_percent}%: -{total_discount // 100}₽)" + + buttons.append([ + InlineKeyboardButton(text=text, callback_data=f"add_traffic_{gb}") + ]) + + buttons.append([ + InlineKeyboardButton( + text=texts.BACK, + callback_data="menu_subscription" + ) + ]) + + return InlineKeyboardMarkup(inline_keyboard=buttons) + + def get_change_devices_keyboard( current_devices: int, language: str = DEFAULT_LANGUAGE, From 90d090a0481b40abe8620abd807d6c809833c921 Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 10 Jan 2026 20:30:30 +0300 Subject: [PATCH 13/84] Update models.py --- app/database/models.py | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/app/database/models.py b/app/database/models.py index b7850486..bba52253 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -762,6 +762,11 @@ class Tariff(Base): # Дополнительные настройки is_trial_available = Column(Boolean, default=False, nullable=False) # Можно ли взять триал на этом тарифе + # Докупка трафика + traffic_topup_enabled = Column(Boolean, default=False, nullable=False) # Разрешена ли докупка трафика + # Пакеты трафика: JSON {"5": 5000, "10": 9000, "20": 15000} (ГБ: цена в копейках) + traffic_topup_packages = Column(JSON, default=dict) + created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) @@ -805,6 +810,29 @@ class Tariff(Base): return True # Если у пользователя нет группы - доступен return any(pg.id == promo_group_id for pg in self.allowed_promo_groups) + def get_traffic_topup_packages(self) -> Dict[int, int]: + """Возвращает пакеты трафика для докупки: {ГБ: цена в копейках}.""" + packages = self.traffic_topup_packages or {} + return {int(gb): int(price) for gb, price in packages.items()} + + def get_traffic_topup_price(self, gb: int) -> Optional[int]: + """Возвращает цену в копейках для указанного пакета трафика.""" + packages = self.get_traffic_topup_packages() + return packages.get(gb) + + def get_available_traffic_packages(self) -> List[int]: + """Возвращает список доступных пакетов трафика в ГБ.""" + packages = self.get_traffic_topup_packages() + return sorted(packages.keys()) + + def can_topup_traffic(self) -> bool: + """Проверяет, можно ли докупить трафик на этом тарифе.""" + return ( + self.traffic_topup_enabled + and bool(self.traffic_topup_packages) + and not self.is_unlimited_traffic + ) + def __repr__(self): return f"" @@ -2249,4 +2277,4 @@ class CabinetRefreshToken(Base): def __repr__(self) -> str: status = "valid" if self.is_valid else ("revoked" if self.is_revoked else "expired") - return f"" \ No newline at end of file + return f"" From 209ce2c25abc601d4a6a6d3c163989831b484546 Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 10 Jan 2026 20:30:47 +0300 Subject: [PATCH 14/84] Update universal_migration.py --- app/database/universal_migration.py | 64 +++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index 92c9904e..ccc8d244 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -5332,6 +5332,64 @@ async def add_tariff_device_price_column() -> bool: return False +async def add_tariff_traffic_topup_columns() -> bool: + """Добавляет колонки для докупки трафика в тарифах.""" + try: + columns_added = 0 + + # Колонка traffic_topup_enabled + if not await check_column_exists('tariffs', 'traffic_topup_enabled'): + async with engine.begin() as conn: + db_type = await get_database_type() + + if db_type == 'sqlite': + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN traffic_topup_enabled INTEGER DEFAULT 0 NOT NULL" + )) + elif db_type == 'postgresql': + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN traffic_topup_enabled BOOLEAN DEFAULT FALSE NOT NULL" + )) + else: # MySQL + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN traffic_topup_enabled TINYINT(1) DEFAULT 0 NOT NULL" + )) + + logger.info("✅ Колонка traffic_topup_enabled добавлена в tariffs") + columns_added += 1 + else: + logger.info("ℹ️ Колонка traffic_topup_enabled уже существует в tariffs") + + # Колонка traffic_topup_packages (JSON) + if not await check_column_exists('tariffs', 'traffic_topup_packages'): + async with engine.begin() as conn: + db_type = await get_database_type() + + if db_type == 'sqlite': + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN traffic_topup_packages TEXT DEFAULT '{}'" + )) + elif db_type == 'postgresql': + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN traffic_topup_packages JSONB DEFAULT '{}'" + )) + else: # MySQL + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN traffic_topup_packages JSON DEFAULT NULL" + )) + + logger.info("✅ Колонка traffic_topup_packages добавлена в tariffs") + columns_added += 1 + else: + logger.info("ℹ️ Колонка traffic_topup_packages уже существует в tariffs") + + return True + + except Exception as error: + logger.error(f"❌ Ошибка добавления колонок докупки трафика: {error}") + return False + + async def run_universal_migration(): logger.info("=== НАЧАЛО УНИВЕРСАЛЬНОЙ МИГРАЦИИ ===") @@ -5834,6 +5892,12 @@ async def run_universal_migration(): else: logger.warning("⚠️ Проблемы с колонкой device_price_kopeks в tariffs") + traffic_topup_columns_ready = await add_tariff_traffic_topup_columns() + if traffic_topup_columns_ready: + logger.info("✅ Колонки докупки трафика в tariffs готовы") + else: + logger.warning("⚠️ Проблемы с колонками докупки трафика в tariffs") + logger.info("=== ОБНОВЛЕНИЕ ВНЕШНИХ КЛЮЧЕЙ ===") fk_updated = await fix_foreign_keys_for_user_deletion() if fk_updated: From 8690d5fa8e4011bb67705c3431bc85b632da703d Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 10 Jan 2026 20:32:20 +0300 Subject: [PATCH 15/84] Update traffic.py --- app/handlers/subscription/traffic.py | 127 +++++++++++++++++++-------- 1 file changed, 89 insertions(+), 38 deletions(-) diff --git a/app/handlers/subscription/traffic.py b/app/handlers/subscription/traffic.py index 1c1a8de5..ed2b8e28 100644 --- a/app/handlers/subscription/traffic.py +++ b/app/handlers/subscription/traffic.py @@ -31,7 +31,7 @@ from app.keyboards.inline import ( get_countries_keyboard, get_devices_keyboard, get_subscription_confirm_keyboard, get_autopay_keyboard, get_autopay_days_keyboard, get_back_keyboard, - get_add_traffic_keyboard, + get_add_traffic_keyboard, get_add_traffic_keyboard_from_tariff, get_change_devices_keyboard, get_reset_traffic_confirm_keyboard, get_manage_countries_keyboard, get_device_selection_keyboard, get_connection_guide_keyboard, @@ -93,41 +93,9 @@ async def handle_add_traffic( db: AsyncSession ): from app.config import settings + from app.database.crud.tariff import get_tariff_by_id texts = get_texts(db_user.language) - - # Проверяем, включена ли функция докупки трафика - if not settings.is_traffic_topup_enabled(): - await callback.answer( - texts.t( - "TRAFFIC_TOPUP_DISABLED", - "⚠️ Функция докупки трафика отключена", - ), - show_alert=True, - ) - return - - # В режиме тарифов докупка трафика недоступна - if settings.is_tariffs_mode(): - await callback.answer( - texts.t( - "TARIFF_TRAFFIC_TOPUP_DISABLED", - "⚠️ В режиме тарифов докупка трафика недоступна", - ), - show_alert=True, - ) - return - - if settings.is_traffic_topup_blocked(): - await callback.answer( - texts.t( - "TRAFFIC_FIXED_MODE", - "⚠️ В текущем режиме трафик фиксированный и не может быть изменен", - ), - show_alert=True, - ) - return - subscription = db_user.subscription if not subscription or subscription.is_trial: @@ -144,6 +112,74 @@ async def handle_add_traffic( ) return + # Режим тарифов - проверяем настройки тарифа + if settings.is_tariffs_mode() and subscription.tariff_id: + tariff = await get_tariff_by_id(db, subscription.tariff_id) + if not tariff or not tariff.can_topup_traffic(): + await callback.answer( + texts.t( + "TARIFF_TRAFFIC_TOPUP_DISABLED", + "⚠️ На вашем тарифе докупка трафика недоступна", + ), + show_alert=True, + ) + return + + # Показываем пакеты из тарифа + current_traffic = subscription.traffic_limit_gb + packages = tariff.get_traffic_topup_packages() + + period_hint_days = _get_period_hint_from_subscription(subscription) + traffic_discount_percent = _get_addon_discount_percent_for_user( + db_user, + "traffic", + period_hint_days, + ) + + prompt_text = texts.t( + "ADD_TRAFFIC_PROMPT", + ( + "📈 Добавить трафик к подписке\n\n" + "Текущий лимит: {current_traffic}\n" + "Выберите дополнительный трафик:" + ), + ).format(current_traffic=texts.format_traffic(current_traffic)) + + await callback.message.edit_text( + prompt_text, + reply_markup=get_add_traffic_keyboard_from_tariff( + db_user.language, + packages, + subscription.end_date, + traffic_discount_percent, + ), + parse_mode="HTML" + ) + + await callback.answer() + return + + # Стандартный режим - проверяем глобальные настройки + if not settings.is_traffic_topup_enabled(): + await callback.answer( + texts.t( + "TRAFFIC_TOPUP_DISABLED", + "⚠️ Функция докупки трафика отключена", + ), + show_alert=True, + ) + return + + if settings.is_traffic_topup_blocked(): + await callback.answer( + texts.t( + "TRAFFIC_FIXED_MODE", + "⚠️ В текущем режиме трафик фиксированный и не может быть изменен", + ), + show_alert=True, + ) + return + current_traffic = subscription.traffic_limit_gb period_hint_days = _get_period_hint_from_subscription(subscription) traffic_discount_percent = _get_addon_discount_percent_for_user( @@ -472,15 +508,30 @@ async def add_traffic( db_user: User, db: AsyncSession ): - if settings.is_traffic_topup_blocked(): - await callback.answer("⚠️ В текущем режиме трафик фиксированный", show_alert=True) - return + from app.database.crud.tariff import get_tariff_by_id traffic_gb = int(callback.data.split('_')[2]) texts = get_texts(db_user.language) subscription = db_user.subscription - base_price = settings.get_traffic_topup_price(traffic_gb) + # Получаем цену: из тарифа или из глобальных настроек + base_price = 0 + tariff = None + + if settings.is_tariffs_mode() and subscription and subscription.tariff_id: + # Режим тарифов - берем цену из тарифа + tariff = await get_tariff_by_id(db, subscription.tariff_id) + if tariff and tariff.can_topup_traffic(): + base_price = tariff.get_traffic_topup_price(traffic_gb) or 0 + else: + await callback.answer("⚠️ На вашем тарифе докупка трафика недоступна", show_alert=True) + return + else: + # Стандартный режим + if settings.is_traffic_topup_blocked(): + await callback.answer("⚠️ В текущем режиме трафик фиксированный", show_alert=True) + return + base_price = settings.get_traffic_topup_price(traffic_gb) if base_price == 0 and traffic_gb != 0: await callback.answer("⚠️ Цена для этого пакета не настроена", show_alert=True) From 69f4fd2b2af955bc4c8365899987cb4e70fa3904 Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 10 Jan 2026 20:32:57 +0300 Subject: [PATCH 16/84] Update tariffs.py --- app/handlers/admin/tariffs.py | 311 ++++++++++++++++++++++++++++++++++ 1 file changed, 311 insertions(+) diff --git a/app/handlers/admin/tariffs.py b/app/handlers/admin/tariffs.py index 86bd7ba4..436ebe37 100644 --- a/app/handlers/admin/tariffs.py +++ b/app/handlers/admin/tariffs.py @@ -191,6 +191,9 @@ def get_tariff_view_keyboard( InlineKeyboardButton(text="📱💰 Цена за устройство", callback_data=f"admin_tariff_edit_device_price:{tariff.id}"), InlineKeyboardButton(text="⏰ Дни триала", callback_data=f"admin_tariff_edit_trial_days:{tariff.id}"), ]) + buttons.append([ + InlineKeyboardButton(text="📈 Докупка трафика", callback_data=f"admin_tariff_edit_traffic_topup:{tariff.id}"), + ]) buttons.append([ InlineKeyboardButton(text="🌐 Серверы", callback_data=f"admin_tariff_edit_squads:{tariff.id}"), InlineKeyboardButton(text="👥 Промогруппы", callback_data=f"admin_tariff_edit_promo:{tariff.id}"), @@ -229,6 +232,23 @@ def get_tariff_view_keyboard( return InlineKeyboardMarkup(inline_keyboard=buttons) +def _format_traffic_topup_packages(tariff: Tariff) -> str: + """Форматирует пакеты докупки трафика для отображения.""" + if not getattr(tariff, 'traffic_topup_enabled', False): + return "❌ Отключено" + + packages = tariff.get_traffic_topup_packages() if hasattr(tariff, 'get_traffic_topup_packages') else {} + if not packages: + return "✅ Включено, но пакеты не настроены" + + lines = ["✅ Включено"] + for gb in sorted(packages.keys()): + price = packages[gb] + lines.append(f" • {gb} ГБ: {_format_price_kopeks(price)}") + + return "\n".join(lines) + + def format_tariff_info(tariff: Tariff, language: str, subs_count: int = 0) -> str: """Форматирует информацию о тарифе.""" texts = get_texts(language) @@ -264,6 +284,9 @@ def format_tariff_info(tariff: Tariff, language: str, subs_count: int = 0) -> st else: device_price_display = "Недоступно" + # Форматируем докупку трафика + traffic_topup_display = _format_traffic_topup_packages(tariff) + return f"""📦 Тариф: {tariff.name} {status} @@ -277,6 +300,9 @@ def format_tariff_info(tariff: Tariff, language: str, subs_count: int = 0) -> st • Триал: {trial_status} • Дней триала: {trial_days_display} +Докупка трафика: +{traffic_topup_display} + Цены: {prices_display} @@ -1310,6 +1336,285 @@ async def process_edit_tariff_trial_days( ) +# ============ РЕДАКТИРОВАНИЕ ДОКУПКИ ТРАФИКА ============ + +def _parse_traffic_topup_packages(text: str) -> Dict[int, int]: + """ + Парсит строку с пакетами докупки трафика. + Формат: "5:5000, 10:9000, 20:15000" (ГБ:цена_в_копейках) + """ + packages = {} + text = text.replace(";", ",").replace("=", ":") + + for part in text.split(","): + part = part.strip() + if not part: + continue + + if ":" not in part: + continue + + gb_str, price_str = part.split(":", 1) + try: + gb = int(gb_str.strip()) + price = int(price_str.strip()) + if gb > 0 and price > 0: + packages[gb] = price + except ValueError: + continue + + return packages + + +def _format_traffic_topup_packages_for_edit(packages: Dict[int, int]) -> str: + """Форматирует пакеты докупки для редактирования.""" + if not packages: + return "5:5000, 10:9000, 20:15000" + + parts = [] + for gb in sorted(packages.keys()): + parts.append(f"{gb}:{packages[gb]}") + + return ", ".join(parts) + + +@admin_required +@error_handler +async def start_edit_tariff_traffic_topup( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Показывает меню настройки докупки трафика.""" + texts = get_texts(db_user.language) + tariff_id = int(callback.data.split(":")[1]) + tariff = await get_tariff_by_id(db, tariff_id) + + if not tariff: + await callback.answer("Тариф не найден", show_alert=True) + return + + # Проверяем, безлимитный ли тариф + if tariff.is_unlimited_traffic: + await callback.answer("Докупка недоступна для безлимитного тарифа", show_alert=True) + return + + is_enabled = getattr(tariff, 'traffic_topup_enabled', False) + packages = tariff.get_traffic_topup_packages() if hasattr(tariff, 'get_traffic_topup_packages') else {} + + # Форматируем текущие настройки + if is_enabled: + status = "✅ Включено" + if packages: + packages_display = "\n".join(f" • {gb} ГБ: {_format_price_kopeks(price)}" for gb, price in sorted(packages.items())) + else: + packages_display = " Пакеты не настроены" + else: + status = "❌ Отключено" + packages_display = " -" + + buttons = [] + + # Переключение вкл/выкл + if is_enabled: + buttons.append([ + InlineKeyboardButton(text="❌ Отключить", callback_data=f"admin_tariff_toggle_traffic_topup:{tariff_id}") + ]) + else: + buttons.append([ + InlineKeyboardButton(text="✅ Включить", callback_data=f"admin_tariff_toggle_traffic_topup:{tariff_id}") + ]) + + # Редактирование пакетов (только если включено) + if is_enabled: + buttons.append([ + InlineKeyboardButton(text="📦 Настроить пакеты", callback_data=f"admin_tariff_edit_topup_packages:{tariff_id}") + ]) + + buttons.append([ + InlineKeyboardButton(text=texts.BACK, callback_data=f"admin_tariff_view:{tariff_id}") + ]) + + await callback.message.edit_text( + f"📈 Докупка трафика для «{tariff.name}»\n\n" + f"Статус: {status}\n\n" + f"Пакеты:\n{packages_display}\n\n" + "Пользователи смогут докупать трафик по заданным ценам.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons), + parse_mode="HTML" + ) + await callback.answer() + + +@admin_required +@error_handler +async def toggle_tariff_traffic_topup( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + """Переключает включение/выключение докупки трафика.""" + tariff_id = int(callback.data.split(":")[1]) + tariff = await get_tariff_by_id(db, tariff_id) + + if not tariff: + await callback.answer("Тариф не найден", show_alert=True) + return + + is_enabled = getattr(tariff, 'traffic_topup_enabled', False) + new_value = not is_enabled + + tariff = await update_tariff(db, tariff, traffic_topup_enabled=new_value) + + status_text = "включена" if new_value else "отключена" + await callback.answer(f"Докупка трафика {status_text}") + + # Перерисовываем меню + texts = get_texts(db_user.language) + packages = tariff.get_traffic_topup_packages() if hasattr(tariff, 'get_traffic_topup_packages') else {} + + if new_value: + status = "✅ Включено" + if packages: + packages_display = "\n".join(f" • {gb} ГБ: {_format_price_kopeks(price)}" for gb, price in sorted(packages.items())) + else: + packages_display = " Пакеты не настроены" + else: + status = "❌ Отключено" + packages_display = " -" + + buttons = [] + + if new_value: + buttons.append([ + InlineKeyboardButton(text="❌ Отключить", callback_data=f"admin_tariff_toggle_traffic_topup:{tariff_id}") + ]) + buttons.append([ + InlineKeyboardButton(text="📦 Настроить пакеты", callback_data=f"admin_tariff_edit_topup_packages:{tariff_id}") + ]) + else: + buttons.append([ + InlineKeyboardButton(text="✅ Включить", callback_data=f"admin_tariff_toggle_traffic_topup:{tariff_id}") + ]) + + buttons.append([ + InlineKeyboardButton(text=texts.BACK, callback_data=f"admin_tariff_view:{tariff_id}") + ]) + + try: + await callback.message.edit_text( + f"📈 Докупка трафика для «{tariff.name}»\n\n" + f"Статус: {status}\n\n" + f"Пакеты:\n{packages_display}\n\n" + "Пользователи смогут докупать трафик по заданным ценам.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons), + parse_mode="HTML" + ) + except TelegramBadRequest: + pass + + +@admin_required +@error_handler +async def start_edit_traffic_topup_packages( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Начинает редактирование пакетов докупки трафика.""" + texts = get_texts(db_user.language) + tariff_id = int(callback.data.split(":")[1]) + tariff = await get_tariff_by_id(db, tariff_id) + + if not tariff: + await callback.answer("Тариф не найден", show_alert=True) + return + + await state.set_state(AdminStates.editing_tariff_traffic_topup_packages) + await state.update_data(tariff_id=tariff_id, language=db_user.language) + + packages = tariff.get_traffic_topup_packages() if hasattr(tariff, 'get_traffic_topup_packages') else {} + current_packages = _format_traffic_topup_packages_for_edit(packages) + + if packages: + packages_display = "\n".join(f" • {gb} ГБ: {_format_price_kopeks(price)}" for gb, price in sorted(packages.items())) + else: + packages_display = " Не настроены" + + await callback.message.edit_text( + f"📦 Настройка пакетов докупки трафика\n\n" + f"Тариф: {tariff.name}\n\n" + f"Текущие пакеты:\n{packages_display}\n\n" + "Введите пакеты в формате:\n" + f"{current_packages}\n\n" + "(ГБ:цена_в_копейках, через запятую)\n" + "Например: 5:5000, 10:9000 = 5ГБ за 50₽, 10ГБ за 90₽", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text=texts.CANCEL, callback_data=f"admin_tariff_edit_traffic_topup:{tariff_id}")] + ]), + parse_mode="HTML" + ) + await callback.answer() + + +@admin_required +@error_handler +async def process_edit_traffic_topup_packages( + message: types.Message, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Обрабатывает новые пакеты докупки трафика.""" + data = await state.get_data() + tariff_id = data.get("tariff_id") + + tariff = await get_tariff_by_id(db, tariff_id) + if not tariff: + await message.answer("Тариф не найден") + await state.clear() + return + + packages = _parse_traffic_topup_packages(message.text.strip()) + + if not packages: + await message.answer( + "Не удалось распознать пакеты.\n\n" + "Формат: ГБ:цена_в_копейках\n" + "Пример: 5:5000, 10:9000, 20:15000", + parse_mode="HTML" + ) + return + + # Преобразуем в формат для JSON (строковые ключи) + packages_json = {str(gb): price for gb, price in packages.items()} + + tariff = await update_tariff(db, tariff, traffic_topup_packages=packages_json) + await state.clear() + + # Показываем обновленное меню + texts = get_texts(db_user.language) + packages_display = "\n".join(f" • {gb} ГБ: {_format_price_kopeks(price)}" for gb, price in sorted(packages.items())) + + buttons = [ + [InlineKeyboardButton(text="❌ Отключить", callback_data=f"admin_tariff_toggle_traffic_topup:{tariff_id}")], + [InlineKeyboardButton(text="📦 Настроить пакеты", callback_data=f"admin_tariff_edit_topup_packages:{tariff_id}")], + [InlineKeyboardButton(text=texts.BACK, callback_data=f"admin_tariff_view:{tariff_id}")] + ] + + await message.answer( + f"✅ Пакеты обновлены!\n\n" + f"📈 Докупка трафика для «{tariff.name}»\n\n" + f"Статус: ✅ Включено\n\n" + f"Пакеты:\n{packages_display}\n\n" + "Пользователи смогут докупать трафик по заданным ценам.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons), + parse_mode="HTML" + ) + + # ============ УДАЛЕНИЕ ТАРИФА ============ @admin_required @@ -1855,6 +2160,12 @@ def register_handlers(dp: Dispatcher): dp.callback_query.register(start_edit_tariff_trial_days, F.data.startswith("admin_tariff_edit_trial_days:")) dp.message.register(process_edit_tariff_trial_days, AdminStates.editing_tariff_trial_days) + # Редактирование докупки трафика + dp.callback_query.register(start_edit_tariff_traffic_topup, F.data.startswith("admin_tariff_edit_traffic_topup:")) + dp.callback_query.register(toggle_tariff_traffic_topup, F.data.startswith("admin_tariff_toggle_traffic_topup:")) + dp.callback_query.register(start_edit_traffic_topup_packages, F.data.startswith("admin_tariff_edit_topup_packages:")) + dp.message.register(process_edit_traffic_topup_packages, AdminStates.editing_tariff_traffic_topup_packages) + # Удаление dp.callback_query.register(confirm_delete_tariff, F.data.startswith("admin_tariff_delete:")) dp.callback_query.register(delete_tariff_confirmed, F.data.startswith("admin_tariff_delete_confirm:")) From 5d1d561882798b21ce2061ddb048b5f571d775c8 Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 10 Jan 2026 20:33:55 +0300 Subject: [PATCH 17/84] Update subscription.py --- app/database/crud/subscription.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index 5eada17c..37df85f2 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -409,8 +409,12 @@ async def extend_subscription( logger.info(f"📊 Обновлен лимит трафика: {old_traffic} ГБ → {traffic_limit_gb} ГБ") elif settings.RESET_TRAFFIC_ON_PAYMENT: subscription.traffic_used_gb = 0.0 - subscription.purchased_traffic_gb = 0 - logger.info("🔄 Сбрасываем использованный и докупленный трафик согласно настройке RESET_TRAFFIC_ON_PAYMENT") + # В режиме тарифов сохраняем докупленный трафик при продлении + if subscription.tariff_id is None: + subscription.purchased_traffic_gb = 0 + logger.info("🔄 Сбрасываем использованный и докупленный трафик согласно настройке RESET_TRAFFIC_ON_PAYMENT") + else: + logger.info("🔄 Сбрасываем использованный трафик, докупленный сохранен (режим тарифов)") if device_limit is not None: old_devices = subscription.device_limit From 744428e4f6ae8cda6c2d869146086d973aecf777 Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 10 Jan 2026 20:37:40 +0300 Subject: [PATCH 18/84] Update tariff.py --- app/database/crud/tariff.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/database/crud/tariff.py b/app/database/crud/tariff.py index 2ade37cc..34210992 100644 --- a/app/database/crud/tariff.py +++ b/app/database/crud/tariff.py @@ -227,6 +227,8 @@ async def update_tariff( tier_level: Optional[int] = None, is_trial_available: Optional[bool] = None, promo_group_ids: Optional[List[int]] = None, + traffic_topup_enabled: Optional[bool] = None, + traffic_topup_packages: Optional[Dict[str, int]] = None, ) -> Tariff: """Обновляет существующий тариф.""" if name is not None: @@ -252,6 +254,10 @@ async def update_tariff( tariff.tier_level = max(1, tier_level) if is_trial_available is not None: tariff.is_trial_available = is_trial_available + if traffic_topup_enabled is not None: + tariff.traffic_topup_enabled = traffic_topup_enabled + if traffic_topup_packages is not None: + tariff.traffic_topup_packages = traffic_topup_packages # Обновляем промогруппы если указаны if promo_group_ids is not None: From 7ea906bd1eb744ca59ba0a741613db3c38043002 Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 10 Jan 2026 20:41:59 +0300 Subject: [PATCH 19/84] Update tariff_purchase.py --- app/handlers/subscription/tariff_purchase.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/app/handlers/subscription/tariff_purchase.py b/app/handlers/subscription/tariff_purchase.py index 4b27debd..649ece7b 100644 --- a/app/handlers/subscription/tariff_purchase.py +++ b/app/handlers/subscription/tariff_purchase.py @@ -1281,15 +1281,16 @@ async def confirm_tariff_switch( if remaining_days < 0: remaining_days = 0 - # Если выбранный период больше оставшегося - добавляем разницу - # Пользователь должен получить минимум то, за что заплатил - days_to_add = max(0, period - remaining_days) + # При смене тарифа пользователь получает максимум из: + # - оплаченного периода (за что заплатил) + # - оставшихся дней (если их больше) + days_for_new_tariff = max(period, remaining_days) # Обновляем подписку с новыми параметрами тарифа subscription = await extend_subscription( db, subscription, - days=days_to_add, # Добавляем только разницу, если период > остатка + days=days_for_new_tariff, # Даем максимум из оплаченного и остатка tariff_id=tariff.id, traffic_limit_gb=tariff.traffic_limit_gb, device_limit=tariff.device_limit, @@ -1325,7 +1326,7 @@ async def confirm_tariff_switch( db_user, subscription, None, # Транзакция отсутствует, оплата с баланса - days_to_add, # Добавленные дни (0 если остаток >= периода) + days_for_new_tariff, # Итоговый срок подписки was_trial_conversion=False, amount_kopeks=final_price, ) From b2e00ecbbe6da9e8c3fa6bee8945cfa3a5adfc7f Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 10 Jan 2026 20:48:42 +0300 Subject: [PATCH 20/84] Update user_service.py --- app/services/user_service.py | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/app/services/user_service.py b/app/services/user_service.py index 404c650d..52474dc3 100644 --- a/app/services/user_service.py +++ b/app/services/user_service.py @@ -23,7 +23,7 @@ from app.database.models import ( ReferralEarning, SubscriptionServer, YooKassaPayment, BroadcastHistory, CryptoBotPayment, PlategaPayment, SubscriptionConversion, UserMessage, WelcomeText, SentNotification, PromoGroup, MulenPayPayment, Pal24Payment, HeleketPayment, - AdvertisingCampaign, AdvertisingCampaignRegistration, PaymentMethod, + FreekassaPayment, AdvertisingCampaign, AdvertisingCampaignRegistration, PaymentMethod, TransactionType ) from app.config import settings @@ -960,6 +960,28 @@ class UserService: except Exception as e: logger.error(f"❌ Ошибка удаления Heleket платежей: {e}") + # Удаляем Freekassa платежи + try: + freekassa_payments_result = await db.execute( + select(FreekassaPayment).where(FreekassaPayment.user_id == user_id) + ) + freekassa_payments = freekassa_payments_result.scalars().all() + + if freekassa_payments: + logger.info(f"🔄 Удаляем {len(freekassa_payments)} Freekassa платежей") + await db.execute( + update(FreekassaPayment) + .where(FreekassaPayment.user_id == user_id) + .values(transaction_id=None) + ) + await db.flush() + await db.execute( + delete(FreekassaPayment).where(FreekassaPayment.user_id == user_id) + ) + await db.flush() + except Exception as e: + logger.error(f"❌ Ошибка удаления Freekassa платежей: {e}") + try: transactions_result = await db.execute( select(Transaction).where(Transaction.user_id == user_id) From 83769df0eec1eef65435b2c053464211c819e43a Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 10 Jan 2026 20:52:25 +0300 Subject: [PATCH 21/84] Update tariff_purchase.py --- app/handlers/subscription/tariff_purchase.py | 30 ++++++-------------- 1 file changed, 8 insertions(+), 22 deletions(-) diff --git a/app/handlers/subscription/tariff_purchase.py b/app/handlers/subscription/tariff_purchase.py index 649ece7b..bbcfc7e3 100644 --- a/app/handlers/subscription/tariff_purchase.py +++ b/app/handlers/subscription/tariff_purchase.py @@ -1176,12 +1176,8 @@ async def select_tariff_switch_period( if subscription and subscription.end_date: remaining_days = max(0, (subscription.end_date - datetime.utcnow()).days) - # Определяем что произойдёт с временем - if remaining_days >= period: - time_info = f"⏰ Осталось дней: {remaining_days} (будет сохранено)" - else: - days_to_add = period - remaining_days - time_info = f"⏰ Осталось дней: {remaining_days} → будет {period} (+{days_to_add})" + # При смене тарифа устанавливается ровно оплаченный период + time_info = f"⏰ Будет установлено: {period} дней" if user_balance >= final_price: discount_text = "" @@ -1275,22 +1271,15 @@ async def confirm_tariff_switch( # Получаем список серверов из тарифа squads = tariff.allowed_squads or [] - # Рассчитываем сколько дней осталось у текущей подписки - from datetime import datetime - remaining_days = (subscription.end_date - datetime.utcnow()).days - if remaining_days < 0: - remaining_days = 0 - - # При смене тарифа пользователь получает максимум из: - # - оплаченного периода (за что заплатил) - # - оставшихся дней (если их больше) - days_for_new_tariff = max(period, remaining_days) + # При смене тарифа пользователь получает ровно тот период, за который заплатил + # Старые дни не сохраняются - это смена тарифа, а не продление + days_for_new_tariff = period # Обновляем подписку с новыми параметрами тарифа subscription = await extend_subscription( db, subscription, - days=days_for_new_tariff, # Даем максимум из оплаченного и остатка + days=days_for_new_tariff, # Даем ровно оплаченный период tariff_id=tariff.id, traffic_limit_gb=tariff.traffic_limit_gb, device_limit=tariff.device_limit, @@ -1344,11 +1333,8 @@ async def confirm_tariff_switch( traffic = _format_traffic(tariff.traffic_limit_gb) - # Формируем текст о времени подписки - if days_to_add > 0: - time_info = f"📅 Добавлено дней: {days_to_add}" - else: - time_info = "📅 Остаток времени подписки сохранён" + # При смене тарифа устанавливается оплаченный период + time_info = f"📅 Период: {days_for_new_tariff} дней" await callback.message.edit_text( f"🎉 Тариф успешно изменён!\n\n" From e6676720f2fa8a5035de5435af7edb1dd9da7b97 Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 10 Jan 2026 20:57:49 +0300 Subject: [PATCH 22/84] Update inline.py --- app/keyboards/inline.py | 35 +++++++++++++++++++++-------------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index b94c866d..80d3eceb 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -508,14 +508,18 @@ def get_main_menu_keyboard( ) # Добавляем кнопку докупки трафика для лимитированных подписок - if ( - settings.BUY_TRAFFIC_BUTTON_VISIBLE - and settings.is_traffic_topup_enabled() - and not settings.is_traffic_topup_blocked() - and subscription - and not subscription.is_trial - and (subscription.traffic_limit_gb or 0) > 0 - ): + # В режиме тарифов проверяем tariff_id (детальная проверка в хендлере) + # В классическом режиме проверяем глобальные настройки + show_traffic_topup = False + if subscription and not subscription.is_trial and (subscription.traffic_limit_gb or 0) > 0: + if settings.is_tariffs_mode() and getattr(subscription, 'tariff_id', None): + # Режим тарифов - показываем кнопку, проверка настроек тарифа в хендлере + show_traffic_topup = settings.BUY_TRAFFIC_BUTTON_VISIBLE + elif settings.is_traffic_topup_enabled() and not settings.is_traffic_topup_blocked(): + # Классический режим - проверяем глобальные настройки + show_traffic_topup = settings.BUY_TRAFFIC_BUTTON_VISIBLE + + if show_traffic_topup: paired_buttons.append( InlineKeyboardButton(text=texts.t("BUY_TRAFFIC_BUTTON", "📈 Докупить трафик"), callback_data="buy_traffic") ) @@ -1002,12 +1006,15 @@ def get_subscription_keyboard( ) ]) # Кнопка докупки трафика для платных подписок - if ( - settings.is_traffic_topup_enabled() - and not settings.is_traffic_topup_blocked() - and subscription - and (subscription.traffic_limit_gb or 0) > 0 - ): + # В режиме тарифов проверяем tariff_id, в классическом - глобальные настройки + show_traffic_topup = False + if subscription and (subscription.traffic_limit_gb or 0) > 0: + if settings.is_tariffs_mode() and getattr(subscription, 'tariff_id', None): + show_traffic_topup = True + elif settings.is_traffic_topup_enabled() and not settings.is_traffic_topup_blocked(): + show_traffic_topup = True + + if show_traffic_topup: keyboard.append([ InlineKeyboardButton( text=texts.t("BUY_TRAFFIC_BUTTON", "📈 Докупить трафик"), From 2c4b77ff8cb985f1da1c36426ebe99325661c278 Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 10 Jan 2026 21:05:58 +0300 Subject: [PATCH 23/84] Update inline.py --- app/keyboards/inline.py | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 80d3eceb..b8bbb1e8 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -975,36 +975,36 @@ def get_subscription_keyboard( if happ_row: keyboard.append(happ_row) - if not is_trial: + if is_trial: keyboard.append([ - InlineKeyboardButton(text=texts.MENU_EXTEND_SUBSCRIPTION, callback_data="subscription_extend") + InlineKeyboardButton(text=texts.MENU_BUY_SUBSCRIPTION, callback_data="subscription_upgrade") ]) + else: + # Ряд: [Продлить] [Автоплатеж] keyboard.append([ + InlineKeyboardButton(text=texts.MENU_EXTEND_SUBSCRIPTION, callback_data="subscription_extend"), InlineKeyboardButton( text=texts.t("AUTOPAY_BUTTON", "💳 Автоплатеж"), callback_data="subscription_autopay", ) ]) - if is_trial: - keyboard.append([ - InlineKeyboardButton(text=texts.MENU_BUY_SUBSCRIPTION, callback_data="subscription_upgrade") - ]) - else: - keyboard.append([ + # Ряд: [Настройки] [Тариф] (если режим тарифов) + settings_row = [ InlineKeyboardButton( - text=texts.t("SUBSCRIPTION_SETTINGS_BUTTON", "⚙️ Настройки подписки"), + text=texts.t("SUBSCRIPTION_SETTINGS_BUTTON", "⚙️ Настройки"), callback_data="subscription_settings", ) - ]) - # Кнопка смены тарифа для режима тарифов + ] if settings.is_tariffs_mode() and subscription: - keyboard.append([ + settings_row.append( InlineKeyboardButton( - text=texts.t("CHANGE_TARIFF_BUTTON", "📦 Сменить тариф"), + text=texts.t("CHANGE_TARIFF_BUTTON", "📦 Тариф"), callback_data="tariff_switch" ) - ]) + ) + keyboard.append(settings_row) + # Кнопка докупки трафика для платных подписок # В режиме тарифов проверяем tariff_id, в классическом - глобальные настройки show_traffic_topup = False From 4c947a3de9f553ffcf4d297047808c3970862e0c Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 10 Jan 2026 21:14:06 +0300 Subject: [PATCH 24/84] Update miniapp.py --- app/webapi/routes/miniapp.py | 169 +++++++++++++++++++++++++++++++++++ 1 file changed, 169 insertions(+) diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index d5859200..4a962ce2 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -192,6 +192,8 @@ from ..schemas.miniapp import ( MiniAppTariffPurchaseResponse, MiniAppCurrentTariff, MiniAppConnectedServer, + MiniAppTrafficTopupRequest, + MiniAppTrafficTopupResponse, ) @@ -3510,6 +3512,8 @@ async def get_subscription_details( async def _get_current_tariff_model(db: AsyncSession, subscription) -> Optional[MiniAppCurrentTariff]: """Возвращает модель текущего тарифа пользователя.""" + from app.webapi.schemas.miniapp import MiniAppTrafficTopupPackage + if not subscription or not getattr(subscription, "tariff_id", None): return None @@ -3519,6 +3523,20 @@ async def _get_current_tariff_model(db: AsyncSession, subscription) -> Optional[ servers_count = len(tariff.allowed_squads) if tariff.allowed_squads else 0 + # Пакеты докупки трафика + traffic_topup_enabled = getattr(tariff, 'traffic_topup_enabled', False) and tariff.traffic_limit_gb > 0 + traffic_topup_packages = [] + + if traffic_topup_enabled and hasattr(tariff, 'get_traffic_topup_packages'): + packages = tariff.get_traffic_topup_packages() + for gb in sorted(packages.keys()): + price = packages[gb] + traffic_topup_packages.append(MiniAppTrafficTopupPackage( + gb=gb, + price_kopeks=price, + price_label=settings.format_price(price), + )) + return MiniAppCurrentTariff( id=tariff.id, name=tariff.name, @@ -3529,6 +3547,8 @@ async def _get_current_tariff_model(db: AsyncSession, subscription) -> Optional[ is_unlimited_traffic=tariff.traffic_limit_gb == 0, device_limit=tariff.device_limit, servers_count=servers_count, + traffic_topup_enabled=traffic_topup_enabled, + traffic_topup_packages=traffic_topup_packages, ) @@ -6253,3 +6273,152 @@ async def purchase_tariff_endpoint( 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, + db: AsyncSession = Depends(get_db_session), +): + """Докупка трафика для подписки.""" + from app.webapi.schemas.miniapp import MiniAppTrafficTopupRequest, MiniAppTrafficTopupResponse + from app.database.crud.subscription import add_subscription_traffic + from app.database.crud.user import subtract_user_balance + from app.database.crud.transaction import create_transaction + from app.database.models import TransactionType + from app.utils.pricing_utils import calculate_prorated_price + + user = await _authorize_miniapp_user(payload.init_data, db) + subscription = _ensure_paid_subscription(user) + _validate_subscription_id(payload.subscription_id, subscription) + + # Проверяем режим тарифов + if not settings.is_tariffs_mode(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "code": "tariffs_mode_disabled", + "message": "Traffic top-up is only available in tariffs mode", + }, + ) + + # Проверяем наличие тарифа + tariff_id = getattr(subscription, 'tariff_id', None) + if not tariff_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "code": "no_tariff", + "message": "Subscription has no tariff", + }, + ) + + tariff = await get_tariff_by_id(db, tariff_id) + if not tariff: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={ + "code": "tariff_not_found", + "message": "Tariff not found", + }, + ) + + # Проверяем, разрешена ли докупка трафика + if not getattr(tariff, 'traffic_topup_enabled', False): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={ + "code": "traffic_topup_disabled", + "message": "Traffic top-up is disabled for this tariff", + }, + ) + + # Проверяем безлимит + if tariff.traffic_limit_gb == 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "code": "unlimited_traffic", + "message": "Cannot add traffic to unlimited subscription", + }, + ) + + # Получаем цену пакета + packages = tariff.get_traffic_topup_packages() if hasattr(tariff, 'get_traffic_topup_packages') else {} + if payload.gb not in packages: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "code": "invalid_package", + "message": f"Traffic package {payload.gb}GB is not available", + }, + ) + + base_price_kopeks = packages[payload.gb] + + # Пропорциональный расчет цены с учетом оставшегося времени подписки + final_price, months_charged = calculate_prorated_price( + base_price_kopeks, + subscription.end_date, + ) + + # Проверяем баланс + if user.balance_kopeks < final_price: + raise HTTPException( + status_code=status.HTTP_402_PAYMENT_REQUIRED, + detail={ + "code": "insufficient_balance", + "message": "Insufficient balance", + "required": final_price, + "balance": user.balance_kopeks, + }, + ) + + # Списываем баланс + success = await subtract_user_balance( + db, user, final_price, + f"Докупка {payload.gb} ГБ трафика" + ) + if not success: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={ + "code": "balance_error", + "message": "Failed to subtract balance", + }, + ) + + # Добавляем трафик + await add_subscription_traffic(db, subscription, payload.gb) + + # Обновляем purchased_traffic_gb + current_purchased = getattr(subscription, 'purchased_traffic_gb', 0) or 0 + subscription.purchased_traffic_gb = current_purchased + payload.gb + await db.commit() + + # Синхронизируем с RemnaWave + try: + service = SubscriptionService() + await service.update_remnawave_user(db, subscription) + except Exception as e: + logger.error(f"Ошибка синхронизации с RemnaWave при докупке трафика: {e}") + + # Создаем транзакцию + await create_transaction( + db, + user_id=user.id, + type=TransactionType.SUBSCRIPTION_PAYMENT, + amount_kopeks=-final_price, + description=f"Докупка {payload.gb} ГБ трафика", + ) + + await db.refresh(user) + await db.refresh(subscription) + + return MiniAppTrafficTopupResponse( + success=True, + message=f"Добавлено {payload.gb} ГБ трафика", + new_traffic_limit_gb=subscription.traffic_limit_gb, + new_balance_kopeks=user.balance_kopeks, + charged_kopeks=final_price, + ) From 16e70bff466a7eb5e6c862d342ce22d32a430429 Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 10 Jan 2026 21:14:40 +0300 Subject: [PATCH 25/84] Update miniapp.py --- app/webapi/schemas/miniapp.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/app/webapi/schemas/miniapp.py b/app/webapi/schemas/miniapp.py index e33ad8b1..7c4ee8b5 100644 --- a/app/webapi/schemas/miniapp.py +++ b/app/webapi/schemas/miniapp.py @@ -522,6 +522,13 @@ class MiniAppTariff(BaseModel): is_available: bool = True +class MiniAppTrafficTopupPackage(BaseModel): + """Пакет докупки трафика.""" + gb: int + price_kopeks: int + price_label: str + + class MiniAppCurrentTariff(BaseModel): """Текущий тариф пользователя.""" id: int @@ -533,6 +540,25 @@ class MiniAppCurrentTariff(BaseModel): is_unlimited_traffic: bool = False device_limit: int servers_count: int + # Докупка трафика + traffic_topup_enabled: bool = False + traffic_topup_packages: List[MiniAppTrafficTopupPackage] = Field(default_factory=list) + + +class MiniAppTrafficTopupRequest(BaseModel): + """Запрос на докупку трафика.""" + init_data: str = Field(..., alias="initData") + subscription_id: int = Field(..., alias="subscriptionId") + gb: int + + +class MiniAppTrafficTopupResponse(BaseModel): + """Ответ на докупку трафика.""" + success: bool = True + message: str = "" + new_traffic_limit_gb: int = 0 + new_balance_kopeks: int = 0 + charged_kopeks: int = 0 class MiniAppTariffsRequest(BaseModel): From 7a86cb601010da9c6e9d66c2d2b32b384fa6245d Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 10 Jan 2026 21:25:03 +0300 Subject: [PATCH 26/84] Update miniapp.py --- app/webapi/routes/miniapp.py | 60 +++++++++++++++++++++++++++++++++--- 1 file changed, 55 insertions(+), 5 deletions(-) diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index 4a962ce2..b9ef69c4 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -6015,6 +6015,7 @@ async def _build_tariff_model( db: AsyncSession, tariff, current_tariff_id: Optional[int] = None, + promo_group=None, ) -> MiniAppTariff: """Преобразует объект тарифа в модель для API.""" servers: List[MiniAppConnectedServer] = [] @@ -6030,10 +6031,28 @@ async def _build_tariff_model( name=server.display_name or squad_uuid[:8], )) + # Получаем скидки промогруппы по периодам + period_discounts = {} + if promo_group: + raw_discounts = getattr(promo_group, 'period_discounts', None) or {} + for k, v in raw_discounts.items(): + try: + period_discounts[int(k)] = max(0, min(100, int(v))) + except (TypeError, ValueError): + pass + periods: List[MiniAppTariffPeriod] = [] if tariff.period_prices: - for period_str, price_kopeks in sorted(tariff.period_prices.items(), key=lambda x: int(x[0])): + for period_str, original_price_kopeks in sorted(tariff.period_prices.items(), key=lambda x: int(x[0])): period_days = int(period_str) + + # Применяем скидку промогруппы + discount_percent = period_discounts.get(period_days, 0) + if discount_percent > 0: + price_kopeks = int(original_price_kopeks * (100 - discount_percent) / 100) + else: + price_kopeks = original_price_kopeks + months = max(1, period_days // 30) per_month = price_kopeks // months if months > 0 else price_kopeks @@ -6045,6 +6064,9 @@ async def _build_tariff_model( price_label=settings.format_price(price_kopeks), price_per_month_kopeks=per_month, price_per_month_label=settings.format_price(per_month), + original_price_kopeks=original_price_kopeks if discount_percent > 0 else None, + original_price_label=settings.format_price(original_price_kopeks) if discount_percent > 0 else None, + discount_percent=discount_percent, )) return MiniAppTariff( @@ -6119,9 +6141,18 @@ async def get_tariffs_endpoint( # Формируем список тарифов tariff_models: List[MiniAppTariff] = [] for tariff in tariffs: - model = await _build_tariff_model(db, tariff, current_tariff_id) + model = await _build_tariff_model(db, tariff, current_tariff_id, promo_group) tariff_models.append(model) + # Формируем модель промогруппы для ответа + promo_group_model = None + if promo_group: + promo_group_model = MiniAppPromoGroup( + id=promo_group.id, + name=promo_group.name, + **_extract_promo_discounts(promo_group), + ) + return MiniAppTariffsResponse( success=True, sales_mode="tariffs", @@ -6129,6 +6160,7 @@ async def get_tariffs_endpoint( current_tariff=current_tariff_model, balance_kopeks=user.balance_kopeks, balance_label=settings.format_price(user.balance_kopeks), + promo_group=promo_group_model, ) @@ -6172,8 +6204,8 @@ async def purchase_tariff_endpoint( ) # Получаем цену за выбранный период - price_kopeks = tariff.get_price_for_period(payload.period_days) - if price_kopeks is None: + base_price_kopeks = tariff.get_price_for_period(payload.period_days) + if base_price_kopeks is None: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail={ @@ -6182,6 +6214,21 @@ async def purchase_tariff_endpoint( }, ) + # Применяем скидку промогруппы + price_kopeks = base_price_kopeks + discount_percent = 0 + if promo_group: + raw_discounts = getattr(promo_group, 'period_discounts', None) or {} + for k, v in raw_discounts.items(): + try: + if int(k) == payload.period_days: + discount_percent = max(0, min(100, int(v))) + break + except (TypeError, ValueError): + pass + if discount_percent > 0: + price_kopeks = int(base_price_kopeks * (100 - discount_percent) / 100) + # Проверяем баланс if user.balance_kopeks < price_kopeks: missing = price_kopeks - user.balance_kopeks @@ -6197,7 +6244,10 @@ async def purchase_tariff_endpoint( subscription = getattr(user, "subscription", None) # Списываем баланс - description = f"Покупка тарифа '{tariff.name}' на {payload.period_days} дней" + if discount_percent > 0: + description = f"Покупка тарифа '{tariff.name}' на {payload.period_days} дней (скидка {discount_percent}%)" + else: + description = f"Покупка тарифа '{tariff.name}' на {payload.period_days} дней" success = await subtract_user_balance(db, user, price_kopeks, description) if not success: raise HTTPException( From 60846ad06b812f0242dfacceb529dfe399a4672d Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 10 Jan 2026 21:29:55 +0300 Subject: [PATCH 27/84] Update miniapp.py --- app/webapi/schemas/miniapp.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/webapi/schemas/miniapp.py b/app/webapi/schemas/miniapp.py index 7c4ee8b5..57b58195 100644 --- a/app/webapi/schemas/miniapp.py +++ b/app/webapi/schemas/miniapp.py @@ -503,6 +503,10 @@ class MiniAppTariffPeriod(BaseModel): price_label: str price_per_month_kopeks: Optional[int] = None price_per_month_label: Optional[str] = None + # Скидка промогруппы + original_price_kopeks: Optional[int] = None # Цена без скидки + original_price_label: Optional[str] = None + discount_percent: int = 0 # Процент скидки class MiniAppTariff(BaseModel): @@ -574,6 +578,7 @@ class MiniAppTariffsResponse(BaseModel): current_tariff: Optional[MiniAppCurrentTariff] = None balance_kopeks: int = 0 balance_label: Optional[str] = None + promo_group: Optional[MiniAppPromoGroup] = None # Промогруппа пользователя для отображения скидок class MiniAppTariffPurchaseRequest(BaseModel): From 29725d602844cddb91e7142d3570f4cb7ec2a141 Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 10 Jan 2026 21:31:41 +0300 Subject: [PATCH 28/84] Update index.html --- miniapp/index.html | 540 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 532 insertions(+), 8 deletions(-) diff --git a/miniapp/index.html b/miniapp/index.html index acab2673..a63b6566 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -725,6 +725,80 @@ font-size: 14px; } + /* Бейдж скидки промогруппы на карточке тарифа */ + .tariff-discount-badge { + display: inline-block; + padding: 3px 8px; + background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%); + color: #fff; + font-size: 11px; + font-weight: 700; + border-radius: 8px; + margin-top: 4px; + box-shadow: 0 2px 4px rgba(34, 197, 94, 0.3); + animation: discount-pulse 2s ease-in-out infinite; + } + + @keyframes discount-pulse { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.05); } + } + + :root[data-theme="dark"] .tariff-discount-badge { + background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%); + box-shadow: 0 2px 8px rgba(34, 197, 94, 0.4); + } + + /* Баннер скидки промогруппы */ + .promo-group-discount-banner { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 16px; + margin-bottom: 16px; + background: linear-gradient(135deg, rgba(34, 197, 94, 0.12) 0%, rgba(16, 163, 74, 0.08) 100%); + border: 1px solid rgba(34, 197, 94, 0.25); + border-radius: 14px; + animation: banner-glow 3s ease-in-out infinite; + } + + @keyframes banner-glow { + 0%, 100% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0); } + 50% { box-shadow: 0 0 15px 2px rgba(34, 197, 94, 0.15); } + } + + .promo-group-discount-icon { + font-size: 28px; + filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.1)); + } + + .promo-group-discount-content { + flex: 1; + min-width: 0; + } + + .promo-group-discount-title { + font-weight: 700; + font-size: 14px; + color: #22c55e; + line-height: 1.3; + } + + .promo-group-discount-subtitle { + font-size: 12px; + color: var(--text-secondary); + margin-top: 2px; + } + + :root[data-theme="dark"] .promo-group-discount-banner { + background: linear-gradient(135deg, rgba(34, 197, 94, 0.18) 0%, rgba(16, 163, 74, 0.1) 100%); + border-color: rgba(34, 197, 94, 0.35); + } + + :root[data-theme="dark"] .promo-group-discount-title { + color: #4ade80; + } + :root[data-theme="dark"] .tariff-name-badge { background: linear-gradient(135deg, rgba(var(--primary-rgb), 0.25), rgba(var(--primary-rgb), 0.1)); } @@ -2418,6 +2492,141 @@ text-align: right; } + .info-action-btn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 50%; + border: none; + background: var(--primary); + color: white; + cursor: pointer; + margin-left: 8px; + transition: all 0.2s ease; + flex-shrink: 0; + } + + .info-action-btn:hover { + transform: scale(1.1); + box-shadow: 0 2px 8px rgba(var(--primary-rgb), 0.4); + } + + .info-action-btn:active { + transform: scale(0.95); + } + + .info-action-btn svg { + width: 16px; + height: 16px; + } + + /* Traffic Topup Modal */ + .traffic-topup-packages { + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 16px; + } + + .traffic-topup-package { + display: flex; + justify-content: space-between; + align-items: center; + padding: 14px 16px; + border-radius: var(--radius); + border: 1px solid var(--border-color); + background: var(--card-bg); + cursor: pointer; + transition: all 0.2s ease; + } + + .traffic-topup-package:hover { + border-color: var(--primary); + background: rgba(var(--primary-rgb), 0.05); + } + + .traffic-topup-package.disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .traffic-topup-package.disabled:hover { + border-color: var(--border-color); + background: var(--card-bg); + } + + .traffic-topup-package-info { + display: flex; + flex-direction: column; + gap: 2px; + } + + .traffic-topup-package-gb { + font-size: 16px; + font-weight: 700; + color: var(--text-primary); + } + + .traffic-topup-package-price { + font-size: 14px; + font-weight: 600; + color: var(--primary); + } + + .traffic-topup-package-btn { + padding: 8px 16px; + border-radius: var(--radius); + border: none; + background: var(--primary); + color: white; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + } + + .traffic-topup-package-btn:hover { + box-shadow: 0 2px 8px rgba(var(--primary-rgb), 0.4); + } + + .traffic-topup-package-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .traffic-topup-balance { + text-align: center; + font-size: 13px; + color: var(--text-secondary); + padding: 8px; + border-radius: var(--radius); + background: var(--surface); + } + + .traffic-topup-success { + text-align: center; + padding: 20px; + } + + .traffic-topup-success-icon { + font-size: 48px; + margin-bottom: 12px; + } + + .traffic-topup-success-title { + font-size: 18px; + font-weight: 700; + color: var(--text-primary); + margin-bottom: 8px; + } + + .traffic-topup-success-message { + font-size: 14px; + color: var(--text-secondary); + } + /* Promo card */ .promo-card .card-header { gap: 12px; @@ -5043,6 +5252,11 @@
Traffic Limit - +
Type @@ -5270,6 +5484,14 @@
Текущий тариф
+ +
Выберите тариф
@@ -5471,6 +5693,27 @@
+ +
Activate promo code
@@ -5922,6 +6165,16 @@ 'topup.status.failed.description': 'We could not confirm the payment automatically. Please check later or contact support.', 'topup.status.retry': 'Try again', 'topup.done': 'Done', + 'traffic_topup.title': 'Buy extra traffic', + 'traffic_topup.subtitle': 'Choose a traffic package', + 'traffic_topup.cancel': 'Close', + 'traffic_topup.gb_suffix': 'GB', + 'traffic_topup.buy_button': 'Buy', + 'traffic_topup.your_balance': 'Your balance', + 'traffic_topup.empty': 'No packages available', + 'traffic_topup.error.generic': 'Failed to purchase traffic. Please try again.', + 'traffic_topup.success.title': 'Traffic added!', + 'traffic_topup.success.message': '+{gb} GB has been added to your subscription.', 'button.buy_subscription': 'Buy Subscription', 'button.open_bot': 'Open Telegram bot', 'subscription_purchase.title': 'Purchase subscription', @@ -6358,6 +6611,16 @@ 'topup.status.failed.description': 'Не удалось подтвердить платеж автоматически. Проверьте позже или обратитесь в поддержку.', 'topup.status.retry': 'Повторить попытку', 'topup.done': 'Готово', + 'traffic_topup.title': 'Докупить трафик', + 'traffic_topup.subtitle': 'Выберите пакет трафика', + 'traffic_topup.cancel': 'Закрыть', + 'traffic_topup.gb_suffix': 'ГБ', + 'traffic_topup.buy_button': 'Купить', + 'traffic_topup.your_balance': 'Ваш баланс', + 'traffic_topup.empty': 'Нет доступных пакетов', + 'traffic_topup.error.generic': 'Не удалось докупить трафик. Попробуйте снова.', + 'traffic_topup.success.title': 'Трафик добавлен!', + 'traffic_topup.success.message': '+{gb} ГБ добавлено к вашей подписке.', 'button.buy_subscription': 'Купить подписку', 'button.open_bot': 'Открыть бота', 'subscription_purchase.title': 'Оформление подписки', @@ -8615,6 +8878,24 @@ } else { currentTariffRow.classList.add('hidden'); } + + // Кнопка докупки трафика + const trafficTopupBtn = document.getElementById('trafficTopupBtn'); + if (trafficTopupBtn) { + const trafficTopupEnabled = currentTariff?.traffic_topup_enabled || currentTariff?.trafficTopupEnabled; + const trafficTopupPackages = currentTariff?.traffic_topup_packages || currentTariff?.trafficTopupPackages || []; + const isTrial = (user?.subscription_status || '').toLowerCase() === 'trial'; + + if (trafficTopupEnabled && trafficTopupPackages.length > 0 && !isTrial) { + trafficTopupBtn.classList.remove('hidden'); + // Сохраняем данные для модального окна + window._trafficTopupPackages = trafficTopupPackages; + window._subscriptionId = userData?.subscription_id; + window._userBalance = userData?.balance_kopeks || 0; + } else { + trafficTopupBtn.classList.add('hidden'); + } + } } const autopayElement = document.getElementById('autopayStatus'); @@ -11839,6 +12120,169 @@ clearTopupError(); } + // ============ Traffic Topup Modal ============ + function openTrafficTopupModal() { + const modal = document.getElementById('trafficTopupModal'); + if (!modal) return; + + modal.classList.remove('hidden'); + document.body.classList.add('modal-open'); + renderTrafficTopupPackages(); + } + + function closeTrafficTopupModal() { + const modal = document.getElementById('trafficTopupModal'); + if (modal) { + modal.classList.add('hidden'); + } + document.body.classList.remove('modal-open'); + clearTrafficTopupError(); + } + + function clearTrafficTopupError() { + const errorEl = document.getElementById('trafficTopupModalError'); + if (errorEl) { + errorEl.classList.add('hidden'); + errorEl.textContent = ''; + } + } + + function showTrafficTopupError(message) { + const errorEl = document.getElementById('trafficTopupModalError'); + if (errorEl) { + errorEl.textContent = message; + errorEl.classList.remove('hidden'); + } + } + + function renderTrafficTopupPackages() { + const packagesContainer = document.getElementById('trafficTopupPackages'); + const balanceContainer = document.getElementById('trafficTopupBalance'); + + if (!packagesContainer) return; + + const packages = window._trafficTopupPackages || []; + const balance = window._userBalance || 0; + + packagesContainer.innerHTML = ''; + + if (packages.length === 0) { + packagesContainer.innerHTML = '
' + t('traffic_topup.empty') + '
'; + return; + } + + packages.forEach(pkg => { + const gb = pkg.gb; + const priceKopeks = pkg.price_kopeks || pkg.priceKopeks; + const priceLabel = pkg.price_label || pkg.priceLabel || formatPrice(priceKopeks); + const canAfford = balance >= priceKopeks; + + const packageEl = document.createElement('div'); + packageEl.className = 'traffic-topup-package' + (canAfford ? '' : ' disabled'); + + packageEl.innerHTML = ` +
+
+${gb} ${t('traffic_topup.gb_suffix')}
+
${priceLabel}
+
+ + `; + + if (canAfford) { + const btn = packageEl.querySelector('.traffic-topup-package-btn'); + btn.addEventListener('click', () => purchaseTrafficPackage(gb, priceKopeks)); + } + + packagesContainer.appendChild(packageEl); + }); + + if (balanceContainer) { + balanceContainer.innerHTML = t('traffic_topup.your_balance') + ': ' + formatPrice(balance) + ''; + } + } + + async function purchaseTrafficPackage(gb, priceKopeks) { + const packagesContainer = document.getElementById('trafficTopupPackages'); + if (!packagesContainer) return; + + // Показываем загрузку + const buttons = packagesContainer.querySelectorAll('.traffic-topup-package-btn'); + buttons.forEach(btn => { + btn.disabled = true; + btn.textContent = '...'; + }); + + clearTrafficTopupError(); + + try { + const response = await fetch('/miniapp/subscription/traffic-topup', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + initData: window._initData, + subscriptionId: window._subscriptionId, + gb: gb + }) + }); + + const data = await response.json(); + + if (!response.ok) { + const errorMessage = data?.detail?.message || data?.message || t('traffic_topup.error.generic'); + showTrafficTopupError(errorMessage); + renderTrafficTopupPackages(); + return; + } + + // Успех - показываем сообщение + packagesContainer.innerHTML = ` +
+
+
${t('traffic_topup.success.title')}
+
${t('traffic_topup.success.message').replace('{gb}', gb)}
+
+ `; + + // Обновляем баланс + window._userBalance = data.new_balance_kopeks || data.newBalanceKopeks || 0; + const balanceContainer = document.getElementById('trafficTopupBalance'); + if (balanceContainer) { + balanceContainer.innerHTML = t('traffic_topup.your_balance') + ': ' + formatPrice(window._userBalance) + ''; + } + + // Обновляем отображение баланса на странице + const balanceAmountEl = document.getElementById('balanceAmount'); + if (balanceAmountEl) { + balanceAmountEl.textContent = formatPrice(window._userBalance); + } + + // Обновляем лимит трафика + const trafficLimitEl = document.getElementById('trafficLimit'); + if (trafficLimitEl && data.new_traffic_limit_gb) { + trafficLimitEl.textContent = formatTrafficLimit(data.new_traffic_limit_gb); + } + + // Обновляем данные через 2 секунды + setTimeout(() => { + refreshSubscriptionData({ silent: true }); + }, 2000); + + } catch (error) { + console.error('Traffic topup error:', error); + showTrafficTopupError(t('traffic_topup.error.generic')); + renderTrafficTopupPackages(); + } + } + + function formatPrice(kopeks) { + const rubles = Math.floor(kopeks / 100); + return rubles + ' ₽'; + } + + // ============ End Traffic Topup Modal ============ + function renderTopupLoading(messageKey = 'topup.loading') { const { body } = getTopupElements(); if (!body) { @@ -19097,6 +19541,31 @@ }); } + // Кнопка докупки трафика + const trafficTopupBtn = document.getElementById('trafficTopupBtn'); + if (trafficTopupBtn) { + trafficTopupBtn.addEventListener('click', () => { + openTrafficTopupModal(); + }); + } + + // Модальное окно докупки трафика + const trafficTopupModal = document.getElementById('trafficTopupModal'); + if (trafficTopupModal) { + trafficTopupModal.addEventListener('click', event => { + if (event.target === trafficTopupModal) { + closeTrafficTopupModal(); + } + }); + } + + const trafficTopupCancelBtn = document.getElementById('trafficTopupModalCancelBtn'); + if (trafficTopupCancelBtn) { + trafficTopupCancelBtn.addEventListener('click', () => { + closeTrafficTopupModal(); + }); + } + const topupModal = document.getElementById('topupModal'); if (topupModal) { topupModal.addEventListener('click', event => { @@ -19314,6 +19783,40 @@ currentBlock?.classList.add('hidden'); } + // Баннер скидки промогруппы + const promoGroupBanner = document.getElementById('tariffsPromoGroupBanner'); + const promoGroupNameEl = document.getElementById('tariffsPromoGroupName'); + const promoGroupDiscountEl = document.getElementById('tariffsPromoGroupDiscount'); + const promoGroup = tariffsData?.promo_group || tariffsData?.promoGroup; + + if (promoGroup && promoGroupBanner) { + // Вычисляем максимальную скидку из period_discounts + const periodDiscounts = promoGroup.period_discounts || promoGroup.periodDiscounts || {}; + let maxDiscount = 0; + Object.values(periodDiscounts).forEach(discount => { + const d = parseInt(discount) || 0; + if (d > maxDiscount) maxDiscount = d; + }); + + if (maxDiscount > 0) { + promoGroupBanner.classList.remove('hidden'); + if (promoGroupNameEl) { + promoGroupNameEl.textContent = currentLang === 'en' + ? `Promo group "${promoGroup.name}"` + : `Промогруппа «${promoGroup.name}»`; + } + if (promoGroupDiscountEl) { + promoGroupDiscountEl.textContent = currentLang === 'en' + ? `Your discount: up to ${maxDiscount}%` + : `Ваша скидка: до ${maxDiscount}%`; + } + } else { + promoGroupBanner.classList.add('hidden'); + } + } else { + promoGroupBanner?.classList.add('hidden'); + } + // Список тарифов if (!list) return; list.innerHTML = ''; @@ -19327,15 +19830,34 @@ tariffs.forEach(tariff => { const isCurrent = tariff.is_current || tariff.isCurrent; const periods = tariff.periods || []; - const minPrice = periods.length > 0 - ? periods.reduce((min, p) => { + + // Находим минимальную цену и максимальную скидку + let minPrice = null; + let minPriceOriginal = null; + let maxDiscountPercent = 0; + + if (periods.length > 0) { + periods.forEach(p => { const price = p.price_kopeks || p.priceKopeks || 0; - return price < min ? price : min; - }, Infinity) - : null; - const minPriceLabel = minPrice !== null && minPrice !== Infinity + const originalPrice = p.original_price_kopeks || p.originalPriceKopeks || price; + const discountPct = p.discount_percent || p.discountPercent || 0; + + if (minPrice === null || price < minPrice) { + minPrice = price; + minPriceOriginal = originalPrice > price ? originalPrice : null; + } + if (discountPct > maxDiscountPercent) { + maxDiscountPercent = discountPct; + } + }); + } + + const minPriceLabel = minPrice !== null ? formatPriceFromKopeks(minPrice, tariffsData?.currency || 'RUB') : null; + const minPriceOriginalLabel = minPriceOriginal !== null + ? formatPriceFromKopeks(minPriceOriginal, tariffsData?.currency || 'RUB') + : null; const div = document.createElement('div'); div.className = 'subscription-settings-toggle' + (selectedTariffId === tariff.id ? ' active' : ''); @@ -19403,7 +19925,9 @@ ${minPriceLabel ? `
от
+ ${minPriceOriginalLabel ? `
${minPriceOriginalLabel}
` : ''}
${minPriceLabel}
+ ${maxDiscountPercent > 0 ? `
-${maxDiscountPercent}%
` : ''}
` : ''}
@@ -19476,9 +20000,9 @@ periods.forEach((period, index) => { const days = period.days || period.period_days || period.periodDays; const priceKopeks = period.price_kopeks || period.priceKopeks || period.final_price || period.finalPrice || 0; - const originalKopeks = period.original_price || period.originalPrice || period.base_price || period.basePrice || priceKopeks; + const originalKopeks = period.original_price_kopeks || period.originalPriceKopeks || period.original_price || period.originalPrice || period.base_price || period.basePrice || priceKopeks; const hasDiscount = originalKopeks > priceKopeks; - const discountPercent = hasDiscount ? Math.round((1 - priceKopeks / originalKopeks) * 100) : 0; + const discountPercent = period.discount_percent || period.discountPercent || (hasDiscount ? Math.round((1 - priceKopeks / originalKopeks) * 100) : 0); const priceLabel = formatPriceFromKopeks(priceKopeks, tariffsData?.currency || 'RUB'); const originalLabel = hasDiscount ? formatPriceFromKopeks(originalKopeks, tariffsData?.currency || 'RUB') : null; From c7a51915c95b337a4d3ef36032d1cb25a13d43b3 Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 10 Jan 2026 21:35:59 +0300 Subject: [PATCH 29/84] Update miniapp.py --- app/webapi/routes/miniapp.py | 55 +++++++++++++++++++++++++++++------- 1 file changed, 45 insertions(+), 10 deletions(-) diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index b9ef69c4..6d55efe0 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -3505,12 +3505,12 @@ async def get_subscription_details( trial_price_kopeks=trial_price_kopeks if trial_payment_required else None, trial_price_label=trial_price_label, sales_mode=settings.get_sales_mode(), - current_tariff=await _get_current_tariff_model(db, subscription) if subscription else None, + current_tariff=await _get_current_tariff_model(db, subscription, user) if subscription else None, **autopay_extras, ) -async def _get_current_tariff_model(db: AsyncSession, subscription) -> Optional[MiniAppCurrentTariff]: +async def _get_current_tariff_model(db: AsyncSession, subscription, user=None) -> Optional[MiniAppCurrentTariff]: """Возвращает модель текущего тарифа пользователя.""" from app.webapi.schemas.miniapp import MiniAppTrafficTopupPackage @@ -3523,6 +3523,14 @@ async def _get_current_tariff_model(db: AsyncSession, subscription) -> Optional[ servers_count = len(tariff.allowed_squads) if tariff.allowed_squads else 0 + # Получаем скидку на трафик из промогруппы + traffic_discount_percent = 0 + promo_group = getattr(user, "promo_group", None) if user else None + if promo_group: + apply_to_addons = getattr(promo_group, 'apply_discounts_to_addons', True) + if apply_to_addons: + traffic_discount_percent = max(0, min(100, int(getattr(promo_group, 'traffic_discount_percent', 0) or 0))) + # Пакеты докупки трафика traffic_topup_enabled = getattr(tariff, 'traffic_topup_enabled', False) and tariff.traffic_limit_gb > 0 traffic_topup_packages = [] @@ -3530,12 +3538,24 @@ async def _get_current_tariff_model(db: AsyncSession, subscription) -> Optional[ if traffic_topup_enabled and hasattr(tariff, 'get_traffic_topup_packages'): packages = tariff.get_traffic_topup_packages() for gb in sorted(packages.keys()): - price = packages[gb] - traffic_topup_packages.append(MiniAppTrafficTopupPackage( - gb=gb, - price_kopeks=price, - price_label=settings.format_price(price), - )) + base_price = packages[gb] + # Применяем скидку + if traffic_discount_percent > 0: + discounted_price = int(base_price * (100 - traffic_discount_percent) / 100) + traffic_topup_packages.append(MiniAppTrafficTopupPackage( + gb=gb, + price_kopeks=discounted_price, + price_label=settings.format_price(discounted_price), + original_price_kopeks=base_price, + original_price_label=settings.format_price(base_price), + discount_percent=traffic_discount_percent, + )) + else: + traffic_topup_packages.append(MiniAppTrafficTopupPackage( + gb=gb, + price_kopeks=base_price, + price_label=settings.format_price(base_price), + )) return MiniAppCurrentTariff( id=tariff.id, @@ -6406,6 +6426,17 @@ async def purchase_traffic_topup_endpoint( base_price_kopeks = packages[payload.gb] + # Применяем скидку промогруппы на трафик + traffic_discount_percent = 0 + promo_group = getattr(user, "promo_group", None) + if promo_group: + apply_to_addons = getattr(promo_group, 'apply_discounts_to_addons', True) + if apply_to_addons: + traffic_discount_percent = max(0, min(100, int(getattr(promo_group, 'traffic_discount_percent', 0) or 0))) + + if traffic_discount_percent > 0: + base_price_kopeks = int(base_price_kopeks * (100 - traffic_discount_percent) / 100) + # Пропорциональный расчет цены с учетом оставшегося времени подписки final_price, months_charged = calculate_prorated_price( base_price_kopeks, @@ -6425,9 +6456,13 @@ async def purchase_traffic_topup_endpoint( ) # Списываем баланс + if traffic_discount_percent > 0: + traffic_description = f"Докупка {payload.gb} ГБ трафика (скидка {traffic_discount_percent}%)" + else: + traffic_description = f"Докупка {payload.gb} ГБ трафика" success = await subtract_user_balance( db, user, final_price, - f"Докупка {payload.gb} ГБ трафика" + traffic_description ) if not success: raise HTTPException( @@ -6459,7 +6494,7 @@ async def purchase_traffic_topup_endpoint( user_id=user.id, type=TransactionType.SUBSCRIPTION_PAYMENT, amount_kopeks=-final_price, - description=f"Докупка {payload.gb} ГБ трафика", + description=traffic_description, ) await db.refresh(user) From c5a35cd254c408765fabc0f7207df5032c852589 Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 10 Jan 2026 21:36:31 +0300 Subject: [PATCH 30/84] Update miniapp.py --- app/webapi/schemas/miniapp.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/webapi/schemas/miniapp.py b/app/webapi/schemas/miniapp.py index 57b58195..9da04713 100644 --- a/app/webapi/schemas/miniapp.py +++ b/app/webapi/schemas/miniapp.py @@ -531,6 +531,10 @@ class MiniAppTrafficTopupPackage(BaseModel): gb: int price_kopeks: int price_label: str + # Скидка промогруппы на трафик + original_price_kopeks: Optional[int] = None + original_price_label: Optional[str] = None + discount_percent: int = 0 class MiniAppCurrentTariff(BaseModel): From a598237465aa93e88adb3feb000c3fdb280e4f8a Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 10 Jan 2026 21:37:17 +0300 Subject: [PATCH 31/84] Update index.html --- miniapp/index.html | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/miniapp/index.html b/miniapp/index.html index a63b6566..82e3b66f 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -2573,6 +2573,32 @@ font-size: 14px; font-weight: 600; color: var(--primary); + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + } + + .traffic-topup-price-original { + font-size: 12px; + color: var(--text-secondary); + text-decoration: line-through; + font-weight: 400; + } + + .traffic-topup-price-current { + color: var(--primary); + } + + .traffic-topup-discount-badge { + display: inline-block; + padding: 2px 6px; + background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%); + color: #fff; + font-size: 10px; + font-weight: 700; + border-radius: 6px; + box-shadow: 0 1px 3px rgba(34, 197, 94, 0.3); } .traffic-topup-package-btn { @@ -12175,6 +12201,10 @@ const gb = pkg.gb; const priceKopeks = pkg.price_kopeks || pkg.priceKopeks; const priceLabel = pkg.price_label || pkg.priceLabel || formatPrice(priceKopeks); + const originalPriceKopeks = pkg.original_price_kopeks || pkg.originalPriceKopeks; + const originalPriceLabel = pkg.original_price_label || pkg.originalPriceLabel; + const discountPercent = pkg.discount_percent || pkg.discountPercent || 0; + const hasDiscount = discountPercent > 0 && originalPriceKopeks; const canAfford = balance >= priceKopeks; const packageEl = document.createElement('div'); @@ -12183,7 +12213,11 @@ packageEl.innerHTML = `
+${gb} ${t('traffic_topup.gb_suffix')}
-
${priceLabel}
+
+ ${hasDiscount ? `${originalPriceLabel}` : ''} + ${priceLabel} + ${hasDiscount ? `-${discountPercent}%` : ''} +
+ + + - - - + + + - - -