Merge pull request #2012 from BEDOLAGA-DEV/revert-2011-rrpx8s-bedolaga/add-referral-income-withdrawal-feature

Revert "Debit balance when closing referral withdrawals"
This commit is contained in:
Egor
2025-11-24 07:30:05 +03:00
committed by GitHub
12 changed files with 18 additions and 1173 deletions

View File

@@ -150,10 +150,6 @@ class Settings(BaseSettings):
REFERRAL_PROGRAM_ENABLED: bool = True
REFERRAL_NOTIFICATIONS_ENABLED: bool = True
REFERRAL_NOTIFICATION_RETRY_ATTEMPTS: int = 3
REFERRAL_WITHDRAWALS_ENABLED: bool = False
REFERRAL_WITHDRAWAL_MIN_AMOUNT_KOPEKS: int = 50000
REFERRAL_WITHDRAWAL_PROMPT_TEXT: str = ""
REFERRAL_WITHDRAWAL_SUCCESS_TEXT: str = ""
AUTOPAY_WARNING_DAYS: str = "3,1"

View File

@@ -1,98 +0,0 @@
import logging
from datetime import datetime
from typing import Iterable, Optional
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import ReferralWithdrawalRequest
logger = logging.getLogger(__name__)
async def create_referral_withdrawal_request(
db: AsyncSession,
user_id: int,
amount_kopeks: int,
requisites: str,
status: str = "pending",
) -> ReferralWithdrawalRequest:
request = ReferralWithdrawalRequest(
user_id=user_id,
amount_kopeks=amount_kopeks,
requisites=requisites,
status=status,
)
db.add(request)
await db.commit()
await db.refresh(request)
logger.info(
"💸 Создан запрос на вывод партнёрки: %s₽ для пользователя %s",
amount_kopeks / 100,
user_id,
)
return request
async def get_total_requested_amount(
db: AsyncSession,
user_id: int,
statuses: Optional[Iterable[str]] = None,
) -> int:
query = select(func.coalesce(func.sum(ReferralWithdrawalRequest.amount_kopeks), 0)).where(
ReferralWithdrawalRequest.user_id == user_id
)
if statuses:
query = query.where(ReferralWithdrawalRequest.status.in_(list(statuses)))
result = await db.execute(query)
return result.scalar() or 0
async def get_referral_withdrawal_requests(
db: AsyncSession,
status: Optional[str] = None,
limit: int = 50,
offset: int = 0,
):
query = select(ReferralWithdrawalRequest).options(
selectinload(ReferralWithdrawalRequest.user),
selectinload(ReferralWithdrawalRequest.closed_by),
).order_by(ReferralWithdrawalRequest.created_at.desc())
if status:
query = query.where(ReferralWithdrawalRequest.status == status)
result = await db.execute(query.offset(offset).limit(limit))
return result.scalars().all()
async def get_referral_withdrawal_request_by_id(
db: AsyncSession, request_id: int
) -> Optional[ReferralWithdrawalRequest]:
result = await db.execute(
select(ReferralWithdrawalRequest)
.options(
selectinload(ReferralWithdrawalRequest.user),
selectinload(ReferralWithdrawalRequest.closed_by),
)
.where(ReferralWithdrawalRequest.id == request_id)
)
return result.scalar_one_or_none()
async def close_referral_withdrawal_request(
db: AsyncSession, request: ReferralWithdrawalRequest, closed_by_id: Optional[int]
) -> ReferralWithdrawalRequest:
request.status = "closed"
request.closed_by_id = closed_by_id
request.closed_at = datetime.utcnow()
await db.commit()
await db.refresh(request)
return request

View File

@@ -957,29 +957,6 @@ class ReferralEarning(Base):
return self.amount_kopeks / 100
class ReferralWithdrawalRequest(Base):
__tablename__ = "referral_withdrawal_requests"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
amount_kopeks = Column(Integer, nullable=False)
requisites = Column(Text, nullable=False)
status = Column(String(32), nullable=False, default="pending")
closed_by_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
closed_at = Column(DateTime, 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])
closed_by = relationship("User", foreign_keys=[closed_by_id])
@property
def amount_rubles(self) -> float:
return self.amount_kopeks / 100
class Squad(Base):
__tablename__ = "squads"

View File

@@ -3819,86 +3819,6 @@ async def add_promocode_promo_group_column() -> bool:
return False
async def create_referral_withdrawal_requests_table() -> bool:
table_exists = await check_table_exists("referral_withdrawal_requests")
if table_exists:
logger.info(" Таблица referral_withdrawal_requests уже существует")
return True
try:
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == "sqlite":
create_table_sql = """
CREATE TABLE referral_withdrawal_requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
amount_kopeks INTEGER NOT NULL,
requisites TEXT NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'pending',
closed_by_id INTEGER NULL,
closed_at DATETIME NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (closed_by_id) REFERENCES users(id) ON DELETE SET NULL
);
"""
create_index_sql = (
"CREATE INDEX idx_referral_withdrawals_user "
"ON referral_withdrawal_requests(user_id);"
)
elif db_type == "postgresql":
create_table_sql = """
CREATE TABLE referral_withdrawal_requests (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
amount_kopeks INTEGER NOT NULL,
requisites TEXT NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'pending',
closed_by_id INTEGER NULL REFERENCES users(id) ON DELETE SET NULL,
closed_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
"""
create_index_sql = (
"CREATE INDEX idx_referral_withdrawals_user "
"ON referral_withdrawal_requests(user_id);"
)
else: # MySQL
create_table_sql = """
CREATE TABLE referral_withdrawal_requests (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
amount_kopeks INT NOT NULL,
requisites TEXT NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'pending',
closed_by_id INT NULL,
closed_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_ref_withdraw_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_ref_withdraw_closed_by FOREIGN KEY (closed_by_id) REFERENCES users(id) ON DELETE SET NULL
);
"""
create_index_sql = (
"CREATE INDEX idx_referral_withdrawals_user "
"ON referral_withdrawal_requests(user_id);"
)
await conn.execute(text(create_table_sql))
await conn.execute(text(create_index_sql))
logger.info("✅ Таблица referral_withdrawal_requests создана")
return True
except Exception as error:
logger.error(f"❌ Ошибка создания таблицы referral_withdrawal_requests: {error}")
return False
async def run_universal_migration():
logger.info("=== НАЧАЛО УНИВЕРСАЛЬНОЙ МИГРАЦИИ ===")
@@ -4101,13 +4021,6 @@ async def run_universal_migration():
else:
logger.warning("⚠️ Проблемы с таблицей main_menu_buttons")
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ REFERRAL_WITHDRAWAL_REQUESTS ===")
referral_withdrawals_created = await create_referral_withdrawal_requests_table()
if referral_withdrawals_created:
logger.info("✅ Таблица заявок на вывод партнёрки готова")
else:
logger.warning("⚠️ Проблемы с таблицей заявок на вывод партнёрки")
template_columns_ready = await ensure_promo_offer_template_active_duration_column()
if template_columns_ready:
logger.info("✅ Колонка active_discount_hours промо-предложений готова")
@@ -4354,7 +4267,6 @@ async def check_migration_status():
"promo_offer_templates_active_discount_column": False,
"promo_offer_logs_table": False,
"subscription_temporary_access_table": False,
"referral_withdrawal_requests_table": False,
}
status["has_made_first_topup_column"] = await check_column_exists('users', 'has_made_first_topup')
@@ -4378,7 +4290,6 @@ async def check_migration_status():
status["promo_offer_templates_active_discount_column"] = await check_column_exists('promo_offer_templates', 'active_discount_hours')
status["promo_offer_logs_table"] = await check_table_exists('promo_offer_logs')
status["subscription_temporary_access_table"] = await check_table_exists('subscription_temporary_access')
status["referral_withdrawal_requests_table"] = await check_table_exists('referral_withdrawal_requests')
status["welcome_texts_is_enabled_column"] = await check_column_exists('welcome_texts', 'is_enabled')
status["users_promo_group_column"] = await check_column_exists('users', 'promo_group_id')
@@ -4447,7 +4358,6 @@ async def check_migration_status():
"promo_offer_templates_active_discount_column": "Колонка active_discount_hours в promo_offer_templates",
"promo_offer_logs_table": "Таблица promo_offer_logs",
"subscription_temporary_access_table": "Таблица subscription_temporary_access",
"referral_withdrawal_requests_table": "Таблица заявок на вывод партнёрки",
}
for check_key, check_status in status.items():

