diff --git a/.gitignore b/.gitignore index 9bb24585..9f6d9e30 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,8 @@ docker-compose.override.yml !requirements.txt !docs/ !docs/** +!migrations/ +!migrations/** # Разрешаем папку app/ и все её содержимое рекурсивно !app/ diff --git a/app/database/models.py b/app/database/models.py index 1b7a7123..f8e9f719 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -595,6 +595,7 @@ class User(Base): lifetime_used_traffic_bytes = Column(BigInteger, default=0) auto_promo_group_assigned = Column(Boolean, nullable=False, default=False) auto_promo_group_threshold_kopeks = Column(BigInteger, nullable=False, default=0) + referral_commission_percent = Column(Integer, nullable=True) promo_offer_discount_percent = Column(Integer, nullable=False, default=0) promo_offer_discount_source = Column(String(100), nullable=True) promo_offer_discount_expires_at = Column(DateTime, nullable=True) diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index e0caeb11..6bf30aff 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -2742,6 +2742,35 @@ async def fix_foreign_keys_for_user_deletion(): logger.error(f"Ошибка обновления внешних ключей: {e}") return False +async def add_referral_commission_percent_column() -> bool: + column_exists = await check_column_exists('users', 'referral_commission_percent') + if column_exists: + logger.info("ℹ️ Колонка referral_commission_percent уже существует") + return True + + try: + async with engine.begin() as conn: + db_type = await get_database_type() + + if db_type == 'sqlite': + alter_sql = "ALTER TABLE users ADD COLUMN referral_commission_percent INTEGER NULL" + elif db_type == 'postgresql': + alter_sql = "ALTER TABLE users ADD COLUMN referral_commission_percent INTEGER NULL" + elif db_type == 'mysql': + alter_sql = "ALTER TABLE users ADD COLUMN referral_commission_percent INT NULL" + else: + logger.error(f"Неподдерживаемый тип БД для добавления referral_commission_percent: {db_type}") + return False + + await conn.execute(text(alter_sql)) + logger.info("✅ Добавлена колонка referral_commission_percent в таблицу users") + return True + + except Exception as error: + logger.error(f"Ошибка добавления referral_commission_percent: {error}") + return False + + async def add_referral_system_columns(): logger.info("=== МИГРАЦИЯ РЕФЕРАЛЬНОЙ СИСТЕМЫ ===") @@ -3809,6 +3838,12 @@ async def run_universal_migration(): if not referral_migration_success: logger.warning("⚠️ Проблемы с миграцией реферальной системы") + commission_column_ready = await add_referral_commission_percent_column() + if commission_column_ready: + logger.info("✅ Колонка referral_commission_percent готова") + else: + logger.warning("⚠️ Проблемы с колонкой referral_commission_percent") + logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ SYSTEM_SETTINGS ===") system_settings_ready = await create_system_settings_table() if system_settings_ready: @@ -4223,6 +4258,7 @@ async def check_migration_status(): "users_promo_offer_discount_percent_column": False, "users_promo_offer_discount_source_column": False, "users_promo_offer_discount_expires_column": False, + "users_referral_commission_percent_column": False, "subscription_crypto_link_column": False, "discount_offers_table": False, "discount_offers_effect_column": False, @@ -4265,6 +4301,7 @@ async def check_migration_status(): status["users_promo_offer_discount_percent_column"] = await check_column_exists('users', 'promo_offer_discount_percent') status["users_promo_offer_discount_source_column"] = await check_column_exists('users', 'promo_offer_discount_source') status["users_promo_offer_discount_expires_column"] = await check_column_exists('users', 'promo_offer_discount_expires_at') + status["users_referral_commission_percent_column"] = await check_column_exists('users', 'referral_commission_percent') status["subscription_crypto_link_column"] = await check_column_exists('subscriptions', 'subscription_crypto_link') media_fields_exist = ( @@ -4312,6 +4349,7 @@ async def check_migration_status(): "users_promo_offer_discount_percent_column": "Колонка процента промо-скидки у пользователей", "users_promo_offer_discount_source_column": "Колонка источника промо-скидки у пользователей", "users_promo_offer_discount_expires_column": "Колонка срока действия промо-скидки у пользователей", + "users_referral_commission_percent_column": "Колонка процента реферальной комиссии у пользователей", "subscription_crypto_link_column": "Колонка subscription_crypto_link в subscriptions", "discount_offers_table": "Таблица discount_offers", "discount_offers_effect_column": "Колонка effect_type в discount_offers", diff --git a/app/handlers/admin/users.py b/app/handlers/admin/users.py index b0ddd157..d25499e6 100644 --- a/app/handlers/admin/users.py +++ b/app/handlers/admin/users.py @@ -32,6 +32,7 @@ from app.services.admin_notification_service import AdminNotificationService from app.database.crud.promo_group import get_promo_groups_with_counts from app.utils.decorators import admin_required, error_handler from app.utils.formatters import format_datetime, format_time_ago +from app.utils.user_utils import get_effective_referral_commission_percent from app.services.remnawave_service import RemnaWaveService from app.external.remnawave_api import TrafficLimitStrategy from app.database.crud.server_squad import ( @@ -1536,6 +1537,9 @@ async def _build_user_referrals_view( referrals = await get_referrals(db, user_id) + effective_percent = get_effective_referral_commission_percent(user) + default_percent = settings.REFERRAL_COMMISSION_PERCENT + header = texts.t( "ADMIN_USER_REFERRALS_TITLE", "🤝 Рефералы пользователя", @@ -1551,6 +1555,24 @@ async def _build_user_referrals_view( lines: List[str] = [header, summary] + if user.referral_commission_percent is None: + lines.append( + texts.t( + "ADMIN_USER_REFERRAL_COMMISSION_DEFAULT", + "• Процент комиссии: {percent}% (стандартное значение)", + ).format(percent=effective_percent) + ) + else: + lines.append( + texts.t( + "ADMIN_USER_REFERRAL_COMMISSION_CUSTOM", + "• Индивидуальный процент: {percent}% (стандарт: {default_percent}%)", + ).format( + percent=user.referral_commission_percent, + default_percent=default_percent, + ) + ) + if referrals: lines.append( texts.t( @@ -1604,6 +1626,15 @@ async def _build_user_referrals_view( keyboard = InlineKeyboardMarkup( inline_keyboard=[ + [ + InlineKeyboardButton( + text=texts.t( + "ADMIN_USER_REFERRAL_COMMISSION_EDIT_BUTTON", + "📈 Изменить процент", + ), + callback_data=f"admin_user_referral_percent_{user_id}", + ) + ], [ InlineKeyboardButton( text=texts.t( @@ -1636,12 +1667,12 @@ async def show_user_referrals( user_id = int(callback.data.split('_')[-1]) current_state = await state.get_state() - if current_state == AdminStates.editing_user_referrals: + if current_state in {AdminStates.editing_user_referrals, AdminStates.editing_user_referral_percent}: data = await state.get_data() preserved_data = { key: value for key, value in data.items() - if key not in {"editing_referrals_user_id", "referrals_message_id"} + if key not in {"editing_referrals_user_id", "referrals_message_id", "editing_referral_percent_user_id"} } await state.clear() if preserved_data: @@ -1661,6 +1692,256 @@ async def show_user_referrals( await callback.answer() +@admin_required +@error_handler +async def start_edit_referral_percent( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + user_id = int(callback.data.split('_')[-1]) + + user = await get_user_by_id(db, user_id) + if not user: + await callback.answer("❌ Пользователь не найден", show_alert=True) + return + + texts = get_texts(db_user.language) + + effective_percent = get_effective_referral_commission_percent(user) + default_percent = settings.REFERRAL_COMMISSION_PERCENT + + prompt = texts.t( + "ADMIN_USER_REFERRAL_COMMISSION_PROMPT", + ( + "📈 Индивидуальный процент реферальной комиссии\n\n" + "Текущее значение: {current}%\n" + "Стандартное значение: {default}%\n\n" + "Отправьте новое значение от 0 до 100 или слово 'стандарт' для сброса." + ), + ).format(current=effective_percent, default=default_percent) + + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text="5%", + callback_data=f"admin_user_referral_percent_set_{user_id}_5", + ), + InlineKeyboardButton( + text="10%", + callback_data=f"admin_user_referral_percent_set_{user_id}_10", + ), + ], + [ + InlineKeyboardButton( + text="15%", + callback_data=f"admin_user_referral_percent_set_{user_id}_15", + ), + InlineKeyboardButton( + text="20%", + callback_data=f"admin_user_referral_percent_set_{user_id}_20", + ), + ], + [ + InlineKeyboardButton( + text=texts.t( + "ADMIN_USER_REFERRAL_COMMISSION_RESET_BUTTON", + "♻️ Сбросить на стандартный", + ), + callback_data=f"admin_user_referral_percent_reset_{user_id}", + ) + ], + [ + InlineKeyboardButton( + text=texts.BACK, + callback_data=f"admin_user_referrals_{user_id}", + ) + ], + ] + ) + + await state.update_data(editing_referral_percent_user_id=user_id) + await state.set_state(AdminStates.editing_user_referral_percent) + + await callback.message.edit_text( + prompt, + reply_markup=keyboard, + ) + await callback.answer() + + +async def _update_referral_commission_percent( + db: AsyncSession, + user_id: int, + percent: Optional[int], + admin_id: int, +) -> Tuple[bool, Optional[int]]: + try: + user = await get_user_by_id(db, user_id) + if not user: + return False, None + + user.referral_commission_percent = percent + user.updated_at = datetime.utcnow() + + await db.commit() + + effective = get_effective_referral_commission_percent(user) + + logger.info( + "Админ %s обновил реферальный процент пользователя %s: %s", + admin_id, + user_id, + percent, + ) + + return True, effective + except Exception as e: + logger.error( + "Ошибка обновления реферального процента пользователя %s: %s", + user_id, + e, + ) + try: + await db.rollback() + except Exception as rollback_error: + logger.error("Ошибка отката транзакции: %s", rollback_error) + return False, None + + +async def _render_referrals_after_update( + callback: types.CallbackQuery, + db: AsyncSession, + db_user: User, + user_id: int, + success_message: str, +): + view = await _build_user_referrals_view(db, db_user.language, user_id) + if view: + text, keyboard = view + text = f"{success_message}\n\n" + text + await callback.message.edit_text(text, reply_markup=keyboard) + else: + await callback.message.edit_text(success_message) + + +@admin_required +@error_handler +async def set_referral_percent_button( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + parts = callback.data.split('_') + + if "reset" in parts: + user_id = int(parts[-1]) + percent_value: Optional[int] = None + else: + user_id = int(parts[-2]) + percent_value = int(parts[-1]) + + texts = get_texts(db_user.language) + + success, effective_percent = await _update_referral_commission_percent( + db, + user_id, + percent_value, + db_user.id, + ) + + if not success: + await callback.answer("❌ Не удалось обновить процент", show_alert=True) + return + + await state.clear() + + success_message = texts.t( + "ADMIN_USER_REFERRAL_COMMISSION_UPDATED", + "✅ Процент обновлён: {percent}%", + ).format(percent=effective_percent) + + await _render_referrals_after_update(callback, db, db_user, user_id, success_message) + await callback.answer() + + +@admin_required +@error_handler +async def process_referral_percent_input( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession, +): + data = await state.get_data() + user_id = data.get("editing_referral_percent_user_id") + + if not user_id: + await message.answer("❌ Не удалось определить пользователя") + return + + raw_text = message.text.strip() + normalized = raw_text.lower() + + percent_value: Optional[int] + + if normalized in {"стандарт", "standard", "default"}: + percent_value = None + else: + normalized_number = raw_text.replace(',', '.').strip() + try: + percent_float = float(normalized_number) + except (TypeError, ValueError): + await message.answer( + get_texts(db_user.language).t( + "ADMIN_USER_REFERRAL_COMMISSION_INVALID", + "❌ Введите число от 0 до 100 или слово 'стандарт'", + ) + ) + return + + percent_value = int(round(percent_float)) + + if percent_value < 0 or percent_value > 100: + await message.answer( + get_texts(db_user.language).t( + "ADMIN_USER_REFERRAL_COMMISSION_INVALID", + "❌ Введите число от 0 до 100 или слово 'стандарт'", + ) + ) + return + + texts = get_texts(db_user.language) + + success, effective_percent = await _update_referral_commission_percent( + db, + int(user_id), + percent_value, + db_user.id, + ) + + if not success: + await message.answer("❌ Не удалось обновить процент") + return + + await state.clear() + + success_message = texts.t( + "ADMIN_USER_REFERRAL_COMMISSION_UPDATED", + "✅ Процент обновлён: {percent}%", + ).format(percent=effective_percent) + + view = await _build_user_referrals_view(db, db_user.language, int(user_id)) + if view: + text, keyboard = view + await message.answer(f"{success_message}\n\n{text}", reply_markup=keyboard) + else: + await message.answer(success_message) + + @admin_required @error_handler async def start_edit_user_referrals( @@ -4621,6 +4902,24 @@ def register_handlers(dp: Dispatcher): F.data.startswith("admin_user_referrals_") & ~F.data.contains("_edit") ) + dp.callback_query.register( + start_edit_referral_percent, + F.data.startswith("admin_user_referral_percent_") + & ~F.data.contains("_set_") + & ~F.data.contains("_reset") + ) + + dp.callback_query.register( + set_referral_percent_button, + F.data.startswith("admin_user_referral_percent_set_") + | F.data.startswith("admin_user_referral_percent_reset_") + ) + + dp.message.register( + process_referral_percent_input, + AdminStates.editing_user_referral_percent, + ) + dp.callback_query.register( start_edit_user_referrals, F.data.startswith("admin_user_referrals_edit_") diff --git a/app/handlers/referral.py b/app/handlers/referral.py index fe3322a8..e2351719 100644 --- a/app/handlers/referral.py +++ b/app/handlers/referral.py @@ -14,6 +14,7 @@ from app.localization.texts import get_texts from app.utils.photo_message import edit_or_answer_photo from app.utils.user_utils import ( get_detailed_referral_list, + get_effective_referral_commission_percent, get_referral_analytics, get_user_referral_summary, ) @@ -86,7 +87,7 @@ async def show_referral_info( + texts.t( "REFERRAL_REWARD_COMMISSION", "• Комиссия с каждого пополнения реферала: {percent}%", - ).format(percent=settings.REFERRAL_COMMISSION_PERCENT) + ).format(percent=get_effective_referral_commission_percent(db_user)) + "\n\n" + texts.t("REFERRAL_LINK_TITLE", "🔗 Ваша реферальная ссылка:") + f"\n{referral_link}\n\n" diff --git a/app/handlers/simple_subscription.py b/app/handlers/simple_subscription.py index 176d6521..1d9cccab 100644 --- a/app/handlers/simple_subscription.py +++ b/app/handlers/simple_subscription.py @@ -134,8 +134,11 @@ async def start_simple_subscription_purchase( if show_devices: message_lines.append(f"📱 Устройства: {subscription_params['device_limit']}") + traffic_limit_gb = subscription_params["traffic_limit_gb"] + traffic_label = "Безлимит" if traffic_limit_gb == 0 else f"{traffic_limit_gb} ГБ" + message_lines.extend([ - f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}", + f"📊 Трафик: {traffic_label}", f"🌍 Сервер: {server_label}", "", f"💰 Стоимость: {settings.format_price(price_kopeks)}", @@ -523,8 +526,11 @@ async def handle_simple_subscription_pay_with_balance( if show_devices: success_lines.append(f"📱 Устройства: {subscription_params['device_limit']}") + success_traffic_gb = subscription_params["traffic_limit_gb"] + success_traffic_label = "Безлимит" if success_traffic_gb == 0 else f"{success_traffic_gb} ГБ" + success_lines.extend([ - f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}", + f"📊 Трафик: {success_traffic_label}", f"🌍 Сервер: {server_label}", "", f"💰 Списано с баланса: {settings.format_price(price_kopeks)}", @@ -721,8 +727,11 @@ async def handle_simple_subscription_other_payment_methods( if show_devices: message_lines.append(f"📱 Устройства: {subscription_params['device_limit']}") + payment_traffic_gb = subscription_params["traffic_limit_gb"] + payment_traffic_label = "Безлимит" if payment_traffic_gb == 0 else f"{payment_traffic_gb} ГБ" + message_lines.extend([ - f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}", + f"📊 Трафик: {payment_traffic_label}", f"🌍 Сервер: {server_label}", "", f"💰 Стоимость: {settings.format_price(price_kopeks)}", @@ -826,6 +835,9 @@ async def handle_simple_subscription_payment_method( stars_count = settings.rubles_to_stars(settings.kopeks_to_rubles(price_kopeks)) + stars_traffic_gb = subscription_params["traffic_limit_gb"] + stars_traffic_label = "Безлимит" if stars_traffic_gb == 0 else f"{stars_traffic_gb} ГБ" + await callback.bot.send_invoice( chat_id=callback.from_user.id, title=f"Подписка на {subscription_params['period_days']} дней", @@ -833,7 +845,7 @@ async def handle_simple_subscription_payment_method( f"Простая покупка подписки\n" f"Период: {subscription_params['period_days']} дней\n" f"Устройства: {subscription_params['device_limit']}\n" - f"Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}" + f"Трафик: {stars_traffic_label}" ), payload=( f"simple_sub_{db_user.id}_{order.id}_{subscription_params['period_days']}" @@ -977,8 +989,11 @@ async def handle_simple_subscription_payment_method( if show_devices: message_lines.append(f"📱 Устройства: {subscription_params['device_limit']}") + yookassa_traffic_gb = subscription_params["traffic_limit_gb"] + yookassa_traffic_label = "Безлимит" if yookassa_traffic_gb == 0 else f"{yookassa_traffic_gb} ГБ" + message_lines.extend([ - f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}", + f"📊 Трафик: {yookassa_traffic_label}", f"💰 Сумма: {settings.format_price(price_kopeks)}", f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...", "", @@ -2220,8 +2235,11 @@ async def confirm_simple_subscription_purchase( if show_devices: success_lines.append(f"📱 Устройства: {subscription_params['device_limit']}") + success_traffic_gb = subscription_params["traffic_limit_gb"] + success_traffic_label = "Безлимит" if success_traffic_gb == 0 else f"{success_traffic_gb} ГБ" + success_lines.extend([ - f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}", + f"📊 Трафик: {success_traffic_label}", f"🌍 Сервер: {server_label}", "", f"💰 Списано с баланса: {settings.format_price(price_kopeks)}", diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index b0d6f203..34067554 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -731,6 +731,22 @@ "ADMIN_USER_PROMO_GROUP_ALREADY": "ℹ️ The user is already in this promo group.", "ADMIN_USER_PROMO_GROUP_BACK": "⬅️ Back to user", "ADMIN_USER_PROMO_GROUP_BUTTON": "👥 Promo group", + "ADMIN_USER_REFERRALS_BUTTON": "🤝 Referrals", + "ADMIN_USER_REFERRALS_TITLE": "🤝 User referrals", + "ADMIN_USER_REFERRALS_SUMMARY": "👤 {name} (ID: {telegram_id})\n👥 Total referrals: {count}", + "ADMIN_USER_REFERRAL_COMMISSION_DEFAULT": "• Commission percent: {percent}% (default)", + "ADMIN_USER_REFERRAL_COMMISSION_CUSTOM": "• Custom percent: {percent}% (default: {default_percent}%)", + "ADMIN_USER_REFERRAL_COMMISSION_EDIT_BUTTON": "📈 Change percent", + "ADMIN_USER_REFERRAL_COMMISSION_PROMPT": "📈 Custom referral commission\n\nCurrent value: {current}%\nDefault value: {default}%\n\nSend a value from 0 to 100 or the word 'standard' to reset.", + "ADMIN_USER_REFERRAL_COMMISSION_RESET_BUTTON": "♻️ Reset to default", + "ADMIN_USER_REFERRAL_COMMISSION_UPDATED": "✅ Percent updated: {percent}%", + "ADMIN_USER_REFERRAL_COMMISSION_INVALID": "❌ Enter a number from 0 to 100 or the word 'standard'", + "ADMIN_USER_REFERRALS_LIST_HEADER": "List of referrals:", + "ADMIN_USER_REFERRALS_LIST_ITEM": "• {name} (ID: {telegram_id}{username_part})", + "ADMIN_USER_REFERRALS_LIST_TRUNCATED": "• … and {count} more referrals", + "ADMIN_USER_REFERRALS_EMPTY": "No referrals yet.", + "ADMIN_USER_REFERRALS_EDIT_HINT": "✏️ To change the list, tap “✏️ Edit” below.", + "ADMIN_USER_REFERRALS_EDIT_BUTTON": "✏️ Edit", "ADMIN_USER_PROMO_GROUP_CURRENT": "Current group: {name}", "ADMIN_USER_PROMO_GROUP_CURRENT_NONE": "Current group: not assigned", "ADMIN_USER_PROMO_GROUP_DISCOUNTS": "Discounts — servers: {servers}%, traffic: {traffic}%, devices: {devices}%", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index 6af92523..3519200a 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -734,6 +734,13 @@ "ADMIN_USER_REFERRALS_BUTTON": "🤝 Рефералы", "ADMIN_USER_REFERRALS_TITLE": "🤝 Рефералы пользователя", "ADMIN_USER_REFERRALS_SUMMARY": "👤 {name} (ID: {telegram_id})\n👥 Всего рефералов: {count}", + "ADMIN_USER_REFERRAL_COMMISSION_DEFAULT": "• Процент комиссии: {percent}% (стандартное значение)", + "ADMIN_USER_REFERRAL_COMMISSION_CUSTOM": "• Индивидуальный процент: {percent}% (стандарт: {default_percent}%)", + "ADMIN_USER_REFERRAL_COMMISSION_EDIT_BUTTON": "📈 Изменить процент", + "ADMIN_USER_REFERRAL_COMMISSION_PROMPT": "📈 Индивидуальный процент реферальной комиссии\n\nТекущее значение: {current}%\nСтандартное значение: {default}%\n\nОтправьте новое значение от 0 до 100 или слово 'стандарт' для сброса.", + "ADMIN_USER_REFERRAL_COMMISSION_RESET_BUTTON": "♻️ Сбросить на стандартный", + "ADMIN_USER_REFERRAL_COMMISSION_UPDATED": "✅ Процент обновлён: {percent}%", + "ADMIN_USER_REFERRAL_COMMISSION_INVALID": "❌ Введите число от 0 до 100 или слово 'стандарт'", "ADMIN_USER_REFERRALS_LIST_HEADER": "Список рефералов:", "ADMIN_USER_REFERRALS_LIST_ITEM": "• {name} (ID: {telegram_id}{username_part})", "ADMIN_USER_REFERRALS_LIST_TRUNCATED": "• … и ещё {count} рефералов", diff --git a/app/localization/locales/ua.json b/app/localization/locales/ua.json index 68897290..f0b24625 100644 --- a/app/localization/locales/ua.json +++ b/app/localization/locales/ua.json @@ -730,12 +730,19 @@ "ADMIN_USER_PROMO_GROUP_ALREADY": "ℹ️ Користувач вже перебуває у цій промогрупі.", "ADMIN_USER_PROMO_GROUP_BACK": "⬅️ До користувача", "ADMIN_USER_PROMO_GROUP_BUTTON": "👥 Промогрупа", - "ADMIN_USER_REFERRALS_BUTTON": "🤝 Реферали", - "ADMIN_USER_REFERRALS_TITLE": "🤝 Реферали користувача", - "ADMIN_USER_REFERRALS_SUMMARY": "👤 {name} (ID: {telegram_id})\n👥 Всього рефералів: {count}", - "ADMIN_USER_REFERRALS_LIST_HEADER": "Список рефералів:", - "ADMIN_USER_REFERRALS_LIST_ITEM": "• {name} (ID: {telegram_id}{username_part})", - "ADMIN_USER_REFERRALS_LIST_TRUNCATED": "• … і ще {count} рефералів", + "ADMIN_USER_REFERRALS_BUTTON": "🤝 Реферали", + "ADMIN_USER_REFERRALS_TITLE": "🤝 Реферали користувача", + "ADMIN_USER_REFERRALS_SUMMARY": "👤 {name} (ID: {telegram_id})\n👥 Всього рефералів: {count}", + "ADMIN_USER_REFERRAL_COMMISSION_DEFAULT": "• Відсоток комісії: {percent}% (стандартне значення)", + "ADMIN_USER_REFERRAL_COMMISSION_CUSTOM": "• Індивідуальний відсоток: {percent}% (стандарт: {default_percent}%)", + "ADMIN_USER_REFERRAL_COMMISSION_EDIT_BUTTON": "📈 Змінити відсоток", + "ADMIN_USER_REFERRAL_COMMISSION_PROMPT": "📈 Індивідуальний відсоток реферальної комісії\n\nПоточне значення: {current}%\nСтандартне значення: {default}%\n\nНадішліть нове значення від 0 до 100 або слово 'стандарт' для скидання.", + "ADMIN_USER_REFERRAL_COMMISSION_RESET_BUTTON": "♻️ Скинути на стандартний", + "ADMIN_USER_REFERRAL_COMMISSION_UPDATED": "✅ Відсоток оновлено: {percent}%", + "ADMIN_USER_REFERRAL_COMMISSION_INVALID": "❌ Введіть число від 0 до 100 або слово 'стандарт'", + "ADMIN_USER_REFERRALS_LIST_HEADER": "Список рефералів:", + "ADMIN_USER_REFERRALS_LIST_ITEM": "• {name} (ID: {telegram_id}{username_part})", + "ADMIN_USER_REFERRALS_LIST_TRUNCATED": "• … і ще {count} рефералів", "ADMIN_USER_REFERRALS_EMPTY": "Рефералів поки немає.", "ADMIN_USER_REFERRALS_EDIT_HINT": "✏️ Щоб змінити список, натисніть «✏️ Редагувати» нижче.", "ADMIN_USER_REFERRALS_EDIT_BUTTON": "✏️ Редагувати", diff --git a/app/localization/locales/zh.json b/app/localization/locales/zh.json index 3c0b906f..d6c32b4e 100644 --- a/app/localization/locales/zh.json +++ b/app/localization/locales/zh.json @@ -733,6 +733,13 @@ "ADMIN_USER_REFERRALS_BUTTON":"🤝推荐", "ADMIN_USER_REFERRALS_TITLE":"🤝用户推荐", "ADMIN_USER_REFERRALS_SUMMARY":"👤{name}(ID:{telegram_id})\n👥总推荐数:{count}", +"ADMIN_USER_REFERRAL_COMMISSION_DEFAULT":"• 佣金比例:{percent}%(默认值)", +"ADMIN_USER_REFERRAL_COMMISSION_CUSTOM":"• 自定义比例:{percent}%(默认:{default_percent}%)", +"ADMIN_USER_REFERRAL_COMMISSION_EDIT_BUTTON":"📈 修改比例", +"ADMIN_USER_REFERRAL_COMMISSION_PROMPT":"📈 自定义推荐佣金比例\n\n当前值:{current}%\n默认值:{default}%\n\n发送0到100之间的数值或输入“标准”以恢复默认。", +"ADMIN_USER_REFERRAL_COMMISSION_RESET_BUTTON":"♻️ 恢复默认", +"ADMIN_USER_REFERRAL_COMMISSION_UPDATED":"✅ 比例已更新:{percent}%", +"ADMIN_USER_REFERRAL_COMMISSION_INVALID":"❌ 请输入0到100之间的数字或“标准”", "ADMIN_USER_REFERRALS_LIST_HEADER":"推荐列表:", "ADMIN_USER_REFERRALS_LIST_ITEM":"•{name}(ID:{telegram_id}{username_part})", "ADMIN_USER_REFERRALS_LIST_TRUNCATED":"•…以及其他{count}个推荐", diff --git a/app/services/referral_service.py b/app/services/referral_service.py index 2302cf45..5324a775 100644 --- a/app/services/referral_service.py +++ b/app/services/referral_service.py @@ -7,6 +7,7 @@ from app.config import settings from app.database.crud.user import add_user_balance, get_user_by_id from app.database.crud.referral import create_referral_earning from app.database.models import TransactionType, ReferralEarning +from app.utils.user_utils import get_effective_referral_commission_percent logger = logging.getLogger(__name__) @@ -48,8 +49,9 @@ async def process_referral_registration( amount_kopeks=0, reason="referral_registration_pending" ) - + if bot: + commission_percent = get_effective_referral_commission_percent(referrer) referral_notification = ( f"🎉 Добро пожаловать!\n\n" f"Вы перешли по реферальной ссылке пользователя {referrer.full_name}!\n\n" @@ -64,8 +66,8 @@ async def process_referral_registration( f"По вашей ссылке зарегистрировался пользователь {new_user.full_name}!\n\n" f"💰 Когда он пополнит баланс от {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}, " f"вы получите минимум {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)} или " - f"{settings.REFERRAL_COMMISSION_PERCENT}% от суммы (что больше).\n\n" - f"📈 С каждого последующего пополнения вы будете получать {settings.REFERRAL_COMMISSION_PERCENT}% комиссии." + f"{commission_percent}% от суммы (что больше).\n\n" + f"📈 С каждого последующего пополнения вы будете получать {commission_percent}% комиссии." ) await send_referral_notification(bot, referrer.telegram_id, inviter_notification) @@ -94,13 +96,14 @@ async def process_referral_topup( logger.error(f"Реферер {user.referred_by_id} не найден") return False + commission_percent = get_effective_referral_commission_percent(referrer) qualifies_for_first_bonus = ( topup_amount_kopeks >= settings.REFERRAL_MINIMUM_TOPUP_KOPEKS ) commission_amount = 0 - if settings.REFERRAL_COMMISSION_PERCENT > 0: + if commission_percent > 0: commission_amount = int( - topup_amount_kopeks * settings.REFERRAL_COMMISSION_PERCENT / 100 + topup_amount_kopeks * commission_percent / 100 ) if not user.has_made_first_topup: @@ -116,7 +119,7 @@ async def process_referral_topup( db, referrer, commission_amount, - f"Комиссия {settings.REFERRAL_COMMISSION_PERCENT}% с пополнения {user.full_name}", + f"Комиссия {commission_percent}% с пополнения {user.full_name}", bot=bot, ) @@ -139,7 +142,7 @@ async def process_referral_topup( f"💰 Реферальная комиссия!\n\n" f"Ваш реферал {user.full_name} пополнил баланс на " f"{settings.format_price(topup_amount_kopeks)}\n\n" - f"🎁 Ваша комиссия ({settings.REFERRAL_COMMISSION_PERCENT}%): " + f"🎁 Ваша комиссия ({commission_percent}%): " f"{settings.format_price(commission_amount)}\n\n" f"💎 Средства зачислены на ваш баланс." ) @@ -180,7 +183,7 @@ async def process_referral_topup( ) await send_referral_notification(bot, user.telegram_id, bonus_notification) - commission_amount = int(topup_amount_kopeks * settings.REFERRAL_COMMISSION_PERCENT / 100) + commission_amount = int(topup_amount_kopeks * commission_percent / 100) inviter_bonus = max(settings.REFERRAL_INVITER_BONUS_KOPEKS, commission_amount) if inviter_bonus > 0: @@ -204,7 +207,7 @@ async def process_referral_topup( f"💰 Реферальная награда!\n\n" f"Ваш реферал {user.full_name} сделал первое пополнение!\n\n" f"🎁 Вы получили награду: {settings.format_price(inviter_bonus)}\n\n" - f"📈 Теперь с каждого его пополнения вы будете получать {settings.REFERRAL_COMMISSION_PERCENT}% комиссии." + f"📈 Теперь с каждого его пополнения вы будете получать {commission_percent}% комиссии." ) await send_referral_notification(bot, referrer.telegram_id, inviter_bonus_notification) @@ -212,7 +215,7 @@ async def process_referral_topup( if commission_amount > 0: await add_user_balance( db, referrer, commission_amount, - f"Комиссия {settings.REFERRAL_COMMISSION_PERCENT}% с пополнения {user.full_name}", + f"Комиссия {commission_percent}% с пополнения {user.full_name}", bot=bot ) @@ -231,7 +234,7 @@ async def process_referral_topup( f"💰 Реферальная комиссия!\n\n" f"Ваш реферал {user.full_name} пополнил баланс на " f"{settings.format_price(topup_amount_kopeks)}\n\n" - f"🎁 Ваша комиссия ({settings.REFERRAL_COMMISSION_PERCENT}%): " + f"🎁 Ваша комиссия ({commission_percent}%): " f"{settings.format_price(commission_amount)}\n\n" f"💎 Средства зачислены на ваш баланс." ) @@ -261,11 +264,7 @@ async def process_referral_purchase( logger.error(f"Реферер {user.referred_by_id} не найден") return False - if not (0 <= settings.REFERRAL_COMMISSION_PERCENT <= 100): - logger.error(f"❌ КРИТИЧЕСКАЯ ОШИБКА: REFERRAL_COMMISSION_PERCENT = {settings.REFERRAL_COMMISSION_PERCENT} некорректный!") - commission_percent = 10 - else: - commission_percent = settings.REFERRAL_COMMISSION_PERCENT + commission_percent = get_effective_referral_commission_percent(referrer) commission_amount = int(purchase_amount_kopeks * commission_percent / 100) diff --git a/app/states.py b/app/states.py index 83295d68..897297d3 100644 --- a/app/states.py +++ b/app/states.py @@ -96,6 +96,7 @@ class AdminStates(StatesGroup): editing_user_devices = State() editing_user_traffic = State() editing_user_referrals = State() + editing_user_referral_percent = State() editing_rules_page = State() editing_privacy_policy = State() diff --git a/app/utils/user_utils.py b/app/utils/user_utils.py index 95f37de2..f34d0708 100644 --- a/app/utils/user_utils.py +++ b/app/utils/user_utils.py @@ -1,12 +1,14 @@ import logging import secrets import string +import logging from datetime import datetime, timedelta from typing import Optional, Dict, List from sqlalchemy import select, func, and_, update from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload +from app.config import settings from app.database.models import User, ReferralEarning, Transaction, TransactionType logger = logging.getLogger(__name__) @@ -58,6 +60,25 @@ async def generate_unique_referral_code(db: AsyncSession, telegram_id: int) -> s return f"ref{timestamp}" +def get_effective_referral_commission_percent(user: User) -> int: + """Возвращает индивидуальный процент комиссии пользователя или дефолтное значение.""" + + percent = getattr(user, "referral_commission_percent", None) + + if percent is None: + percent = settings.REFERRAL_COMMISSION_PERCENT + + if percent < 0 or percent > 100: + logger.error( + "❌ Некорректный процент комиссии для пользователя %s: %s", + getattr(user, "telegram_id", None), + percent, + ) + return max(0, min(100, settings.REFERRAL_COMMISSION_PERCENT)) + + return percent + + async def mark_user_as_had_paid_subscription(db: AsyncSession, user: User) -> bool: try: if user.has_had_paid_subscription: diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index 64f4c43c..85724dc5 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -96,6 +96,7 @@ from app.utils.telegram_webapp import ( parse_webapp_init_data, ) from app.utils.user_utils import ( + get_effective_referral_commission_percent, get_detailed_referral_list, get_user_referral_summary, ) @@ -2447,7 +2448,12 @@ async def _build_referral_info( minimum_topup_kopeks = int(referral_settings.get("minimum_topup_kopeks") or 0) first_topup_bonus_kopeks = int(referral_settings.get("first_topup_bonus_kopeks") or 0) inviter_bonus_kopeks = int(referral_settings.get("inviter_bonus_kopeks") or 0) - commission_percent = float(referral_settings.get("commission_percent") or 0) + commission_percent = float( + get_effective_referral_commission_percent(user) + if user + else referral_settings.get("commission_percent") + or 0 + ) terms = MiniAppReferralTerms( minimum_topup_kopeks=minimum_topup_kopeks, diff --git a/migrations/alembic/versions/e3c1e0b5b4a7_add_referral_commission_percent_to_users.py b/migrations/alembic/versions/e3c1e0b5b4a7_add_referral_commission_percent_to_users.py new file mode 100644 index 00000000..a3708b2d --- /dev/null +++ b/migrations/alembic/versions/e3c1e0b5b4a7_add_referral_commission_percent_to_users.py @@ -0,0 +1,19 @@ +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "e3c1e0b5b4a7" +down_revision: Union[str, None] = "c2f9c3b5f5c4" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("users", sa.Column("referral_commission_percent", sa.Integer(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("users", "referral_commission_percent")