mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-01-20 03:40:26 +00:00
Добавлена система вывода реферального баланса
Новая функциональность вывода средств: - config.py: добавлены настройки вывода (минимальная сумма, кулдаун, анализ подозрительности, тестовый режим) - models.py: добавлена модель WithdrawalRequest с полями для заявок, анализа рисков и обработки админ
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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(
|
||||
<i>🕐 Обновлено: {current_time}</i>
|
||||
"""
|
||||
|
||||
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 = "📋 <b>Заявки на вывод</b>\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"📋 <b>Заявки на вывод ({len(requests)})</b>\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"<b>#{req.id}</b> — {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"""
|
||||
📋 <b>Заявка #{request.id}</b>
|
||||
|
||||
👤 Пользователь: {user_name}
|
||||
🆔 ID: <code>{user_tg_id}</code>
|
||||
💰 Сумма: <b>{request.amount_kopeks / 100:.0f}₽</b>
|
||||
📊 Статус: {status_text}
|
||||
|
||||
💳 <b>Реквизиты:</b>
|
||||
<code>{request.payment_details}</code>
|
||||
|
||||
📅 Создана: {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",
|
||||
"✅ <b>Заявка на вывод #{id} одобрена!</b>\n\n"
|
||||
"Сумма: <b>{amount}</b>\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",
|
||||
"❌ <b>Заявка на вывод #{id} отклонена</b>\n\n"
|
||||
"Сумма: <b>{amount}</b>\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",
|
||||
"💸 <b>Выплата по заявке #{id} выполнена!</b>\n\n"
|
||||
"Сумма: <b>{amount}</b>\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 = """
|
||||
🧪 <b>Тестовое начисление реферального дохода</b>
|
||||
|
||||
Введите данные в формате:
|
||||
<code>telegram_id сумма_в_рублях</code>
|
||||
|
||||
Примеры:
|
||||
• <code>123456789 500</code> — начислит 500₽ пользователю 123456789
|
||||
• <code>987654321 1000</code> — начислит 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(
|
||||
"❌ Неверный формат. Введите: <code>telegram_id сумма</code>\n\n"
|
||||
"Например: <code>123456789 500</code>"
|
||||
)
|
||||
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(
|
||||
"❌ Неверный формат чисел. Введите: <code>telegram_id сумма</code>\n\n"
|
||||
"Например: <code>123456789 500</code>"
|
||||
)
|
||||
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"✅ <b>Тестовое начисление создано!</b>\n\n"
|
||||
f"👤 Пользователь: {target_user.full_name or 'Без имени'}\n"
|
||||
f"🆔 ID: <code>{target_telegram_id}</code>\n"
|
||||
f"💰 Сумма: <b>{amount_rubles:.0f}₽</b>\n"
|
||||
f"💳 Новый баланс: <b>{target_user.balance_kopeks / 100:.0f}₽</b>\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)
|
||||
|
||||
@@ -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", "💸 <b>Вывод реферального баланса</b>") + "\n\n"
|
||||
|
||||
# Показываем детальную статистику
|
||||
text += referral_withdrawal_service.format_balance_stats_for_user(stats, texts)
|
||||
text += "\n"
|
||||
|
||||
text += texts.t(
|
||||
"REFERRAL_WITHDRAWAL_MIN_AMOUNT",
|
||||
"📊 Минимальная сумма: <b>{amount}</b>"
|
||||
).format(amount=texts.format_price(min_amount)) + "\n"
|
||||
text += texts.t(
|
||||
"REFERRAL_WITHDRAWAL_COOLDOWN",
|
||||
"⏱ Частота вывода: раз в <b>{days}</b> дней"
|
||||
).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Доступно: <b>{amount}</b>"
|
||||
).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", "📋 <b>Подтверждение заявки</b>") + "\n\n"
|
||||
text += texts.t(
|
||||
"REFERRAL_WITHDRAWAL_CONFIRM_AMOUNT",
|
||||
"💰 Сумма: <b>{amount}</b>"
|
||||
).format(amount=texts.format_price(amount_kopeks)) + "\n\n"
|
||||
text += texts.t(
|
||||
"REFERRAL_WITHDRAWAL_CONFIRM_DETAILS",
|
||||
"💳 Реквизиты:\n<code>{details}</code>"
|
||||
).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"""
|
||||
🔔 <b>Новая заявка на вывод #{request.id}</b>
|
||||
|
||||
👤 Пользователь: {db_user.full_name or 'Без имени'}
|
||||
🆔 ID: <code>{db_user.telegram_id}</code>
|
||||
💰 Сумма: <b>{amount_kopeks / 100:.0f}₽</b>
|
||||
|
||||
💳 Реквизиты:
|
||||
<code>{payment_details}</code>
|
||||
|
||||
{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",
|
||||
"✅ <b>Заявка #{id} создана!</b>\n\n"
|
||||
"Сумма: <b>{amount}</b>\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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
665
app/services/referral_withdrawal_service.py
Normal file
665
app/services/referral_withdrawal_service.py
Normal file
@@ -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",
|
||||
"📈 Всего заработано с рефералов: <b>{amount}</b>"
|
||||
).format(amount=texts.format_price(stats["total_earned"])) + "\n"
|
||||
|
||||
text += texts.t(
|
||||
"REFERRAL_WITHDRAWAL_STATS_SPENT",
|
||||
"💳 Потрачено на подписки: <b>{amount}</b>"
|
||||
).format(amount=texts.format_price(stats["referral_spent"])) + "\n"
|
||||
|
||||
text += texts.t(
|
||||
"REFERRAL_WITHDRAWAL_STATS_WITHDRAWN",
|
||||
"💸 Выведено: <b>{amount}</b>"
|
||||
).format(amount=texts.format_price(stats["withdrawn"])) + "\n"
|
||||
|
||||
if stats["pending"] > 0:
|
||||
text += texts.t(
|
||||
"REFERRAL_WITHDRAWAL_STATS_PENDING",
|
||||
"⏳ На рассмотрении: <b>{amount}</b>"
|
||||
).format(amount=texts.format_price(stats["pending"])) + "\n"
|
||||
|
||||
text += "\n"
|
||||
text += texts.t(
|
||||
"REFERRAL_WITHDRAWAL_STATS_AVAILABLE",
|
||||
"✅ <b>Доступно к выводу: {amount}</b>"
|
||||
).format(amount=texts.format_price(stats["available_total"])) + "\n"
|
||||
|
||||
if stats["only_referral_mode"]:
|
||||
text += texts.t(
|
||||
"REFERRAL_WITHDRAWAL_ONLY_REF_MODE",
|
||||
"<i>ℹ️ Выводить можно только реферальный баланс</i>"
|
||||
) + "\n"
|
||||
|
||||
return text
|
||||
|
||||
def format_analysis_for_admin(self, analysis: Dict) -> str:
|
||||
"""Форматирует анализ для отображения админу."""
|
||||
risk_emoji = {
|
||||
"low": "🟢",
|
||||
"medium": "🟡",
|
||||
"high": "🟠",
|
||||
"critical": "🔴"
|
||||
}
|
||||
|
||||
text = f"""
|
||||
🔍 <b>Анализ на подозрительную активность</b>
|
||||
|
||||
{risk_emoji.get(analysis['risk_level'], '⚪')} Уровень риска: <b>{analysis['risk_level'].upper()}</b>
|
||||
📊 Оценка риска: <b>{analysis['risk_score']}/100</b>
|
||||
{analysis.get('recommendation_text', '')}
|
||||
"""
|
||||
|
||||
if analysis.get("flags"):
|
||||
text += "\n⚠️ <b>Предупреждения:</b>\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💰 <b>Баланс:</b>\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👥 <b>Рефералы:</b>\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🚨 <b>Подозрительные рефералы:</b>\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📊 <b>Источники дохода:</b>\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()
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user