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