View File

@@ -1,17 +1,13 @@
import logging
import datetime
from html import escape
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
import datetime
from app.config import settings
from app.database.models import User
from app.localization.texts import get_texts
from app.database.crud.referral import get_referral_statistics, get_user_referral_stats
from app.database.crud.user import get_user_by_id
from app.services.referral_withdrawal_service import ReferralWithdrawalService
from app.states import AdminStates
from app.utils.decorators import admin_required, error_handler
logger = logging.getLogger(__name__)
@@ -25,7 +21,6 @@ async def show_referral_statistics(
db: AsyncSession
):
try:
texts = get_texts(db_user.language)
stats = await get_referral_statistics(db)
avg_per_referrer = 0
@@ -82,12 +77,6 @@ async def show_referral_statistics(
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_referrals")],
[types.InlineKeyboardButton(text="👥 Топ рефереров", callback_data="admin_referrals_top")],
[
types.InlineKeyboardButton(
text=texts.t("ADMIN_REFERRAL_WITHDRAWALS", "💸 Вывод партнёрки"),
callback_data="admin_referral_withdrawals",
)
],
[types.InlineKeyboardButton(text="⚙️ Настройки", callback_data="admin_referrals_settings")],
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")]
])
@@ -213,428 +202,12 @@ async def show_referral_settings(
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="⬅️ К статистике", callback_data="admin_referrals")]
])
await callback.message.edit_text(text, reply_markup=keyboard)
await callback.answer()
@admin_required
@error_handler
async def show_referral_withdrawals_settings(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
texts = get_texts(db_user.language)
settings_obj = await ReferralWithdrawalService.get_settings(db)
pending_requests = await ReferralWithdrawalService.list_requests(
db, status="pending", limit=200
)
text = (
texts.t("ADMIN_REFERRAL_WITHDRAWALS_TITLE", "💸 <b>Вывод партнёрки</b>")
+ "\n\n"
+ texts.t(
"ADMIN_REFERRAL_WITHDRAWALS_STATUS",
"Статус: {status}",
).format(status="✅ Включен" if settings_obj.enabled else "❌ Выключен")
+ "\n"
+ texts.t(
"ADMIN_REFERRAL_WITHDRAWALS_MIN_AMOUNT",
"Минимальная сумма: {amount}",
).format(amount=settings.format_price(settings_obj.min_amount_kopeks))
+ "\n"
+ texts.t(
"ADMIN_REFERRAL_WITHDRAWALS_PENDING",
"Заявок в ожидании: {count}",
).format(count=len(pending_requests))
+ "\n\n"
+ texts.t(
"ADMIN_REFERRAL_WITHDRAWALS_PROMPT_TEXT",
"Текст запроса:\n{prompt}",
).format(prompt=settings_obj.prompt_text)
+ "\n\n"
+ texts.t(
"ADMIN_REFERRAL_WITHDRAWALS_SUCCESS_TEXT",
"Текст подтверждения:\n{success}",
).format(success=settings_obj.success_text)
)
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=("⏸️ Выключить" if settings_obj.enabled else "▶️ Включить"),
callback_data="admin_referral_withdrawals_toggle",
)
],
[
types.InlineKeyboardButton(
text="✏️ Минимальная сумма",
callback_data="admin_referral_withdrawals_min",
)
],
[
types.InlineKeyboardButton(
text="✏️ Текст запроса",
callback_data="admin_referral_withdrawals_prompt",
)
],
[
types.InlineKeyboardButton(
text="✏️ Текст подтверждения",
callback_data="admin_referral_withdrawals_success",
)
],
[
types.InlineKeyboardButton(
text=texts.t("ADMIN_REFERRAL_WITHDRAWALS", "💸 Вывод партнёрки"),
callback_data="admin_referral_withdrawal_requests",
)
],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_referrals")],
]
)
await state.set_state(None)
await callback.message.edit_text(text, reply_markup=keyboard)
await callback.answer()
@admin_required
@error_handler
async def toggle_referral_withdrawals(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
current_settings = await ReferralWithdrawalService.get_settings(db)
await ReferralWithdrawalService.set_enabled(db, not current_settings.enabled)
await callback.answer("Изменено")
await show_referral_withdrawals_settings(callback, db_user, db, state)
@admin_required
@error_handler
async def prompt_referral_withdraw_min_amount(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
):
texts = get_texts(db_user.language)
await state.set_state(AdminStates.editing_referral_withdraw_min_amount)
await callback.message.edit_text(
texts.t(
"ADMIN_REFERRAL_WITHDRAWALS_MIN_PROMPT",
"Введите минимальную сумму для вывода (в рублях):",
),
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_referral_withdrawals")]]
),
)
await callback.answer()
@admin_required
@error_handler
async def prompt_referral_withdraw_prompt_text(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
):
texts = get_texts(db_user.language)
await state.set_state(AdminStates.editing_referral_withdraw_prompt_text)
await callback.message.edit_text(
texts.t(
"ADMIN_REFERRAL_WITHDRAWALS_PROMPT_EDIT",
"Отправьте текст, который увидит пользователь при запросе вывода."
"\nДоступные плейсхолдеры: {available}, {min_amount}.",
),
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_referral_withdrawals")]]
),
)
await callback.answer()
@admin_required
@error_handler
async def prompt_referral_withdraw_success_text(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
):
texts = get_texts(db_user.language)
await state.set_state(AdminStates.editing_referral_withdraw_success_text)
await callback.message.edit_text(
texts.t(
"ADMIN_REFERRAL_WITHDRAWALS_SUCCESS_EDIT",
"Отправьте текст подтверждения после создания заявки."
"\nДоступные плейсхолдеры: {amount}, {available}.",
),
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_referral_withdrawals")]]
),
)
await callback.answer()
@admin_required
@error_handler
async def handle_referral_withdraw_min_amount(
message: types.Message,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
texts = get_texts(db_user.language)
amount_kopeks = ReferralWithdrawalService.parse_amount_to_kopeks(message.text or "")
if amount_kopeks is None:
await message.answer(
texts.t(
"ADMIN_REFERRAL_WITHDRAWALS_MIN_INVALID",
"Некорректная сумма. Введите число, например 500 или 750.50",
)
)
return
await ReferralWithdrawalService.set_min_amount(db, amount_kopeks)
await state.set_state(None)
await message.answer(
texts.t("ADMIN_REFERRAL_WITHDRAWALS_MIN_SAVED", "Минимум сохранён."),
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_referral_withdrawals")]]
),
)
@admin_required
@error_handler
async def handle_referral_withdraw_prompt_text(
message: types.Message,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
texts = get_texts(db_user.language)
template = message.text or ""
if not ReferralWithdrawalService.validate_prompt_template(template):
await message.answer(
texts.t(
"ADMIN_REFERRAL_WITHDRAWALS_INVALID_TEMPLATE",
"Используйте только плейсхолдеры {available} и {min_amount}.",
)
)
return
await ReferralWithdrawalService.set_prompt_text(db, template)
await state.set_state(None)
await message.answer(
texts.t("ADMIN_REFERRAL_WITHDRAWALS_PROMPT_SAVED", "Текст сохранён."),
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_referral_withdrawals")]]
),
)
@admin_required
@error_handler
async def handle_referral_withdraw_success_text(
message: types.Message,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
texts = get_texts(db_user.language)
template = message.text or ""
if not ReferralWithdrawalService.validate_success_template(template):
await message.answer(
texts.t(
"ADMIN_REFERRAL_WITHDRAWALS_INVALID_SUCCESS_TEMPLATE",
"Используйте только плейсхолдеры {amount} и {available}.",
)
)
return
await ReferralWithdrawalService.set_success_text(db, template)
await state.set_state(None)
await message.answer(
texts.t("ADMIN_REFERRAL_WITHDRAWALS_SUCCESS_SAVED", "Текст сохранён."),
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_referral_withdrawals")]]
),
)
@admin_required
@error_handler
async def show_referral_withdrawal_requests(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
requests = await ReferralWithdrawalService.list_requests(db, limit=50)
if not requests:
await callback.message.edit_text(
texts.t("ADMIN_REFERRAL_WITHDRAWALS_EMPTY", "Нет заявок на вывод."),
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_referral_withdrawals")]]
),
)
await callback.answer()
return
keyboard = []
for request in requests:
user_display = request.user.full_name if request.user else f"ID{request.user_id}"
button_text = f"#{request.id} | {settings.format_price(request.amount_kopeks)} | {user_display}"
keyboard.append(
[
types.InlineKeyboardButton(
text=button_text,
callback_data=f"admin_referral_withdrawal_{request.id}",
)
]
)
keyboard.append(
[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_referral_withdrawals")]
)
await callback.message.edit_text(
texts.t(
"ADMIN_REFERRAL_WITHDRAWALS_LIST_TITLE",
"📨 Заявки на вывод партнёрки",
),
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
)
await callback.answer()
@admin_required
@error_handler
async def show_referral_withdrawal_request(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
request_id = int(callback.data.split("_")[-1])
request = await ReferralWithdrawalService.get_request(db, request_id)
if not request:
await callback.answer("Заявка не найдена", show_alert=True)
return
user = request.user
user_info = (
f"{user.full_name}\n@{user.username}" if user and user.username else (user.full_name if user else "")
)
escaped_requisites = escape(request.requisites or "")
text = (
f"💸 <b>Заявка #{request.id}</b>\n\n"
f"👤 {user_info}\n"
f"🆔 {user.telegram_id if user else request.user_id}\n"
f"💰 Сумма: {settings.format_price(request.amount_kopeks)}\n"
f"📅 Создана: {request.created_at.strftime('%d.%m.%Y %H:%M')}\n"
f"📌 Статус: {request.status}\n\n"
f"🧾 Реквизиты:\n{escaped_requisites}"
)
if request.status == "closed" and request.closed_at:
text += f"\n\n✅ Закрыта: {request.closed_at.strftime('%d.%m.%Y %H:%M')}"
if request.closed_by:
text += f"\n👮‍♂️ Закрыл: {request.closed_by.full_name}"
keyboard = []
if request.status != "closed":
keyboard.append(
[
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_REFERRAL_WITHDRAWALS_CLOSE", "✅ Закрыть заявку"
),
callback_data=f"admin_referral_withdrawal_close_{request.id}",
)
]
)
keyboard.append(
[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_referral_withdrawal_requests")]
)
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
)
await callback.answer()
@admin_required
@error_handler
async def close_referral_withdrawal_request(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
request_id = int(callback.data.split("_")[-1])
closed = await ReferralWithdrawalService.close_request(db, request_id, db_user.id)
if not closed:
await callback.answer("Не удалось закрыть заявку", show_alert=True)
return
await callback.answer("Заявка закрыта")
await show_referral_withdrawal_request(callback, db_user, db)
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_referral_settings, F.data == "admin_referrals_settings")
dp.callback_query.register(
show_referral_withdrawals_settings,
F.data == "admin_referral_withdrawals",
)
dp.callback_query.register(
toggle_referral_withdrawals, F.data == "admin_referral_withdrawals_toggle"
)
dp.callback_query.register(
prompt_referral_withdraw_min_amount,
F.data == "admin_referral_withdrawals_min",
)
dp.callback_query.register(
prompt_referral_withdraw_prompt_text,
F.data == "admin_referral_withdrawals_prompt",
)
dp.callback_query.register(
prompt_referral_withdraw_success_text,
F.data == "admin_referral_withdrawals_success",
)
dp.callback_query.register(
show_referral_withdrawal_requests,
F.data == "admin_referral_withdrawal_requests",
)
dp.callback_query.register(
close_referral_withdrawal_request,
F.data.startswith("admin_referral_withdrawal_close_"),
)
dp.callback_query.register(
show_referral_withdrawal_request,
F.data.startswith("admin_referral_withdrawal_"),
)
dp.message.register(
handle_referral_withdraw_min_amount,
AdminStates.editing_referral_withdraw_min_amount,
)
dp.message.register(
handle_referral_withdraw_prompt_text,
AdminStates.editing_referral_withdraw_prompt_text,
)
dp.message.register(
handle_referral_withdraw_success_text,
AdminStates.editing_referral_withdraw_success_text,
)

