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()