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 ",