View File

@@ -3,7 +3,6 @@ from pathlib import Path
import qrcode
from aiogram import Dispatcher, F, types
from aiogram.fsm.context import FSMContext
from aiogram.exceptions import TelegramBadRequest
from aiogram.types import FSInputFile
from sqlalchemy.ext.asyncio import AsyncSession
@@ -12,8 +11,6 @@ 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 ReferralWithdrawalService
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,
@@ -28,15 +25,11 @@ logger = logging.getLogger(__name__)
async def show_referral_info(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
db: AsyncSession
):
await state.clear()
texts = get_texts(db_user.language)
summary = await get_user_referral_summary(db, db_user.id)
withdrawal_settings = await ReferralWithdrawalService.get_settings(db)
bot_username = (await callback.bot.get_me()).username
referral_link = f"https://t.me/{bot_username}?start={db_user.referral_code}"
@@ -188,10 +181,7 @@ async def show_referral_info(
await edit_or_answer_photo(
callback,
referral_text,
get_referral_keyboard(
db_user.language,
show_withdrawal_button=withdrawal_settings.enabled,
),
get_referral_keyboard(db_user.language),
)
await callback.answer()
@@ -464,124 +454,6 @@ async def create_invite_message(
await callback.answer()
async def start_referral_withdrawal_request(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
texts = get_texts(db_user.language)
settings_obj = await ReferralWithdrawalService.get_settings(db)
if not settings_obj.enabled:
await callback.answer(
texts.t(
"REFERRAL_WITHDRAWAL_DISABLED",
"Вывод реферального дохода сейчас недоступен.",
),
show_alert=True,
)
return
available = await ReferralWithdrawalService.get_available_amount(db, db_user.id)
if available < settings_obj.min_amount_kopeks:
await callback.answer(
texts.t(
"REFERRAL_WITHDRAWAL_TOO_LOW",
"Минимальная сумма для вывода: {min_amount}. Доступно: {available}.",
).format(
min_amount=texts.format_price(settings_obj.min_amount_kopeks),
available=texts.format_price(available),
),
show_alert=True,
)
return
fallback_prompt = texts.t(
"REFERRAL_WITHDRAWAL_PROMPT",
ReferralWithdrawalService.DEFAULT_PROMPT,
)
prompt_template = settings_obj.prompt_text or fallback_prompt
prompt_text = ReferralWithdrawalService.format_prompt_text(
prompt_template,
{
"available": texts.format_price(available),
"min_amount": texts.format_price(settings_obj.min_amount_kopeks),
},
fallback_prompt,
)
await state.set_state(ReferralWithdrawalStates.waiting_for_requisites)
await state.update_data(referral_withdraw_available=available)
await callback.message.answer(
prompt_text,
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_referrals")]]
),
)
await callback.answer()
async def handle_referral_withdrawal_requisites(
message: types.Message,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
texts = get_texts(db_user.language)
requisites = (message.text or "").strip()
if not requisites:
await message.answer(
texts.t(
"REFERRAL_WITHDRAWAL_ENTER_REQUISITES",
"Пожалуйста, отправьте реквизиты для вывода.",
)
)
return
request = await ReferralWithdrawalService.create_request(
db, db_user.id, requisites
)
await state.clear()
if not request:
settings_obj = await ReferralWithdrawalService.get_settings(db)
await message.answer(
texts.t(
"REFERRAL_WITHDRAWAL_TOO_LOW",
"Минимальная сумма для вывода: {min_amount}. Доступно: {available}.",
).format(
min_amount=texts.format_price(settings_obj.min_amount_kopeks),
available=texts.format_price(
await ReferralWithdrawalService.get_available_amount(db, db_user.id)
),
)
)
return
settings_obj = await ReferralWithdrawalService.get_settings(db)
fallback_success = texts.t(
"REFERRAL_WITHDRAWAL_SUBMITTED",
ReferralWithdrawalService.DEFAULT_SUCCESS,
)
success_template = settings_obj.success_text or fallback_success
await message.answer(
ReferralWithdrawalService.format_success_text(
success_template,
{
"amount": texts.format_price(request.amount_kopeks),
"available": texts.format_price(
await ReferralWithdrawalService.get_available_amount(db, db_user.id)
),
},
fallback_success,
)
)
def register_handlers(dp: Dispatcher):
dp.callback_query.register(
@@ -608,16 +480,6 @@ def register_handlers(dp: Dispatcher):
show_referral_analytics,
F.data == "referral_analytics"
)
dp.callback_query.register(
start_referral_withdrawal_request,
F.data == "referral_withdrawal_request",
)
dp.message.register(
handle_referral_withdrawal_requisites,
ReferralWithdrawalStates.waiting_for_requisites,
)
dp.callback_query.register(
lambda callback, db_user, db: show_detailed_referral_list(

View File

@@ -1329,11 +1329,9 @@ def get_subscription_expiring_keyboard(subscription_id: int, language: str = DEF
]
])
def get_referral_keyboard(
language: str = DEFAULT_LANGUAGE, *, show_withdrawal_button: bool = False
) -> InlineKeyboardMarkup:
def get_referral_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
texts = get_texts(language)
keyboard = [
[
InlineKeyboardButton(
@@ -1353,19 +1351,6 @@ def get_referral_keyboard(
callback_data="referral_list"
)
],
]
if show_withdrawal_button:
keyboard.append([
InlineKeyboardButton(
text=texts.t(
"REFERRAL_WITHDRAWAL_BUTTON", "💸 Запросить вывод"
),
callback_data="referral_withdrawal_request",
)
])
keyboard.extend([
[
InlineKeyboardButton(
text=texts.t("REFERRAL_ANALYTICS_BUTTON", "📊 Аналитика"),
@@ -1375,10 +1360,10 @@ def get_referral_keyboard(
[
InlineKeyboardButton(
text=texts.BACK,
callback_data="back_to_menu"
callback_data="back_to_menu"
)
]
])
]
return InlineKeyboardMarkup(inline_keyboard=keyboard)

View File

@@ -621,25 +621,6 @@
"ADMIN_STATISTICS": "📊 Statistics",
"ADMIN_STATS_BUTTON": "📊 Statistics",
"ADMIN_STATS_REFERRALS": "🤝 Referrals",
"ADMIN_REFERRAL_WITHDRAWALS": "💸 Referral payouts",
"ADMIN_REFERRAL_WITHDRAWALS_TITLE": "💸 <b>Referral payouts</b>",
"ADMIN_REFERRAL_WITHDRAWALS_STATUS": "Status: {status}",
"ADMIN_REFERRAL_WITHDRAWALS_MIN_AMOUNT": "Minimum amount: {amount}",
"ADMIN_REFERRAL_WITHDRAWALS_PENDING": "Pending requests: {count}",
"ADMIN_REFERRAL_WITHDRAWALS_PROMPT_TEXT": "Request text:\n{prompt}",
"ADMIN_REFERRAL_WITHDRAWALS_SUCCESS_TEXT": "Confirmation text:\n{success}",
"ADMIN_REFERRAL_WITHDRAWALS_MIN_PROMPT": "Enter the minimum withdrawal amount:",
"ADMIN_REFERRAL_WITHDRAWALS_MIN_INVALID": "Invalid amount. Send a number like 500 or 750.50",
"ADMIN_REFERRAL_WITHDRAWALS_MIN_SAVED": "Minimum saved.",
"ADMIN_REFERRAL_WITHDRAWALS_PROMPT_SAVED": "Text saved.",
"ADMIN_REFERRAL_WITHDRAWALS_SUCCESS_SAVED": "Text saved.",
"ADMIN_REFERRAL_WITHDRAWALS_INVALID_TEMPLATE": "Only placeholders {available} and {min_amount} are allowed.",
"ADMIN_REFERRAL_WITHDRAWALS_INVALID_SUCCESS_TEMPLATE": "Only placeholders {amount} and {available} are allowed.",
"ADMIN_REFERRAL_WITHDRAWALS_PROMPT_EDIT": "Send the text users will see when requesting a payout.\nAvailable placeholders: {available}, {min_amount}.",
"ADMIN_REFERRAL_WITHDRAWALS_SUCCESS_EDIT": "Send the confirmation text after a request is created.\nAvailable placeholders: {amount}, {available}.",
"ADMIN_REFERRAL_WITHDRAWALS_EMPTY": "No payout requests yet.",
"ADMIN_REFERRAL_WITHDRAWALS_LIST_TITLE": "📨 Referral payout requests",
"ADMIN_REFERRAL_WITHDRAWALS_CLOSE": "✅ Close request",
"ADMIN_STATS_REVENUE": "💰 Revenue",
"ADMIN_STATS_SUBSCRIPTIONS": "📱 Subscriptions",
"ADMIN_STATS_SUMMARY": "📊 Summary",
@@ -1210,12 +1191,6 @@
"REFERRAL_LINK_CAPTION": "🔗 Your referral link:\n{link}",
"REFERRAL_LINK_TITLE": "🔗 <b>Your referral link:</b>",
"REFERRAL_LIST_BUTTON": "👥 Referral list",
"REFERRAL_WITHDRAWAL_BUTTON": "💸 Request payout",
"REFERRAL_WITHDRAWAL_DISABLED": "Referral payouts are currently unavailable.",
"REFERRAL_WITHDRAWAL_TOO_LOW": "Minimum payout: {min_amount}. Available: {available}.",
"REFERRAL_WITHDRAWAL_ENTER_REQUISITES": "Please send payout details.",
"REFERRAL_WITHDRAWAL_SUBMITTED": "✅ Payout request sent! We'll contact you for confirmation. Amount: {amount}.",
"REFERRAL_WITHDRAWAL_PROMPT": "✉️ Share payout details and your contact method.\n\nAvailable to withdraw: {available}. Minimum: {min_amount}.",
"REFERRAL_LIST_EMPTY": "📋 You have no referrals yet.\n\nShare your referral link to start earning!",
"REFERRAL_LIST_HEADER": "👥 <b>Your referrals</b> (page {current}/{total})",
"REFERRAL_LIST_ITEM_ACTIVITY": " 🕐 Activity: {days} days ago",

View File

@@ -621,25 +621,6 @@
"ADMIN_STATISTICS": "📊 Статистика",
"ADMIN_STATS_BUTTON": "📊 Статистика",
"ADMIN_STATS_REFERRALS": "🤝 Партнерка",
"ADMIN_REFERRAL_WITHDRAWALS": "💸 Вывод партнёрки",
"ADMIN_REFERRAL_WITHDRAWALS_TITLE": "💸 <b>Вывод партнёрки</b>",
"ADMIN_REFERRAL_WITHDRAWALS_STATUS": "Статус: {status}",
"ADMIN_REFERRAL_WITHDRAWALS_MIN_AMOUNT": "Минимальная сумма: {amount}",
"ADMIN_REFERRAL_WITHDRAWALS_PENDING": "Заявок в ожидании: {count}",
"ADMIN_REFERRAL_WITHDRAWALS_PROMPT_TEXT": "Текст запроса:\n{prompt}",
"ADMIN_REFERRAL_WITHDRAWALS_SUCCESS_TEXT": "Текст подтверждения:\n{success}",
"ADMIN_REFERRAL_WITHDRAWALS_MIN_PROMPT": "Введите минимальную сумму для вывода (в рублях):",
"ADMIN_REFERRAL_WITHDRAWALS_MIN_INVALID": "Некорректная сумма. Введите число, например 500 или 750.50",
"ADMIN_REFERRAL_WITHDRAWALS_MIN_SAVED": "Минимум сохранён.",
"ADMIN_REFERRAL_WITHDRAWALS_PROMPT_SAVED": "Текст сохранён.",
"ADMIN_REFERRAL_WITHDRAWALS_SUCCESS_SAVED": "Текст сохранён.",
"ADMIN_REFERRAL_WITHDRAWALS_INVALID_TEMPLATE": "Используйте только плейсхолдеры {available} и {min_amount}.",
"ADMIN_REFERRAL_WITHDRAWALS_INVALID_SUCCESS_TEMPLATE": "Используйте только плейсхолдеры {amount} и {available}.",
"ADMIN_REFERRAL_WITHDRAWALS_PROMPT_EDIT": "Отправьте текст, который увидит пользователь при запросе вывода.\nДоступные плейсхолдеры: {available}, {min_amount}.",
"ADMIN_REFERRAL_WITHDRAWALS_SUCCESS_EDIT": "Отправьте текст подтверждения после создания заявки.\nДоступные плейсхолдеры: {amount}, {available}.",
"ADMIN_REFERRAL_WITHDRAWALS_EMPTY": "Нет заявок на вывод.",
"ADMIN_REFERRAL_WITHDRAWALS_LIST_TITLE": "📨 Заявки на вывод партнёрки",
"ADMIN_REFERRAL_WITHDRAWALS_CLOSE": "✅ Закрыть заявку",
"ADMIN_STATS_REVENUE": "💰 Доходы",
"ADMIN_STATS_SUBSCRIPTIONS": "📱 Подписки",
"ADMIN_STATS_SUMMARY": "📊 Общая сводка",
@@ -1221,12 +1202,6 @@
"REFERRAL_LINK_CAPTION": "🔗 Ваша реферальная ссылка:\n{link}",
"REFERRAL_LINK_TITLE": "🔗 <b>Ваша реферальная ссылка:</b>",
"REFERRAL_LIST_BUTTON": "👥 Список рефералов",
"REFERRAL_WITHDRAWAL_BUTTON": "💸 Запросить вывод",
"REFERRAL_WITHDRAWAL_DISABLED": "Вывод реферального дохода сейчас недоступен.",
"REFERRAL_WITHDRAWAL_TOO_LOW": "Минимальная сумма для вывода: {min_amount}. Доступно: {available}.",
"REFERRAL_WITHDRAWAL_ENTER_REQUISITES": "Пожалуйста, отправьте реквизиты для вывода.",
"REFERRAL_WITHDRAWAL_SUBMITTED": "✅ Заявка отправлена! Мы свяжемся с вами для подтверждения. Сумма: {amount}.",
"REFERRAL_WITHDRAWAL_PROMPT": "✉️ Укажите реквизиты для вывода и удобный способ связи.\n\nДоступно к выводу: {available}. Минимум: {min_amount}.",
"REFERRAL_LIST_EMPTY": "📋 У вас пока нет рефералов.\n\nПоделитесь своей реферальной ссылкой, чтобы начать зарабатывать!",
"REFERRAL_LIST_HEADER": "👥 <b>Ваши рефералы</b> (стр. {current}/{total})",
"REFERRAL_LIST_ITEM_ACTIVITY": " 🕐 Активность: {days} дн. назад",

View File

@@ -617,29 +617,10 @@
"ADMIN_SQUAD_MIGRATION_TITLE": "🚚 <b>Переїзд сквадів</b>",
"ADMIN_SQUAD_REMOVE_ALL": "❌ Видалити всіх користувачів",
"ADMIN_SQUAD_RENAME": "✏️ Перейменувати",
"ADMIN_STATISTICS": "📊 Статистика",
"ADMIN_STATS_BUTTON": "📊 Статистика",
"ADMIN_STATS_REFERRALS": "🤝 Партнерка",
"ADMIN_REFERRAL_WITHDRAWALS": "💸 Виведення партнерки",
"ADMIN_REFERRAL_WITHDRAWALS_TITLE": "💸 <b>Виведення партнерки</b>",
"ADMIN_REFERRAL_WITHDRAWALS_STATUS": "Статус: {status}",
"ADMIN_REFERRAL_WITHDRAWALS_MIN_AMOUNT": "Мінімальна сума: {amount}",
"ADMIN_REFERRAL_WITHDRAWALS_PENDING": "Заявок в очікуванні: {count}",
"ADMIN_REFERRAL_WITHDRAWALS_PROMPT_TEXT": "Текст запиту:\n{prompt}",
"ADMIN_REFERRAL_WITHDRAWALS_SUCCESS_TEXT": "Текст підтвердження:\n{success}",
"ADMIN_REFERRAL_WITHDRAWALS_MIN_PROMPT": "Введіть мінімальну суму для виведення (в гривнях):",
"ADMIN_REFERRAL_WITHDRAWALS_MIN_INVALID": "Некоректна сума. Введіть число, наприклад 500 або 750.50",
"ADMIN_REFERRAL_WITHDRAWALS_MIN_SAVED": "Мінімум збережено.",
"ADMIN_REFERRAL_WITHDRAWALS_PROMPT_SAVED": "Текст збережено.",
"ADMIN_REFERRAL_WITHDRAWALS_SUCCESS_SAVED": "Текст збережено.",
"ADMIN_REFERRAL_WITHDRAWALS_INVALID_TEMPLATE": "Використовуйте лише плейсхолдери {available} та {min_amount}.",
"ADMIN_REFERRAL_WITHDRAWALS_INVALID_SUCCESS_TEMPLATE": "Використовуйте лише плейсхолдери {amount} та {available}.",
"ADMIN_REFERRAL_WITHDRAWALS_PROMPT_EDIT": "Надішліть текст, який побачить користувач під час запиту виведення.\nДоступні плейсхолдери: {available}, {min_amount}.",
"ADMIN_REFERRAL_WITHDRAWALS_SUCCESS_EDIT": "Надішліть текст підтвердження після створення заявки.\nДоступні плейсхолдери: {amount}, {available}.",
"ADMIN_REFERRAL_WITHDRAWALS_EMPTY": "Немає заявок на виведення.",
"ADMIN_REFERRAL_WITHDRAWALS_LIST_TITLE": "📨 Заявки на виведення партнерки",
"ADMIN_REFERRAL_WITHDRAWALS_CLOSE": "✅ Закрити заявку",
"ADMIN_STATS_REVENUE": "💰 Доходи",
"ADMIN_STATISTICS": "📊 Статистика",
"ADMIN_STATS_BUTTON": "📊 Статистика",
"ADMIN_STATS_REFERRALS": "🤝 Партнерка",
"ADMIN_STATS_REVENUE": "💰 Доходи",
"ADMIN_STATS_SUBSCRIPTIONS": "📱 Підписки",
"ADMIN_STATS_SUMMARY": "📊 Загальне зведення",
"ADMIN_STATS_USERS": "👥 Користувачі",
@@ -1216,17 +1197,11 @@
"REFERRAL_INVITE_LINK_PROMPT": "👇 Переходь за посиланням:",
"REFERRAL_INVITE_MESSAGE": "\n🎯 <b>Запрошення до VPN сервісу</b>\n\nПривіт! Запрошую тебе у відмінний VPN сервіс!\n\n🎁 За моїм посиланням ти отримаєш бонус: {bonus}\n\n🔗 Переходь: {link}\n🎫 Або використовуй промокод: {code}\n\n💪 Швидко, надійно, недорого!\n",
"REFERRAL_INVITE_TITLE": "🎉 Приєднуйся до VPN сервісу!",
"REFERRAL_LINK_CAPTION": "🔗 Ваше реферальне посилання:\n{link}",
"REFERRAL_LINK_TITLE": "🔗 <b>Ваше реферальне посилання:</b>",
"REFERRAL_LIST_BUTTON": "👥 Список рефералів",
"REFERRAL_WITHDRAWAL_BUTTON": "💸 Запит на виведення",
"REFERRAL_WITHDRAWAL_DISABLED": "Виведення реферального доходу зараз недоступне.",
"REFERRAL_WITHDRAWAL_TOO_LOW": "Мінімальна сума для виведення: {min_amount}. Доступно: {available}.",
"REFERRAL_WITHDRAWAL_ENTER_REQUISITES": "Будь ласка, надішліть реквізити для виведення.",
"REFERRAL_WITHDRAWAL_SUBMITTED": "✅ Заявку на виведення відправлено! Ми зв'яжемося з вами для підтвердження. Сума: {amount}.",
"REFERRAL_WITHDRAWAL_PROMPT": "✉️ Вкажіть реквізити для виведення та зручний спосіб зв'язку.\n\nДоступно до виведення: {available}. Мінімум: {min_amount}.",
"REFERRAL_LIST_EMPTY": "📋 У вас поки немає рефералів.\n\nПоділіться своїм реферальним посиланням, щоб почати заробляти!",
"REFERRAL_LIST_HEADER": "👥 <b>Ваші реферали</b> (стор. {current}/{total})",
"REFERRAL_LINK_CAPTION": "🔗 Ваше реферальне посилання:\n{link}",
"REFERRAL_LINK_TITLE": "🔗 <b>Ваше реферальне посилання:</b>",
"REFERRAL_LIST_BUTTON": "👥 Список рефералів",
"REFERRAL_LIST_EMPTY": "📋 У вас поки немає рефералів.\n\nПоділіться своїм реферальним посиланням, щоб почати заробляти!",
"REFERRAL_LIST_HEADER": "👥 <b>Ваші реферали</b> (стор. {current}/{total})",
"REFERRAL_LIST_ITEM_ACTIVITY": " 🕐 Активність: {days} дн. тому",
"REFERRAL_LIST_ITEM_ACTIVITY_LONG_AGO": " 🕐 Активність: давно",
"REFERRAL_LIST_ITEM_EARNED": " 💎 Зароблено з нього: {amount}",

View File

@@ -1,278 +0,0 @@
from __future__ import annotations
import logging
import string
from dataclasses import dataclass
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.referral_withdrawal import (
close_referral_withdrawal_request,
create_referral_withdrawal_request,
get_referral_withdrawal_request_by_id,
get_referral_withdrawal_requests,
get_total_requested_amount,
)
from app.database.crud.system_setting import upsert_system_setting
from app.database.crud.user import subtract_user_balance
from app.database.models import ReferralWithdrawalRequest, SystemSetting
from app.utils.user_utils import get_user_referral_summary
logger = logging.getLogger(__name__)
@dataclass(slots=True)
class ReferralWithdrawalSettings:
enabled: bool
min_amount_kopeks: int
prompt_text: str
success_text: str
class ReferralWithdrawalService:
ENABLED_KEY = "REFERRAL_WITHDRAWALS_ENABLED"
MIN_AMOUNT_KEY = "REFERRAL_WITHDRAWAL_MIN_AMOUNT_KOPEKS"
PROMPT_TEXT_KEY = "REFERRAL_WITHDRAWAL_PROMPT_TEXT"
SUCCESS_TEXT_KEY = "REFERRAL_WITHDRAWAL_SUCCESS_TEXT"
PROMPT_ALLOWED_FIELDS = {"available", "min_amount"}
SUCCESS_ALLOWED_FIELDS = {"amount", "available"}
DEFAULT_PROMPT = (
"✉️ Укажите реквизиты для вывода и удобный способ связи."
"\n\nДоступно к выводу: {available}. Минимум: {min_amount}."
)
DEFAULT_SUCCESS = (
"✅ Заявка отправлена! Мы свяжемся с вами, когда обработаем выплату."
)
@classmethod
async def get_settings(cls, db: AsyncSession) -> ReferralWithdrawalSettings:
result = await db.execute(
select(SystemSetting.key, SystemSetting.value).where(
SystemSetting.key.in_(
[
cls.ENABLED_KEY,
cls.MIN_AMOUNT_KEY,
cls.PROMPT_TEXT_KEY,
cls.SUCCESS_TEXT_KEY,
]
)
)
)
rows = dict(result.all())
enabled = cls._parse_bool(
rows.get(cls.ENABLED_KEY), settings.REFERRAL_WITHDRAWALS_ENABLED
)
min_amount = cls._parse_int(
rows.get(cls.MIN_AMOUNT_KEY), settings.REFERRAL_WITHDRAWAL_MIN_AMOUNT_KOPEKS
)
prompt_text = rows.get(cls.PROMPT_TEXT_KEY) or settings.REFERRAL_WITHDRAWAL_PROMPT_TEXT
success_text = rows.get(cls.SUCCESS_TEXT_KEY) or settings.REFERRAL_WITHDRAWAL_SUCCESS_TEXT
return ReferralWithdrawalSettings(
enabled=enabled,
min_amount_kopeks=max(min_amount, 0),
prompt_text=prompt_text or cls.DEFAULT_PROMPT,
success_text=success_text or cls.DEFAULT_SUCCESS,
)
@classmethod
async def set_enabled(cls, db: AsyncSession, enabled: bool) -> None:
await upsert_system_setting(db, cls.ENABLED_KEY, "1" if enabled else "0")
await db.commit()
@classmethod
async def set_min_amount(cls, db: AsyncSession, amount_kopeks: int) -> None:
amount = max(int(amount_kopeks), 0)
await upsert_system_setting(db, cls.MIN_AMOUNT_KEY, str(amount))
await db.commit()
@classmethod
async def set_prompt_text(cls, db: AsyncSession, text: str) -> None:
await upsert_system_setting(db, cls.PROMPT_TEXT_KEY, text)
await db.commit()
@classmethod
async def set_success_text(cls, db: AsyncSession, text: str) -> None:
await upsert_system_setting(db, cls.SUCCESS_TEXT_KEY, text)
await db.commit()
@classmethod
def _validate_template_fields(cls, template: str, allowed_fields: set[str]) -> bool:
formatter = string.Formatter()
try:
for _literal, field_name, _format_spec, _conversion in formatter.parse(
template
):
if field_name and field_name not in allowed_fields:
return False
template.format(**{field: "" for field in allowed_fields})
except (KeyError, ValueError):
return False
return True
@classmethod
def validate_prompt_template(cls, template: str) -> bool:
return cls._validate_template_fields(template, cls.PROMPT_ALLOWED_FIELDS)
@classmethod
def validate_success_template(cls, template: str) -> bool:
return cls._validate_template_fields(template, cls.SUCCESS_ALLOWED_FIELDS)
@classmethod
def _safe_format_template(
cls,
template: str,
values: dict[str, str],
allowed_fields: set[str],
fallback_template: str,
) -> str:
if not cls._validate_template_fields(template, allowed_fields):
logger.warning("Неверные плейсхолдеры в шаблоне вывода: %s", template)
template = fallback_template
try:
return template.format(**values)
except Exception:
logger.exception("Ошибка форматирования шаблона вывода: %s", template)
return fallback_template.format(**values)
@classmethod
def format_prompt_text(
cls,
template: str,
values: dict[str, str],
fallback_template: str,
) -> str:
return cls._safe_format_template(
template, values, cls.PROMPT_ALLOWED_FIELDS, fallback_template
)
@classmethod
def format_success_text(
cls,
template: str,
values: dict[str, str],
fallback_template: str,
) -> str:
return cls._safe_format_template(
template, values, cls.SUCCESS_ALLOWED_FIELDS, fallback_template
)
@classmethod
async def get_available_amount(cls, db: AsyncSession, user_id: int) -> int:
summary = await get_user_referral_summary(db, user_id)
total_earned = summary.get("total_earned_kopeks", 0)
already_requested = await get_total_requested_amount(db, user_id)
available = max(total_earned - already_requested, 0)
logger.debug(
"Расчёт доступного реферального дохода: total=%s, requested=%s, available=%s",
total_earned,
already_requested,
available,
)
return available
@classmethod
async def create_request(
cls, db: AsyncSession, user_id: int, requisites: str
) -> Optional[ReferralWithdrawalRequest]:
settings_obj = await cls.get_settings(db)
available = await cls.get_available_amount(db, user_id)
if not settings_obj.enabled:
logger.info("Попытка создать заявку на вывод при отключенной функции")
return None
if available < settings_obj.min_amount_kopeks:
logger.info(
"Недостаточно средств для вывода: user=%s, available=%s, min=%s",
user_id,
available,
settings_obj.min_amount_kopeks,
)
return None
return await create_referral_withdrawal_request(
db=db,
user_id=user_id,
amount_kopeks=available,
requisites=requisites,
)
@classmethod
async def list_requests(
cls, db: AsyncSession, status: Optional[str] = None, limit: int = 50
):
return await get_referral_withdrawal_requests(db, status=status, limit=limit)
@classmethod
async def get_request(
cls, db: AsyncSession, request_id: int
) -> Optional[ReferralWithdrawalRequest]:
return await get_referral_withdrawal_request_by_id(db, request_id)
@classmethod
async def close_request(
cls, db: AsyncSession, request_id: int, closed_by_id: Optional[int]
) -> Optional[ReferralWithdrawalRequest]:
request = await get_referral_withdrawal_request_by_id(db, request_id)
if not request:
return None
if request.status == "closed":
return request
if not request.user:
logger.error(
"Нельзя закрыть заявку %s: пользователь не найден", request_id
)
return None
debited = await subtract_user_balance(
db=db,
user=request.user,
amount_kopeks=request.amount_kopeks,
description="Вывод реферального дохода",
create_transaction=True,
)
if not debited:
logger.warning(
"Не удалось списать баланс для заявки на вывод партнёрки: %s", request_id
)
return None
return await close_referral_withdrawal_request(
db=db, request=request, closed_by_id=closed_by_id
)
@staticmethod
def _parse_bool(value: Optional[str], default: bool) -> bool:
if value is None:
return bool(default)
return str(value).strip().lower() in {"1", "true", "yes", "y"}
@staticmethod
def _parse_int(value: Optional[str], default: int) -> int:
try:
return int(value)
except Exception:
return int(default)
@staticmethod
def parse_amount_to_kopeks(raw: str) -> Optional[int]:
try:
cleaned = raw.replace(" ", "").replace(",", ".")
amount = float(cleaned)
if amount <= 0:
return None
return int(amount * 100)
except Exception:
return None

View File

@@ -35,10 +35,6 @@ class PromoCodeStates(StatesGroup):
waiting_for_code = State()
waiting_for_referral_code = State()
class ReferralWithdrawalStates(StatesGroup):
waiting_for_requisites = State()
class AdminStates(StatesGroup):
waiting_for_user_search = State()
@@ -101,9 +97,6 @@ class AdminStates(StatesGroup):
editing_user_traffic = State()
editing_user_referrals = State()
editing_user_referral_percent = State()
editing_referral_withdraw_min_amount = State()
editing_referral_withdraw_prompt_text = State()
editing_referral_withdraw_success_text = State()
editing_rules_page = State()
editing_privacy_policy = State()