From 4afefcafa45ce2ade56545771d62fcec861af9f5 Mon Sep 17 00:00:00 2001 From: gy9vin Date: Wed, 7 Jan 2026 14:54:50 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B0=20=D1=81=D0=B8=D1=81=D1=82=D0=B5=D0=BC=D0=B0=20?= =?UTF-8?q?=D0=B2=D1=8B=D0=B2=D0=BE=D0=B4=D0=B0=20=D1=80=D0=B5=D1=84=D0=B5?= =?UTF-8?q?=D1=80=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=B1=D0=B0?= =?UTF-8?q?=D0=BB=D0=B0=D0=BD=D1=81=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Новая функциональность вывода средств: - config.py: добавлены настройки вывода (минимальная сумма, кулдаун, анализ подозрительности, тестовый режим) - models.py: добавлена модель WithdrawalRequest с полями для заявок, анализа рисков и обработки админ --- app/config.py | 30 +- app/database/models.py | 42 ++ app/database/universal_migration.py | 93 +++ app/handlers/admin/referrals.py | 468 +++++++++++++- app/handlers/referral.py | 427 ++++++++++++- app/keyboards/inline.py | 26 +- app/services/referral_withdrawal_service.py | 665 ++++++++++++++++++++ app/states.py | 9 + 8 files changed, 1744 insertions(+), 16 deletions(-) create mode 100644 app/services/referral_withdrawal_service.py diff --git a/app/config.py b/app/config.py index 3361c0df..e3e85b96 100644 --- a/app/config.py +++ b/app/config.py @@ -181,15 +181,30 @@ class Settings(BaseSettings): # Базовая цена сброса в копейках (используется если режим "period" или как минимальная цена) TRAFFIC_RESET_BASE_PRICE: int = 0 # 0 = использовать PERIOD_PRICES[30] - REFERRAL_MINIMUM_TOPUP_KOPEKS: int = 10000 - REFERRAL_FIRST_TOPUP_BONUS_KOPEKS: int = 10000 - REFERRAL_INVITER_BONUS_KOPEKS: int = 10000 - REFERRAL_COMMISSION_PERCENT: int = 25 + REFERRAL_MINIMUM_TOPUP_KOPEKS: int = 10000 + REFERRAL_FIRST_TOPUP_BONUS_KOPEKS: int = 10000 + REFERRAL_INVITER_BONUS_KOPEKS: int = 10000 + REFERRAL_COMMISSION_PERCENT: int = 25 REFERRAL_PROGRAM_ENABLED: bool = True REFERRAL_NOTIFICATIONS_ENABLED: bool = True REFERRAL_NOTIFICATION_RETRY_ATTEMPTS: int = 3 + # Настройки вывода реферального баланса + REFERRAL_WITHDRAWAL_ENABLED: bool = False # Включить возможность вывода + REFERRAL_WITHDRAWAL_MIN_AMOUNT_KOPEKS: int = 100000 # Мин. сумма вывода (1000₽) + REFERRAL_WITHDRAWAL_COOLDOWN_DAYS: int = 30 # Частота запросов на вывод + REFERRAL_WITHDRAWAL_ONLY_REFERRAL_BALANCE: bool = True # Только реф. баланс (False = реф + свой) + REFERRAL_WITHDRAWAL_NOTIFICATIONS_TOPIC_ID: Optional[int] = None # Топик для уведомлений + + # Настройки анализа на подозрительность + REFERRAL_WITHDRAWAL_SUSPICIOUS_MIN_DEPOSIT_KOPEKS: int = 50000 # Мин. сумма от 1 реферала (500₽) + REFERRAL_WITHDRAWAL_SUSPICIOUS_MAX_DEPOSITS_PER_MONTH: int = 10 # Макс. пополнений от 1 реферала/мес + REFERRAL_WITHDRAWAL_SUSPICIOUS_NO_PURCHASES_RATIO: float = 2.0 # Пополнил в X раз больше чем потратил + + # Тестовый режим для вывода (позволяет админам вручную начислять реф. доход) + REFERRAL_WITHDRAWAL_TEST_MODE: bool = False + # Конкурсы (глобальный флаг, будет расширяться под разные типы) CONTESTS_ENABLED: bool = False CONTESTS_BUTTON_VISIBLE: bool = False @@ -1719,7 +1734,14 @@ class Settings(BaseSettings): "inviter_bonus_kopeks": self.REFERRAL_INVITER_BONUS_KOPEKS, "commission_percent": self.REFERRAL_COMMISSION_PERCENT, "notifications_enabled": self.REFERRAL_NOTIFICATIONS_ENABLED, + "withdrawal_enabled": self.REFERRAL_WITHDRAWAL_ENABLED, + "withdrawal_min_amount_kopeks": self.REFERRAL_WITHDRAWAL_MIN_AMOUNT_KOPEKS, + "withdrawal_cooldown_days": self.REFERRAL_WITHDRAWAL_COOLDOWN_DAYS, } + + def is_referral_withdrawal_enabled(self) -> bool: + """Проверяет, включена ли функция вывода реферального баланса.""" + return self.is_referral_program_enabled() and self.REFERRAL_WITHDRAWAL_ENABLED def is_referral_program_enabled(self) -> bool: return bool(self.REFERRAL_PROGRAM_ENABLED) diff --git a/app/database/models.py b/app/database/models.py index 8272e63b..d9ac5d69 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -1067,6 +1067,48 @@ class ReferralEarning(Base): return self.amount_kopeks / 100 +class WithdrawalRequestStatus(Enum): + """Статусы заявки на вывод реферального баланса.""" + PENDING = "pending" # Ожидает рассмотрения + APPROVED = "approved" # Одобрена + REJECTED = "rejected" # Отклонена + COMPLETED = "completed" # Выполнена (деньги переведены) + CANCELLED = "cancelled" # Отменена пользователем + + +class WithdrawalRequest(Base): + """Заявка на вывод реферального баланса.""" + __tablename__ = "withdrawal_requests" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + + amount_kopeks = Column(Integer, nullable=False) # Сумма к выводу + status = Column(String(50), default=WithdrawalRequestStatus.PENDING.value, nullable=False) + + # Данные для вывода (заполняет пользователь) + payment_details = Column(Text, nullable=True) # Реквизиты для перевода + + # Анализ на отмывание + risk_score = Column(Integer, default=0) # 0-100, чем выше — тем подозрительнее + risk_analysis = Column(Text, nullable=True) # JSON с деталями анализа + + # Обработка админом + processed_by = Column(Integer, ForeignKey("users.id"), nullable=True) + processed_at = Column(DateTime, nullable=True) + admin_comment = Column(Text, nullable=True) + + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + + user = relationship("User", foreign_keys=[user_id], backref="withdrawal_requests") + admin = relationship("User", foreign_keys=[processed_by]) + + @property + def amount_rubles(self) -> float: + return self.amount_kopeks / 100 + + class ReferralContest(Base): __tablename__ = "referral_contests" diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index 479a0c46..29eb3cae 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -4937,6 +4937,92 @@ async def add_transaction_receipt_columns() -> bool: return False +async def create_withdrawal_requests_table() -> bool: + """Создаёт таблицу для заявок на вывод реферального баланса.""" + try: + if await check_table_exists('withdrawal_requests'): + logger.debug("Таблица withdrawal_requests уже существует") + return True + + async with engine.begin() as conn: + db_type = await get_database_type() + + if db_type == 'sqlite': + create_sql = """ + CREATE TABLE withdrawal_requests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + amount_kopeks INTEGER NOT NULL, + status VARCHAR(50) NOT NULL DEFAULT 'pending', + payment_details TEXT, + risk_score INTEGER DEFAULT 0, + risk_analysis TEXT, + processed_by INTEGER, + processed_at DATETIME, + admin_comment TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (processed_by) REFERENCES users(id) ON DELETE SET NULL + ) + """ + elif db_type == 'postgresql': + create_sql = """ + CREATE TABLE withdrawal_requests ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + amount_kopeks INTEGER NOT NULL, + status VARCHAR(50) NOT NULL DEFAULT 'pending', + payment_details TEXT, + risk_score INTEGER DEFAULT 0, + risk_analysis TEXT, + processed_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + processed_at TIMESTAMP, + admin_comment TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """ + else: # mysql + create_sql = """ + CREATE TABLE withdrawal_requests ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + amount_kopeks INT NOT NULL, + status VARCHAR(50) NOT NULL DEFAULT 'pending', + payment_details TEXT, + risk_score INT DEFAULT 0, + risk_analysis TEXT, + processed_by INT, + processed_at DATETIME, + admin_comment TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (processed_by) REFERENCES users(id) ON DELETE SET NULL + ) + """ + + await conn.execute(text(create_sql)) + logger.info("✅ Таблица withdrawal_requests создана") + + # Создаём индексы + try: + await conn.execute(text( + "CREATE INDEX idx_withdrawal_requests_user_id ON withdrawal_requests(user_id)" + )) + await conn.execute(text( + "CREATE INDEX idx_withdrawal_requests_status ON withdrawal_requests(status)" + )) + except Exception: + pass # Индексы могут уже существовать + + return True + except Exception as error: + logger.error(f"❌ Ошибка создания таблицы withdrawal_requests: {error}") + return False + + async def run_universal_migration(): logger.info("=== НАЧАЛО УНИВЕРСАЛЬНОЙ МИГРАЦИИ ===") @@ -5435,6 +5521,13 @@ async def run_universal_migration(): else: logger.warning("⚠️ Проблемы с колонками чеков в transactions") + logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ WITHDRAWAL_REQUESTS ===") + withdrawal_requests_ready = await create_withdrawal_requests_table() + if withdrawal_requests_ready: + logger.info("✅ Таблица withdrawal_requests готова") + else: + logger.warning("⚠️ Проблемы с таблицей withdrawal_requests") + async with engine.begin() as conn: total_subs = await conn.execute(text("SELECT COUNT(*) FROM subscriptions")) unique_users = await conn.execute(text("SELECT COUNT(DISTINCT user_id) FROM subscriptions")) diff --git a/app/handlers/admin/referrals.py b/app/handlers/admin/referrals.py index f94ffbd6..78a2ebf4 100644 --- a/app/handlers/admin/referrals.py +++ b/app/handlers/admin/referrals.py @@ -1,17 +1,22 @@ +import json import logging from aiogram import Dispatcher, types, F +from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select import datetime from app.config import settings -from app.database.models import User +from app.database.models import User, WithdrawalRequest, WithdrawalRequestStatus, ReferralEarning from app.localization.texts import get_texts from app.database.crud.referral import ( get_referral_statistics, get_top_referrers_by_period, get_user_referral_stats, ) -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 +from app.services.referral_withdrawal_service import referral_withdrawal_service +from app.states import AdminStates from app.utils.decorators import admin_required, error_handler logger = logging.getLogger(__name__) @@ -78,12 +83,26 @@ async def show_referral_statistics( 🕐 Обновлено: {current_time} """ - keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ + keyboard_rows = [ [types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_referrals")], [types.InlineKeyboardButton(text="👥 Топ рефереров", callback_data="admin_referrals_top")], + ] + + # Кнопка заявок на вывод (если функция включена) + if settings.is_referral_withdrawal_enabled(): + keyboard_rows.append([ + types.InlineKeyboardButton( + text="💸 Заявки на вывод", + callback_data="admin_withdrawal_requests" + ) + ]) + + keyboard_rows.extend([ [types.InlineKeyboardButton(text="⚙️ Настройки", callback_data="admin_referrals_settings")], [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")] ]) + + keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows) try: await callback.message.edit_text(text, reply_markup=keyboard) @@ -292,8 +311,451 @@ async def show_referral_settings( await callback.answer() +@admin_required +@error_handler +async def show_pending_withdrawal_requests( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + """Показывает список ожидающих заявок на вывод.""" + requests = await referral_withdrawal_service.get_pending_requests(db) + + if not requests: + text = "📋 Заявки на вывод\n\nНет ожидающих заявок." + + keyboard_rows = [] + # Кнопка тестового начисления (только в тестовом режиме) + if settings.REFERRAL_WITHDRAWAL_TEST_MODE: + keyboard_rows.append([ + types.InlineKeyboardButton( + text="🧪 Тестовое начисление", + callback_data="admin_test_referral_earning" + ) + ]) + keyboard_rows.append([ + types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_referrals") + ]) + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows) + ) + await callback.answer() + return + + text = f"📋 Заявки на вывод ({len(requests)})\n\n" + + for req in requests[:10]: + user = await get_user_by_id(db, req.user_id) + user_name = user.full_name if user else "Неизвестно" + user_tg_id = user.telegram_id if user else "N/A" + + risk_emoji = "🟢" if req.risk_score < 30 else "🟡" if req.risk_score < 50 else "🟠" if req.risk_score < 70 else "🔴" + + text += f"#{req.id} — {user_name} (ID{user_tg_id})\n" + text += f"💰 {req.amount_kopeks / 100:.0f}₽ | {risk_emoji} Риск: {req.risk_score}/100\n" + text += f"📅 {req.created_at.strftime('%d.%m.%Y %H:%M')}\n\n" + + keyboard_rows = [] + for req in requests[:5]: + keyboard_rows.append([ + types.InlineKeyboardButton( + text=f"#{req.id} — {req.amount_kopeks / 100:.0f}₽", + callback_data=f"admin_withdrawal_view_{req.id}" + ) + ]) + + # Кнопка тестового начисления (только в тестовом режиме) + if settings.REFERRAL_WITHDRAWAL_TEST_MODE: + keyboard_rows.append([ + types.InlineKeyboardButton( + text="🧪 Тестовое начисление", + callback_data="admin_test_referral_earning" + ) + ]) + + keyboard_rows.append([ + types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_referrals") + ]) + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows) + ) + await callback.answer() + + +@admin_required +@error_handler +async def view_withdrawal_request( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + """Показывает детали заявки на вывод.""" + request_id = int(callback.data.split("_")[-1]) + + result = await db.execute( + select(WithdrawalRequest).where(WithdrawalRequest.id == request_id) + ) + request = result.scalar_one_or_none() + + if not request: + await callback.answer("Заявка не найдена", show_alert=True) + return + + user = await get_user_by_id(db, request.user_id) + user_name = user.full_name if user else "Неизвестно" + user_tg_id = user.telegram_id if user else "N/A" + + analysis = json.loads(request.risk_analysis) if request.risk_analysis else {} + + status_text = { + WithdrawalRequestStatus.PENDING.value: "⏳ Ожидает", + WithdrawalRequestStatus.APPROVED.value: "✅ Одобрена", + WithdrawalRequestStatus.REJECTED.value: "❌ Отклонена", + WithdrawalRequestStatus.COMPLETED.value: "✅ Выполнена", + WithdrawalRequestStatus.CANCELLED.value: "🚫 Отменена", + }.get(request.status, request.status) + + text = f""" +📋 Заявка #{request.id} + +👤 Пользователь: {user_name} +🆔 ID: {user_tg_id} +💰 Сумма: {request.amount_kopeks / 100:.0f}₽ +📊 Статус: {status_text} + +💳 Реквизиты: +{request.payment_details} + +📅 Создана: {request.created_at.strftime('%d.%m.%Y %H:%M')} + +{referral_withdrawal_service.format_analysis_for_admin(analysis)} +""" + + keyboard = [] + + if request.status == WithdrawalRequestStatus.PENDING.value: + keyboard.append([ + types.InlineKeyboardButton( + text="✅ Одобрить", + callback_data=f"admin_withdrawal_approve_{request.id}" + ), + types.InlineKeyboardButton( + text="❌ Отклонить", + callback_data=f"admin_withdrawal_reject_{request.id}" + ) + ]) + + if request.status == WithdrawalRequestStatus.APPROVED.value: + keyboard.append([ + types.InlineKeyboardButton( + text="✅ Деньги переведены", + callback_data=f"admin_withdrawal_complete_{request.id}" + ) + ]) + + keyboard.append([ + types.InlineKeyboardButton( + text="👤 Профиль пользователя", + callback_data=f"admin_user_{user_tg_id}" + ) + ]) + keyboard.append([ + types.InlineKeyboardButton(text="⬅️ К списку", callback_data="admin_withdrawal_requests") + ]) + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) + ) + await callback.answer() + + +@admin_required +@error_handler +async def approve_withdrawal_request( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + """Одобряет заявку на вывод.""" + request_id = int(callback.data.split("_")[-1]) + + result = await db.execute( + select(WithdrawalRequest).where(WithdrawalRequest.id == request_id) + ) + request = result.scalar_one_or_none() + + if not request: + await callback.answer("Заявка не найдена", show_alert=True) + return + + success, error = await referral_withdrawal_service.approve_request( + db, request_id, db_user.id + ) + + if success: + # Уведомляем пользователя + user = await get_user_by_id(db, request.user_id) + if user: + try: + texts = get_texts(user.language) + await callback.bot.send_message( + user.telegram_id, + texts.t( + "REFERRAL_WITHDRAWAL_APPROVED", + "✅ Заявка на вывод #{id} одобрена!\n\n" + "Сумма: {amount}\n" + "Средства списаны с баланса.\n\n" + "Ожидайте перевод на указанные реквизиты." + ).format(id=request.id, amount=texts.format_price(request.amount_kopeks)) + ) + except Exception as e: + logger.error(f"Ошибка отправки уведомления пользователю: {e}") + + await callback.answer("✅ Заявка одобрена, средства списаны с баланса") + + # Обновляем отображение + await view_withdrawal_request(callback, db_user, db) + else: + await callback.answer(f"❌ {error}", show_alert=True) + + +@admin_required +@error_handler +async def reject_withdrawal_request( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + """Отклоняет заявку на вывод.""" + request_id = int(callback.data.split("_")[-1]) + + result = await db.execute( + select(WithdrawalRequest).where(WithdrawalRequest.id == request_id) + ) + request = result.scalar_one_or_none() + + if not request: + await callback.answer("Заявка не найдена", show_alert=True) + return + + success = await referral_withdrawal_service.reject_request( + db, request_id, db_user.id, "Отклонено администратором" + ) + + if success: + # Уведомляем пользователя + user = await get_user_by_id(db, request.user_id) + if user: + try: + texts = get_texts(user.language) + await callback.bot.send_message( + user.telegram_id, + texts.t( + "REFERRAL_WITHDRAWAL_REJECTED", + "❌ Заявка на вывод #{id} отклонена\n\n" + "Сумма: {amount}\n\n" + "Если у вас есть вопросы, обратитесь в поддержку." + ).format(id=request.id, amount=texts.format_price(request.amount_kopeks)) + ) + except Exception as e: + logger.error(f"Ошибка отправки уведомления пользователю: {e}") + + await callback.answer("❌ Заявка отклонена") + + # Обновляем отображение + await view_withdrawal_request(callback, db_user, db) + else: + await callback.answer("❌ Ошибка отклонения", show_alert=True) + + +@admin_required +@error_handler +async def complete_withdrawal_request( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + """Отмечает заявку как выполненную (деньги переведены).""" + request_id = int(callback.data.split("_")[-1]) + + result = await db.execute( + select(WithdrawalRequest).where(WithdrawalRequest.id == request_id) + ) + request = result.scalar_one_or_none() + + if not request: + await callback.answer("Заявка не найдена", show_alert=True) + return + + success = await referral_withdrawal_service.complete_request( + db, request_id, db_user.id, "Перевод выполнен" + ) + + if success: + # Уведомляем пользователя + user = await get_user_by_id(db, request.user_id) + if user: + try: + texts = get_texts(user.language) + await callback.bot.send_message( + user.telegram_id, + texts.t( + "REFERRAL_WITHDRAWAL_COMPLETED", + "💸 Выплата по заявке #{id} выполнена!\n\n" + "Сумма: {amount}\n\n" + "Деньги отправлены на указанные реквизиты." + ).format(id=request.id, amount=texts.format_price(request.amount_kopeks)) + ) + except Exception as e: + logger.error(f"Ошибка отправки уведомления пользователю: {e}") + + await callback.answer("✅ Заявка выполнена") + + # Обновляем отображение + await view_withdrawal_request(callback, db_user, db) + else: + await callback.answer("❌ Ошибка выполнения", show_alert=True) + + +@admin_required +@error_handler +async def start_test_referral_earning( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext +): + """Начинает процесс тестового начисления реферального дохода.""" + if not settings.REFERRAL_WITHDRAWAL_TEST_MODE: + await callback.answer("Тестовый режим отключён", show_alert=True) + return + + await state.set_state(AdminStates.test_referral_earning_input) + + text = """ +🧪 Тестовое начисление реферального дохода + +Введите данные в формате: +telegram_id сумма_в_рублях + +Примеры: +• 123456789 500 — начислит 500₽ пользователю 123456789 +• 987654321 1000 — начислит 1000₽ пользователю 987654321 + +⚠️ Это создаст реальную запись ReferralEarning, как будто пользователь заработал с реферала. +""" + + keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_withdrawal_requests")] + ]) + + await callback.message.edit_text(text, reply_markup=keyboard) + await callback.answer() + + +@admin_required +@error_handler +async def process_test_referral_earning( + message: types.Message, + db_user: User, + db: AsyncSession, + state: FSMContext +): + """Обрабатывает ввод тестового начисления.""" + if not settings.REFERRAL_WITHDRAWAL_TEST_MODE: + await message.answer("❌ Тестовый режим отключён") + await state.clear() + return + + text_input = message.text.strip() + parts = text_input.split() + + if len(parts) != 2: + await message.answer( + "❌ Неверный формат. Введите: telegram_id сумма\n\n" + "Например: 123456789 500" + ) + return + + try: + target_telegram_id = int(parts[0]) + amount_rubles = float(parts[1].replace(",", ".")) + amount_kopeks = int(amount_rubles * 100) + + if amount_kopeks <= 0: + await message.answer("❌ Сумма должна быть положительной") + return + + if amount_kopeks > 10000000: # Лимит 100 000₽ + await message.answer("❌ Максимальная сумма тестового начисления: 100 000₽") + return + + except ValueError: + await message.answer( + "❌ Неверный формат чисел. Введите: telegram_id сумма\n\n" + "Например: 123456789 500" + ) + return + + # Ищем целевого пользователя + target_user = await get_user_by_telegram_id(db, target_telegram_id) + if not target_user: + await message.answer(f"❌ Пользователь с ID {target_telegram_id} не найден в базе") + return + + # Создаём тестовое начисление + earning = ReferralEarning( + user_id=target_user.id, + referral_id=target_user.id, # Сам на себя (тестовое) + amount_kopeks=amount_kopeks, + reason="test_earning", + description=f"Тестовое начисление от админа {db_user.telegram_id}" + ) + db.add(earning) + + # Добавляем на баланс пользователя + target_user.balance_kopeks += amount_kopeks + + await db.commit() + await state.clear() + + await message.answer( + f"✅ Тестовое начисление создано!\n\n" + f"👤 Пользователь: {target_user.full_name or 'Без имени'}\n" + f"🆔 ID: {target_telegram_id}\n" + f"💰 Сумма: {amount_rubles:.0f}₽\n" + f"💳 Новый баланс: {target_user.balance_kopeks / 100:.0f}₽\n\n" + f"Начисление добавлено как реферальный доход.", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="📋 К заявкам", callback_data="admin_withdrawal_requests")], + [types.InlineKeyboardButton(text="👤 Профиль", callback_data=f"admin_user_{target_telegram_id}")] + ]) + ) + + logger.info( + f"Тестовое начисление: админ {db_user.telegram_id} начислил {amount_rubles}₽ " + f"пользователю {target_telegram_id}" + ) + + def register_handlers(dp: Dispatcher): dp.callback_query.register(show_referral_statistics, F.data == "admin_referrals") dp.callback_query.register(show_top_referrers, F.data == "admin_referrals_top") dp.callback_query.register(show_top_referrers_filtered, F.data.startswith("admin_top_ref:")) dp.callback_query.register(show_referral_settings, F.data == "admin_referrals_settings") + + # Хендлеры заявок на вывод + dp.callback_query.register(show_pending_withdrawal_requests, F.data == "admin_withdrawal_requests") + dp.callback_query.register(view_withdrawal_request, F.data.startswith("admin_withdrawal_view_")) + dp.callback_query.register(approve_withdrawal_request, F.data.startswith("admin_withdrawal_approve_")) + dp.callback_query.register(reject_withdrawal_request, F.data.startswith("admin_withdrawal_reject_")) + dp.callback_query.register(complete_withdrawal_request, F.data.startswith("admin_withdrawal_complete_")) + + # Тестовое начисление + dp.callback_query.register(start_test_referral_earning, F.data == "admin_test_referral_earning") + dp.message.register(process_test_referral_earning, AdminStates.test_referral_earning_input) diff --git a/app/handlers/referral.py b/app/handlers/referral.py index fe91f1c3..30df38f9 100644 --- a/app/handlers/referral.py +++ b/app/handlers/referral.py @@ -1,9 +1,11 @@ +import json import logging from pathlib import Path import qrcode from aiogram import Dispatcher, F, types from aiogram.exceptions import TelegramBadRequest +from aiogram.fsm.context import FSMContext from aiogram.types import FSInputFile from sqlalchemy.ext.asyncio import AsyncSession @@ -11,6 +13,9 @@ from app.config import settings from app.database.models import User from app.keyboards.inline import get_referral_keyboard from app.localization.texts import get_texts +from app.services.referral_withdrawal_service import referral_withdrawal_service +from app.services.admin_notification_service import AdminNotificationService +from app.states import ReferralWithdrawalStates from app.utils.photo_message import edit_or_answer_photo from app.utils.user_utils import ( get_detailed_referral_list, @@ -463,8 +468,390 @@ async def create_invite_message( await callback.answer() +async def show_withdrawal_info( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext +): + """Показывает информацию о выводе реферального баланса.""" + texts = get_texts(db_user.language) + + if not settings.is_referral_withdrawal_enabled(): + await callback.answer( + texts.t("REFERRAL_WITHDRAWAL_DISABLED", "Функция вывода отключена"), + show_alert=True + ) + return + + # Получаем детальную статистику баланса + stats = await referral_withdrawal_service.get_referral_balance_stats(db, db_user.id) + min_amount = settings.REFERRAL_WITHDRAWAL_MIN_AMOUNT_KOPEKS + cooldown_days = settings.REFERRAL_WITHDRAWAL_COOLDOWN_DAYS + + # Проверяем возможность вывода + can_request, reason = await referral_withdrawal_service.can_request_withdrawal(db, db_user.id) + + text = texts.t("REFERRAL_WITHDRAWAL_TITLE", "💸 Вывод реферального баланса") + "\n\n" + + # Показываем детальную статистику + text += referral_withdrawal_service.format_balance_stats_for_user(stats, texts) + text += "\n" + + text += texts.t( + "REFERRAL_WITHDRAWAL_MIN_AMOUNT", + "📊 Минимальная сумма: {amount}" + ).format(amount=texts.format_price(min_amount)) + "\n" + text += texts.t( + "REFERRAL_WITHDRAWAL_COOLDOWN", + "⏱ Частота вывода: раз в {days} дней" + ).format(days=cooldown_days) + "\n\n" + + keyboard = [] + + if can_request: + text += texts.t( + "REFERRAL_WITHDRAWAL_READY", + "✅ Вы можете запросить вывод средств" + ) + "\n" + keyboard.append([types.InlineKeyboardButton( + text=texts.t("REFERRAL_WITHDRAWAL_REQUEST_BUTTON", "📝 Оформить заявку"), + callback_data="referral_withdrawal_start" + )]) + else: + text += f"❌ {reason}\n" + + keyboard.append([types.InlineKeyboardButton( + text=texts.BACK, + callback_data="menu_referrals" + )]) + + await edit_or_answer_photo( + callback, + text, + types.InlineKeyboardMarkup(inline_keyboard=keyboard) + ) + await callback.answer() + + +async def start_withdrawal_request( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext +): + """Начинает процесс оформления заявки на вывод.""" + texts = get_texts(db_user.language) + + # Повторная проверка + can_request, reason = await referral_withdrawal_service.can_request_withdrawal(db, db_user.id) + if not can_request: + await callback.answer(reason, show_alert=True) + return + + available = await referral_withdrawal_service.get_available_for_withdrawal(db, db_user.id) + + # Сохраняем доступный баланс в состоянии + await state.update_data(available_balance=available) + await state.set_state(ReferralWithdrawalStates.waiting_for_amount) + + text = texts.t( + "REFERRAL_WITHDRAWAL_ENTER_AMOUNT", + "💸 Введите сумму для вывода в рублях\n\nДоступно: {amount}" + ).format(amount=texts.format_price(available)) + + keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton( + text=texts.t("REFERRAL_WITHDRAWAL_ALL", f"Вывести всё ({available / 100:.0f}₽)"), + callback_data=f"referral_withdrawal_amount_{available}" + )], + [types.InlineKeyboardButton( + text=texts.t("CANCEL", "❌ Отмена"), + callback_data="referral_withdrawal_cancel" + )] + ]) + + await edit_or_answer_photo(callback, text, keyboard) + await callback.answer() + + +async def process_withdrawal_amount( + message: types.Message, + db_user: User, + db: AsyncSession, + state: FSMContext +): + """Обрабатывает ввод суммы для вывода.""" + texts = get_texts(db_user.language) + data = await state.get_data() + available = data.get("available_balance", 0) + + try: + # Парсим сумму (в рублях) + amount_text = message.text.strip().replace(",", ".").replace("₽", "").replace(" ", "") + amount_rubles = float(amount_text) + amount_kopeks = int(amount_rubles * 100) + + if amount_kopeks <= 0: + await message.answer(texts.t("REFERRAL_WITHDRAWAL_INVALID_AMOUNT", "❌ Введите положительную сумму")) + return + + min_amount = settings.REFERRAL_WITHDRAWAL_MIN_AMOUNT_KOPEKS + if amount_kopeks < min_amount: + await message.answer( + texts.t( + "REFERRAL_WITHDRAWAL_MIN_ERROR", + "❌ Минимальная сумма: {amount}" + ).format(amount=texts.format_price(min_amount)) + ) + return + + if amount_kopeks > available: + await message.answer( + texts.t( + "REFERRAL_WITHDRAWAL_INSUFFICIENT", + "❌ Недостаточно средств. Доступно: {amount}" + ).format(amount=texts.format_price(available)) + ) + return + + # Сохраняем сумму и переходим к вводу реквизитов + await state.update_data(withdrawal_amount=amount_kopeks) + await state.set_state(ReferralWithdrawalStates.waiting_for_payment_details) + + text = texts.t( + "REFERRAL_WITHDRAWAL_ENTER_DETAILS", + "💳 Введите реквизиты для перевода:\n\n" + "Например:\n" + "• Номер карты: 1234 5678 9012 3456\n" + "• СБП: +7 999 123-45-67 (Сбербанк)\n" + "• Кошелёк: ЮMoney 4100..." + ) + + keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton( + text=texts.t("CANCEL", "❌ Отмена"), + callback_data="referral_withdrawal_cancel" + )] + ]) + + await message.answer(text, reply_markup=keyboard) + + except ValueError: + await message.answer(texts.t("REFERRAL_WITHDRAWAL_INVALID_AMOUNT", "❌ Введите корректную сумму")) + + +async def process_withdrawal_amount_callback( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext +): + """Обрабатывает выбор суммы для вывода через кнопку.""" + texts = get_texts(db_user.language) + + # Получаем сумму из callback_data + amount_kopeks = int(callback.data.split("_")[-1]) + + # Сохраняем сумму и переходим к вводу реквизитов + await state.update_data(withdrawal_amount=amount_kopeks) + await state.set_state(ReferralWithdrawalStates.waiting_for_payment_details) + + text = texts.t( + "REFERRAL_WITHDRAWAL_ENTER_DETAILS", + "💳 Введите реквизиты для перевода:\n\n" + "Например:\n" + "• Номер карты: 1234 5678 9012 3456\n" + "• СБП: +7 999 123-45-67 (Сбербанк)\n" + "• Кошелёк: ЮMoney 4100..." + ) + + keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton( + text=texts.t("CANCEL", "❌ Отмена"), + callback_data="referral_withdrawal_cancel" + )] + ]) + + await edit_or_answer_photo(callback, text, keyboard) + await callback.answer() + + +async def process_payment_details( + message: types.Message, + db_user: User, + db: AsyncSession, + state: FSMContext +): + """Обрабатывает ввод реквизитов и показывает подтверждение.""" + texts = get_texts(db_user.language) + data = await state.get_data() + amount_kopeks = data.get("withdrawal_amount", 0) + payment_details = message.text.strip() + + if len(payment_details) < 10: + await message.answer( + texts.t("REFERRAL_WITHDRAWAL_DETAILS_TOO_SHORT", "❌ Реквизиты слишком короткие") + ) + return + + # Сохраняем реквизиты + await state.update_data(payment_details=payment_details) + await state.set_state(ReferralWithdrawalStates.confirming) + + text = texts.t("REFERRAL_WITHDRAWAL_CONFIRM_TITLE", "📋 Подтверждение заявки") + "\n\n" + text += texts.t( + "REFERRAL_WITHDRAWAL_CONFIRM_AMOUNT", + "💰 Сумма: {amount}" + ).format(amount=texts.format_price(amount_kopeks)) + "\n\n" + text += texts.t( + "REFERRAL_WITHDRAWAL_CONFIRM_DETAILS", + "💳 Реквизиты:\n{details}" + ).format(details=payment_details) + "\n\n" + text += texts.t( + "REFERRAL_WITHDRAWAL_CONFIRM_WARNING", + "⚠️ После отправки заявка будет рассмотрена администрацией" + ) + + keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton( + text=texts.t("REFERRAL_WITHDRAWAL_CONFIRM_BUTTON", "✅ Подтвердить"), + callback_data="referral_withdrawal_confirm" + )], + [types.InlineKeyboardButton( + text=texts.t("CANCEL", "❌ Отмена"), + callback_data="referral_withdrawal_cancel" + )] + ]) + + await message.answer(text, reply_markup=keyboard) + + +async def confirm_withdrawal_request( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext +): + """Подтверждает и создаёт заявку на вывод.""" + texts = get_texts(db_user.language) + data = await state.get_data() + amount_kopeks = data.get("withdrawal_amount", 0) + payment_details = data.get("payment_details", "") + + await state.clear() + + # Создаём заявку + request, error = await referral_withdrawal_service.create_withdrawal_request( + db, db_user.id, amount_kopeks, payment_details + ) + + if error: + await callback.answer(f"❌ {error}", show_alert=True) + return + + # Отправляем уведомление админам + analysis = json.loads(request.risk_analysis) if request.risk_analysis else {} + + admin_text = f""" +🔔 Новая заявка на вывод #{request.id} + +👤 Пользователь: {db_user.full_name or 'Без имени'} +🆔 ID: {db_user.telegram_id} +💰 Сумма: {amount_kopeks / 100:.0f}₽ + +💳 Реквизиты: +{payment_details} + +{referral_withdrawal_service.format_analysis_for_admin(analysis)} +""" + + admin_keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ + [ + types.InlineKeyboardButton( + text="✅ Одобрить", + callback_data=f"admin_withdrawal_approve_{request.id}" + ), + types.InlineKeyboardButton( + text="❌ Отклонить", + callback_data=f"admin_withdrawal_reject_{request.id}" + ) + ], + [types.InlineKeyboardButton( + text="👤 Профиль пользователя", + callback_data=f"admin_user_{db_user.telegram_id}" + )] + ]) + + try: + notification_service = AdminNotificationService(callback.bot) + await notification_service.send_notification( + admin_text, + keyboard=admin_keyboard + ) + except Exception as e: + logger.error(f"Ошибка отправки уведомления админам о заявке на вывод: {e}") + + # Уведомление в топик, если настроено + topic_id = settings.REFERRAL_WITHDRAWAL_NOTIFICATIONS_TOPIC_ID + if topic_id and settings.NOTIFICATIONS_CHAT_ID: + try: + await callback.bot.send_message( + chat_id=settings.NOTIFICATIONS_CHAT_ID, + message_thread_id=topic_id, + text=admin_text, + reply_markup=admin_keyboard, + parse_mode="HTML" + ) + except Exception as e: + logger.error(f"Ошибка отправки уведомления в топик о заявке на вывод: {e}") + + # Отвечаем пользователю + text = texts.t( + "REFERRAL_WITHDRAWAL_SUCCESS", + "✅ Заявка #{id} создана!\n\n" + "Сумма: {amount}\n\n" + "Ваша заявка будет рассмотрена администрацией. " + "Мы уведомим вас о результате." + ).format(id=request.id, amount=texts.format_price(amount_kopeks)) + + keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton( + text=texts.BACK, + callback_data="menu_referrals" + )] + ]) + + await edit_or_answer_photo(callback, text, keyboard) + await callback.answer() + + +async def cancel_withdrawal_request( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext +): + """Отменяет процесс создания заявки на вывод.""" + await state.clear() + texts = get_texts(db_user.language) + await callback.answer(texts.t("CANCELLED", "Отменено")) + + # Возвращаем в меню партнёрки + keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton( + text=texts.BACK, + callback_data="menu_referrals" + )] + ]) + await edit_or_answer_photo( + callback, + texts.t("REFERRAL_WITHDRAWAL_CANCELLED", "❌ Заявка отменена"), + keyboard + ) + + def register_handlers(dp: Dispatcher): - + dp.callback_query.register( show_referral_info, F.data == "menu_referrals" @@ -498,3 +885,41 @@ def register_handlers(dp: Dispatcher): handle_referral_list_page, F.data.startswith("referral_list_page_") ) + + # Хендлеры вывода реферального баланса + dp.callback_query.register( + show_withdrawal_info, + F.data == "referral_withdrawal" + ) + + dp.callback_query.register( + start_withdrawal_request, + F.data == "referral_withdrawal_start" + ) + + dp.callback_query.register( + process_withdrawal_amount_callback, + F.data.startswith("referral_withdrawal_amount_") + ) + + dp.callback_query.register( + confirm_withdrawal_request, + F.data == "referral_withdrawal_confirm" + ) + + dp.callback_query.register( + cancel_withdrawal_request, + F.data == "referral_withdrawal_cancel" + ) + + # Обработка текстового ввода суммы + dp.message.register( + process_withdrawal_amount, + ReferralWithdrawalStates.waiting_for_amount + ) + + # Обработка текстового ввода реквизитов + dp.message.register( + process_payment_details, + ReferralWithdrawalStates.waiting_for_payment_details + ) diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 8ac064d0..c44a1a23 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -1530,7 +1530,7 @@ def get_subscription_expiring_keyboard(subscription_id: int, language: str = DEF def get_referral_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: texts = get_texts(language) - + keyboard = [ [ InlineKeyboardButton( @@ -1556,14 +1556,24 @@ def get_referral_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMar callback_data="referral_analytics" ) ], - [ - InlineKeyboardButton( - text=texts.BACK, - callback_data="back_to_menu" - ) - ] ] - + + # Добавляем кнопку вывода, если включена + if settings.is_referral_withdrawal_enabled(): + keyboard.append([ + InlineKeyboardButton( + text=texts.t("REFERRAL_WITHDRAWAL_BUTTON", "💸 Запросить вывод"), + callback_data="referral_withdrawal" + ) + ]) + + keyboard.append([ + InlineKeyboardButton( + text=texts.BACK, + callback_data="back_to_menu" + ) + ]) + return InlineKeyboardMarkup(inline_keyboard=keyboard) diff --git a/app/services/referral_withdrawal_service.py b/app/services/referral_withdrawal_service.py new file mode 100644 index 00000000..23870dbb --- /dev/null +++ b/app/services/referral_withdrawal_service.py @@ -0,0 +1,665 @@ +""" +Сервис для обработки запросов на вывод реферального баланса +с анализом на подозрительную активность (отмывание денег). +""" +import json +import logging +from datetime import datetime, timedelta +from typing import Dict, List, Optional, Tuple + +from sqlalchemy import select, func, and_ +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.models import ( + User, + Transaction, + ReferralEarning, + WithdrawalRequest, + WithdrawalRequestStatus, +) + +logger = logging.getLogger(__name__) + + +class ReferralWithdrawalService: + """Сервис для обработки запросов на вывод реферального баланса.""" + + # ==================== МЕТОДЫ РАСЧЁТА БАЛАНСОВ ==================== + + async def get_total_referral_earnings(self, db: AsyncSession, user_id: int) -> int: + """ + Получает ОБЩУЮ сумму реферальных начислений (за всё время). + Возвращает сумму в копейках. + """ + result = await db.execute( + select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)) + .where(ReferralEarning.user_id == user_id) + ) + return result.scalar() or 0 + + async def get_user_own_deposits(self, db: AsyncSession, user_id: int) -> int: + """ + Получает сумму собственных пополнений пользователя (НЕ реферальные). + """ + result = await db.execute( + select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)) + .where( + Transaction.user_id == user_id, + Transaction.type == "deposit", + Transaction.is_completed == True + ) + ) + return result.scalar() or 0 + + async def get_user_spending(self, db: AsyncSession, user_id: int) -> int: + """ + Получает сумму трат пользователя (покупки подписок, сброс трафика и т.д.). + """ + result = await db.execute( + select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)) + .where( + Transaction.user_id == user_id, + Transaction.type.in_(["subscription_payment", "withdrawal"]), + Transaction.is_completed == True + ) + ) + return abs(result.scalar() or 0) + + async def get_withdrawn_amount(self, db: AsyncSession, user_id: int) -> int: + """ + Получает сумму уже выведенных средств (одобренные/выполненные заявки). + """ + result = await db.execute( + select(func.coalesce(func.sum(WithdrawalRequest.amount_kopeks), 0)) + .where( + WithdrawalRequest.user_id == user_id, + WithdrawalRequest.status.in_([ + WithdrawalRequestStatus.APPROVED.value, + WithdrawalRequestStatus.COMPLETED.value + ]) + ) + ) + return result.scalar() or 0 + + async def get_pending_withdrawal_amount(self, db: AsyncSession, user_id: int) -> int: + """ + Получает сумму заявок в ожидании (заморожено). + """ + result = await db.execute( + select(func.coalesce(func.sum(WithdrawalRequest.amount_kopeks), 0)) + .where( + WithdrawalRequest.user_id == user_id, + WithdrawalRequest.status == WithdrawalRequestStatus.PENDING.value + ) + ) + return result.scalar() or 0 + + async def get_referral_balance_stats(self, db: AsyncSession, user_id: int) -> Dict: + """ + Получает полную статистику реферального баланса. + """ + total_earned = await self.get_total_referral_earnings(db, user_id) + own_deposits = await self.get_user_own_deposits(db, user_id) + spending = await self.get_user_spending(db, user_id) + withdrawn = await self.get_withdrawn_amount(db, user_id) + pending = await self.get_pending_withdrawal_amount(db, user_id) + + # Сколько реф. баланса потрачено = мин(траты, реф_заработок) + # Логика: сначала тратим реф. баланс, потом свой + referral_spent = min(spending, total_earned) + + # Доступный реферальный баланс + available_referral = max(0, total_earned - referral_spent - withdrawn - pending) + + # Если разрешено выводить и свой баланс + if not settings.REFERRAL_WITHDRAWAL_ONLY_REFERRAL_BALANCE: + # Свой остаток = пополнения - (траты - реф_потрачено) + own_remaining = max(0, own_deposits - max(0, spending - referral_spent)) + available_total = available_referral + own_remaining + else: + own_remaining = 0 + available_total = available_referral + + return { + "total_earned": total_earned, # Всего заработано с рефералов + "own_deposits": own_deposits, # Собственные пополнения + "spending": spending, # Потрачено на подписки и пр. + "referral_spent": referral_spent, # Сколько реф. баланса потрачено + "withdrawn": withdrawn, # Уже выведено + "pending": pending, # На рассмотрении + "available_referral": available_referral, # Доступно реф. баланса + "available_total": available_total, # Всего доступно к выводу + "only_referral_mode": settings.REFERRAL_WITHDRAWAL_ONLY_REFERRAL_BALANCE, + } + + async def get_available_for_withdrawal(self, db: AsyncSession, user_id: int) -> int: + """Получает сумму, доступную для вывода.""" + stats = await self.get_referral_balance_stats(db, user_id) + return stats["available_total"] + + # ==================== ПРОВЕРКИ ==================== + + async def get_last_withdrawal_request( + self, db: AsyncSession, user_id: int + ) -> Optional[WithdrawalRequest]: + """Получает последнюю заявку на вывод пользователя.""" + result = await db.execute( + select(WithdrawalRequest) + .where(WithdrawalRequest.user_id == user_id) + .order_by(WithdrawalRequest.created_at.desc()) + .limit(1) + ) + return result.scalar_one_or_none() + + async def can_request_withdrawal( + self, db: AsyncSession, user_id: int + ) -> Tuple[bool, str]: + """ + Проверяет, может ли пользователь запросить вывод. + Возвращает (can_request, reason). + """ + if not settings.is_referral_withdrawal_enabled(): + return False, "Функция вывода реферального баланса отключена" + + # Проверяем доступный баланс + stats = await self.get_referral_balance_stats(db, user_id) + available = stats["available_total"] + min_amount = settings.REFERRAL_WITHDRAWAL_MIN_AMOUNT_KOPEKS + + if available < min_amount: + return False, f"Минимальная сумма вывода: {min_amount / 100:.0f}₽. Доступно: {available / 100:.0f}₽" + + # Проверяем cooldown + last_request = await self.get_last_withdrawal_request(db, user_id) + if last_request: + cooldown_days = settings.REFERRAL_WITHDRAWAL_COOLDOWN_DAYS + cooldown_end = last_request.created_at + timedelta(days=cooldown_days) + + if datetime.utcnow() < cooldown_end: + days_left = (cooldown_end - datetime.utcnow()).days + 1 + return False, f"Следующий запрос на вывод будет доступен через {days_left} дн." + + # Проверяем, нет ли активной заявки + if last_request.status == WithdrawalRequestStatus.PENDING.value: + return False, "У вас уже есть активная заявка на рассмотрении" + + return True, "OK" + + # ==================== АНАЛИЗ НА ОТМЫВАНИЕ ==================== + + async def analyze_for_money_laundering( + self, db: AsyncSession, user_id: int + ) -> Dict: + """ + Детальный анализ активности пользователя на предмет отмывания денег. + """ + analysis = { + "risk_score": 0, + "risk_level": "low", + "recommendation": "approve", + "flags": [], + "details": {} + } + + # Получаем статистику баланса + balance_stats = await self.get_referral_balance_stats(db, user_id) + analysis["details"]["balance_stats"] = balance_stats + + # 1. ПРОВЕРКА: Пользователь пополнил но не покупал подписки + own_deposits = balance_stats["own_deposits"] + spending = balance_stats["spending"] + ratio_threshold = settings.REFERRAL_WITHDRAWAL_SUSPICIOUS_NO_PURCHASES_RATIO + + if own_deposits > 0 and spending == 0: + analysis["risk_score"] += 40 + analysis["flags"].append( + f"🔴 Пополнил {own_deposits / 100:.0f}₽, но ничего не покупал!" + ) + elif own_deposits > spending * ratio_threshold and spending > 0: + analysis["risk_score"] += 25 + analysis["flags"].append( + f"🟠 Пополнил {own_deposits / 100:.0f}₽, потратил только {spending / 100:.0f}₽" + ) + + # 2. Получаем информацию о рефералах + referrals = await db.execute( + select(User).where(User.referred_by_id == user_id) + ) + referrals_list = referrals.scalars().all() + referral_count = len(referrals_list) + analysis["details"]["referral_count"] = referral_count + + if referral_count == 0 and balance_stats["total_earned"] > 0: + analysis["risk_score"] += 50 + analysis["flags"].append("🔴 Нет рефералов, но есть реферальный доход!") + + # 3. Анализ пополнений каждого реферала + referral_ids = [r.id for r in referrals_list] + suspicious_referrals = [] + + if referral_ids: + # Получаем детальную статистику по каждому рефералу за последний месяц + month_ago = datetime.utcnow() - timedelta(days=30) + + for ref_id in referral_ids: + ref_user = next((r for r in referrals_list if r.id == ref_id), None) + ref_name = ref_user.full_name if ref_user else f"ID{ref_id}" + + # Пополнения этого реферала за месяц + ref_deposits = await db.execute( + select( + func.count().label("count"), + func.coalesce(func.sum(Transaction.amount_kopeks), 0).label("total") + ) + .where( + Transaction.user_id == ref_id, + Transaction.type == "deposit", + Transaction.is_completed == True, + Transaction.created_at >= month_ago + ) + ) + deposit_data = ref_deposits.fetchone() + deposit_count = deposit_data.count + deposit_total = deposit_data.total + + suspicious_flags = [] + + # Проверка: слишком много пополнений от одного реферала + max_deposits = settings.REFERRAL_WITHDRAWAL_SUSPICIOUS_MAX_DEPOSITS_PER_MONTH + if deposit_count > max_deposits: + analysis["risk_score"] += 15 + suspicious_flags.append(f"{deposit_count} пополнений/мес") + + # Проверка: большие суммы от одного реферала + min_suspicious = settings.REFERRAL_WITHDRAWAL_SUSPICIOUS_MIN_DEPOSIT_KOPEKS + if deposit_total > min_suspicious: + analysis["risk_score"] += 10 + suspicious_flags.append(f"сумма {deposit_total / 100:.0f}₽") + + if suspicious_flags: + suspicious_referrals.append({ + "name": ref_name, + "deposits_count": deposit_count, + "deposits_total": deposit_total, + "flags": suspicious_flags + }) + + analysis["details"]["suspicious_referrals"] = suspicious_referrals + + if suspicious_referrals: + analysis["flags"].append( + f"⚠️ Подозрительная активность у {len(suspicious_referrals)} реферала(ов)" + ) + + # Общая статистика по рефералам + all_ref_deposits = await db.execute( + select( + func.count(func.distinct(Transaction.user_id)).label("paying_count"), + func.count().label("total_deposits"), + func.coalesce(func.sum(Transaction.amount_kopeks), 0).label("total_amount") + ) + .where( + Transaction.user_id.in_(referral_ids), + Transaction.type == "deposit", + Transaction.is_completed == True + ) + ) + ref_stats = all_ref_deposits.fetchone() + analysis["details"]["referral_deposits"] = { + "paying_referrals": ref_stats.paying_count, + "total_deposits": ref_stats.total_deposits, + "total_amount": ref_stats.total_amount + } + + # Проверка: только 1 платящий реферал + if ref_stats.paying_count == 1 and balance_stats["total_earned"] > 50000: + analysis["risk_score"] += 20 + analysis["flags"].append("⚠️ Весь доход от одного реферала") + + # 4. Анализ реферальных начислений по типам + earnings = await db.execute( + select( + ReferralEarning.reason, + func.count().label("count"), + func.sum(ReferralEarning.amount_kopeks).label("total") + ) + .where(ReferralEarning.user_id == user_id) + .group_by(ReferralEarning.reason) + ) + earnings_by_reason = {r.reason: {"count": r.count, "total": r.total} for r in earnings.fetchall()} + analysis["details"]["earnings_by_reason"] = earnings_by_reason + + # 5. Проверка: много начислений за последнюю неделю + week_ago = datetime.utcnow() - timedelta(days=7) + recent_earnings = await db.execute( + select(func.count(), func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)) + .where( + ReferralEarning.user_id == user_id, + ReferralEarning.created_at >= week_ago + ) + ) + recent_data = recent_earnings.fetchone() + recent_count, recent_amount = recent_data + + if recent_count > 20: + analysis["risk_score"] += 15 + analysis["flags"].append(f"⚠️ {recent_count} начислений за неделю ({recent_amount / 100:.0f}₽)") + + analysis["details"]["recent_activity"] = { + "week_earnings_count": recent_count, + "week_earnings_amount": recent_amount + } + + # ==================== ИТОГОВАЯ ОЦЕНКА ==================== + + score = analysis["risk_score"] + + # Ограничиваем максимум + score = min(score, 100) + analysis["risk_score"] = score + + if score >= 70: + analysis["risk_level"] = "critical" + analysis["recommendation"] = "reject" + analysis["recommendation_text"] = "🔴 РЕКОМЕНДУЕТСЯ ОТКЛОНИТЬ" + elif score >= 50: + analysis["risk_level"] = "high" + analysis["recommendation"] = "review" + analysis["recommendation_text"] = "🟠 ТРЕБУЕТ ПРОВЕРКИ" + elif score >= 30: + analysis["risk_level"] = "medium" + analysis["recommendation"] = "review" + analysis["recommendation_text"] = "🟡 Рекомендуется проверить" + else: + analysis["risk_level"] = "low" + analysis["recommendation"] = "approve" + analysis["recommendation_text"] = "🟢 Можно одобрить" + + return analysis + + # ==================== СОЗДАНИЕ И УПРАВЛЕНИЕ ЗАЯВКАМИ ==================== + + async def create_withdrawal_request( + self, + db: AsyncSession, + user_id: int, + amount_kopeks: int, + payment_details: str + ) -> Tuple[Optional[WithdrawalRequest], str]: + """ + Создаёт заявку на вывод с анализом на отмывание. + Возвращает (request, error_message). + """ + # Проверяем возможность вывода + can_request, reason = await self.can_request_withdrawal(db, user_id) + if not can_request: + return None, reason + + # Проверяем сумму + stats = await self.get_referral_balance_stats(db, user_id) + available = stats["available_total"] + + if amount_kopeks > available: + return None, f"Недостаточно средств. Доступно: {available / 100:.0f}₽" + + # В режиме "только реф. баланс" проверяем реф. баланс + if settings.REFERRAL_WITHDRAWAL_ONLY_REFERRAL_BALANCE: + if amount_kopeks > stats["available_referral"]: + return None, f"Недостаточно реферального баланса. Доступно: {stats['available_referral'] / 100:.0f}₽" + + # Анализируем на отмывание + analysis = await self.analyze_for_money_laundering(db, user_id) + + # Создаём заявку + request = WithdrawalRequest( + user_id=user_id, + amount_kopeks=amount_kopeks, + payment_details=payment_details, + risk_score=analysis["risk_score"], + risk_analysis=json.dumps(analysis, ensure_ascii=False, default=str) + ) + + db.add(request) + await db.commit() + await db.refresh(request) + + return request, "" + + async def get_pending_requests(self, db: AsyncSession) -> List[WithdrawalRequest]: + """Получает все ожидающие заявки на вывод.""" + result = await db.execute( + select(WithdrawalRequest) + .where(WithdrawalRequest.status == WithdrawalRequestStatus.PENDING.value) + .order_by(WithdrawalRequest.created_at.asc()) + ) + return result.scalars().all() + + async def get_all_requests( + self, db: AsyncSession, limit: int = 50, offset: int = 0 + ) -> List[WithdrawalRequest]: + """Получает все заявки на вывод (журнал).""" + result = await db.execute( + select(WithdrawalRequest) + .order_by(WithdrawalRequest.created_at.desc()) + .limit(limit) + .offset(offset) + ) + return result.scalars().all() + + async def approve_request( + self, + db: AsyncSession, + request_id: int, + admin_id: int, + comment: Optional[str] = None + ) -> Tuple[bool, str]: + """ + Одобряет заявку на вывод и списывает средства с баланса. + Возвращает (success, error_message). + """ + result = await db.execute( + select(WithdrawalRequest).where(WithdrawalRequest.id == request_id) + ) + request = result.scalar_one_or_none() + + if not request: + return False, "Заявка не найдена" + + if request.status != WithdrawalRequestStatus.PENDING.value: + return False, "Заявка уже обработана" + + # Проверяем, что баланс всё ещё достаточен + stats = await self.get_referral_balance_stats(db, request.user_id) + if request.amount_kopeks > stats["available_total"]: + return False, f"Недостаточно средств у пользователя. Доступно: {stats['available_total'] / 100:.0f}₽" + + # Получаем пользователя для списания с баланса + user_result = await db.execute( + select(User).where(User.id == request.user_id) + ) + user = user_result.scalar_one_or_none() + + if not user: + return False, "Пользователь не найден" + + # Списываем с баланса + if user.balance_kopeks < request.amount_kopeks: + return False, f"Недостаточно средств на балансе. Баланс: {user.balance_kopeks / 100:.0f}₽" + + user.balance_kopeks -= request.amount_kopeks + + # Создаём транзакцию списания + withdrawal_tx = Transaction( + user_id=request.user_id, + type="withdrawal", + amount_kopeks=-request.amount_kopeks, + description=f"Вывод реферального баланса (заявка #{request.id})", + is_completed=True, + completed_at=datetime.utcnow() + ) + db.add(withdrawal_tx) + + # Обновляем статус заявки + request.status = WithdrawalRequestStatus.APPROVED.value + request.processed_by = admin_id + request.processed_at = datetime.utcnow() + request.admin_comment = comment + + await db.commit() + return True, "" + + async def reject_request( + self, + db: AsyncSession, + request_id: int, + admin_id: int, + comment: Optional[str] = None + ) -> bool: + """Отклоняет заявку на вывод.""" + result = await db.execute( + select(WithdrawalRequest).where(WithdrawalRequest.id == request_id) + ) + request = result.scalar_one_or_none() + + if not request or request.status != WithdrawalRequestStatus.PENDING.value: + return False + + request.status = WithdrawalRequestStatus.REJECTED.value + request.processed_by = admin_id + request.processed_at = datetime.utcnow() + request.admin_comment = comment + + await db.commit() + return True + + async def complete_request( + self, + db: AsyncSession, + request_id: int, + admin_id: int, + comment: Optional[str] = None + ) -> bool: + """Отмечает заявку как выполненную (деньги переведены).""" + result = await db.execute( + select(WithdrawalRequest).where(WithdrawalRequest.id == request_id) + ) + request = result.scalar_one_or_none() + + if not request or request.status != WithdrawalRequestStatus.APPROVED.value: + return False + + request.status = WithdrawalRequestStatus.COMPLETED.value + request.processed_by = admin_id + request.processed_at = datetime.utcnow() + if comment: + request.admin_comment = (request.admin_comment or "") + f"\n{comment}" + + await db.commit() + return True + + # ==================== ФОРМАТИРОВАНИЕ ==================== + + def format_balance_stats_for_user(self, stats: Dict, texts) -> str: + """Форматирует статистику баланса для пользователя.""" + text = "" + text += texts.t( + "REFERRAL_WITHDRAWAL_STATS_EARNED", + "📈 Всего заработано с рефералов: {amount}" + ).format(amount=texts.format_price(stats["total_earned"])) + "\n" + + text += texts.t( + "REFERRAL_WITHDRAWAL_STATS_SPENT", + "💳 Потрачено на подписки: {amount}" + ).format(amount=texts.format_price(stats["referral_spent"])) + "\n" + + text += texts.t( + "REFERRAL_WITHDRAWAL_STATS_WITHDRAWN", + "💸 Выведено: {amount}" + ).format(amount=texts.format_price(stats["withdrawn"])) + "\n" + + if stats["pending"] > 0: + text += texts.t( + "REFERRAL_WITHDRAWAL_STATS_PENDING", + "⏳ На рассмотрении: {amount}" + ).format(amount=texts.format_price(stats["pending"])) + "\n" + + text += "\n" + text += texts.t( + "REFERRAL_WITHDRAWAL_STATS_AVAILABLE", + "✅ Доступно к выводу: {amount}" + ).format(amount=texts.format_price(stats["available_total"])) + "\n" + + if stats["only_referral_mode"]: + text += texts.t( + "REFERRAL_WITHDRAWAL_ONLY_REF_MODE", + "ℹ️ Выводить можно только реферальный баланс" + ) + "\n" + + return text + + def format_analysis_for_admin(self, analysis: Dict) -> str: + """Форматирует анализ для отображения админу.""" + risk_emoji = { + "low": "🟢", + "medium": "🟡", + "high": "🟠", + "critical": "🔴" + } + + text = f""" +🔍 Анализ на подозрительную активность + +{risk_emoji.get(analysis['risk_level'], '⚪')} Уровень риска: {analysis['risk_level'].upper()} +📊 Оценка риска: {analysis['risk_score']}/100 +{analysis.get('recommendation_text', '')} +""" + + if analysis.get("flags"): + text += "\n⚠️ Предупреждения:\n" + for flag in analysis["flags"]: + text += f" {flag}\n" + + details = analysis.get("details", {}) + + # Статистика баланса + if "balance_stats" in details: + bs = details["balance_stats"] + text += "\n💰 Баланс:\n" + text += f"• Заработано с рефералов: {bs['total_earned'] / 100:.0f}₽\n" + text += f"• Собственные пополнения: {bs['own_deposits'] / 100:.0f}₽\n" + text += f"• Потрачено: {bs['spending'] / 100:.0f}₽\n" + text += f"• Уже выведено: {bs['withdrawn'] / 100:.0f}₽\n" + + # Статистика по рефералам + if "referral_deposits" in details: + rd = details["referral_deposits"] + text += f"\n👥 Рефералы:\n" + text += f"• Всего: {details.get('referral_count', 0)}\n" + text += f"• Платящих: {rd['paying_referrals']}\n" + text += f"• Всего пополнений: {rd['total_deposits']} ({rd['total_amount'] / 100:.0f}₽)\n" + + # Подозрительные рефералы + if details.get("suspicious_referrals"): + text += "\n🚨 Подозрительные рефералы:\n" + for sr in details["suspicious_referrals"][:5]: + text += f"• {sr['name']}: {sr['deposits_count']} поп., {sr['deposits_total'] / 100:.0f}₽\n" + text += f" Флаги: {', '.join(sr['flags'])}\n" + + # Источники дохода + if "earnings_by_reason" in details: + text += "\n📊 Источники дохода:\n" + reason_names = { + "referral_first_topup": "Бонус за 1-е пополнение", + "referral_commission_topup": "Комиссия с пополнений", + "referral_commission": "Комиссия с покупок" + } + for reason, data in details["earnings_by_reason"].items(): + name = reason_names.get(reason, reason) + text += f"• {name}: {data['count']} шт. ({data['total'] / 100:.0f}₽)\n" + + return text + + +# Синглтон сервиса +referral_withdrawal_service = ReferralWithdrawalService() diff --git a/app/states.py b/app/states.py index 5c2a5a68..dab13c73 100644 --- a/app/states.py +++ b/app/states.py @@ -111,6 +111,9 @@ class AdminStates(StatesGroup): editing_user_referrals = State() editing_user_referral_percent = State() + # Тестовое начисление реферального дохода + test_referral_earning_input = State() + editing_rules_page = State() editing_privacy_policy = State() editing_public_offer = State() @@ -218,3 +221,9 @@ class AdminSubmenuStates(StatesGroup): class BlacklistStates(StatesGroup): waiting_for_blacklist_url = State() + + +class ReferralWithdrawalStates(StatesGroup): + waiting_for_amount = State() + waiting_for_payment_details = State() + confirming = State()