From de0b361062e51ff1fb4bf95d6cc427a507238f6a Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 24 Sep 2025 05:42:49 +0300 Subject: [PATCH] Add auto promo group assignment on top-up --- app/database/crud/promo_group.py | 47 +++++++- app/database/crud/transaction.py | 15 +++ app/database/crud/user.py | 1 + app/database/models.py | 4 +- app/database/universal_migration.py | 93 +++++++++++++++ app/handlers/admin/promo_groups.py | 168 +++++++++++++++++++++++++++- app/services/payment_service.py | 20 +++- app/services/promo_group_service.py | 77 +++++++++++++ app/services/tribute_service.py | 15 ++- app/states.py | 2 + locales/en.json | 5 + locales/ru.json | 5 + 12 files changed, 435 insertions(+), 17 deletions(-) create mode 100644 app/services/promo_group_service.py diff --git a/app/database/crud/promo_group.py b/app/database/crud/promo_group.py index d63c3107..7e5fab72 100644 --- a/app/database/crud/promo_group.py +++ b/app/database/crud/promo_group.py @@ -1,7 +1,7 @@ import logging -from typing import Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional, Tuple -from sqlalchemy import func, select, update +from sqlalchemy import desc, func, select, update from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -27,6 +27,23 @@ def _normalize_period_discounts(period_discounts: Optional[Dict[int, int]]) -> D logger = logging.getLogger(__name__) +_UNSET = object() + + +def _normalize_auto_assign_amount(amount_kopeks: Optional[int]) -> Optional[int]: + if amount_kopeks is None: + return None + + try: + normalized = int(amount_kopeks) + except (TypeError, ValueError): + return None + + if normalized <= 0: + return None + + return normalized + async def get_promo_groups_with_counts( db: AsyncSession, @@ -59,8 +76,10 @@ async def create_promo_group( traffic_discount_percent: int, device_discount_percent: int, period_discounts: Optional[Dict[int, int]] = None, + auto_assign_amount_kopeks: Optional[int] = None, ) -> PromoGroup: normalized_period_discounts = _normalize_period_discounts(period_discounts) + normalized_auto_amount = _normalize_auto_assign_amount(auto_assign_amount_kopeks) promo_group = PromoGroup( name=name.strip(), @@ -68,6 +87,7 @@ async def create_promo_group( traffic_discount_percent=max(0, min(100, traffic_discount_percent)), device_discount_percent=max(0, min(100, device_discount_percent)), period_discounts=normalized_period_discounts or None, + auto_assign_amount_kopeks=normalized_auto_amount, is_default=False, ) @@ -96,6 +116,7 @@ async def update_promo_group( traffic_discount_percent: Optional[int] = None, device_discount_percent: Optional[int] = None, period_discounts: Optional[Dict[int, int]] = None, + auto_assign_amount_kopeks: Any = _UNSET, ) -> PromoGroup: if name is not None: group.name = name.strip() @@ -108,6 +129,8 @@ async def update_promo_group( if period_discounts is not None: normalized_period_discounts = _normalize_period_discounts(period_discounts) group.period_discounts = normalized_period_discounts or None + if auto_assign_amount_kopeks is not _UNSET: + group.auto_assign_amount_kopeks = _normalize_auto_assign_amount(auto_assign_amount_kopeks) await db.commit() await db.refresh(group) @@ -170,3 +193,23 @@ async def count_promo_group_members(db: AsyncSession, group_id: int) -> int: select(func.count(User.id)).where(User.promo_group_id == group_id) ) return result.scalar_one() + + +async def get_auto_assign_promo_group( + db: AsyncSession, + total_amount_kopeks: int, +) -> Optional[PromoGroup]: + if total_amount_kopeks <= 0: + return None + + result = await db.execute( + select(PromoGroup) + .where( + PromoGroup.auto_assign_amount_kopeks.is_not(None), + PromoGroup.auto_assign_amount_kopeks > 0, + PromoGroup.auto_assign_amount_kopeks <= total_amount_kopeks, + ) + .order_by(desc(PromoGroup.auto_assign_amount_kopeks), PromoGroup.id) + ) + + return result.scalars().first() diff --git a/app/database/crud/transaction.py b/app/database/crud/transaction.py index b258f1b2..5102100a 100644 --- a/app/database/crud/transaction.py +++ b/app/database/crud/transaction.py @@ -98,6 +98,21 @@ async def get_user_transactions_count( return result.scalar() +async def get_user_total_completed_deposits(db: AsyncSession, user_id: int) -> int: + result = await db.execute( + select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)) + .where( + and_( + Transaction.user_id == user_id, + Transaction.type == TransactionType.DEPOSIT.value, + Transaction.is_completed.is_(True), + ) + ) + ) + + return result.scalar_one() + + async def complete_transaction(db: AsyncSession, transaction: Transaction) -> Transaction: transaction.is_completed = True diff --git a/app/database/crud/user.py b/app/database/crud/user.py index 582c8695..4e228735 100644 --- a/app/database/crud/user.py +++ b/app/database/crud/user.py @@ -116,6 +116,7 @@ async def create_user( has_had_paid_subscription=False, has_made_first_topup=False, promo_group_id=promo_group_id, + promo_group_auto_assigned=False, ) db.add(user) diff --git a/app/database/models.py b/app/database/models.py index 9cdeaa86..df882864 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -271,6 +271,7 @@ class PromoGroup(Base): traffic_discount_percent = Column(Integer, nullable=False, default=0) device_discount_percent = Column(Integer, nullable=False, default=0) period_discounts = Column(JSON, nullable=True, default=dict) + auto_assign_amount_kopeks = Column(Integer, nullable=True) is_default = Column(Boolean, nullable=False, default=False) created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) @@ -335,7 +336,7 @@ class PromoGroup(Base): class User(Base): __tablename__ = "users" - + id = Column(Integer, primary_key=True, index=True) telegram_id = Column(BigInteger, unique=True, index=True, nullable=False) username = Column(String(255), nullable=True) @@ -365,6 +366,7 @@ class User(Base): has_made_first_topup: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) promo_group_id = Column(Integer, ForeignKey("promo_groups.id", ondelete="RESTRICT"), nullable=False, index=True) promo_group = relationship("PromoGroup", back_populates="users") + promo_group_auto_assigned: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) @property def balance_rubles(self) -> float: diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index ce270f13..17179520 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -902,6 +902,81 @@ async def ensure_promo_groups_setup(): logger.error(f"Ошибка настройки промо групп: {e}") return False + +async def add_promo_group_auto_assign_column(): + logger.info("=== ДОБАВЛЕНИЕ ПОЛЯ АВТОВЫДАЧИ ДЛЯ ПРОМОГРУПП ===") + + try: + if await check_column_exists("promo_groups", "auto_assign_amount_kopeks"): + logger.info("Колонка auto_assign_amount_kopeks уже существует в promo_groups") + return True + + db_type = await get_database_type() + + if db_type == "sqlite": + column_definition = "INTEGER" + elif db_type == "postgresql": + column_definition = "INTEGER" + elif db_type == "mysql": + column_definition = "INT" + else: + logger.error(f"Неподдерживаемый тип БД для auto_assign_amount_kopeks: {db_type}") + return False + + async with engine.begin() as conn: + await conn.execute( + text( + f"ALTER TABLE promo_groups ADD COLUMN auto_assign_amount_kopeks {column_definition}" + ) + ) + + logger.info("Добавлена колонка promo_groups.auto_assign_amount_kopeks") + return True + + except Exception as e: + logger.error(f"Ошибка добавления колонки auto_assign_amount_kopeks: {e}") + return False + + +async def add_user_promo_group_auto_flag_column(): + logger.info("=== ДОБАВЛЕНИЕ ФЛАГА АВТО-ПРОМОГРУППЫ ДЛЯ ПОЛЬЗОВАТЕЛЕЙ ===") + + try: + if await check_column_exists("users", "promo_group_auto_assigned"): + logger.info("Колонка promo_group_auto_assigned уже существует в users") + return True + + db_type = await get_database_type() + + if db_type == "sqlite": + column_definition = "BOOLEAN NOT NULL DEFAULT 0" + reset_sql = "UPDATE users SET promo_group_auto_assigned = 0 WHERE promo_group_auto_assigned IS NULL" + elif db_type == "postgresql": + column_definition = "BOOLEAN NOT NULL DEFAULT FALSE" + reset_sql = "UPDATE users SET promo_group_auto_assigned = FALSE WHERE promo_group_auto_assigned IS NULL" + elif db_type == "mysql": + column_definition = "TINYINT(1) NOT NULL DEFAULT 0" + reset_sql = "UPDATE users SET promo_group_auto_assigned = 0 WHERE promo_group_auto_assigned IS NULL" + else: + logger.error(f"Неподдерживаемый тип БД для promo_group_auto_assigned: {db_type}") + return False + + async with engine.begin() as conn: + await conn.execute( + text( + f"ALTER TABLE users ADD COLUMN promo_group_auto_assigned {column_definition}" + ) + ) + await conn.execute(text(reset_sql)) + + logger.info("Добавлена колонка users.promo_group_auto_assigned") + return True + + except Exception as e: + logger.error(f"Ошибка добавления колонки promo_group_auto_assigned: {e}") + return False + + async def add_welcome_text_is_enabled_column(): column_exists = await check_column_exists('welcome_texts', 'is_enabled') if column_exists: @@ -1511,6 +1586,18 @@ async def run_universal_migration(): else: logger.warning("⚠️ Проблемы с настройкой промо групп") + promo_auto_column_added = await add_promo_group_auto_assign_column() + if promo_auto_column_added: + logger.info("✅ Добавлено поле авто-выдачи в промо группах") + else: + logger.warning("⚠️ Не удалось добавить поле авто-выдачи в промо группах") + + user_auto_flag_added = await add_user_promo_group_auto_flag_column() + if user_auto_flag_added: + logger.info("✅ Добавлен флаг auto_assigned у пользователей") + else: + logger.warning("⚠️ Не удалось добавить флаг auto_assigned у пользователей") + logger.info("=== ОБНОВЛЕНИЕ ВНЕШНИХ КЛЮЧЕЙ ===") fk_updated = await fix_foreign_keys_for_user_deletion() if fk_updated: @@ -1585,6 +1672,8 @@ async def check_migration_status(): "promo_groups_table": False, "users_promo_group_column": False, "promo_groups_period_discounts_column": False, + "promo_groups_auto_assign_column": False, + "users_promo_group_auto_flag_column": False, } status["has_made_first_topup_column"] = await check_column_exists('users', 'has_made_first_topup') @@ -1598,6 +1687,8 @@ async def check_migration_status(): 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') status["promo_groups_period_discounts_column"] = await check_column_exists('promo_groups', 'period_discounts') + status["promo_groups_auto_assign_column"] = await check_column_exists('promo_groups', 'auto_assign_amount_kopeks') + status["users_promo_group_auto_flag_column"] = await check_column_exists('users', 'promo_group_auto_assigned') media_fields_exist = ( await check_column_exists('broadcast_history', 'has_media') and @@ -1631,6 +1722,8 @@ async def check_migration_status(): "promo_groups_table": "Таблица промо-групп", "users_promo_group_column": "Колонка promo_group_id у пользователей", "promo_groups_period_discounts_column": "Колонка period_discounts у промо-групп", + "promo_groups_auto_assign_column": "Колонка auto_assign_amount_kopeks у промо-групп", + "users_promo_group_auto_flag_column": "Флаг auto_assigned у пользователей", } for check_key, check_status in status.items(): diff --git a/app/handlers/admin/promo_groups.py b/app/handlers/admin/promo_groups.py index 0546550e..567eead2 100644 --- a/app/handlers/admin/promo_groups.py +++ b/app/handlers/admin/promo_groups.py @@ -1,5 +1,5 @@ import logging -import logging +from decimal import Decimal, InvalidOperation, ROUND_HALF_UP from typing import Dict, Optional from aiogram import Dispatcher, types, F @@ -109,6 +109,23 @@ def _format_period_discounts_value(discounts: Dict[int, int]) -> str: ) +def _format_auto_assign_line(texts, group: PromoGroup) -> Optional[str]: + amount = getattr(group, "auto_assign_amount_kopeks", None) + if not amount: + return None + + return texts.t( + "ADMIN_PROMO_GROUP_AUTO_ASSIGN_LINE", + "🎯 Автовыдача с суммы: {amount}", + ).format(amount=settings.format_price(amount)) + + +def _format_auto_assign_value(amount_kopeks: Optional[int]) -> str: + if not amount_kopeks: + return settings.format_price(0) + return settings.format_price(amount_kopeks) + + def _parse_period_discounts_input(value: str) -> Dict[int, int]: cleaned = (value or "").strip() @@ -140,6 +157,29 @@ def _parse_period_discounts_input(value: str) -> Dict[int, int]: return discounts +def _parse_auto_assign_amount_input(value: str) -> Optional[int]: + cleaned = (value or "").strip() + + if not cleaned: + raise ValueError + + normalized = cleaned.replace(" ", "").replace(",", ".") + + if normalized in {"0", "-", "нет", "off", "disable"}: + return None + + try: + decimal_value = Decimal(normalized) + except (InvalidOperation, ValueError): + raise ValueError + + if decimal_value <= 0: + return None + + kopeks = (decimal_value * Decimal("100")).quantize(Decimal("1"), rounding=ROUND_HALF_UP) + return int(kopeks) + + async def _prompt_for_period_discounts( message: types.Message, state: FSMContext, @@ -161,6 +201,27 @@ async def _prompt_for_period_discounts( await message.answer(prompt_text) +async def _prompt_for_auto_assign_amount( + message: types.Message, + state: FSMContext, + prompt_key: str, + default_text: str, + *, + current_value: Optional[str] = None, +): + data = await state.get_data() + texts = get_texts(data.get("language", "ru")) + prompt_text = texts.t(prompt_key, default_text) + + if current_value is not None: + try: + prompt_text = prompt_text.format(current=current_value) + except KeyError: + pass + + await message.answer(prompt_text) + + @admin_required @error_handler async def show_promo_groups_menu( @@ -191,11 +252,18 @@ async def show_promo_groups_menu( group_lines = [ f"{'⭐' if group.is_default else '🎯'} {group.name}{default_suffix}", _format_discount_line(texts, group), + ] + + auto_line = _format_auto_assign_line(texts, group) + if auto_line: + group_lines.append(auto_line) + + group_lines.extend([ texts.t( "ADMIN_PROMO_GROUPS_MEMBERS_COUNT", "Участников: {count}", ).format(count=member_count), - ] + ]) period_lines = _format_period_discounts_lines(texts, group, db_user.language) group_lines.extend(period_lines) @@ -265,11 +333,18 @@ async def show_promo_group_details( "💳 Промогруппа: {name}", ).format(name=group.name), _format_discount_line(texts, group), + ] + + auto_line = _format_auto_assign_line(texts, group) + if auto_line: + lines.append(auto_line) + + lines.append( texts.t( "ADMIN_PROMO_GROUP_DETAILS_MEMBERS", "Участников: {count}", ).format(count=member_count), - ] + ) period_lines = _format_period_discounts_lines(texts, group, db_user.language) lines.extend(period_lines) @@ -464,6 +539,39 @@ async def process_create_group_period_discounts( ) return + await state.update_data(new_group_period_discounts=period_discounts) + await state.set_state(AdminStates.creating_promo_group_auto_amount) + + await _prompt_for_auto_assign_amount( + message, + state, + "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT", + "Введите сумму пополнений (в рублях) для автоматической выдачи. Отправьте 0, если не нужно.", + ) + + +@admin_required +@error_handler +async def process_create_group_auto_amount( + message: types.Message, + state: FSMContext, + db_user, + db: AsyncSession, +): + data = await state.get_data() + texts = get_texts(data.get("language", db_user.language)) + + try: + auto_amount = _parse_auto_assign_amount_input(message.text) + except ValueError: + await message.answer( + texts.t( + "ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN", + "Введите корректную сумму или 0 для отключения.", + ) + ) + return + try: group = await create_promo_group( db, @@ -471,7 +579,8 @@ async def process_create_group_period_discounts( traffic_discount_percent=data["new_group_traffic"], server_discount_percent=data["new_group_servers"], device_discount_percent=data["new_group_devices"], - period_discounts=period_discounts, + period_discounts=data.get("new_group_period_discounts"), + auto_assign_amount_kopeks=auto_amount, ) except Exception as e: logger.error(f"Не удалось создать промогруппу: {e}") @@ -647,6 +756,46 @@ async def process_edit_group_period_discounts( await state.clear() return + await state.update_data(edit_group_period_discounts=period_discounts) + await state.set_state(AdminStates.editing_promo_group_auto_amount) + + await _prompt_for_auto_assign_amount( + message, + state, + "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT", + "Введите новую сумму (текущая: {current}). Отправьте 0, если без автовыдачи.", + current_value=_format_auto_assign_value(getattr(group, "auto_assign_amount_kopeks", None)), + ) + + +@admin_required +@error_handler +async def process_edit_group_auto_amount( + message: types.Message, + state: FSMContext, + db_user, + db: AsyncSession, +): + data = await state.get_data() + texts = get_texts(data.get("language", db_user.language)) + + try: + auto_amount = _parse_auto_assign_amount_input(message.text) + except ValueError: + await message.answer( + texts.t( + "ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN", + "Введите корректную сумму или 0 для отключения.", + ) + ) + return + + group = await get_promo_group_by_id(db, data.get("edit_group_id")) + if not group: + await message.answer("❌ Промогруппа не найдена") + await state.clear() + return + await update_promo_group( db, group, @@ -654,7 +803,8 @@ async def process_edit_group_period_discounts( traffic_discount_percent=data["edit_group_traffic"], server_discount_percent=data["edit_group_servers"], device_discount_percent=data["edit_group_devices"], - period_discounts=period_discounts, + period_discounts=data.get("edit_group_period_discounts"), + auto_assign_amount_kopeks=auto_amount, ) await state.clear() @@ -828,6 +978,10 @@ def register_handlers(dp: Dispatcher): process_create_group_period_discounts, AdminStates.creating_promo_group_period_discount, ) + dp.message.register( + process_create_group_auto_amount, + AdminStates.creating_promo_group_auto_amount, + ) dp.message.register(process_edit_group_name, AdminStates.editing_promo_group_name) dp.message.register( @@ -846,3 +1000,7 @@ def register_handlers(dp: Dispatcher): process_edit_group_period_discounts, AdminStates.editing_promo_group_period_discount, ) + dp.message.register( + process_edit_group_auto_amount, + AdminStates.editing_promo_group_auto_amount, + ) diff --git a/app/services/payment_service.py b/app/services/payment_service.py index a761ef5c..17c39bd7 100644 --- a/app/services/payment_service.py +++ b/app/services/payment_service.py @@ -30,6 +30,7 @@ from app.services.subscription_checkout_service import ( ) from app.services.mulenpay_service import MulenPayService from app.services.pal24_service import Pal24Service, Pal24APIError +from app.services.promo_group_service import maybe_assign_auto_promo_group from app.database.crud.mulenpay import ( create_mulenpay_payment, get_mulenpay_payment_by_local_id, @@ -179,7 +180,9 @@ class PaymentService: logger.error(f"Ошибка обработки реферального пополнения: {e}") else: logger.info(f"❌ Описание '{description_for_referral}' не подходит для реферальной логики") - + + await maybe_assign_auto_promo_group(db, user, self.bot) + if self.bot: try: from app.services.admin_notification_service import AdminNotificationService @@ -461,7 +464,9 @@ class PaymentService: await process_referral_topup(db, user.id, updated_payment.amount_kopeks, self.bot) except Exception as e: logger.error(f"Ошибка обработки реферального пополнения YooKassa: {e}") - + + await maybe_assign_auto_promo_group(db, user, self.bot) + if self.bot: try: from app.services.admin_notification_service import AdminNotificationService @@ -528,7 +533,8 @@ class PaymentService: user = await get_user_by_id(db, payment.user_id) if user: await add_user_balance(db, user, payment.amount_kopeks, f"Пополнение YooKassa: {payment.amount_kopeks//100}₽") - + await maybe_assign_auto_promo_group(db, user, self.bot) + logger.info(f"Успешно обработан платеж YooKassa {payment.yookassa_payment_id}: " f"пользователь {payment.user_id} получил {payment.amount_kopeks/100}₽") @@ -993,6 +999,8 @@ class PaymentService: referral_error, ) + await maybe_assign_auto_promo_group(db, user, self.bot) + await update_mulenpay_payment_status( db, payment=payment, @@ -1182,6 +1190,8 @@ class PaymentService: except Exception as referral_error: logger.error("Ошибка обработки реферального пополнения Pal24: %s", referral_error) + await maybe_assign_auto_promo_group(db, user, self.bot) + if self.bot: try: from app.services.admin_notification_service import AdminNotificationService @@ -1438,7 +1448,9 @@ class PaymentService: await process_referral_topup(db, user.id, amount_kopeks, self.bot) except Exception as e: logger.error(f"Ошибка обработки реферального пополнения CryptoBot: {e}") - + + await maybe_assign_auto_promo_group(db, user, self.bot) + if self.bot: try: from app.services.admin_notification_service import AdminNotificationService diff --git a/app/services/promo_group_service.py b/app/services/promo_group_service.py new file mode 100644 index 00000000..02ad4dbc --- /dev/null +++ b/app/services/promo_group_service.py @@ -0,0 +1,77 @@ +import logging +from datetime import datetime +from typing import Optional + +from aiogram import Bot +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.crud.promo_group import get_auto_assign_promo_group +from app.database.crud.transaction import get_user_total_completed_deposits +from app.database.models import PromoGroup, User +from app.localization.texts import get_texts + +logger = logging.getLogger(__name__) + + +def _format_total_amount(total_amount_kopeks: int) -> str: + return settings.format_price(total_amount_kopeks) + + +async def maybe_assign_auto_promo_group( + db: AsyncSession, + user: User, + bot: Optional[Bot] = None, +) -> Optional[PromoGroup]: + """Назначает промогруппу автоматически при достижении нужной суммы пополнений.""" + try: + if getattr(user, "promo_group_auto_assigned", False): + return None + + total_amount_kopeks = await get_user_total_completed_deposits(db, user.id) + target_group = await get_auto_assign_promo_group(db, total_amount_kopeks) + + if not target_group or target_group.id == user.promo_group_id: + return None + + user.promo_group_id = target_group.id + user.promo_group = target_group + user.promo_group_auto_assigned = True + user.updated_at = datetime.utcnow() + + await db.commit() + await db.refresh(user) + + logger.info( + "Автоматически назначена промогруппа '%s' пользователю %s (сумма пополнений: %s)", + target_group.name, + user.telegram_id, + _format_total_amount(total_amount_kopeks), + ) + + if bot: + try: + texts = get_texts(user.language) + message = texts.t( + "PROMO_GROUP_AUTO_ASSIGN_NOTIFICATION", + "🎉 Вы автоматически переведены в промогруппу «{name}» за пополнения на {amount}.", + ).format(name=target_group.name, amount=_format_total_amount(total_amount_kopeks)) + await bot.send_message(user.telegram_id, message, parse_mode="HTML") + except Exception as notify_error: + logger.error( + "Ошибка отправки уведомления об автоназначении промогруппы пользователю %s: %s", + user.telegram_id, + notify_error, + ) + + return target_group + + except Exception as error: + logger.error( + "Ошибка автоматического назначения промогруппы пользователю %s: %s", + getattr(user, "telegram_id", "unknown"), + error, + exc_info=True, + ) + await db.rollback() + return None diff --git a/app/services/tribute_service.py b/app/services/tribute_service.py index 6d5de2eb..8dd427f4 100644 --- a/app/services/tribute_service.py +++ b/app/services/tribute_service.py @@ -14,6 +14,7 @@ from app.database.crud.transaction import ( from app.database.crud.user import get_user_by_telegram_id, add_user_balance from app.external.tribute import TributeService as TributeAPI from app.services.payment_service import PaymentService +from app.services.promo_group_service import maybe_assign_auto_promo_group logger = logging.getLogger(__name__) @@ -139,8 +140,10 @@ class TributeService: if not user.has_made_first_topup: user.has_made_first_topup = True logger.info(f"Отмечен первый топап для пользователя {user_telegram_id}") - - + + await maybe_assign_auto_promo_group(session, user, self.bot) + + try: from app.services.admin_notification_service import AdminNotificationService notification_service = AdminNotificationService(self.bot) @@ -333,11 +336,13 @@ class TributeService: old_balance = user.balance_kopeks user.balance_kopeks += amount_kopeks user.updated_at = datetime.utcnow() - + await session.commit() - + + await maybe_assign_auto_promo_group(session, user, self.bot) + logger.info(f"💰 ПРИНУДИТЕЛЬНО обновлен баланс: {old_balance} -> {user.balance_kopeks} коп") - + await self._send_success_notification(user_id, amount_kopeks) logger.info(f"✅ Принудительно обработан платеж {payment_id}") diff --git a/app/states.py b/app/states.py index 0073a4d4..940986d2 100644 --- a/app/states.py +++ b/app/states.py @@ -69,12 +69,14 @@ class AdminStates(StatesGroup): creating_promo_group_server_discount = State() creating_promo_group_device_discount = State() creating_promo_group_period_discount = State() + creating_promo_group_auto_amount = State() editing_promo_group_name = State() editing_promo_group_traffic_discount = State() editing_promo_group_server_discount = State() editing_promo_group_device_discount = State() editing_promo_group_period_discount = State() + editing_promo_group_auto_amount = State() editing_squad_price = State() editing_traffic_price = State() diff --git a/locales/en.json b/locales/en.json index 419dbe95..51508f0d 100644 --- a/locales/en.json +++ b/locales/en.json @@ -141,6 +141,7 @@ "ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (default)", "ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Members: {count}", "ADMIN_PROMO_GROUPS_EMPTY": "No promo groups found.", + "ADMIN_PROMO_GROUP_AUTO_ASSIGN_LINE": "🎯 Auto assignment from: {amount}", "CREATE_TICKET_BUTTON": "🎫 Create ticket", "MY_TICKETS_BUTTON": "📋 My tickets", "CONTACT_SUPPORT_BUTTON": "💬 Contact support", @@ -226,8 +227,10 @@ "ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Enter server discount (0-100):", "ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Enter device discount (0-100):", "ADMIN_PROMO_GROUP_CREATE_PERIOD_PROMPT": "Enter subscription period discounts (e.g. 30:10, 90:15). Send 0 if none.", + "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Enter the top-up amount (in rubles) for automatic assignment. Send 0 to disable.", "ADMIN_PROMO_GROUP_INVALID_PERCENT": "Enter a number from 0 to 100.", "ADMIN_PROMO_GROUP_INVALID_PERIOD_DISCOUNTS": "Enter period:discount pairs separated by commas, e.g. 30:10, 90:15, or 0.", + "ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN": "Enter a valid amount or 0 to disable.", "ADMIN_PROMO_GROUP_CREATED": "Promo group “{name}” created.", "ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ Back to promo groups", "ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Enter a new name (current: {name}):", @@ -235,6 +238,7 @@ "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Enter new server discount (0-100):", "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Enter new device discount (0-100):", "ADMIN_PROMO_GROUP_EDIT_PERIOD_PROMPT": "Enter new period discounts (current: {current}). Send 0 if none.", + "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT": "Enter a new amount (current: {current}). Send 0 to disable.", "ADMIN_PROMO_GROUP_UPDATED": "Promo group “{name}” updated.", "ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Members of {name}", "ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "This group has no members yet.", @@ -263,6 +267,7 @@ "PROMO_GROUP_DISCOUNT_DEVICES": "📱 Extra devices: {percent}%", "PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Long-term period discounts:", "PROMO_GROUP_PERIOD_DISCOUNT_ITEM": "{period} — {percent}%", + "PROMO_GROUP_AUTO_ASSIGN_NOTIFICATION": "🎉 You have been automatically moved to the promo group “{name}” for topping up {amount}.", "CHANGE_DEVICES_CONFIRM": "\n📱 Confirm change\n\nCurrent amount: {current_devices} devices\nNew amount: {new_devices} devices\n\nAction: {action}\n💰 {cost}\n\nApply this change?\n", "CHANGE_DEVICES_INFO": "\n📱 Adjust device limit\n\nCurrent limit: {current_devices} devices\n\nChoose the new number of devices:\n\n💡 Important:\n• Increasing — extra charge proportional to the remaining time\n• Decreasing — funds are not refunded\n", "CHANGE_DEVICES_SUCCESS_DECREASE": "\n✅ Device limit decreased!\n\n📱 Was: {old_count} → Now: {new_count}\nℹ️ Payments are not refunded\n", diff --git a/locales/ru.json b/locales/ru.json index e69b9d92..ac1c6d0d 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -19,6 +19,7 @@ "ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (базовая)", "ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Участников: {count}", "ADMIN_PROMO_GROUPS_EMPTY": "Промогруппы не найдены.", + "ADMIN_PROMO_GROUP_AUTO_ASSIGN_LINE": "🎯 Автовыдача с суммы: {amount}", "CREATE_TICKET_BUTTON": "🎫 Создать тикет", "MY_TICKETS_BUTTON": "📋 Мои тикеты", "CONTACT_SUPPORT_BUTTON": "💬 Связаться с поддержкой", @@ -104,8 +105,10 @@ "ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Введите скидку на серверы (0-100):", "ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Введите скидку на устройства (0-100):", "ADMIN_PROMO_GROUP_CREATE_PERIOD_PROMPT": "Введите скидки на периоды подписки (например, 30:10, 90:15). Отправьте 0, если без скидок.", + "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Введите сумму пополнений (в рублях) для автоматической выдачи. Отправьте 0, если без автовыдачи.", "ADMIN_PROMO_GROUP_INVALID_PERCENT": "Введите число от 0 до 100.", "ADMIN_PROMO_GROUP_INVALID_PERIOD_DISCOUNTS": "Введите пары период:скидка через запятую, например 30:10, 90:15, или 0.", + "ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN": "Введите корректную сумму или 0 для отключения.", "ADMIN_PROMO_GROUP_CREATED": "Промогруппа «{name}» создана.", "ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ К промогруппам", "ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Введите новое название промогруппы (текущее: {name}):", @@ -113,6 +116,7 @@ "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Введите новую скидку на серверы (0-100):", "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Введите новую скидку на устройства (0-100):", "ADMIN_PROMO_GROUP_EDIT_PERIOD_PROMPT": "Введите новые скидки на периоды (текущие: {current}). Отправьте 0, если без скидок.", + "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT": "Введите новую сумму (текущая: {current}). Отправьте 0, если без автовыдачи.", "ADMIN_PROMO_GROUP_UPDATED": "Промогруппа «{name}» обновлена.", "ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Участники группы {name}", "ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "В этой группе пока нет участников.", @@ -146,6 +150,7 @@ "PROMO_GROUP_DISCOUNT_DEVICES": "📱 Доп. устройства: {percent}%", "PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Скидки за длительный период:", "PROMO_GROUP_PERIOD_DISCOUNT_ITEM": "{period} — {percent}%", + "PROMO_GROUP_AUTO_ASSIGN_NOTIFICATION": "🎉 Вы автоматически переведены в промогруппу «{name}» за пополнения на {amount}.", "CANCEL": "❌ Отмена", "CHANGE_DEVICES_BUTTON": "📱 Изменить устройства", "CHANGE_DEVICES_CONFIRM": "\n 📱 Подтверждение изменения\n\n Текущее количество: {current_devices} устройств\n Новое количество: {new_devices} устройств\n\n Действие: {action}\n 💰 {cost}\n\n Подтвердить изменение?\n ",