diff --git a/.env.example b/.env.example
index 829c6830..50b4e0b9 100644
--- a/.env.example
+++ b/.env.example
@@ -252,8 +252,13 @@ YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED=true
# Отключить отображение кнопок выбора суммы пополнения (оставить только ввод вручную)
DISABLE_TOPUP_BUTTONS=false
+# Автоматическая проверка зависших пополнений и повторные обращения к провайдерам
+PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED=false
+# Интервал (в минутах) между автоматическими проверками пополнений
+PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES=10
+
# ===== НАСТРОЙКИ ОПИСАНИЙ ПЛАТЕЖЕЙ =====
-# Эти настройки позволяют изменить описания платежей,
+# Эти настройки позволяют изменить описания платежей,
# чтобы избежать блокировок платежных систем
PAYMENT_SERVICE_NAME=Интернет-сервис
PAYMENT_BALANCE_DESCRIPTION=Пополнение баланса
diff --git a/app/database/crud/user.py b/app/database/crud/user.py
index 8613ad20..e0295e73 100644
--- a/app/database/crud/user.py
+++ b/app/database/crud/user.py
@@ -62,10 +62,34 @@ async def get_user_by_telegram_id(db: AsyncSession, telegram_id: int) -> Optiona
.where(User.telegram_id == telegram_id)
)
user = result.scalar_one_or_none()
-
+
if user and user.subscription:
_ = user.subscription.is_active
-
+
+ return user
+
+
+async def get_user_by_username(db: AsyncSession, username: str) -> Optional[User]:
+ if not username:
+ return None
+
+ normalized = username.lower()
+
+ result = await db.execute(
+ select(User)
+ .options(
+ selectinload(User.subscription),
+ selectinload(User.promo_group),
+ selectinload(User.referrer),
+ )
+ .where(func.lower(User.username) == normalized)
+ )
+
+ user = result.scalar_one_or_none()
+
+ if user and user.subscription:
+ _ = user.subscription.is_active
+
return user
diff --git a/app/handlers/admin/users.py b/app/handlers/admin/users.py
index ed18af5d..32d3b02e 100644
--- a/app/handlers/admin/users.py
+++ b/app/handlers/admin/users.py
@@ -1,6 +1,7 @@
import logging
+import re
from datetime import datetime, timedelta
-from typing import Optional
+from typing import Optional, List, Tuple
from aiogram import Dispatcher, types, F
from aiogram.exceptions import TelegramForbiddenError, TelegramBadRequest
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
@@ -10,7 +11,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.states import AdminStates
from app.database.models import User, UserStatus, Subscription, SubscriptionStatus, TransactionType
-from app.database.crud.user import get_user_by_id
+from app.database.crud.user import (
+ get_user_by_id,
+ get_user_by_telegram_id,
+ get_user_by_username,
+ get_referrals,
+)
from app.database.crud.campaign import (
get_campaign_registration_by_user,
get_campaign_statistics,
@@ -1489,6 +1495,395 @@ async def show_user_management(
await callback.answer()
+async def _build_user_referrals_view(
+ db: AsyncSession,
+ language: str,
+ user_id: int,
+ limit: int = 30,
+) -> Optional[Tuple[str, InlineKeyboardMarkup]]:
+ texts = get_texts(language)
+
+ user = await get_user_by_id(db, user_id)
+ if not user:
+ return None
+
+ referrals = await get_referrals(db, user_id)
+
+ header = texts.t(
+ "ADMIN_USER_REFERRALS_TITLE",
+ "🤝 Рефералы пользователя",
+ )
+ summary = texts.t(
+ "ADMIN_USER_REFERRALS_SUMMARY",
+ "👤 {name} (ID: {telegram_id})\n👥 Всего рефералов: {count}",
+ ).format(
+ name=user.full_name,
+ telegram_id=user.telegram_id,
+ count=len(referrals),
+ )
+
+ lines: List[str] = [header, summary]
+
+ if referrals:
+ lines.append(
+ texts.t(
+ "ADMIN_USER_REFERRALS_LIST_HEADER",
+ "Список рефералов:",
+ )
+ )
+ items = []
+ for referral in referrals[:limit]:
+ username_part = (
+ f", @{referral.username}"
+ if referral.username
+ else ""
+ )
+ items.append(
+ texts.t(
+ "ADMIN_USER_REFERRALS_LIST_ITEM",
+ "• {name} (ID: {telegram_id}{username_part})",
+ ).format(
+ name=referral.full_name,
+ telegram_id=referral.telegram_id,
+ username_part=username_part,
+ )
+ )
+
+ lines.append("\n".join(items))
+
+ if len(referrals) > limit:
+ remaining = len(referrals) - limit
+ lines.append(
+ texts.t(
+ "ADMIN_USER_REFERRALS_LIST_TRUNCATED",
+ "• … и ещё {count} рефералов",
+ ).format(count=remaining)
+ )
+ else:
+ lines.append(
+ texts.t(
+ "ADMIN_USER_REFERRALS_EMPTY",
+ "Рефералов пока нет.",
+ )
+ )
+
+ lines.append(
+ texts.t(
+ "ADMIN_USER_REFERRALS_EDIT_HINT",
+ "✏️ Чтобы изменить список, нажмите «✏️ Редактировать» ниже.",
+ )
+ )
+
+ keyboard = InlineKeyboardMarkup(
+ inline_keyboard=[
+ [
+ InlineKeyboardButton(
+ text=texts.t(
+ "ADMIN_USER_REFERRALS_EDIT_BUTTON",
+ "✏️ Редактировать",
+ ),
+ callback_data=f"admin_user_referrals_edit_{user_id}",
+ )
+ ],
+ [
+ InlineKeyboardButton(
+ text=texts.BACK,
+ callback_data=f"admin_user_manage_{user_id}",
+ )
+ ],
+ ]
+ )
+
+ return "\n\n".join(lines), keyboard
+
+
+@admin_required
+@error_handler
+async def show_user_referrals(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession,
+ state: FSMContext,
+):
+ user_id = int(callback.data.split('_')[-1])
+
+ current_state = await state.get_state()
+ if current_state == AdminStates.editing_user_referrals:
+ data = await state.get_data()
+ preserved_data = {
+ key: value
+ for key, value in data.items()
+ if key not in {"editing_referrals_user_id", "referrals_message_id"}
+ }
+ await state.clear()
+ if preserved_data:
+ await state.update_data(**preserved_data)
+
+ view = await _build_user_referrals_view(db, db_user.language, user_id)
+ if not view:
+ await callback.answer("❌ Пользователь не найден", show_alert=True)
+ return
+
+ text, keyboard = view
+
+ await callback.message.edit_text(
+ text,
+ reply_markup=keyboard,
+ )
+ await callback.answer()
+
+
+@admin_required
+@error_handler
+async def start_edit_user_referrals(
+ callback: types.CallbackQuery,
+ db_user: User,
+ state: FSMContext,
+ db: AsyncSession,
+):
+ user_id = int(callback.data.split('_')[-1])
+
+ user = await get_user_by_id(db, user_id)
+ if not user:
+ await callback.answer("❌ Пользователь не найден", show_alert=True)
+ return
+
+ texts = get_texts(db_user.language)
+
+ prompt = texts.t(
+ "ADMIN_USER_REFERRALS_EDIT_PROMPT",
+ (
+ "✏️ Редактирование рефералов\n\n"
+ "Отправьте список рефералов для пользователя {name} (ID: {telegram_id}):\n"
+ "• Используйте TG ID или @username\n"
+ "• Значения можно указывать через запятую, пробел или с новой строки\n"
+ "• Чтобы очистить список, отправьте 0 или слово 'нет'\n\n"
+ "Или нажмите кнопку ниже, чтобы отменить."
+ ),
+ ).format(
+ name=user.full_name,
+ telegram_id=user.telegram_id,
+ )
+
+ await state.update_data(
+ editing_referrals_user_id=user_id,
+ referrals_message_id=callback.message.message_id,
+ )
+
+ await callback.message.edit_text(
+ prompt,
+ reply_markup=InlineKeyboardMarkup(
+ inline_keyboard=[
+ [
+ InlineKeyboardButton(
+ text=texts.BACK,
+ callback_data=f"admin_user_referrals_{user_id}",
+ )
+ ]
+ ]
+ ),
+ )
+
+ await state.set_state(AdminStates.editing_user_referrals)
+ await callback.answer()
+
+
+@admin_required
+@error_handler
+async def process_edit_user_referrals(
+ message: types.Message,
+ db_user: User,
+ state: FSMContext,
+ db: AsyncSession,
+):
+ texts = get_texts(db_user.language)
+ data = await state.get_data()
+
+ user_id = data.get("editing_referrals_user_id")
+ if not user_id:
+ await message.answer(
+ texts.t(
+ "ADMIN_USER_REFERRALS_STATE_LOST",
+ "❌ Не удалось определить пользователя. Попробуйте начать сначала.",
+ )
+ )
+ await state.clear()
+ return
+
+ raw_text = message.text.strip()
+ lower_text = raw_text.lower()
+ clear_keywords = {"0", "нет", "none", "пусто", "clear"}
+ clear_requested = lower_text in clear_keywords
+
+ tokens: List[str] = []
+ if not clear_requested:
+ parts = re.split(r"[,\n]+", raw_text)
+ for part in parts:
+ for token in part.split():
+ cleaned = token.strip()
+ if cleaned and cleaned not in tokens:
+ tokens.append(cleaned)
+
+ found_users: List[User] = []
+ not_found: List[str] = []
+ skipped_self: List[str] = []
+ duplicate_tokens: List[str] = []
+
+ seen_ids = set()
+
+ for token in tokens:
+ normalized = token.strip()
+ if not normalized:
+ continue
+
+ if normalized.startswith("@"):
+ normalized = normalized[1:]
+
+ user = None
+ if normalized.isdigit():
+ try:
+ user = await get_user_by_telegram_id(db, int(normalized))
+ except ValueError:
+ user = None
+ else:
+ user = await get_user_by_username(db, normalized)
+
+ if not user:
+ not_found.append(token)
+ continue
+
+ if user.id == user_id:
+ skipped_self.append(token)
+ continue
+
+ if user.id in seen_ids:
+ duplicate_tokens.append(token)
+ continue
+
+ seen_ids.add(user.id)
+ found_users.append(user)
+
+ if not found_users and not clear_requested:
+ error_lines = [
+ texts.t(
+ "ADMIN_USER_REFERRALS_NO_VALID",
+ "❌ Не удалось найти ни одного пользователя по введённым данным.",
+ )
+ ]
+ if not_found:
+ error_lines.append(
+ texts.t(
+ "ADMIN_USER_REFERRALS_INVALID_ENTRIES",
+ "Не найдены: {values}",
+ ).format(values=", ".join(not_found))
+ )
+ if skipped_self:
+ error_lines.append(
+ texts.t(
+ "ADMIN_USER_REFERRALS_SELF_SKIPPED",
+ "Пропущены значения пользователя: {values}",
+ ).format(values=", ".join(skipped_self))
+ )
+ await message.answer("\n".join(error_lines))
+ return
+
+ user_service = UserService()
+
+ new_referral_ids = [user.id for user in found_users] if not clear_requested else []
+
+ success, details = await user_service.update_user_referrals(
+ db,
+ user_id,
+ new_referral_ids,
+ db_user.id,
+ )
+
+ if not success:
+ await message.answer(
+ texts.t(
+ "ADMIN_USER_REFERRALS_UPDATE_ERROR",
+ "❌ Не удалось обновить рефералов. Попробуйте позже.",
+ )
+ )
+ return
+
+ response_lines = [
+ texts.t(
+ "ADMIN_USER_REFERRALS_UPDATED",
+ "✅ Список рефералов обновлён.",
+ )
+ ]
+
+ total_referrals = details.get("total", len(new_referral_ids))
+ added = details.get("added", 0)
+ removed = details.get("removed", 0)
+
+ response_lines.append(
+ texts.t(
+ "ADMIN_USER_REFERRALS_UPDATED_TOTAL",
+ "• Текущий список: {total}",
+ ).format(total=total_referrals)
+ )
+
+ if added > 0:
+ response_lines.append(
+ texts.t(
+ "ADMIN_USER_REFERRALS_UPDATED_ADDED",
+ "• Добавлено: {count}",
+ ).format(count=added)
+ )
+
+ if removed > 0:
+ response_lines.append(
+ texts.t(
+ "ADMIN_USER_REFERRALS_UPDATED_REMOVED",
+ "• Удалено: {count}",
+ ).format(count=removed)
+ )
+
+ if not_found:
+ response_lines.append(
+ texts.t(
+ "ADMIN_USER_REFERRALS_INVALID_ENTRIES",
+ "Не найдены: {values}",
+ ).format(values=", ".join(not_found))
+ )
+
+ if skipped_self:
+ response_lines.append(
+ texts.t(
+ "ADMIN_USER_REFERRALS_SELF_SKIPPED",
+ "Пропущены значения пользователя: {values}",
+ ).format(values=", ".join(skipped_self))
+ )
+
+ if duplicate_tokens:
+ response_lines.append(
+ texts.t(
+ "ADMIN_USER_REFERRALS_DUPLICATES",
+ "Игнорированы дубли: {values}",
+ ).format(values=", ".join(duplicate_tokens))
+ )
+
+ view = await _build_user_referrals_view(db, db_user.language, user_id)
+ message_id = data.get("referrals_message_id")
+
+ if view and message_id:
+ try:
+ await message.bot.edit_message_text(
+ view[0],
+ chat_id=message.chat.id,
+ message_id=message_id,
+ reply_markup=view[1],
+ )
+ except TelegramBadRequest:
+ await message.answer(view[0], reply_markup=view[1])
+ elif view:
+ await message.answer(view[0], reply_markup=view[1])
+
+ await message.answer("\n".join(response_lines))
+ await state.clear()
+
async def _render_user_promo_group(
message: types.Message,
language: str,
@@ -4159,6 +4554,21 @@ def register_handlers(dp: Dispatcher):
AdminStates.editing_user_balance
)
+ dp.callback_query.register(
+ show_user_referrals,
+ F.data.startswith("admin_user_referrals_") & ~F.data.contains("_edit")
+ )
+
+ dp.callback_query.register(
+ start_edit_user_referrals,
+ F.data.startswith("admin_user_referrals_edit_")
+ )
+
+ dp.message.register(
+ process_edit_user_referrals,
+ AdminStates.editing_user_referrals
+ )
+
dp.callback_query.register(
start_send_user_message,
F.data.startswith("admin_user_send_message_")
diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py
index 5d993543..fb0b21b1 100644
--- a/app/keyboards/admin.py
+++ b/app/keyboards/admin.py
@@ -760,6 +760,12 @@ def get_user_management_keyboard(user_id: int, user_status: str, language: str =
callback_data=f"admin_user_promo_group_{user_id}"
)
],
+ [
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_USER_REFERRALS_BUTTON", "🤝 Рефералы"),
+ callback_data=f"admin_user_referrals_{user_id}"
+ )
+ ],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_USER_STATISTICS", "📊 Статистика"),
diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json
index 67437d35..fc188245 100644
--- a/app/localization/locales/ru.json
+++ b/app/localization/locales/ru.json
@@ -713,6 +713,26 @@
"ADMIN_USER_PROMO_GROUP_ALREADY": "ℹ️ Пользователь уже состоит в этой промогруппе.",
"ADMIN_USER_PROMO_GROUP_BACK": "⬅️ К пользователю",
"ADMIN_USER_PROMO_GROUP_BUTTON": "👥 Промогруппа",
+ "ADMIN_USER_REFERRALS_BUTTON": "🤝 Рефералы",
+ "ADMIN_USER_REFERRALS_TITLE": "🤝 Рефералы пользователя",
+ "ADMIN_USER_REFERRALS_SUMMARY": "👤 {name} (ID: {telegram_id})\n👥 Всего рефералов: {count}",
+ "ADMIN_USER_REFERRALS_LIST_HEADER": "Список рефералов:",
+ "ADMIN_USER_REFERRALS_LIST_ITEM": "• {name} (ID: {telegram_id}{username_part})",
+ "ADMIN_USER_REFERRALS_LIST_TRUNCATED": "• … и ещё {count} рефералов",
+ "ADMIN_USER_REFERRALS_EMPTY": "Рефералов пока нет.",
+ "ADMIN_USER_REFERRALS_EDIT_HINT": "✏️ Чтобы изменить список, нажмите «✏️ Редактировать» ниже.",
+ "ADMIN_USER_REFERRALS_EDIT_BUTTON": "✏️ Редактировать",
+ "ADMIN_USER_REFERRALS_EDIT_PROMPT": "✏️ Редактирование рефералов\n\nОтправьте список рефералов для пользователя {name} (ID: {telegram_id}):\n• Используйте TG ID или @username\n• Значения можно указывать через запятую, пробел или с новой строки\n• Чтобы очистить список, отправьте 0 или слово 'нет'\n\nИли нажмите кнопку ниже, чтобы отменить.",
+ "ADMIN_USER_REFERRALS_STATE_LOST": "❌ Не удалось определить пользователя. Попробуйте начать сначала.",
+ "ADMIN_USER_REFERRALS_NO_VALID": "❌ Не удалось найти ни одного пользователя по введённым данным.",
+ "ADMIN_USER_REFERRALS_INVALID_ENTRIES": "Не найдены: {values}",
+ "ADMIN_USER_REFERRALS_SELF_SKIPPED": "Пропущены значения пользователя: {values}",
+ "ADMIN_USER_REFERRALS_DUPLICATES": "Игнорированы дубли: {values}",
+ "ADMIN_USER_REFERRALS_UPDATE_ERROR": "❌ Не удалось обновить рефералов. Попробуйте позже.",
+ "ADMIN_USER_REFERRALS_UPDATED": "✅ Список рефералов обновлён.",
+ "ADMIN_USER_REFERRALS_UPDATED_TOTAL": "• Текущий список: {total}",
+ "ADMIN_USER_REFERRALS_UPDATED_ADDED": "• Добавлено: {count}",
+ "ADMIN_USER_REFERRALS_UPDATED_REMOVED": "• Удалено: {count}",
"ADMIN_USER_PROMO_GROUP_CURRENT": "Текущая группа: {name}",
"ADMIN_USER_PROMO_GROUP_CURRENT_NONE": "Текущая группа: не назначена",
"ADMIN_USER_PROMO_GROUP_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%",
diff --git a/app/services/admin_notification_service.py b/app/services/admin_notification_service.py
index 5da6c2cb..de7407b9 100644
--- a/app/services/admin_notification_service.py
+++ b/app/services/admin_notification_service.py
@@ -77,6 +77,20 @@ class AdminNotificationService:
)
return None
+ def _get_user_display(self, user: User) -> str:
+ first_name = getattr(user, "first_name", "") or ""
+ if first_name:
+ return first_name
+
+ username = getattr(user, "username", "") or ""
+ if username:
+ return username
+
+ telegram_id = getattr(user, "telegram_id", None)
+ if telegram_id is None:
+ return "IDUnknown"
+ return f"ID{telegram_id}"
+
def _format_promo_group_discounts(self, promo_group: PromoGroup) -> List[str]:
discount_lines: List[str] = []
@@ -185,6 +199,7 @@ class AdminNotificationService:
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
promo_group = await self._get_user_promo_group(db, user)
promo_block = self._format_promo_group_block(promo_group)
+ user_display = self._get_user_display(user)
trial_device_limit = subscription.device_limit
if trial_device_limit is None:
@@ -196,7 +211,7 @@ class AdminNotificationService:
message = f"""🎯 АКТИВАЦИЯ ТРИАЛА
-👤 Пользователь: {getattr(user, 'first_name', '') or getattr(user, 'username', '') or f"ID{getattr(user, 'telegram_id', 'Unknown')}"}
+👤 Пользователь: {user_display}
🆔 Telegram ID: {user.telegram_id}
📱 Username: @{getattr(user, 'username', None) or 'отсутствует'}
👥 Статус: {user_status}
@@ -235,26 +250,27 @@ class AdminNotificationService:
try:
event_type = "🔄 КОНВЕРСИЯ ИЗ ТРИАЛА" if was_trial_conversion else "💎 ПОКУПКА ПОДПИСКИ"
-
+
if was_trial_conversion:
user_status = "🎯 Конверсия из триала"
elif user.has_had_paid_subscription:
user_status = "🔄 Продление/Обновление"
else:
user_status = "🆕 Первая покупка"
-
+
servers_info = await self._get_servers_info(subscription.connected_squads)
payment_method = self._get_payment_method_display(transaction.payment_method) if transaction else "Баланс"
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
promo_group = await self._get_user_promo_group(db, user)
promo_block = self._format_promo_group_block(promo_group)
+ user_display = self._get_user_display(user)
total_amount = amount_kopeks if amount_kopeks is not None else (transaction.amount_kopeks if transaction else 0)
transaction_id = transaction.id if transaction else "—"
message = f"""💎 {event_type}
-👤 Пользователь: {getattr(user, 'first_name', '') or getattr(user, 'username', '') or f"ID{getattr(user, 'telegram_id', 'Unknown')}"}
+👤 Пользователь: {user_display}
🆔 Telegram ID: {user.telegram_id}
📱 Username: @{getattr(user, 'username', None) or 'отсутствует'}
👥 Статус: {user_status}
@@ -372,10 +388,11 @@ class AdminNotificationService:
subscription_status = self._get_subscription_status(subscription)
promo_block = self._format_promo_group_block(promo_group)
timestamp = datetime.now().strftime('%d.%m.%Y %H:%M:%S')
+ user_display = self._get_user_display(user)
return f"""💰 ПОПОЛНЕНИЕ БАЛАНСА
-👤 Пользователь: {getattr(user, 'first_name', '') or getattr(user, 'username', '') or f"ID{getattr(user, 'telegram_id', 'Unknown')}"}
+👤 Пользователь: {user_display}
🆔 Telegram ID: {user.telegram_id}
📱 Username: @{getattr(user, 'username', None) or 'отсутствует'}
💳 Статус: {topup_status}
@@ -548,13 +565,14 @@ class AdminNotificationService:
servers_info = await self._get_servers_info(subscription.connected_squads)
promo_group = await self._get_user_promo_group(db, user)
promo_block = self._format_promo_group_block(promo_group)
+ user_display = self._get_user_display(user)
current_end_date = new_end_date or subscription.end_date
current_balance = balance_after if balance_after is not None else user.balance_kopeks
message = f"""⏰ ПРОДЛЕНИЕ ПОДПИСКИ
-👤 Пользователь: {getattr(user, 'first_name', '') or getattr(user, 'username', '') or f"ID{getattr(user, 'telegram_id', 'Unknown')}"}
+👤 Пользователь: {user_display}
🆔 Telegram ID: {user.telegram_id}
📱 Username: @{getattr(user, 'username', None) or 'отсутствует'}
@@ -600,11 +618,12 @@ class AdminNotificationService:
promo_block = self._format_promo_group_block(promo_group)
type_display = self._get_promocode_type_display(promocode_data.get("type"))
usage_info = f"{promocode_data.get('current_uses', 0)}/{promocode_data.get('max_uses', 0)}"
+ user_display = self._get_user_display(user)
message_lines = [
"🎫 АКТИВАЦИЯ ПРОМОКОДА",
"",
- f"👤 Пользователь: {getattr(user, 'first_name', '') or getattr(user, 'username', '') or f"ID{getattr(user, 'telegram_id', 'Unknown')}"}",
+ f"👤 Пользователь: {user_display}",
f"🆔 Telegram ID: {user.telegram_id}",
f"📱 Username: @{getattr(user, 'username', None) or 'отсутствует'}",
"",
@@ -727,11 +746,12 @@ class AdminNotificationService:
)
elif automatic:
initiator_line = "🤖 Автоматическое назначение"
+ user_display = self._get_user_display(user)
message_lines = [
f"{title}",
"",
- f"👤 Пользователь: {getattr(user, 'first_name', '') or getattr(user, 'username', '') or f"ID{getattr(user, 'telegram_id', 'Unknown')}"}",
+ f"👤 Пользователь: {user_display}",
f"🆔 Telegram ID: {user.telegram_id}",
f"📱 Username: @{getattr(user, 'username', None) or 'отсутствует'}",
"",
@@ -1103,6 +1123,7 @@ class AdminNotificationService:
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
promo_group = await self._get_user_promo_group(db, user)
promo_block = self._format_promo_group_block(promo_group)
+ user_display = self._get_user_display(user)
update_types = {
"traffic": ("📊 ИЗМЕНЕНИЕ ТРАФИКА", "трафик"),
@@ -1115,7 +1136,7 @@ class AdminNotificationService:
message_lines = [
f"{title}",
"",
- f"👤 Пользователь: {getattr(user, 'first_name', '') or getattr(user, 'username', '') or f"ID{getattr(user, 'telegram_id', 'Unknown')}"}",
+ f"👤 Пользователь: {user_display}",
f"🆔 Telegram ID: {user.telegram_id}",
f"📱 Username: @{getattr(user, 'username', None) or 'отсутствует'}",
"",
diff --git a/app/services/referral_service.py b/app/services/referral_service.py
index bbf6e810..2302cf45 100644
--- a/app/services/referral_service.py
+++ b/app/services/referral_service.py
@@ -89,16 +89,64 @@ async def process_referral_topup(
logger.info(f"Пользователь {user_id} не является рефералом")
return True
- if topup_amount_kopeks < settings.REFERRAL_MINIMUM_TOPUP_KOPEKS:
- logger.info(f"Пополнение {user_id} на {topup_amount_kopeks/100}₽ меньше минимума")
- return True
-
referrer = await get_user_by_id(db, user.referred_by_id)
if not referrer:
logger.error(f"Реферер {user.referred_by_id} не найден")
return False
-
+
+ qualifies_for_first_bonus = (
+ topup_amount_kopeks >= settings.REFERRAL_MINIMUM_TOPUP_KOPEKS
+ )
+ commission_amount = 0
+ if settings.REFERRAL_COMMISSION_PERCENT > 0:
+ commission_amount = int(
+ topup_amount_kopeks * settings.REFERRAL_COMMISSION_PERCENT / 100
+ )
+
if not user.has_made_first_topup:
+ if not qualifies_for_first_bonus:
+ logger.info(
+ "Пополнение %s на %s₽ меньше минимума для первого бонуса, но комиссия будет начислена",
+ user_id,
+ topup_amount_kopeks / 100,
+ )
+
+ if commission_amount > 0:
+ await add_user_balance(
+ db,
+ referrer,
+ commission_amount,
+ f"Комиссия {settings.REFERRAL_COMMISSION_PERCENT}% с пополнения {user.full_name}",
+ bot=bot,
+ )
+
+ await create_referral_earning(
+ db=db,
+ user_id=referrer.id,
+ referral_id=user.id,
+ amount_kopeks=commission_amount,
+ reason="referral_commission_topup",
+ )
+
+ logger.info(
+ "💰 Комиссия с пополнения: %s получил %s₽ (до первого бонуса)",
+ referrer.telegram_id,
+ commission_amount / 100,
+ )
+
+ if bot:
+ commission_notification = (
+ f"💰 Реферальная комиссия!\n\n"
+ f"Ваш реферал {user.full_name} пополнил баланс на "
+ f"{settings.format_price(topup_amount_kopeks)}\n\n"
+ f"🎁 Ваша комиссия ({settings.REFERRAL_COMMISSION_PERCENT}%): "
+ f"{settings.format_price(commission_amount)}\n\n"
+ f"💎 Средства зачислены на ваш баланс."
+ )
+ await send_referral_notification(bot, referrer.telegram_id, commission_notification)
+
+ return True
+
user.has_made_first_topup = True
await db.commit()
@@ -161,36 +209,33 @@ async def process_referral_topup(
await send_referral_notification(bot, referrer.telegram_id, inviter_bonus_notification)
else:
- if settings.REFERRAL_COMMISSION_PERCENT > 0:
- commission_amount = int(topup_amount_kopeks * settings.REFERRAL_COMMISSION_PERCENT / 100)
-
- if commission_amount > 0:
- await add_user_balance(
- db, referrer, commission_amount,
- f"Комиссия {settings.REFERRAL_COMMISSION_PERCENT}% с пополнения {user.full_name}",
- bot=bot
+ if commission_amount > 0:
+ await add_user_balance(
+ db, referrer, commission_amount,
+ f"Комиссия {settings.REFERRAL_COMMISSION_PERCENT}% с пополнения {user.full_name}",
+ bot=bot
+ )
+
+ await create_referral_earning(
+ db=db,
+ user_id=referrer.id,
+ referral_id=user.id,
+ amount_kopeks=commission_amount,
+ reason="referral_commission_topup"
+ )
+
+ logger.info(f"💰 Комиссия с пополнения: {referrer.telegram_id} получил {commission_amount/100}₽")
+
+ if bot:
+ commission_notification = (
+ f"💰 Реферальная комиссия!\n\n"
+ f"Ваш реферал {user.full_name} пополнил баланс на "
+ f"{settings.format_price(topup_amount_kopeks)}\n\n"
+ f"🎁 Ваша комиссия ({settings.REFERRAL_COMMISSION_PERCENT}%): "
+ f"{settings.format_price(commission_amount)}\n\n"
+ f"💎 Средства зачислены на ваш баланс."
)
-
- await create_referral_earning(
- db=db,
- user_id=referrer.id,
- referral_id=user.id,
- amount_kopeks=commission_amount,
- reason="referral_commission_topup"
- )
-
- logger.info(f"💰 Комиссия с пополнения: {referrer.telegram_id} получил {commission_amount/100}₽")
-
- if bot:
- commission_notification = (
- f"💰 Реферальная комиссия!\n\n"
- f"Ваш реферал {user.full_name} пополнил баланс на "
- f"{settings.format_price(topup_amount_kopeks)}\n\n"
- f"🎁 Ваша комиссия ({settings.REFERRAL_COMMISSION_PERCENT}%): "
- f"{settings.format_price(commission_amount)}\n\n"
- f"💎 Средства зачислены на ваш баланс."
- )
- await send_referral_notification(bot, referrer.telegram_id, commission_notification)
+ await send_referral_notification(bot, referrer.telegram_id, commission_notification)
return True
diff --git a/app/services/user_service.py b/app/services/user_service.py
index 6c19472b..9d551fec 100644
--- a/app/services/user_service.py
+++ b/app/services/user_service.py
@@ -9,7 +9,7 @@ from app.database.crud.user import (
get_user_by_id, get_user_by_telegram_id, get_users_list,
get_users_count, get_users_statistics, get_inactive_users,
add_user_balance, subtract_user_balance, update_user, delete_user,
- get_users_spending_stats
+ get_users_spending_stats, get_referrals
)
from app.database.crud.promo_group import get_promo_group_by_id
from app.database.crud.transaction import get_user_transactions_count
@@ -411,6 +411,72 @@ class UserService:
logger.error(f"Ошибка обновления промогруппы пользователя {user_id}: {e}")
return False, None, None, None
+ async def update_user_referrals(
+ self,
+ db: AsyncSession,
+ user_id: int,
+ referral_user_ids: List[int],
+ admin_id: int,
+ ) -> Tuple[bool, Dict[str, int]]:
+ try:
+ user = await get_user_by_id(db, user_id)
+ if not user:
+ return False, {"error": "user_not_found"}
+
+ unique_ids: List[int] = []
+ for referral_id in referral_user_ids:
+ if referral_id == user_id:
+ continue
+ if referral_id not in unique_ids:
+ unique_ids.append(referral_id)
+
+ current_referrals = await get_referrals(db, user_id)
+ current_ids = {ref.id for ref in current_referrals}
+
+ to_assign = unique_ids
+ to_remove = [rid for rid in current_ids if rid not in unique_ids]
+ to_add = [rid for rid in unique_ids if rid not in current_ids]
+
+ if to_assign:
+ await db.execute(
+ update(User)
+ .where(User.id.in_(to_assign))
+ .values(referred_by_id=user_id)
+ )
+
+ if to_remove:
+ await db.execute(
+ update(User)
+ .where(User.id.in_(to_remove))
+ .values(referred_by_id=None)
+ )
+
+ await db.commit()
+
+ logger.info(
+ "Админ %s обновил рефералов пользователя %s: добавлено %s, удалено %s, всего %s",
+ admin_id,
+ user_id,
+ len(to_add),
+ len(to_remove),
+ len(unique_ids),
+ )
+
+ return True, {
+ "added": len(to_add),
+ "removed": len(to_remove),
+ "total": len(unique_ids),
+ }
+
+ except Exception as e:
+ await db.rollback()
+ logger.error(
+ "Ошибка обновления рефералов пользователя %s: %s",
+ user_id,
+ e,
+ )
+ return False, {"error": "update_failed"}
+
async def block_user(
self,
db: AsyncSession,
diff --git a/app/states.py b/app/states.py
index 549e314f..981c314a 100644
--- a/app/states.py
+++ b/app/states.py
@@ -90,6 +90,7 @@ class AdminStates(StatesGroup):
editing_device_price = State()
editing_user_devices = State()
editing_user_traffic = State()
+ editing_user_referrals = State()
editing_rules_page = State()
editing_privacy_policy = State()
diff --git a/app/webapi/routes/promo_groups.py b/app/webapi/routes/promo_groups.py
index cec284d8..07ffc9e8 100644
--- a/app/webapi/routes/promo_groups.py
+++ b/app/webapi/routes/promo_groups.py
@@ -52,12 +52,12 @@ def _serialize(group: PromoGroup, members_count: int = 0) -> PromoGroupResponse:
apply_discounts_to_addons=group.apply_discounts_to_addons,
is_default=group.is_default,
members_count=members_count,
- created_at=group.created_at,
- updated_at=group.updated_at,
+ created_at=getattr(group, "created_at", None),
+ updated_at=getattr(group, "updated_at", None),
)
-@router.get("", response_model=PromoGroupListResponse)
+@router.get("", response_model=PromoGroupListResponse, response_model_exclude_none=True)
async def list_promo_groups(
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
@@ -79,7 +79,7 @@ async def list_promo_groups(
)
-@router.get("/{group_id}", response_model=PromoGroupResponse)
+@router.get("/{group_id}", response_model=PromoGroupResponse, response_model_exclude_none=True)
async def get_promo_group(
group_id: int,
_: Any = Security(require_api_token),
@@ -93,7 +93,12 @@ async def get_promo_group(
return _serialize(group, members_count=members_count)
-@router.post("", response_model=PromoGroupResponse, status_code=status.HTTP_201_CREATED)
+@router.post(
+ "",
+ response_model=PromoGroupResponse,
+ response_model_exclude_none=True,
+ status_code=status.HTTP_201_CREATED,
+)
async def create_promo_group_endpoint(
payload: PromoGroupCreateRequest,
_: Any = Security(require_api_token),
@@ -120,7 +125,11 @@ async def create_promo_group_endpoint(
return _serialize(group, members_count=0)
-@router.patch("/{group_id}", response_model=PromoGroupResponse)
+@router.patch(
+ "/{group_id}",
+ response_model=PromoGroupResponse,
+ response_model_exclude_none=True,
+)
async def update_promo_group_endpoint(
group_id: int,
payload: PromoGroupUpdateRequest,
diff --git a/app/webapi/schemas/promo_groups.py b/app/webapi/schemas/promo_groups.py
index ffd2e68e..d006f9e7 100644
--- a/app/webapi/schemas/promo_groups.py
+++ b/app/webapi/schemas/promo_groups.py
@@ -3,7 +3,7 @@ from __future__ import annotations
from datetime import datetime
from typing import Dict, Optional
-from pydantic import BaseModel, Field, validator
+from pydantic import BaseModel, ConfigDict, Field, validator
def _normalize_period_discounts(value: Optional[Dict[object, object]]) -> Optional[Dict[int, int]]:
@@ -23,6 +23,8 @@ def _normalize_period_discounts(value: Optional[Dict[object, object]]) -> Option
class PromoGroupResponse(BaseModel):
+ model_config = ConfigDict(from_attributes=True)
+
id: int
name: str
server_discount_percent: int
@@ -33,8 +35,8 @@ class PromoGroupResponse(BaseModel):
apply_discounts_to_addons: bool
is_default: bool
members_count: int = 0
- created_at: datetime
- updated_at: datetime
+ created_at: Optional[datetime] = None
+ updated_at: Optional[datetime] = None
class _PromoGroupBase(BaseModel):
diff --git a/tests/services/test_referral_service.py b/tests/services/test_referral_service.py
new file mode 100644
index 00000000..c59c5bd5
--- /dev/null
+++ b/tests/services/test_referral_service.py
@@ -0,0 +1,67 @@
+from pathlib import Path
+import sys
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+import pytest
+
+ROOT_DIR = Path(__file__).resolve().parents[2]
+if str(ROOT_DIR) not in sys.path:
+ sys.path.insert(0, str(ROOT_DIR))
+
+from app.services import referral_service # noqa: E402
+
+
+@pytest.mark.asyncio
+async def test_commission_accrues_before_minimum_first_topup(monkeypatch):
+ user = SimpleNamespace(
+ id=1,
+ telegram_id=101,
+ full_name="Test User",
+ referred_by_id=2,
+ has_made_first_topup=False,
+ )
+ referrer = SimpleNamespace(
+ id=2,
+ telegram_id=202,
+ full_name="Referrer",
+ )
+
+ db = SimpleNamespace(
+ commit=AsyncMock(),
+ execute=AsyncMock(),
+ )
+
+ get_user_mock = AsyncMock(side_effect=[user, referrer])
+ monkeypatch.setattr(referral_service, "get_user_by_id", get_user_mock)
+ add_user_balance_mock = AsyncMock()
+ monkeypatch.setattr(referral_service, "add_user_balance", add_user_balance_mock)
+ create_referral_earning_mock = AsyncMock()
+ monkeypatch.setattr(referral_service, "create_referral_earning", create_referral_earning_mock)
+
+ monkeypatch.setattr(referral_service.settings, "REFERRAL_MINIMUM_TOPUP_KOPEKS", 20000)
+ monkeypatch.setattr(referral_service.settings, "REFERRAL_FIRST_TOPUP_BONUS_KOPEKS", 5000)
+ monkeypatch.setattr(referral_service.settings, "REFERRAL_INVITER_BONUS_KOPEKS", 10000)
+ monkeypatch.setattr(referral_service.settings, "REFERRAL_COMMISSION_PERCENT", 25)
+
+ topup_amount = 15000
+
+ result = await referral_service.process_referral_topup(db, user.id, topup_amount)
+
+ assert result is True
+ assert user.has_made_first_topup is False
+
+ add_user_balance_mock.assert_awaited_once()
+ add_call = add_user_balance_mock.await_args
+ assert add_call.args[1] is referrer
+ assert add_call.args[2] == 3750
+ assert "Комиссия" in add_call.args[3]
+ assert add_call.kwargs.get("bot") is None
+
+ create_referral_earning_mock.assert_awaited_once()
+ earning_call = create_referral_earning_mock.await_args
+ assert earning_call.kwargs["amount_kopeks"] == 3750
+ assert earning_call.kwargs["reason"] == "referral_commission_topup"
+
+ db.commit.assert_not_awaited()
+ db.execute.assert_not_awaited()