Merge pull request #262 from Fr1ngg/nus2xf-bedolaga/add-auto-assignment-feature-for-promo-groups

Add promo group auto assignment after top-ups
This commit is contained in:
Egor
2025-09-24 05:43:06 +03:00
committed by GitHub
12 changed files with 435 additions and 17 deletions

View File

@@ -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()

View File

@@ -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

View File

@@ -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)

View File

@@ -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:

View File

@@ -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():

View File

@@ -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 '🎯'} <b>{group.name}</b>{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(
"💳 <b>Промогруппа:</b> {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,
)

View File

@@ -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

View File

@@ -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

View File

@@ -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}")

View File

@@ -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()

View File

@@ -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📱 <b>Confirm change</b>\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📱 <b>Adjust device limit</b>\n\nCurrent limit: {current_devices} devices\n\nChoose the new number of devices:\n\n💡 <b>Important:</b>\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",

View File

@@ -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 📱 <b>Подтверждение изменения</b>\n\n Текущее количество: {current_devices} устройств\n Новое количество: {new_devices} устройств\n\n Действие: {action}\n 💰 {cost}\n\n Подтвердить изменение?\n ",