diff --git a/.env.example b/.env.example
index 742841e8..1cce3ab8 100644
--- a/.env.example
+++ b/.env.example
@@ -14,6 +14,7 @@ ADMIN_NOTIFICATIONS_ENABLED=true
ADMIN_NOTIFICATIONS_CHAT_ID=-1001234567890 # Замени на ID твоего канала (-100) - ПРЕФИКС ЗАКРЫТОГО КАНАЛА! ВСТАВИТЬ СВОЙ ID СРАЗУ ПОСЛЕ (-100) БЕЗ ПРОБЕЛОВ!
ADMIN_NOTIFICATIONS_TOPIC_ID=123 # Опционально: ID топика
ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID=126 # Опционально: ID топика для тикетов
+ADMIN_NOTIFICATIONS_NALOG_TOPIC_ID=133 # Опционально: ID топика для уведомлений о чеках NaloGO
# Автоматические отчеты
ADMIN_REPORTS_ENABLED=false
ADMIN_REPORTS_CHAT_ID= # Опционально: чат для отчетов (по умолчанию ADMIN_NOTIFICATIONS_CHAT_ID)
@@ -38,6 +39,7 @@ CHANNEL_SUB_ID= # Опционально ID твоего канала (-100)
CHANNEL_IS_REQUIRED_SUB=false # Обязательна ли подписка на канал
CHANNEL_LINK= # Опционально ссылка на канал
CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE=true # Отключать триальные подписки при отписке от канала
+CHANNEL_REQUIRED_FOR_ALL=false # Требовать подписку на канал для ВСЕХ пользователей (платных и триальных)
# ===== DATABASE CONFIGURATION =====
# Режим базы данных: "auto", "postgresql", "sqlite"
@@ -101,6 +103,9 @@ REMNAWAVE_USER_DELETE_MODE=delete
TRIAL_DURATION_DAYS=3
TRIAL_TRAFFIC_LIMIT_GB=10
TRIAL_DEVICE_LIMIT=1
+# Платный триал: если TRIAL_ACTIVATION_PRICE > 0, триал становится платным
+# Цена в копейках (1000 = 10 рублей). Пользователь может оплатить триал любым методом оплаты.
+# TRIAL_PAYMENT_ENABLED опционален (для обратной совместимости)
TRIAL_PAYMENT_ENABLED=false
TRIAL_ACTIVATION_PRICE=0
@@ -132,6 +137,26 @@ TRAFFIC_SELECTION_MODE=selectable
# 0 = безлимит
FIXED_TRAFFIC_LIMIT_GB=100
+# ===== ДОКУПКА ТРАФИКА =====
+# Включить/выключить функцию докупки трафика к существующей подписке
+TRAFFIC_TOPUP_ENABLED=true
+
+# Пакеты для докупки трафика (формат: "гб:цена_в_копейках:enabled")
+# Пустая строка = использовать TRAFFIC_PACKAGES_CONFIG
+# Пример: "10:5000:true,25:10000:true,50:15000:true,100:25000:true"
+TRAFFIC_TOPUP_PACKAGES_CONFIG=
+
+# ===== СБРОС ТРАФИКА =====
+# Режим расчета цены сброса трафика:
+# "period" - фиксированная цена = стоимость периода 30 дней (старое поведение, может быть абьюзом!)
+# "traffic" - цена = стоимость текущего пакета трафика подписки
+# "traffic_with_purchased" - цена = стоимость базового + докупленного трафика (рекомендуется)
+TRAFFIC_RESET_PRICE_MODE=traffic_with_purchased
+
+# Базовая цена сброса в копейках (0 = использовать PERIOD_PRICES[30])
+# Используется как минимальная цена или фиксированная в режиме "period"
+TRAFFIC_RESET_BASE_PRICE=0
+
# ===== ПЕРИОДЫ ПОДПИСКИ =====
# Доступные периоды подписки (через запятую)
# Возможные значения: 14,30,60,90,180,360
@@ -174,6 +199,16 @@ PRICE_PER_DEVICE=10000
DEVICES_SELECTION_ENABLED=true
# Единое количество устройств для режима без выбора (0 — не назначать устройства)
DEVICES_SELECTION_DISABLED_AMOUNT=0
+
+# ===== МОДЕМ =====
+# Включить функционал подключения модема
+MODEM_ENABLED=false
+# Цена модема в копейках за месяц (добавляется к ежемесячному платежу)
+MODEM_PRICE_PER_MONTH=10000
+# Скидки на модем за длительный срок: "месяцев:процент,месяцев:процент"
+# Пример: 3 мес = 15%, 6 мес = 20%, 12 мес = 25%
+MODEM_PERIOD_DISCOUNTS=3:15,6:20,12:25
+
# Отключение превью ссылок в сообщениях бота
DISABLE_WEB_PAGE_PREVIEW=false
@@ -287,6 +322,9 @@ NALOGO_INN= # ИНН самозанятого
NALOGO_PASSWORD= # Пароль от личного кабинета налоговой
NALOGO_DEVICE_ID= # Опционально: ID устройства для авторизации
NALOGO_STORAGE_PATH=./nalogo_tokens.json # Путь к файлу с токенами
+NALOGO_QUEUE_CHECK_INTERVAL=300 # Интервал проверки очереди чеков (секунды)
+NALOGO_QUEUE_RECEIPT_DELAY=3 # Задержка между отправкой чеков (секунды)
+NALOGO_QUEUE_MAX_ATTEMPTS=10 # Максимум попыток отправки одного чека
# ===== НАСТРОЙКИ ОПИСАНИЙ ПЛАТЕЖЕЙ =====
# Эти настройки позволяют изменить описания платежей,
@@ -460,6 +498,12 @@ DEFAULT_LANGUAGE=ru
AVAILABLE_LANGUAGES=ru,en,ua,zh
# Включить выбор языка при старте и отображение кнопки в меню
LANGUAGE_SELECTION_ENABLED=true
+
+# Округление цен при отображении (≤50 коп вниз, >50 коп вверх)
+# true: 14.78₽ → 15₽, 14.12₽ → 14₽
+# false: показывать точные суммы с копейками
+PRICE_ROUNDING_ENABLED=true
+
# Часовой пояс
TZ=Europe/Moscow # или UTC, America/New_York и т.д.
@@ -513,6 +557,13 @@ BOT_RUN_MODE=polling # polling, webhook или both
CONTESTS_ENABLED=false
CONTESTS_BUTTON_VISIBLE=false
+# ===== АВТОАКТИВАЦИЯ ПОСЛЕ ПОПОЛНЕНИЯ =====
+# Автоматическая покупка из сохранённой корзины после пополнения баланса
+AUTO_PURCHASE_AFTER_TOPUP_ENABLED=false
+# Умная автоактивация: система сама решает — продлить или создать подписку
+# Работает даже без сохранённой корзины. Выбирает максимальный период <= баланса
+AUTO_ACTIVATE_AFTER_TOPUP_ENABLED=false
+
# ===== КНОПКА АКТИВАЦИИ =====
ACTIVATE_BUTTON_VISIBLE=false
# ACTIVATE_BUTTON_TEXT=активировать
diff --git a/app/config.py b/app/config.py
index 32c1f597..fab2409d 100644
--- a/app/config.py
+++ b/app/config.py
@@ -45,6 +45,12 @@ class Settings(BaseSettings):
ADMIN_NOTIFICATIONS_CHAT_ID: Optional[str] = None
ADMIN_NOTIFICATIONS_TOPIC_ID: Optional[int] = None
ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID: Optional[int] = None
+ ADMIN_NOTIFICATIONS_NALOG_TOPIC_ID: Optional[int] = None
+
+ # Настройки очереди чеков NaloGO
+ NALOGO_QUEUE_CHECK_INTERVAL: int = 300 # Интервал проверки очереди (секунды)
+ NALOGO_QUEUE_RECEIPT_DELAY: int = 3 # Задержка между отправкой чеков (секунды)
+ NALOGO_QUEUE_MAX_ATTEMPTS: int = 10 # Максимум попыток отправки чека
ADMIN_REPORTS_ENABLED: bool = False
ADMIN_REPORTS_CHAT_ID: Optional[str] = None
@@ -55,7 +61,8 @@ class Settings(BaseSettings):
CHANNEL_LINK: Optional[str] = None
CHANNEL_IS_REQUIRED_SUB: bool = False
CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE: bool = True
-
+ CHANNEL_REQUIRED_FOR_ALL: bool = False
+
DATABASE_URL: Optional[str] = None
POSTGRES_HOST: str = "postgres"
@@ -143,12 +150,31 @@ class Settings(BaseSettings):
DEVICES_SELECTION_ENABLED: bool = True
DEVICES_SELECTION_DISABLED_AMOUNT: Optional[int] = None
+ # Настройки модема
+ MODEM_ENABLED: bool = False
+ MODEM_PRICE_PER_MONTH: int = 10000 # Цена модема в копейках за месяц
+ MODEM_PERIOD_DISCOUNTS: str = "" # Скидки на модем: "месяцев:процент,месяцев:процент" (напр. "3:10,6:15,12:20")
+
BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED: bool = False
BASE_PROMO_GROUP_PERIOD_DISCOUNTS: str = ""
TRAFFIC_SELECTION_MODE: str = "selectable"
FIXED_TRAFFIC_LIMIT_GB: int = 100
- BUY_TRAFFIC_BUTTON_VISIBLE: bool = True
+ BUY_TRAFFIC_BUTTON_VISIBLE: bool = True
+
+ # Настройки докупки трафика
+ TRAFFIC_TOPUP_ENABLED: bool = True # Включить/выключить функцию докупки трафика
+ # Пакеты для докупки трафика (формат: "гб:цена:enabled", пустая строка = использовать TRAFFIC_PACKAGES_CONFIG)
+ TRAFFIC_TOPUP_PACKAGES_CONFIG: str = ""
+
+ # Настройки сброса трафика
+ # Режимы расчета цены сброса:
+ # "period" - фиксированная цена = стоимость периода 30 дней (старое поведение)
+ # "traffic" - цена зависит от текущего лимита трафика (цена пакета трафика)
+ # "traffic_with_purchased" - цена = базовый трафик + докупленный трафик (рекомендуется)
+ TRAFFIC_RESET_PRICE_MODE: str = "traffic_with_purchased"
+ # Базовая цена сброса в копейках (используется если режим "period" или как минимальная цена)
+ TRAFFIC_RESET_BASE_PRICE: int = 0 # 0 = использовать PERIOD_PRICES[30]
REFERRAL_MINIMUM_TOPUP_KOPEKS: int = 10000
REFERRAL_FIRST_TOPUP_BONUS_KOPEKS: int = 10000
@@ -244,6 +270,7 @@ class Settings(BaseSettings):
NALOGO_STORAGE_PATH: str = "./nalogo_tokens.json"
AUTO_PURCHASE_AFTER_TOPUP_ENABLED: bool = False
+ AUTO_ACTIVATE_AFTER_TOPUP_ENABLED: bool = False
# Отключение превью ссылок в сообщениях бота
DISABLE_WEB_PAGE_PREVIEW: bool = False
@@ -393,7 +420,10 @@ class Settings(BaseSettings):
DEFAULT_LANGUAGE: str = "ru"
AVAILABLE_LANGUAGES: str = "ru,en"
LANGUAGE_SELECTION_ENABLED: bool = True
-
+
+ # Округление цен при отображении (≤50 коп вниз, >50 коп вверх)
+ PRICE_ROUNDING_ENABLED: bool = True
+
LOG_LEVEL: str = "INFO"
LOG_FILE: str = "logs/bot.log"
@@ -762,7 +792,17 @@ class Settings(BaseSettings):
return normalized in {"1", "true", "yes", "on"}
return bool(value)
-
+
+ def is_auto_activate_after_topup_enabled(self) -> bool:
+ """Умная автоактивация после пополнения баланса (без корзины)."""
+ value = getattr(self, "AUTO_ACTIVATE_AFTER_TOPUP_ENABLED", False)
+
+ if isinstance(value, str):
+ normalized = value.strip().lower()
+ return normalized in {"1", "true", "yes", "on"}
+
+ return bool(value)
+
def get_available_languages(self) -> List[str]:
defaults = ["ru", "en", "ua", "zh"]
@@ -802,10 +842,32 @@ class Settings(BaseSettings):
def is_language_selection_enabled(self) -> bool:
return bool(getattr(self, "LANGUAGE_SELECTION_ENABLED", True))
- def format_price(self, price_kopeks: int) -> str:
- sign = "-" if price_kopeks < 0 else ""
- rubles, kopeks = divmod(abs(price_kopeks), 100)
+ def format_price(self, price_kopeks: int, round_kopeks: Optional[bool] = None) -> str:
+ """
+ Форматирует цену в копейках для отображения пользователю.
+ Args:
+ price_kopeks: Сумма в копейках
+ round_kopeks: Если True, округляет копейки (≤50 вниз, >50 вверх).
+ Если None, использует настройку PRICE_ROUNDING_ENABLED.
+
+ Returns:
+ Отформатированная строка цены (например, "150 ₽")
+ """
+ # Используем настройку если не передано явно
+ should_round = round_kopeks if round_kopeks is not None else self.PRICE_ROUNDING_ENABLED
+
+ sign = "-" if price_kopeks < 0 else ""
+ abs_kopeks = abs(price_kopeks)
+ rubles, kopeks = divmod(abs_kopeks, 100)
+
+ if should_round:
+ # Округление: ≤50 коп вниз, >50 коп вверх
+ if kopeks > 50:
+ rubles += 1
+ return f"{sign}{rubles} ₽"
+
+ # Без округления - показываем точное значение
if kopeks:
value = f"{sign}{rubles}.{kopeks:02d}".rstrip("0").rstrip(".")
return f"{value} ₽"
@@ -986,6 +1048,57 @@ class Settings(BaseSettings):
def get_fixed_traffic_limit(self) -> int:
return self.FIXED_TRAFFIC_LIMIT_GB
+ def is_traffic_topup_enabled(self) -> bool:
+ return self.TRAFFIC_TOPUP_ENABLED
+
+ def get_traffic_topup_packages(self) -> List[Dict]:
+ """Возвращает пакеты для докупки трафика. Если не настроены - использует TRAFFIC_PACKAGES_CONFIG."""
+ config_str = self.TRAFFIC_TOPUP_PACKAGES_CONFIG.strip()
+
+ if not config_str:
+ # Если не настроены отдельные пакеты для докупки - используем основные
+ return self.get_traffic_packages()
+
+ packages = []
+ for package_config in config_str.split(','):
+ package_config = package_config.strip()
+ if not package_config:
+ continue
+
+ parts = package_config.split(':')
+ if len(parts) >= 2:
+ try:
+ gb = int(parts[0])
+ price = int(parts[1])
+ enabled = parts[2].lower() == 'true' if len(parts) > 2 else True
+ packages.append({"gb": gb, "price": price, "enabled": enabled})
+ except (ValueError, IndexError):
+ continue
+
+ return packages if packages else self.get_traffic_packages()
+
+ def get_traffic_topup_price(self, gb: Optional[int]) -> int:
+ """Возвращает цену докупки для указанного количества ГБ."""
+ packages = self.get_traffic_topup_packages()
+ enabled_packages = [pkg for pkg in packages if pkg["enabled"]]
+
+ if not enabled_packages:
+ return 0
+
+ # Ищем точное совпадение
+ for pkg in enabled_packages:
+ if pkg["gb"] == gb:
+ return pkg["price"]
+
+ # Если не нашли - возвращаем 0
+ return 0
+
+ def get_traffic_reset_price_mode(self) -> str:
+ return self.TRAFFIC_RESET_PRICE_MODE.lower()
+
+ def get_traffic_reset_base_price(self) -> int:
+ return self.TRAFFIC_RESET_BASE_PRICE
+
def is_devices_selection_enabled(self) -> bool:
return self.DEVICES_SELECTION_ENABLED
@@ -1012,7 +1125,70 @@ class Settings(BaseSettings):
def get_disabled_mode_device_limit(self) -> Optional[int]:
return self.get_devices_selection_disabled_amount()
+ def is_modem_enabled(self) -> bool:
+ return bool(self.MODEM_ENABLED)
+
+ def get_modem_price_per_month(self) -> int:
+ try:
+ value = int(self.MODEM_PRICE_PER_MONTH)
+ except (TypeError, ValueError):
+ logger.warning(
+ "Некорректное значение MODEM_PRICE_PER_MONTH: %s",
+ self.MODEM_PRICE_PER_MONTH,
+ )
+ return 10000
+ return max(0, value)
+
+ def get_modem_period_discounts(self) -> Dict[int, int]:
+ """Возвращает скидки на модем по количеству месяцев: {месяцев: процент_скидки}"""
+ try:
+ config_str = (self.MODEM_PERIOD_DISCOUNTS or "").strip()
+ if not config_str:
+ return {}
+
+ discounts: Dict[int, int] = {}
+ for part in config_str.split(','):
+ part = part.strip()
+ if not part:
+ continue
+
+ months_and_discount = part.split(':')
+ if len(months_and_discount) != 2:
+ continue
+
+ months_str, discount_str = months_and_discount
+ try:
+ months = int(months_str.strip())
+ discount_percent = int(discount_str.strip())
+ except ValueError:
+ continue
+
+ discounts[months] = max(0, min(100, discount_percent))
+
+ return discounts
+ except Exception:
+ return {}
+
+ def get_modem_period_discount(self, months: int) -> int:
+ """Возвращает процент скидки для указанного количества месяцев"""
+ if months <= 0:
+ return 0
+
+ discounts = self.get_modem_period_discounts()
+
+ # Ищем точное совпадение или ближайшее меньшее
+ applicable_discount = 0
+ for discount_months, discount_percent in sorted(discounts.items()):
+ if months >= discount_months:
+ applicable_discount = discount_percent
+
+ return applicable_discount
+
def is_trial_paid_activation_enabled(self) -> bool:
+ # Если цена > 0, триал автоматически платный
+ # (TRIAL_PAYMENT_ENABLED теперь опционален - для обратной совместимости)
+ if self.TRIAL_ACTIVATION_PRICE > 0:
+ return True
return bool(self.TRIAL_PAYMENT_ENABLED)
def get_trial_activation_price(self) -> int:
diff --git a/app/database/crud/contest.py b/app/database/crud/contest.py
index 4f2426d4..06074749 100644
--- a/app/database/crud/contest.py
+++ b/app/database/crud/contest.py
@@ -40,7 +40,8 @@ async def upsert_template(
slug: str,
name: str,
description: str = "",
- prize_days: int = 1,
+ prize_type: str = "days",
+ prize_value: str = "1",
max_winners: int = 1,
attempts_per_user: int = 1,
times_per_day: int = 1,
@@ -56,7 +57,8 @@ async def upsert_template(
template.name = name
template.description = description
- template.prize_days = prize_days
+ template.prize_type = prize_type
+ template.prize_value = prize_value
template.max_winners = max_winners
template.attempts_per_user = attempts_per_user
template.times_per_day = times_per_day
@@ -189,6 +191,22 @@ async def create_attempt(
return attempt
+async def update_attempt(
+ db: AsyncSession,
+ attempt: ContestAttempt,
+ *,
+ answer: Optional[str] = None,
+ is_winner: bool = False,
+) -> ContestAttempt:
+ """Update existing attempt with answer and winner status."""
+ if answer is not None:
+ attempt.answer = answer
+ attempt.is_winner = is_winner
+ await db.commit()
+ await db.refresh(attempt)
+ return attempt
+
+
async def clear_attempts(db: AsyncSession, round_id: int) -> int:
result = await db.execute(delete(ContestAttempt).where(ContestAttempt.round_id == round_id))
deleted_count = result.rowcount
diff --git a/app/database/crud/referral.py b/app/database/crud/referral.py
index b00337ef..acbb6ef7 100644
--- a/app/database/crud/referral.py
+++ b/app/database/crud/referral.py
@@ -269,6 +269,176 @@ async def get_referral_statistics(db: AsyncSession) -> dict:
}
+async def get_top_referrers_by_period(
+ db: AsyncSession,
+ period: str = "week", # "week" или "month"
+ sort_by: str = "earnings", # "earnings" или "invited"
+ limit: int = 20
+) -> list:
+ """
+ Получает топ рефереров за период.
+
+ Args:
+ period: "week" (7 дней) или "month" (30 дней)
+ sort_by: "earnings" (по заработку) или "invited" (по приглашённым)
+ limit: количество записей
+
+ Returns:
+ Список словарей с данными рефереров
+ """
+ from app.database.models import Transaction, TransactionType
+
+ now = datetime.utcnow()
+ if period == "week":
+ start_date = now - timedelta(days=7)
+ else: # month
+ start_date = now - timedelta(days=30)
+
+ if sort_by == "invited":
+ # Топ по количеству приглашённых за период
+ referrals_result = await db.execute(
+ select(
+ User.referred_by_id.label('referrer_id'),
+ func.count(User.id).label('invited_count')
+ )
+ .where(
+ and_(
+ User.referred_by_id.isnot(None),
+ User.created_at >= start_date
+ )
+ )
+ .group_by(User.referred_by_id)
+ .order_by(func.count(User.id).desc())
+ .limit(limit)
+ )
+
+ top_data = []
+ for row in referrals_result:
+ # Получаем заработок за период для этого реферера
+ earnings_result = await db.execute(
+ select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))
+ .where(
+ and_(
+ ReferralEarning.user_id == row.referrer_id,
+ ReferralEarning.created_at >= start_date
+ )
+ )
+ )
+ earnings = earnings_result.scalar() or 0
+
+ # Добавляем транзакции REFERRAL_REWARD
+ trans_earnings_result = await db.execute(
+ select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
+ .where(
+ and_(
+ Transaction.user_id == row.referrer_id,
+ Transaction.type == TransactionType.REFERRAL_REWARD.value,
+ Transaction.created_at >= start_date
+ )
+ )
+ )
+ earnings += trans_earnings_result.scalar() or 0
+
+ top_data.append({
+ 'referrer_id': row.referrer_id,
+ 'invited_count': row.invited_count,
+ 'earnings_kopeks': earnings
+ })
+ else:
+ # Топ по заработку за период
+ # Собираем заработки из ReferralEarning
+ referral_earnings_result = await db.execute(
+ select(
+ ReferralEarning.user_id.label('referrer_id'),
+ func.sum(ReferralEarning.amount_kopeks).label('ref_earnings')
+ )
+ .where(ReferralEarning.created_at >= start_date)
+ .group_by(ReferralEarning.user_id)
+ )
+ referral_earnings = {row.referrer_id: row.ref_earnings for row in referral_earnings_result}
+
+ # Добавляем транзакции REFERRAL_REWARD
+ transaction_earnings_result = await db.execute(
+ select(
+ Transaction.user_id.label('referrer_id'),
+ func.sum(Transaction.amount_kopeks).label('trans_earnings')
+ )
+ .where(
+ and_(
+ Transaction.type == TransactionType.REFERRAL_REWARD.value,
+ Transaction.created_at >= start_date
+ )
+ )
+ .group_by(Transaction.user_id)
+ )
+
+ # Объединяем заработки
+ combined_earnings = dict(referral_earnings)
+ for row in transaction_earnings_result:
+ if row.referrer_id in combined_earnings:
+ combined_earnings[row.referrer_id] += row.trans_earnings or 0
+ else:
+ combined_earnings[row.referrer_id] = row.trans_earnings or 0
+
+ # Сортируем и берём топ
+ sorted_referrers = sorted(
+ combined_earnings.items(),
+ key=lambda x: x[1],
+ reverse=True
+ )[:limit]
+
+ top_data = []
+ for referrer_id, earnings in sorted_referrers:
+ # Получаем количество приглашённых за период
+ invited_result = await db.execute(
+ select(func.count(User.id))
+ .where(
+ and_(
+ User.referred_by_id == referrer_id,
+ User.created_at >= start_date
+ )
+ )
+ )
+ invited_count = invited_result.scalar() or 0
+
+ top_data.append({
+ 'referrer_id': referrer_id,
+ 'invited_count': invited_count,
+ 'earnings_kopeks': earnings
+ })
+
+ # Добавляем информацию о пользователях
+ result = []
+ for data in top_data:
+ user_result = await db.execute(
+ select(User.id, User.username, User.first_name, User.last_name, User.telegram_id)
+ .where(User.id == data['referrer_id'])
+ )
+ user = user_result.first()
+
+ if user:
+ display_name = ""
+ if user.first_name:
+ display_name = user.first_name
+ if user.last_name:
+ display_name += f" {user.last_name}"
+ elif user.username:
+ display_name = f"@{user.username}"
+ else:
+ display_name = f"ID{user.telegram_id}"
+
+ result.append({
+ 'user_id': user.id,
+ 'telegram_id': user.telegram_id,
+ 'username': user.username,
+ 'display_name': display_name,
+ 'invited_count': data['invited_count'],
+ 'earnings_kopeks': data['earnings_kopeks']
+ })
+
+ return result
+
+
async def get_user_referral_stats(db: AsyncSession, user_id: int) -> dict:
invited_count_result = await db.execute(
diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py
index 120f7f59..847d73cb 100644
--- a/app/database/crud/subscription.py
+++ b/app/database/crud/subscription.py
@@ -128,17 +128,18 @@ async def create_paid_subscription(
device_limit: Optional[int] = None,
connected_squads: List[str] = None,
update_server_counters: bool = False,
+ is_trial: bool = False,
) -> Subscription:
end_date = datetime.utcnow() + timedelta(days=duration_days)
-
+
if device_limit is None:
device_limit = settings.DEFAULT_DEVICE_LIMIT
subscription = Subscription(
user_id=user_id,
status=SubscriptionStatus.ACTIVE.value,
- is_trial=False,
+ is_trial=is_trial,
start_date=datetime.utcnow(),
end_date=end_date,
traffic_limit_gb=traffic_limit_gb,
@@ -222,6 +223,7 @@ async def replace_subscription(
subscription.end_date = current_time + timedelta(days=duration_days)
subscription.traffic_limit_gb = traffic_limit_gb
subscription.traffic_used_gb = 0.0
+ subscription.purchased_traffic_gb = 0 # Сбрасываем докупленный трафик при замене подписки
subscription.device_limit = device_limit
subscription.connected_squads = list(new_squads)
subscription.subscription_url = None
@@ -339,7 +341,8 @@ async def extend_subscription(
if settings.RESET_TRAFFIC_ON_PAYMENT:
subscription.traffic_used_gb = 0.0
- logger.info("🔄 Сбрасываем использованный трафик согласно настройке RESET_TRAFFIC_ON_PAYMENT")
+ subscription.purchased_traffic_gb = 0 # Сбрасываем докупленный трафик вместе с использованным
+ logger.info("🔄 Сбрасываем использованный и докупленный трафик согласно настройке RESET_TRAFFIC_ON_PAYMENT")
subscription.updated_at = current_time
@@ -1545,7 +1548,84 @@ async def create_pending_subscription(
subscription.id,
payment_method,
)
-
+
+ return subscription
+
+
+async def create_pending_trial_subscription(
+ db: AsyncSession,
+ user_id: int,
+ duration_days: int,
+ traffic_limit_gb: int = 0,
+ device_limit: int = 1,
+ connected_squads: List[str] = None,
+ payment_method: str = "pending",
+ total_price_kopeks: int = 0
+) -> Subscription:
+ """Creates a pending trial subscription that will be activated after payment."""
+
+ current_time = datetime.utcnow()
+ end_date = current_time + timedelta(days=duration_days)
+
+ existing_subscription = await get_subscription_by_user_id(db, user_id)
+
+ if existing_subscription:
+ if (
+ existing_subscription.status == SubscriptionStatus.ACTIVE.value
+ and existing_subscription.end_date > current_time
+ ):
+ logger.warning(
+ "⚠️ Попытка создать pending триал для активного пользователя %s. Возвращаем существующую запись.",
+ user_id,
+ )
+ return existing_subscription
+
+ # Обновляем существующую подписку
+ existing_subscription.status = SubscriptionStatus.PENDING.value
+ existing_subscription.is_trial = True # Помечаем как триальную
+ existing_subscription.start_date = current_time
+ existing_subscription.end_date = end_date
+ existing_subscription.traffic_limit_gb = traffic_limit_gb
+ existing_subscription.device_limit = device_limit
+ existing_subscription.connected_squads = connected_squads or []
+ existing_subscription.traffic_used_gb = 0.0
+ existing_subscription.updated_at = current_time
+
+ await db.commit()
+ await db.refresh(existing_subscription)
+
+ logger.info(
+ "♻️ Обновлена ожидающая триальная подписка пользователя %s, ID: %s, метод оплаты: %s",
+ user_id,
+ existing_subscription.id,
+ payment_method,
+ )
+ return existing_subscription
+
+ subscription = Subscription(
+ user_id=user_id,
+ status=SubscriptionStatus.PENDING.value,
+ is_trial=True, # Помечаем как триальную
+ start_date=current_time,
+ end_date=end_date,
+ traffic_limit_gb=traffic_limit_gb,
+ device_limit=device_limit,
+ connected_squads=connected_squads or [],
+ autopay_enabled=settings.is_autopay_enabled_by_default(),
+ autopay_days_before=settings.DEFAULT_AUTOPAY_DAYS_BEFORE,
+ )
+
+ db.add(subscription)
+ await db.commit()
+ await db.refresh(subscription)
+
+ logger.info(
+ "💳 Создана ожидающая триальная подписка для пользователя %s, ID: %s, метод оплаты: %s",
+ user_id,
+ subscription.id,
+ payment_method,
+ )
+
return subscription
diff --git a/app/database/crud/user.py b/app/database/crud/user.py
index 27fa7c1d..4b1001e6 100644
--- a/app/database/crud/user.py
+++ b/app/database/crud/user.py
@@ -331,13 +331,14 @@ async def add_user_balance(
description: str = "Пополнение баланса",
create_transaction: bool = True,
transaction_type: TransactionType = TransactionType.DEPOSIT,
- bot = None
+ bot = None,
+ payment_method: Optional[PaymentMethod] = None
) -> bool:
try:
old_balance = user.balance_kopeks
user.balance_kopeks += amount_kopeks
user.updated_at = datetime.utcnow()
-
+
if create_transaction:
from app.database.crud.transaction import create_transaction as create_trans
@@ -346,7 +347,8 @@ async def add_user_balance(
user_id=user.id,
type=transaction_type,
amount_kopeks=amount_kopeks,
- description=description
+ description=description,
+ payment_method=payment_method
)
await db.commit()
@@ -368,19 +370,21 @@ async def add_user_balance_by_id(
amount_kopeks: int,
description: str = "Пополнение баланса",
transaction_type: TransactionType = TransactionType.DEPOSIT,
+ payment_method: Optional[PaymentMethod] = None,
) -> bool:
try:
user = await get_user_by_telegram_id(db, telegram_id)
if not user:
logger.error(f"Пользователь с telegram_id {telegram_id} не найден")
return False
-
+
return await add_user_balance(
db,
user,
amount_kopeks,
description,
transaction_type=transaction_type,
+ payment_method=payment_method,
)
except Exception as e:
diff --git a/app/database/models.py b/app/database/models.py
index 2f0a58ba..d07fecae 100644
--- a/app/database/models.py
+++ b/app/database/models.py
@@ -755,11 +755,13 @@ class Subscription(Base):
traffic_limit_gb = Column(Integer, default=0)
traffic_used_gb = Column(Float, default=0.0)
+ purchased_traffic_gb = Column(Integer, default=0) # Докупленный трафик (для расчета цены сброса)
subscription_url = Column(String, nullable=True)
subscription_crypto_link = Column(String, nullable=True)
device_limit = Column(Integer, default=1)
+ modem_enabled = Column(Boolean, default=False)
connected_squads = Column(JSON, default=list)
@@ -1107,7 +1109,8 @@ class ContestTemplate(Base):
name = Column(String(100), nullable=False)
slug = Column(String(50), nullable=False, unique=True, index=True)
description = Column(Text, nullable=True)
- prize_days = Column(Integer, nullable=False, default=1)
+ prize_type = Column(String(20), nullable=False, default="days")
+ prize_value = Column(String(50), nullable=False, default="1")
max_winners = Column(Integer, nullable=False, default=1)
attempts_per_user = Column(Integer, nullable=False, default=1)
times_per_day = Column(Integer, nullable=False, default=1)
diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py
index e0d7e6c8..a22d80a9 100644
--- a/app/database/universal_migration.py
+++ b/app/database/universal_migration.py
@@ -4555,6 +4555,130 @@ async def add_promocode_promo_group_column() -> bool:
return False
+async def migrate_contest_templates_prize_columns() -> bool:
+ """Миграция contest_templates: prize_days -> prize_type + prize_value."""
+ try:
+ prize_type_exists = await check_column_exists("contest_templates", "prize_type")
+ prize_value_exists = await check_column_exists("contest_templates", "prize_value")
+
+ if prize_type_exists and prize_value_exists:
+ logger.info("Колонки prize_type и prize_value уже существуют в contest_templates")
+ return True
+
+ async with engine.begin() as conn:
+ db_type = await get_database_type()
+
+ # Добавляем prize_type
+ if not prize_type_exists:
+ if db_type == "sqlite":
+ await conn.execute(text(
+ "ALTER TABLE contest_templates ADD COLUMN prize_type VARCHAR(20) NOT NULL DEFAULT 'days'"
+ ))
+ elif db_type == "postgresql":
+ await conn.execute(text(
+ "ALTER TABLE contest_templates ADD COLUMN prize_type VARCHAR(20) NOT NULL DEFAULT 'days'"
+ ))
+ else:
+ await conn.execute(text(
+ "ALTER TABLE contest_templates ADD COLUMN prize_type VARCHAR(20) NOT NULL DEFAULT 'days'"
+ ))
+ logger.info("✅ Добавлена колонка prize_type в contest_templates")
+
+ # Добавляем prize_value
+ if not prize_value_exists:
+ if db_type == "sqlite":
+ await conn.execute(text(
+ "ALTER TABLE contest_templates ADD COLUMN prize_value VARCHAR(50) NOT NULL DEFAULT '1'"
+ ))
+ elif db_type == "postgresql":
+ await conn.execute(text(
+ "ALTER TABLE contest_templates ADD COLUMN prize_value VARCHAR(50) NOT NULL DEFAULT '1'"
+ ))
+ else:
+ await conn.execute(text(
+ "ALTER TABLE contest_templates ADD COLUMN prize_value VARCHAR(50) NOT NULL DEFAULT '1'"
+ ))
+ logger.info("✅ Добавлена колонка prize_value в contest_templates")
+
+ # Мигрируем данные из prize_days в prize_value (если prize_days существует)
+ prize_days_exists = await check_column_exists("contest_templates", "prize_days")
+ if prize_days_exists:
+ await conn.execute(text(
+ "UPDATE contest_templates SET prize_value = CAST(prize_days AS VARCHAR) WHERE prize_type = 'days'"
+ ))
+ logger.info("✅ Данные из prize_days перенесены в prize_value")
+
+ return True
+
+ except Exception as error:
+ logger.error(f"❌ Ошибка миграции prize_type/prize_value в contest_templates: {error}")
+ return False
+
+
+async def add_subscription_modem_enabled_column() -> bool:
+ """Добавить колонку modem_enabled в subscriptions."""
+ try:
+ column_exists = await check_column_exists("subscriptions", "modem_enabled")
+ if column_exists:
+ logger.info("Колонка modem_enabled уже существует в subscriptions")
+ return True
+
+ async with engine.begin() as conn:
+ db_type = await get_database_type()
+
+ if db_type == "sqlite":
+ await conn.execute(text(
+ "ALTER TABLE subscriptions ADD COLUMN modem_enabled BOOLEAN DEFAULT 0"
+ ))
+ elif db_type == "postgresql":
+ await conn.execute(text(
+ "ALTER TABLE subscriptions ADD COLUMN modem_enabled BOOLEAN DEFAULT FALSE"
+ ))
+ else:
+ await conn.execute(text(
+ "ALTER TABLE subscriptions ADD COLUMN modem_enabled TINYINT(1) DEFAULT 0"
+ ))
+
+ logger.info("✅ Добавлена колонка modem_enabled в subscriptions")
+ return True
+
+ except Exception as error:
+ logger.error(f"❌ Ошибка добавления modem_enabled в subscriptions: {error}")
+ return False
+
+
+async def add_subscription_purchased_traffic_column() -> bool:
+ """Добавить колонку purchased_traffic_gb в subscriptions."""
+ try:
+ column_exists = await check_column_exists("subscriptions", "purchased_traffic_gb")
+ if column_exists:
+ logger.info("Колонка purchased_traffic_gb уже существует в subscriptions")
+ return True
+
+ async with engine.begin() as conn:
+ db_type = await get_database_type()
+
+ if db_type == "sqlite":
+ await conn.execute(text(
+ "ALTER TABLE subscriptions ADD COLUMN purchased_traffic_gb INTEGER DEFAULT 0"
+ ))
+ elif db_type == "postgresql":
+ await conn.execute(text(
+ "ALTER TABLE subscriptions ADD COLUMN purchased_traffic_gb INTEGER DEFAULT 0"
+ ))
+ else:
+ await conn.execute(text(
+ "ALTER TABLE subscriptions ADD COLUMN purchased_traffic_gb INT DEFAULT 0"
+ ))
+
+ logger.info("✅ Добавлена колонка purchased_traffic_gb в subscriptions")
+ return True
+
+ except Exception as error:
+ logger.error(f"❌ Ошибка добавления purchased_traffic_gb в subscriptions: {error}")
+ return False
+
+
async def run_universal_migration():
logger.info("=== НАЧАЛО УНИВЕРСАЛЬНОЙ МИГРАЦИИ ===")
@@ -4742,6 +4866,13 @@ async def run_universal_migration():
else:
logger.warning("⚠️ Проблемы с таблицей contest_templates")
+ logger.info("=== МИГРАЦИЯ КОЛОНОК ПРИЗА В CONTEST_TEMPLATES ===")
+ prize_columns_ready = await migrate_contest_templates_prize_columns()
+ if prize_columns_ready:
+ logger.info("✅ Колонки prize_type и prize_value готовы")
+ else:
+ logger.warning("⚠️ Проблемы с миграцией prize_type/prize_value")
+
contest_rounds_ready = await create_contest_rounds_table()
if contest_rounds_ready:
logger.info("✅ Таблица contest_rounds готова")
@@ -4897,6 +5028,20 @@ async def run_universal_migration():
else:
logger.warning("⚠️ Проблемы с добавлением колонки subscription_crypto_link")
+ logger.info("=== ДОБАВЛЕНИЕ КОЛОНКИ MODEM_ENABLED ДЛЯ ПОДПИСОК ===")
+ modem_enabled_added = await add_subscription_modem_enabled_column()
+ if modem_enabled_added:
+ logger.info("✅ Колонка modem_enabled готова")
+ else:
+ logger.warning("⚠️ Проблемы с добавлением колонки modem_enabled")
+
+ logger.info("=== ДОБАВЛЕНИЕ КОЛОНКИ PURCHASED_TRAFFIC_GB ДЛЯ ПОДПИСОК ===")
+ purchased_traffic_added = await add_subscription_purchased_traffic_column()
+ if purchased_traffic_added:
+ logger.info("✅ Колонка purchased_traffic_gb готова")
+ else:
+ logger.warning("⚠️ Проблемы с добавлением колонки purchased_traffic_gb")
+
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ АУДИТА ПОДДЕРЖКИ ===")
try:
async with engine.begin() as conn:
@@ -5079,6 +5224,10 @@ async def check_migration_status():
"users_promo_offer_discount_expires_column": False,
"users_referral_commission_percent_column": False,
"subscription_crypto_link_column": False,
+ "subscription_modem_enabled_column": False,
+ "subscription_purchased_traffic_column": False,
+ "contest_templates_prize_type_column": False,
+ "contest_templates_prize_value_column": False,
"discount_offers_table": False,
"discount_offers_effect_column": False,
"discount_offers_extra_column": False,
@@ -5139,7 +5288,11 @@ async def check_migration_status():
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')
-
+ status["subscription_modem_enabled_column"] = await check_column_exists('subscriptions', 'modem_enabled')
+ status["subscription_purchased_traffic_column"] = await check_column_exists('subscriptions', 'purchased_traffic_gb')
+ status["contest_templates_prize_type_column"] = await check_column_exists('contest_templates', 'prize_type')
+ status["contest_templates_prize_value_column"] = await check_column_exists('contest_templates', 'prize_value')
+
media_fields_exist = (
await check_column_exists('broadcast_history', 'has_media') and
await check_column_exists('broadcast_history', 'media_type') and
@@ -5211,6 +5364,10 @@ async def check_migration_status():
"users_promo_offer_discount_expires_column": "Колонка срока действия промо-скидки у пользователей",
"users_referral_commission_percent_column": "Колонка процента реферальной комиссии у пользователей",
"subscription_crypto_link_column": "Колонка subscription_crypto_link в subscriptions",
+ "subscription_modem_enabled_column": "Колонка modem_enabled в subscriptions",
+ "subscription_purchased_traffic_column": "Колонка purchased_traffic_gb в subscriptions",
+ "contest_templates_prize_type_column": "Колонка prize_type в contest_templates",
+ "contest_templates_prize_value_column": "Колонка prize_value в contest_templates",
"discount_offers_table": "Таблица discount_offers",
"discount_offers_effect_column": "Колонка effect_type в discount_offers",
"discount_offers_extra_column": "Колонка extra_data в discount_offers",
diff --git a/app/handlers/admin/daily_contests.py b/app/handlers/admin/daily_contests.py
index 13731163..88d7506b 100644
--- a/app/handlers/admin/daily_contests.py
+++ b/app/handlers/admin/daily_contests.py
@@ -9,10 +9,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.contest import (
+ clear_attempts,
+ create_round,
get_template_by_id,
list_templates,
update_template_fields,
- create_round,
)
from app.database.models import ContestTemplate
from app.keyboards.admin import (
@@ -28,7 +29,8 @@ from app.utils.decorators import admin_required, error_handler
logger = logging.getLogger(__name__)
EDITABLE_FIELDS: Dict[str, Dict] = {
- "prize_days": {"type": int, "min": 1, "label": "приз (дни)"},
+ "prize_type": {"type": str, "label": "тип приза (days/balance/custom)"},
+ "prize_value": {"type": str, "label": "значение приза"},
"max_winners": {"type": int, "min": 1, "label": "макс. победителей"},
"attempts_per_user": {"type": int, "min": 1, "label": "попыток на пользователя"},
"times_per_day": {"type": int, "min": 1, "label": "раундов в день"},
@@ -57,7 +59,8 @@ async def show_daily_contests(
else:
for tpl in templates:
status = "🟢" if tpl.is_enabled else "⚪️"
- lines.append(f"{status} {tpl.name} (slug: {tpl.slug}) — приз {tpl.prize_days}д, макс {tpl.max_winners}")
+ prize_info = f"{tpl.prize_value} ({tpl.prize_type})" if tpl.prize_type else tpl.prize_value
+ lines.append(f"{status} {tpl.name} (slug: {tpl.slug}) — приз {prize_info}, макс {tpl.max_winners}")
keyboard_rows = []
if templates:
@@ -101,10 +104,12 @@ async def show_daily_contest(
await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True)
return
+ prize_display = f"{tpl.prize_value} ({tpl.prize_type})" if tpl.prize_type else tpl.prize_value
lines = [
f"🏷 {tpl.name} (slug: {tpl.slug})",
f"{texts.t('ADMIN_CONTEST_STATUS_ACTIVE','🟢 Активен') if tpl.is_enabled else texts.t('ADMIN_CONTEST_STATUS_INACTIVE','⚪️ Выключен')}",
- f"Приз: {tpl.prize_days} дн. | Макс победителей: {tpl.max_winners}",
+ f"Тип приза: {tpl.prize_type or 'days'} | Значение: {tpl.prize_value or '1'}",
+ f"Макс победителей: {tpl.max_winners}",
f"Попыток/польз: {tpl.attempts_per_user}",
f"Раундов в день: {tpl.times_per_day}",
f"Расписание: {tpl.schedule_times or '-'}",
diff --git a/app/handlers/admin/monitoring.py b/app/handlers/admin/monitoring.py
index d7dc7444..7c45e1f7 100644
--- a/app/handlers/admin/monitoring.py
+++ b/app/handlers/admin/monitoring.py
@@ -10,6 +10,7 @@ from aiogram.exceptions import TelegramBadRequest
from app.config import settings
from app.database.database import get_db
from app.services.monitoring_service import monitoring_service
+from app.services.nalogo_queue_service import nalogo_queue_service
from app.utils.decorators import admin_required
from app.utils.pagination import paginate_list
from app.keyboards.admin import get_monitoring_keyboard, get_admin_main_keyboard
@@ -911,11 +912,36 @@ async def monitoring_statistics_callback(callback: CallbackQuery):
• Уведомления: {'🟢 Вкл' if getattr(settings, 'ENABLE_NOTIFICATIONS', True) else '🔴 Выкл'}
• Автооплата: {', '.join(map(str, settings.get_autopay_warning_days()))} дней
"""
-
+
+ # Добавляем информацию о чеках NaloGO
+ if settings.is_nalogo_enabled():
+ nalogo_status = await nalogo_queue_service.get_status()
+ queue_len = nalogo_status.get("queue_length", 0)
+ total_amount = nalogo_status.get("total_amount", 0)
+ running = nalogo_status.get("running", False)
+
+ nalogo_section = f"""
+🧾 Чеки NaloGO:
+• Сервис: {'🟢 Работает' if running else '🔴 Остановлен'}
+• В очереди: {queue_len} чек(ов)"""
+ if queue_len > 0:
+ nalogo_section += f"\n• На сумму: {total_amount:,.2f} ₽"
+ text += nalogo_section
+
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
- keyboard = InlineKeyboardMarkup(inline_keyboard=[
- [InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")]
- ])
+
+ buttons = []
+ # Кнопка обработки очереди чеков если есть что обрабатывать
+ if settings.is_nalogo_enabled():
+ nalogo_status = await nalogo_queue_service.get_status()
+ if nalogo_status.get("queue_length", 0) > 0:
+ buttons.append([InlineKeyboardButton(
+ text=f"🧾 Отправить чеки ({nalogo_status['queue_length']} шт.)",
+ callback_data="admin_mon_nalogo_force_process"
+ )])
+
+ buttons.append([InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")])
+ keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
break
@@ -925,6 +951,110 @@ async def monitoring_statistics_callback(callback: CallbackQuery):
await callback.answer(f"❌ Ошибка получения статистики: {str(e)}", show_alert=True)
+@router.callback_query(F.data == "admin_mon_nalogo_force_process")
+@admin_required
+async def nalogo_force_process_callback(callback: CallbackQuery):
+ """Принудительная отправка чеков из очереди."""
+ try:
+ await callback.answer("🔄 Запускаю обработку очереди чеков...", show_alert=False)
+
+ result = await nalogo_queue_service.force_process()
+
+ if "error" in result:
+ await callback.answer(f"❌ {result['error']}", show_alert=True)
+ return
+
+ message = result.get("message", "Готово")
+ processed = result.get("processed", 0)
+ remaining = result.get("remaining", 0)
+
+ if processed > 0:
+ text = f"✅ Обработано: {processed} чек(ов)"
+ if remaining > 0:
+ text += f"\n⏳ Осталось в очереди: {remaining}"
+ else:
+ if remaining > 0:
+ text = f"⚠️ Сервис nalog.ru недоступен\n⏳ В очереди: {remaining} чек(ов)"
+ else:
+ text = "📭 Очередь пуста"
+
+ await callback.answer(text, show_alert=True)
+
+ # Обновляем страницу статистики
+ from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
+
+ # Перезагружаем статистику
+ async for db in get_db():
+ from app.database.crud.subscription import get_subscriptions_statistics
+ sub_stats = await get_subscriptions_statistics(db)
+ mon_status = await monitoring_service.get_monitoring_status(db)
+
+ week_ago = datetime.now() - timedelta(days=7)
+ week_logs = await monitoring_service.get_monitoring_logs(db, limit=1000)
+ week_logs = [log for log in week_logs if log['created_at'] >= week_ago]
+ week_success = sum(1 for log in week_logs if log['is_success'])
+ week_errors = len(week_logs) - week_success
+
+ stats_text = f"""
+📊 Статистика мониторинга
+
+📱 Подписки:
+• Всего: {sub_stats['total_subscriptions']}
+• Активных: {sub_stats['active_subscriptions']}
+• Тестовых: {sub_stats['trial_subscriptions']}
+• Платных: {sub_stats['paid_subscriptions']}
+
+📈 За сегодня:
+• Успешных операций: {mon_status['stats_24h']['successful']}
+• Ошибок: {mon_status['stats_24h']['failed']}
+• Успешность: {mon_status['stats_24h']['success_rate']}%
+
+📊 За неделю:
+• Всего событий: {len(week_logs)}
+• Успешных: {week_success}
+• Ошибок: {week_errors}
+• Успешность: {round(week_success/len(week_logs)*100, 1) if week_logs else 0}%
+
+🔧 Система:
+• Интервал: {settings.MONITORING_INTERVAL} мин
+• Уведомления: {'🟢 Вкл' if getattr(settings, 'ENABLE_NOTIFICATIONS', True) else '🔴 Выкл'}
+• Автооплата: {', '.join(map(str, settings.get_autopay_warning_days()))} дней
+"""
+
+ if settings.is_nalogo_enabled():
+ nalogo_status = await nalogo_queue_service.get_status()
+ queue_len = nalogo_status.get("queue_length", 0)
+ total_amount = nalogo_status.get("total_amount", 0)
+ running = nalogo_status.get("running", False)
+
+ nalogo_section = f"""
+🧾 Чеки NaloGO:
+• Сервис: {'🟢 Работает' if running else '🔴 Остановлен'}
+• В очереди: {queue_len} чек(ов)"""
+ if queue_len > 0:
+ nalogo_section += f"\n• На сумму: {total_amount:,.2f} ₽"
+ stats_text += nalogo_section
+
+ buttons = []
+ if settings.is_nalogo_enabled():
+ nalogo_status = await nalogo_queue_service.get_status()
+ if nalogo_status.get("queue_length", 0) > 0:
+ buttons.append([InlineKeyboardButton(
+ text=f"🧾 Отправить чеки ({nalogo_status['queue_length']} шт.)",
+ callback_data="admin_mon_nalogo_force_process"
+ )])
+
+ buttons.append([InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")])
+ keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
+
+ await callback.message.edit_text(stats_text, parse_mode="HTML", reply_markup=keyboard)
+ break
+
+ except Exception as e:
+ logger.error(f"Ошибка принудительной обработки чеков: {e}")
+ await callback.answer(f"❌ Ошибка: {str(e)}", show_alert=True)
+
+
def get_monitoring_logs_keyboard(current_page: int, total_pages: int):
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
diff --git a/app/handlers/admin/referrals.py b/app/handlers/admin/referrals.py
index 9de1bb65..f94ffbd6 100644
--- a/app/handlers/admin/referrals.py
+++ b/app/handlers/admin/referrals.py
@@ -6,7 +6,11 @@ import datetime
from app.config import settings
from app.database.models import User
from app.localization.texts import get_texts
-from app.database.crud.referral import get_referral_statistics, get_user_referral_stats
+from app.database.crud.referral import (
+ get_referral_statistics,
+ get_top_referrers_by_period,
+ get_user_referral_stats,
+)
from app.database.crud.user import get_user_by_id
from app.utils.decorators import admin_required, error_handler
@@ -121,6 +125,39 @@ async def show_referral_statistics(
await callback.answer("Произошла ошибка при загрузке статистики")
+def _get_top_keyboard(period: str, sort_by: str) -> types.InlineKeyboardMarkup:
+ """Создаёт клавиатуру для выбора периода и сортировки."""
+ period_week = "✅ Неделя" if period == "week" else "Неделя"
+ period_month = "✅ Месяц" if period == "month" else "Месяц"
+ sort_earnings = "✅ По заработку" if sort_by == "earnings" else "По заработку"
+ sort_invited = "✅ По приглашённым" if sort_by == "invited" else "По приглашённым"
+
+ return types.InlineKeyboardMarkup(inline_keyboard=[
+ [
+ types.InlineKeyboardButton(
+ text=period_week,
+ callback_data=f"admin_top_ref:week:{sort_by}"
+ ),
+ types.InlineKeyboardButton(
+ text=period_month,
+ callback_data=f"admin_top_ref:month:{sort_by}"
+ ),
+ ],
+ [
+ types.InlineKeyboardButton(
+ text=sort_earnings,
+ callback_data=f"admin_top_ref:{period}:earnings"
+ ),
+ types.InlineKeyboardButton(
+ text=sort_invited,
+ callback_data=f"admin_top_ref:{period}:invited"
+ ),
+ ],
+ [types.InlineKeyboardButton(text="🔄 Обновить", callback_data=f"admin_top_ref:{period}:{sort_by}")],
+ [types.InlineKeyboardButton(text="⬅️ К статистике", callback_data="admin_referrals")]
+ ])
+
+
@admin_required
@error_handler
async def show_top_referrers(
@@ -128,27 +165,66 @@ async def show_top_referrers(
db_user: User,
db: AsyncSession
):
+ """Показывает топ рефереров (по умолчанию: неделя, по заработку)."""
+ await _show_top_referrers_filtered(callback, db, period="week", sort_by="earnings")
+
+
+@admin_required
+@error_handler
+async def show_top_referrers_filtered(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
+):
+ """Обрабатывает выбор периода и сортировки."""
+ # Парсим callback_data: admin_top_ref:period:sort_by
+ parts = callback.data.split(":")
+ if len(parts) != 3:
+ await callback.answer("Ошибка параметров")
+ return
+
+ period = parts[1] # week или month
+ sort_by = parts[2] # earnings или invited
+
+ if period not in ("week", "month"):
+ period = "week"
+ if sort_by not in ("earnings", "invited"):
+ sort_by = "earnings"
+
+ await _show_top_referrers_filtered(callback, db, period, sort_by)
+
+
+async def _show_top_referrers_filtered(
+ callback: types.CallbackQuery,
+ db: AsyncSession,
+ period: str,
+ sort_by: str
+):
+ """Внутренняя функция отображения топа с фильтрами."""
try:
- stats = await get_referral_statistics(db)
- top_referrers = stats.get('top_referrers', [])
-
- text = "🏆 Топ рефереров\n\n"
-
+ top_referrers = await get_top_referrers_by_period(db, period=period, sort_by=sort_by)
+
+ period_text = "за неделю" if period == "week" else "за месяц"
+ sort_text = "по заработку" if sort_by == "earnings" else "по приглашённым"
+
+ text = f"🏆 Топ рефереров {period_text}\n"
+ text += f"Сортировка: {sort_text}\n\n"
+
if top_referrers:
- for i, referrer in enumerate(top_referrers[:20], 1):
- earned = referrer.get('total_earned_kopeks', 0)
- count = referrer.get('referrals_count', 0)
+ for i, referrer in enumerate(top_referrers[:20], 1):
+ earned = referrer.get('earnings_kopeks', 0)
+ count = referrer.get('invited_count', 0)
display_name = referrer.get('display_name', 'N/A')
username = referrer.get('username', '')
telegram_id = referrer.get('telegram_id', 'N/A')
-
+
if username:
display_text = f"@{username} (ID{telegram_id})"
elif display_name and display_name != f"ID{telegram_id}":
display_text = f"{display_name} (ID{telegram_id})"
else:
display_text = f"ID{telegram_id}"
-
+
emoji = ""
if i == 1:
emoji = "🥇 "
@@ -156,21 +232,30 @@ async def show_top_referrers(
emoji = "🥈 "
elif i == 3:
emoji = "🥉 "
-
- text += f"{emoji}{i}. {display_text}\n"
- text += f" 💰 {settings.format_price(earned)} | 👥 {count} реф.\n\n"
+
+ # Выделяем основную метрику в зависимости от сортировки
+ if sort_by == "invited":
+ text += f"{emoji}{i}. {display_text}\n"
+ text += f" 👥 {count} приглашённых | 💰 {settings.format_price(earned)}\n\n"
+ else:
+ text += f"{emoji}{i}. {display_text}\n"
+ text += f" 💰 {settings.format_price(earned)} | 👥 {count} приглашённых\n\n"
else:
- text += "Нет данных о рефererах\n"
-
- keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
- [types.InlineKeyboardButton(text="⬅️ К статистике", callback_data="admin_referrals")]
- ])
-
- await callback.message.edit_text(text, reply_markup=keyboard)
- await callback.answer()
-
+ text += "Нет данных за выбранный период\n"
+
+ keyboard = _get_top_keyboard(period, sort_by)
+
+ try:
+ await callback.message.edit_text(text, reply_markup=keyboard)
+ await callback.answer()
+ except Exception as edit_error:
+ if "message is not modified" in str(edit_error):
+ await callback.answer("Данные актуальны")
+ else:
+ raise
+
except Exception as e:
- logger.error(f"Ошибка в show_top_referrers: {e}", exc_info=True)
+ logger.error(f"Ошибка в show_top_referrers_filtered: {e}", exc_info=True)
await callback.answer("Ошибка загрузки топа рефереров")
@@ -210,4 +295,5 @@ async def show_referral_settings(
def register_handlers(dp: Dispatcher):
dp.callback_query.register(show_referral_statistics, F.data == "admin_referrals")
dp.callback_query.register(show_top_referrers, F.data == "admin_referrals_top")
+ dp.callback_query.register(show_top_referrers_filtered, F.data.startswith("admin_top_ref:"))
dp.callback_query.register(show_referral_settings, F.data == "admin_referrals_settings")
diff --git a/app/handlers/admin/users.py b/app/handlers/admin/users.py
index 5c145416..cc7d7e34 100644
--- a/app/handlers/admin/users.py
+++ b/app/handlers/admin/users.py
@@ -1241,6 +1241,15 @@ async def _render_user_subscription_overview(
]
]
+ if settings.is_modem_enabled():
+ modem_status = "✅" if getattr(subscription, 'modem_enabled', False) else "❌"
+ keyboard.append([
+ types.InlineKeyboardButton(
+ text=f"📡 Модем ({modem_status})",
+ callback_data=f"admin_user_modem_{user_id}"
+ )
+ ])
+
if subscription.is_active:
keyboard.append([
types.InlineKeyboardButton(
@@ -3803,6 +3812,68 @@ async def set_user_devices_button(
await callback.answer()
+@admin_required
+@error_handler
+async def toggle_user_modem(
+ callback: types.CallbackQuery,
+ db_user: User,
+ 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
+
+ subscription = user.subscription
+ if not subscription:
+ await callback.answer("❌ У пользователя нет подписки", show_alert=True)
+ return
+
+ modem_enabled = getattr(subscription, 'modem_enabled', False) or False
+
+ if modem_enabled:
+ # Отключаем модем
+ subscription.modem_enabled = False
+ if subscription.device_limit and subscription.device_limit > 1:
+ subscription.device_limit = subscription.device_limit - 1
+ action_text = "отключен"
+ else:
+ # Включаем модем
+ subscription.modem_enabled = True
+ subscription.device_limit = (subscription.device_limit or 1) + 1
+ action_text = "подключен"
+
+ subscription.updated_at = datetime.utcnow()
+ await db.commit()
+
+ # Обновляем в RemnaWave
+ try:
+ subscription_service = SubscriptionService()
+ await subscription_service.update_remnawave_user(db, subscription)
+ except Exception as e:
+ logger.error(f"Ошибка обновления RemnaWave при переключении модема: {e}")
+
+ await db.refresh(subscription)
+
+ modem_status = "✅ Подключен" if subscription.modem_enabled else "❌ Отключен"
+
+ await callback.message.edit_text(
+ f"📡 Модем {action_text}\n\n"
+ f"Статус модема: {modem_status}\n"
+ f"Лимит устройств: {subscription.device_limit}",
+ reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
+ [types.InlineKeyboardButton(text="📱 Подписка и настройки", callback_data=f"admin_user_subscription_{user_id}")]
+ ]),
+ parse_mode="HTML"
+ )
+
+ logger.info(f"Админ {db_user.telegram_id} {action_text} модем для пользователя {user_id}")
+ await callback.answer()
+
+
@admin_required
@error_handler
async def process_devices_edit_text(
@@ -5198,6 +5269,11 @@ def register_handlers(dp: Dispatcher):
F.data.startswith("admin_user_devices_set_")
)
+ dp.callback_query.register(
+ toggle_user_modem,
+ F.data.startswith("admin_user_modem_")
+ )
+
dp.message.register(
process_devices_edit_text,
AdminStates.editing_user_devices
diff --git a/app/handlers/contests.py b/app/handlers/contests.py
index a32f3914..dd54de6d 100644
--- a/app/handlers/contests.py
+++ b/app/handlers/contests.py
@@ -1,41 +1,73 @@
+"""Contest handlers for daily games."""
+
import logging
-import random
-from datetime import datetime, timedelta
+from datetime import datetime
+from typing import Optional
from aiogram import Dispatcher, F, types
-from aiogram.fsm.context import FSMContext
from aiogram.filters import Command
+from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
-from app.config import settings
-from app.database.crud.contest import (
- get_active_rounds,
- get_attempt,
- create_attempt,
- increment_winner_count,
-)
-from app.database.database import AsyncSessionLocal
-from app.database.models import ContestRound, ContestTemplate, SubscriptionStatus
-from app.localization.texts import get_texts
-from app.services.contest_rotation_service import (
- GAME_QUEST,
- GAME_LOCKS,
- GAME_CIPHER,
- GAME_SERVER,
- GAME_BLITZ,
- GAME_EMOJI,
- GAME_ANAGRAM,
-)
+from app.database.crud.contest import get_active_rounds, get_attempt
from app.database.crud.subscription import get_subscription_by_user_id
-from app.database.crud.subscription import extend_subscription
-from app.utils.decorators import auth_required, error_handler
+from app.database.database import AsyncSessionLocal
+from app.database.models import ContestRound, SubscriptionStatus
from app.keyboards.inline import get_back_keyboard
+from app.localization.texts import get_texts
+from app.services.contests import (
+ ContestAttemptService,
+ GameType,
+ get_game_strategy,
+)
from app.states import ContestStates
+from app.utils.decorators import auth_required, error_handler
logger = logging.getLogger(__name__)
+# Rate limiting storage
+_rate_limits: dict = {}
+
+# Service instance
+_attempt_service = ContestAttemptService()
+
+
+def _check_rate_limit(user_id: int, action: str, limit: int = 1, window_seconds: int = 5) -> bool:
+ """Check if user exceeds rate limit for contest actions."""
+ key = f"{user_id}_{action}"
+ now = datetime.utcnow().timestamp()
+
+ if key not in _rate_limits:
+ _rate_limits[key] = []
+
+ # Clean old entries
+ _rate_limits[key] = [t for t in _rate_limits[key] if now - t < window_seconds]
+
+ if len(_rate_limits[key]) >= limit:
+ return False
+
+ _rate_limits[key].append(now)
+ return True
+
+
+def _validate_callback_data(data: str) -> Optional[list]:
+ """Validate and parse callback data safely."""
+ if not data or not isinstance(data, str):
+ return None
+
+ parts = data.split("_")
+ if len(parts) < 2 or parts[0] != "contest":
+ return None
+
+ for part in parts:
+ if not part or len(part) > 50:
+ return None
+
+ return parts
+
def _user_allowed(subscription) -> bool:
+ """Check if user has active or trial subscription."""
if not subscription:
return False
return subscription.status in {
@@ -44,22 +76,13 @@ def _user_allowed(subscription) -> bool:
}
-async def _award_prize(db: AsyncSession, user_id: int, prize_days: int, language: str) -> str:
- from app.database.crud.user import get_user_by_id
- user = await get_user_by_id(db, user_id)
- if not user:
- return ""
- subscription = await get_subscription_by_user_id(db, user_id)
- if not subscription:
- return ""
- await extend_subscription(db, subscription, prize_days)
- texts = get_texts(language)
- return texts.t("CONTEST_PRIZE_GRANTED", "Бонус {days} дней зачислен!").format(days=prize_days)
-
-
async def _reply_not_eligible(callback: types.CallbackQuery, language: str):
+ """Reply that user is not eligible to play."""
texts = get_texts(language)
- await callback.answer(texts.t("CONTEST_NOT_ELIGIBLE", "Игры доступны только с активной или триальной подпиской."), show_alert=True)
+ await callback.answer(
+ texts.t("CONTEST_NOT_ELIGIBLE", "Игры доступны только с активной или триальной подпиской."),
+ show_alert=True,
+ )
# ---------- Handlers ----------
@@ -68,13 +91,17 @@ async def _reply_not_eligible(callback: types.CallbackQuery, language: str):
@auth_required
@error_handler
async def show_contests_menu(callback: types.CallbackQuery, db_user, db: AsyncSession):
+ """Show menu with available contest games."""
texts = get_texts(db_user.language)
+
subscription = await get_subscription_by_user_id(db, db_user.id)
if not _user_allowed(subscription):
await _reply_not_eligible(callback, db_user.language)
return
active_rounds = await get_active_rounds(db)
+
+ # Group by template, take one round per template
unique_templates = {}
for rnd in active_rounds:
if not rnd.template or not rnd.template.is_enabled:
@@ -86,19 +113,24 @@ async def show_contests_menu(callback: types.CallbackQuery, db_user, db: AsyncSe
buttons = []
for tpl_slug, rnd in unique_templates.items():
title = rnd.template.name if rnd.template else tpl_slug
- buttons.append(
- [
- types.InlineKeyboardButton(
- text=f"▶️ {title}",
- callback_data=f"contest_play_{tpl_slug}_{rnd.id}",
- )
- ]
- )
+ buttons.append([
+ types.InlineKeyboardButton(
+ text=f"▶️ {title}",
+ callback_data=f"contest_play_{tpl_slug}_{rnd.id}",
+ )
+ ])
+
if not buttons:
- buttons.append(
- [types.InlineKeyboardButton(text=texts.t("CONTEST_EMPTY", "Сейчас игр нет"), callback_data="noop")]
- )
- buttons.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")])
+ buttons.append([
+ types.InlineKeyboardButton(
+ text=texts.t("CONTEST_EMPTY", "Сейчас игр нет"),
+ callback_data="noop",
+ )
+ ])
+
+ buttons.append([
+ types.InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")
+ ])
await callback.message.edit_text(
texts.t("CONTEST_MENU_TITLE", "🎲 Игры/Конкурсы\nВыберите игру:"),
@@ -110,14 +142,25 @@ async def show_contests_menu(callback: types.CallbackQuery, db_user, db: AsyncSe
@auth_required
@error_handler
async def play_contest(callback: types.CallbackQuery, state: FSMContext, db_user, db: AsyncSession):
+ """Start playing a specific contest."""
texts = get_texts(db_user.language)
+
subscription = await get_subscription_by_user_id(db, db_user.id)
if not _user_allowed(subscription):
await _reply_not_eligible(callback, db_user.language)
return
- parts = callback.data.split("_")
- if len(parts) < 4 or parts[0] != "contest" or parts[1] != "play":
+ # Rate limit check
+ if not _check_rate_limit(db_user.id, "contest_play", limit=2, window_seconds=10):
+ await callback.answer(
+ texts.t("CONTEST_TOO_FAST", "Слишком быстро! Подождите."),
+ show_alert=True,
+ )
+ return
+
+ # Validate callback data
+ parts = _validate_callback_data(callback.data)
+ if not parts or len(parts) < 4 or parts[1] != "play":
await callback.answer("Некорректные данные", show_alert=True)
return
@@ -128,285 +171,169 @@ async def play_contest(callback: types.CallbackQuery, state: FSMContext, db_user
await callback.answer("Некорректные данные", show_alert=True)
return
- slug = "_".join(parts[2:-1])
-
- # reload round with template
+ # Get round with template
async with AsyncSessionLocal() as db2:
active_rounds = await get_active_rounds(db2)
round_obj = next((r for r in active_rounds if r.id == round_id), None)
+
if not round_obj:
- await callback.answer(texts.t("CONTEST_ROUND_FINISHED", "Раунд завершён или недоступен."), show_alert=True)
+ await callback.answer(
+ texts.t("CONTEST_ROUND_FINISHED", "Раунд завершён или недоступен."),
+ show_alert=True,
+ )
return
+
if not round_obj.template or not round_obj.template.is_enabled:
- await callback.answer(texts.t("CONTEST_DISABLED", "Игра отключена."), show_alert=True)
+ await callback.answer(
+ texts.t("CONTEST_DISABLED", "Игра отключена."),
+ show_alert=True,
+ )
return
+
+ # Check if user already played
attempt = await get_attempt(db2, round_id, db_user.id)
if attempt:
- await callback.answer(texts.t("CONTEST_ALREADY_PLAYED", "У вас уже была попытка в этом раунде."), show_alert=True)
+ await callback.answer(
+ texts.t("CONTEST_ALREADY_PLAYED", "У вас уже была попытка в этом раунде."),
+ show_alert=True,
+ )
return
+ # Get game strategy and render
tpl = round_obj.template
- if tpl.slug == GAME_QUEST:
- await _render_quest(callback, db_user, round_obj, tpl)
- elif tpl.slug == GAME_LOCKS:
- await _render_locks(callback, db_user, round_obj, tpl)
- elif tpl.slug == GAME_SERVER:
- await _render_server_lottery(callback, db_user, round_obj, tpl)
- elif tpl.slug == GAME_CIPHER:
- await _render_cipher(callback, db_user, round_obj, tpl, state)
- elif tpl.slug == GAME_EMOJI:
- await _render_emoji(callback, db_user, round_obj, tpl, state)
- elif tpl.slug == GAME_ANAGRAM:
- await _render_anagram(callback, db_user, round_obj, tpl, state)
- elif tpl.slug == GAME_BLITZ:
- await _render_blitz(callback, db_user, round_obj, tpl)
- else:
- await callback.answer(texts.t("CONTEST_UNKNOWN", "Тип конкурса не поддерживается."), show_alert=True)
+ strategy = get_game_strategy(tpl.slug)
-
-async def _render_quest(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate):
- texts = get_texts(db_user.language)
- rows = round_obj.payload.get("rows", 3)
- cols = round_obj.payload.get("cols", 3)
- keyboard = []
- for r in range(rows):
- row_buttons = []
- for c in range(cols):
- idx = r * cols + c
- row_buttons.append(
- types.InlineKeyboardButton(
- text="🎛",
- callback_data=f"contest_pick_{round_obj.id}_quest_{idx}"
- )
+ if not strategy:
+ await callback.answer(
+ texts.t("CONTEST_UNKNOWN", "Тип конкурса не поддерживается."),
+ show_alert=True,
)
- keyboard.append(row_buttons)
- keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="contests_menu")])
- await callback.message.edit_text(
- texts.t("CONTEST_QUEST_PROMPT", "Выбери один из узлов 3×3:"),
- reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
- )
- await callback.answer()
+ return
+ render_result = strategy.render(
+ round_id=round_obj.id,
+ payload=round_obj.payload or {},
+ language=db_user.language,
+ )
-async def _render_locks(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate):
- texts = get_texts(db_user.language)
- total = round_obj.payload.get("total", 20)
- keyboard = []
- row = []
- for i in range(total):
- row.append(types.InlineKeyboardButton(text="🔒", callback_data=f"contest_pick_{round_obj.id}_locks_{i}"))
- if len(row) == 5:
- keyboard.append(row)
- row = []
- if row:
- keyboard.append(row)
- keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="contests_menu")])
- await callback.message.edit_text(
- texts.t("CONTEST_LOCKS_PROMPT", "Найди взломанную кнопку среди замков:"),
- reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
- )
- await callback.answer()
+ # For text input games, create pending attempt and set FSM state
+ if render_result.requires_text_input:
+ await _attempt_service.create_pending_attempt(db2, round_obj.id, db_user.id)
+ await state.set_state(ContestStates.waiting_for_answer)
+ await state.update_data(contest_round_id=round_obj.id)
-
-async def _render_server_lottery(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate):
- texts = get_texts(db_user.language)
- flags = round_obj.payload.get("flags") or []
- shuffled_flags = flags.copy()
- random.shuffle(shuffled_flags)
- keyboard = []
- row = []
- for flag in shuffled_flags:
- row.append(types.InlineKeyboardButton(text=flag, callback_data=f"contest_pick_{round_obj.id}_{flag}"))
- if len(row) == 5:
- keyboard.append(row)
- row = []
- if row:
- keyboard.append(row)
- keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="contests_menu")])
- await callback.message.edit_text(
- texts.t("CONTEST_SERVER_PROMPT", "Выбери сервер:"),
- reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
- )
- await callback.answer()
-
-
-async def _render_cipher(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate, state: FSMContext):
- texts = get_texts(db_user.language)
- question = round_obj.payload.get("question", "")
- await state.set_state(ContestStates.waiting_for_answer)
- await state.update_data(contest_round_id=round_obj.id)
- await callback.message.edit_text(
- texts.t("CONTEST_CIPHER_PROMPT", "Расшифруй: {q}").format(q=question),
- reply_markup=get_back_keyboard(db_user.language),
- )
- await callback.answer()
-
-
-async def _render_emoji(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate, state: FSMContext):
- texts = get_texts(db_user.language)
- question = round_obj.payload.get("question", "🤔")
- emoji_list = question.split()
- random.shuffle(emoji_list)
- shuffled_question = " ".join(emoji_list)
- await state.set_state(ContestStates.waiting_for_answer)
- await state.update_data(contest_round_id=round_obj.id)
- await callback.message.edit_text(
- texts.t("CONTEST_EMOJI_PROMPT", "Угадай сервис по эмодзи: {q}").format(q=shuffled_question),
- reply_markup=get_back_keyboard(db_user.language),
- )
- await callback.answer()
-
-
-async def _render_anagram(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate, state: FSMContext):
- texts = get_texts(db_user.language)
- letters = round_obj.payload.get("letters", "")
- await state.set_state(ContestStates.waiting_for_answer)
- await state.update_data(contest_round_id=round_obj.id)
- await callback.message.edit_text(
- texts.t("CONTEST_ANAGRAM_PROMPT", "Составь слово: {letters}").format(letters=letters),
- reply_markup=get_back_keyboard(db_user.language),
- )
- await callback.answer()
-
-
-async def _render_blitz(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate):
- texts = get_texts(db_user.language)
- keyboard = types.InlineKeyboardMarkup(
- inline_keyboard=[
- [types.InlineKeyboardButton(text=texts.t("CONTEST_BLITZ_BUTTON", "Я здесь!"), callback_data=f"contest_pick_{round_obj.id}_blitz")]
- ]
- )
- await callback.message.edit_text(
- texts.t("CONTEST_BLITZ_PROMPT", "⚡️ Блиц! Нажми «Я здесь!»"),
- reply_markup=keyboard,
- )
- await callback.answer()
+ await callback.message.edit_text(
+ render_result.text,
+ reply_markup=render_result.keyboard,
+ )
+ await callback.answer()
@auth_required
@error_handler
async def handle_pick(callback: types.CallbackQuery, db_user, db: AsyncSession):
+ """Handle button pick in contest games."""
texts = get_texts(db_user.language)
- parts = callback.data.split("_")
- if len(parts) < 4 or parts[0] != "contest" or parts[1] != "pick":
+
+ # Rate limit check
+ if not _check_rate_limit(db_user.id, "contest_pick", limit=1, window_seconds=3):
+ await callback.answer(
+ texts.t("CONTEST_TOO_FAST", "Слишком быстро! Подождите."),
+ show_alert=True,
+ )
+ return
+
+ # Validate callback data
+ parts = _validate_callback_data(callback.data)
+ if not parts or len(parts) < 4 or parts[1] != "pick":
await callback.answer("Некорректные данные", show_alert=True)
return
round_id_str = parts[2]
pick = "_".join(parts[3:])
+
try:
round_id = int(round_id_str)
except ValueError:
await callback.answer("Некорректные данные", show_alert=True)
return
- async with AsyncSessionLocal() as db2:
- active_rounds = await get_active_rounds(db2)
- round_obj = next((r for r in active_rounds if r.id == round_id), None)
- if not round_obj:
- await callback.answer(texts.t("CONTEST_ROUND_FINISHED", "Раунд завершён."), show_alert=True)
- return
-
- tpl = round_obj.template
- attempt = await get_attempt(db2, round_id, db_user.id)
- if attempt:
- await callback.answer(texts.t("CONTEST_ALREADY_PLAYED", "У вас уже была попытка."), show_alert=True)
- return
-
- secret_idx = round_obj.payload.get("secret_idx")
- correct_flag = ""
- if tpl.slug == GAME_SERVER:
- flags = round_obj.payload.get("flags") or []
- correct_flag = flags[secret_idx] if secret_idx is not None and secret_idx < len(flags) else ""
-
- is_winner = False
- if tpl.slug == GAME_SERVER:
- is_winner = pick == correct_flag
- elif tpl.slug == GAME_QUEST:
- # Format: quest_{idx}
- try:
- if pick.startswith("quest_"):
- idx = int(pick.split("_")[1])
- is_winner = secret_idx is not None and idx == secret_idx
- except (ValueError, IndexError):
- is_winner = False
- elif tpl.slug == GAME_LOCKS:
- # Format: locks_{idx}
- try:
- if pick.startswith("locks_"):
- idx = int(pick.split("_")[1])
- is_winner = secret_idx is not None and idx == secret_idx
- except (ValueError, IndexError):
- is_winner = False
- elif tpl.slug == GAME_BLITZ:
- is_winner = pick == "blitz"
- else:
- is_winner = False
-
- # Check if max winners already reached
- if is_winner and round_obj.winners_count >= round_obj.max_winners:
- is_winner = False # Too late, max winners already reached
-
- await create_attempt(db2, round_id=round_obj.id, user_id=db_user.id, answer=str(pick), is_winner=is_winner)
-
- if is_winner:
- await increment_winner_count(db2, round_obj)
- prize_text = await _award_prize(db2, db_user.id, tpl.prize_days, db_user.language)
- await callback.answer(texts.t("CONTEST_WIN", "🎉 Победа! ") + (prize_text or ""), show_alert=True)
- else:
- responses = {
- GAME_QUEST: ["Пусто", "Ложный сервер", "Найди другой узел"],
- GAME_LOCKS: ["Заблокировано", "Попробуй ещё", "Нет доступа"],
- GAME_SERVER: ["Сервер перегружен", "Нет ответа", "Попробуй завтра"],
- }.get(tpl.slug, ["Неудача"])
- await callback.answer(random.choice(responses), show_alert=True)
-
-
-@auth_required
-@error_handler
-async def handle_text_answer(message: types.Message, state: FSMContext, db_user, db: AsyncSession):
- texts = get_texts(db_user.language)
- data = await state.get_data()
- round_id = data.get("contest_round_id")
- if not round_id:
+ # Re-check subscription
+ subscription = await get_subscription_by_user_id(db, db_user.id)
+ if not _user_allowed(subscription):
+ await callback.answer(
+ texts.t("CONTEST_NOT_ELIGIBLE", "Игра недоступна без активной подписки."),
+ show_alert=True,
+ )
return
async with AsyncSessionLocal() as db2:
active_rounds = await get_active_rounds(db2)
round_obj = next((r for r in active_rounds if r.id == round_id), None)
+
if not round_obj:
- await message.answer(texts.t("CONTEST_ROUND_FINISHED", "Раунд завершён."), reply_markup=get_back_keyboard(db_user.language))
+ await callback.answer(
+ texts.t("CONTEST_ROUND_FINISHED", "Раунд завершён."),
+ show_alert=True,
+ )
+ return
+
+ # Process attempt using service
+ result = await _attempt_service.process_button_attempt(
+ db=db2,
+ round_obj=round_obj,
+ user_id=db_user.id,
+ pick=pick,
+ language=db_user.language,
+ )
+
+ await callback.answer(result.message, show_alert=True)
+
+
+@auth_required
+@error_handler
+async def handle_text_answer(message: types.Message, state: FSMContext, db_user, db: AsyncSession):
+ """Handle text answer in contest games."""
+ texts = get_texts(db_user.language)
+
+ data = await state.get_data()
+ round_id = data.get("contest_round_id")
+ if not round_id:
+ await state.clear()
+ return
+
+ async with AsyncSessionLocal() as db2:
+ active_rounds = await get_active_rounds(db2)
+ round_obj = next((r for r in active_rounds if r.id == round_id), None)
+
+ if not round_obj:
+ await message.answer(
+ texts.t("CONTEST_ROUND_FINISHED", "Раунд завершён."),
+ reply_markup=get_back_keyboard(db_user.language),
+ )
await state.clear()
return
- attempt = await get_attempt(db2, round_obj.id, db_user.id)
- if attempt:
- await message.answer(texts.t("CONTEST_ALREADY_PLAYED", "У вас уже была попытка."), reply_markup=get_back_keyboard(db_user.language))
- await state.clear()
- return
+ # Process attempt using service
+ text_answer = (message.text or "").strip()
+ result = await _attempt_service.process_text_attempt(
+ db=db2,
+ round_obj=round_obj,
+ user_id=db_user.id,
+ text_answer=text_answer,
+ language=db_user.language,
+ )
- answer = (message.text or "").strip().upper()
- tpl = round_obj.template
- correct = (round_obj.payload.get("answer") or "").upper()
+ await message.answer(
+ result.message,
+ reply_markup=get_back_keyboard(db_user.language),
+ )
- is_winner = correct and answer == correct
-
- # Check if max winners already reached
- if is_winner and round_obj.winners_count >= round_obj.max_winners:
- is_winner = False # Too late, max winners already reached
-
- await create_attempt(db2, round_id=round_obj.id, user_id=db_user.id, answer=answer, is_winner=is_winner)
-
- if is_winner:
- await increment_winner_count(db2, round_obj)
- prize_text = await _award_prize(db2, db_user.id, tpl.prize_days, db_user.language)
- await message.answer(texts.t("CONTEST_WIN", "🎉 Победа! ") + (prize_text or ""), reply_markup=get_back_keyboard(db_user.language))
- else:
- await message.answer(texts.t("CONTEST_LOSE", "Не верно, попробуй снова в следующем раунде."), reply_markup=get_back_keyboard(db_user.language))
await state.clear()
def register_handlers(dp: Dispatcher):
+ """Register contest handlers."""
dp.callback_query.register(show_contests_menu, F.data == "contests_menu")
dp.callback_query.register(play_contest, F.data.startswith("contest_play_"))
dp.callback_query.register(handle_pick, F.data.startswith("contest_pick_"))
diff --git a/app/handlers/menu.py b/app/handlers/menu.py
index a70cb9b1..72fcc793 100644
--- a/app/handlers/menu.py
+++ b/app/handlers/menu.py
@@ -1238,39 +1238,68 @@ async def handle_activate_button(
db_user: User,
db: AsyncSession
):
+ """
+ Умная кнопка активации — система сама решает что делать:
+ - Если подписка активна — ничего не делать
+ - Если подписка истекла — продлить с теми же параметрами
+ - Если подписки нет — создать новую с дефолтными параметрами
+ Выбирает максимальный период, который можно оплатить из баланса.
+ """
texts = get_texts(db_user.language)
-
- # Получить подписку пользователя
- from app.database.crud.subscription import get_subscription_by_user_id
+
+ from app.database.crud.subscription import get_subscription_by_user_id, create_paid_subscription
+ from app.database.crud.server_squad import get_server_ids_by_uuids, get_available_server_squads
+ from app.database.crud.transaction import create_transaction
+ from app.database.crud.user import subtract_user_balance
+ from app.database.models import TransactionType, PaymentMethod
+ from app.services.subscription_service import SubscriptionService
+ from app.services.subscription_renewal_service import SubscriptionRenewalService
+
subscription = await get_subscription_by_user_id(db, db_user.id)
-
+
+ # Если подписка активна — ничего не делаем
if subscription and subscription.status == "ACTIVE" and subscription.end_date > datetime.utcnow():
await callback.answer(
texts.t("SUBSCRIPTION_ALREADY_ACTIVE", "✅ Подписка уже активна!"),
show_alert=True,
)
return
-
- # Параметры из подписки или дефолтные
- device_limit = subscription.device_limit if subscription else settings.DEFAULT_DEVICE_LIMIT
- traffic_limit_gb = subscription.traffic_limit_gb if subscription else 0
- connected_squads = subscription.connected_squads if subscription else []
-
- # Получить IDs серверов из UUIDs
- from app.database.crud.server_squad import get_server_ids_by_uuids
+
+ # Определяем параметры подписки
+ if subscription:
+ # Есть подписка (возможно истекшая) — берём её параметры
+ device_limit = subscription.device_limit or settings.DEFAULT_DEVICE_LIMIT
+ traffic_limit_gb = subscription.traffic_limit_gb or 0
+ connected_squads = subscription.connected_squads or []
+ else:
+ # Нет подписки — дефолтные параметры
+ device_limit = settings.DEFAULT_DEVICE_LIMIT
+ traffic_limit_gb = 0
+ connected_squads = []
+
+ # Если серверы не выбраны — берём бесплатные по умолчанию
+ if not connected_squads:
+ available_servers = await get_available_server_squads(db, promo_group_id=db_user.promo_group_id)
+ connected_squads = [
+ s.squad_uuid for s in available_servers
+ if s.is_available and s.price_kopeks == 0
+ ]
+ # Если бесплатных нет — берём первый доступный
+ if not connected_squads and available_servers:
+ connected_squads = [available_servers[0].squad_uuid]
+
server_ids = await get_server_ids_by_uuids(db, connected_squads) if connected_squads else []
-
+
balance = db_user.balance_kopeks
- available_periods = [int(p) for p in settings.AVAILABLE_SUBSCRIPTION_PERIODS]
-
+ available_periods = sorted([int(p) for p in settings.AVAILABLE_SUBSCRIPTION_PERIODS], reverse=True)
+
+ subscription_service = SubscriptionService()
+
+ # Найти максимальный период <= баланса
best_period = None
best_price = 0
-
- from app.services.subscription_service import SubscriptionService
- subscription_service = SubscriptionService()
-
- # Найти максимальный период, цена которого <= баланса
- for period in sorted(available_periods, reverse=True):
+
+ for period in available_periods:
price, _ = await subscription_service.calculate_subscription_price_with_months(
period,
traffic_limit_gb,
@@ -1283,31 +1312,88 @@ async def handle_activate_button(
best_period = period
best_price = price
break
-
- if best_period:
- # Создать новую подписку
- from app.database.crud.subscription import create_paid_subscription
- new_subscription = await create_paid_subscription(
- db,
- db_user.id,
- best_period,
- traffic_limit_gb=traffic_limit_gb,
- device_limit=device_limit,
- connected_squads=connected_squads,
- update_server_counters=True
+
+ if not best_period:
+ # Показать сколько не хватает для минимального периода
+ min_period = min(available_periods) if available_periods else 30
+ min_price, _ = await subscription_service.calculate_subscription_price_with_months(
+ min_period, traffic_limit_gb, server_ids, device_limit, db, user=db_user
)
-
- # Списать деньги
- db_user.balance_kopeks -= best_price
- await db.commit()
-
+ missing = min_price - balance
await callback.answer(
- texts.t("ACTIVATION_SUCCESS", f"✅ Подписка активирована на {best_period} дней за {best_price//100} руб!"),
+ texts.t(
+ "INSUFFICIENT_FUNDS_DETAILED",
+ f"❌ Недостаточно средств. Не хватает {missing // 100} ₽"
+ ),
show_alert=True,
)
- else:
+ return
+
+ try:
+ if subscription:
+ # Продление существующей подписки
+ renewal_service = SubscriptionRenewalService()
+ pricing = await renewal_service.calculate_pricing(
+ db, db_user, subscription, best_period
+ )
+
+ result = await renewal_service.finalize(
+ db, db_user, subscription,
+ pricing,
+ description=f"Автоматическое продление на {best_period} дней",
+ payment_method=PaymentMethod.BALANCE,
+ )
+
+ await callback.answer(
+ texts.t(
+ "ACTIVATION_SUCCESS",
+ f"✅ Подписка продлена на {best_period} дней за {best_price // 100} ₽!"
+ ),
+ show_alert=True,
+ )
+ else:
+ # Создание новой подписки
+ new_subscription = await create_paid_subscription(
+ db,
+ db_user.id,
+ best_period,
+ traffic_limit_gb=traffic_limit_gb,
+ device_limit=device_limit,
+ connected_squads=connected_squads,
+ update_server_counters=True
+ )
+
+ # Списать баланс правильно
+ await subtract_user_balance(
+ db, db_user, best_price,
+ f"Активация подписки на {best_period} дней"
+ )
+
+ # Создать пользователя в RemnaWave
+ await subscription_service.create_remnawave_user(db, new_subscription)
+
+ # Создать транзакцию
+ await create_transaction(
+ db=db,
+ user_id=db_user.id,
+ type=TransactionType.SUBSCRIPTION_PAYMENT,
+ amount_kopeks=best_price,
+ description=f"Активация подписки на {best_period} дней",
+ payment_method=PaymentMethod.BALANCE,
+ )
+
+ await callback.answer(
+ texts.t(
+ "ACTIVATION_SUCCESS",
+ f"✅ Подписка активирована на {best_period} дней за {best_price // 100} ₽!"
+ ),
+ show_alert=True,
+ )
+
+ except Exception as e:
+ logger.error(f"Ошибка автоматической активации для {db_user.telegram_id}: {e}")
await callback.answer(
- texts.t("INSUFFICIENT_FUNDS", "❌ Недостаточно средств для активации подписки"),
+ texts.t("ACTIVATION_ERROR", "❌ Ошибка активации. Попробуйте позже."),
show_alert=True,
)
diff --git a/app/handlers/referral.py b/app/handlers/referral.py
index e2351719..fda981e0 100644
--- a/app/handlers/referral.py
+++ b/app/handlers/referral.py
@@ -481,9 +481,11 @@ def register_handlers(dp: Dispatcher):
F.data == "referral_analytics"
)
+ async def handle_referral_list_page(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
+ page = int(callback.data.split('_')[-1])
+ await show_detailed_referral_list(callback, db_user, db, page)
+
dp.callback_query.register(
- lambda callback, db_user, db: show_detailed_referral_list(
- callback, db_user, db, int(callback.data.split('_')[-1])
- ),
+ handle_referral_list_page,
F.data.startswith("referral_list_page_")
)
diff --git a/app/handlers/subscription/__init__.py b/app/handlers/subscription/__init__.py
index 6e373666..60a83a6b 100644
--- a/app/handlers/subscription/__init__.py
+++ b/app/handlers/subscription/__init__.py
@@ -120,6 +120,14 @@ from .traffic import (
select_traffic,
)
+from .modem import (
+ handle_modem_menu,
+ handle_modem_enable,
+ handle_modem_confirm,
+ handle_modem_disable,
+ register_modem_handlers,
+)
+
__all__ = [
'activate_trial',
'add_traffic',
@@ -185,6 +193,11 @@ __all__ = [
'handle_subscription_config_back',
'handle_subscription_settings',
'handle_switch_traffic',
+ 'handle_modem_menu',
+ 'handle_modem_enable',
+ 'handle_modem_confirm',
+ 'handle_modem_disable',
+ 'register_modem_handlers',
'load_app_config',
'refresh_traffic_config',
'register_handlers',
diff --git a/app/handlers/subscription/autopay.py b/app/handlers/subscription/autopay.py
index b2c1f3b0..71059e4d 100644
--- a/app/handlers/subscription/autopay.py
+++ b/app/handlers/subscription/autopay.py
@@ -79,7 +79,12 @@ from app.utils.promo_offer import (
get_user_active_promo_discount_percent,
)
-from .countries import _get_available_countries, _should_show_countries_management
+from .countries import (
+ _build_countries_selection_text,
+ _get_available_countries,
+ _get_preselected_free_countries,
+ _should_show_countries_management,
+)
from .pricing import _build_subscription_period_prompt
async def handle_autopay_menu(
@@ -260,9 +265,18 @@ async def _show_previous_configuration_step(
data = await state.get_data()
selected_countries = data.get('countries', [])
+ # Если страны не выбраны — автоматически предвыбираем бесплатные
+ if not selected_countries:
+ selected_countries = _get_preselected_free_countries(countries)
+ data['countries'] = selected_countries
+ await state.set_data(data)
+
+ # Формируем текст с описаниями сквадов
+ selection_text = _build_countries_selection_text(countries, texts.SELECT_COUNTRIES)
await callback.message.edit_text(
- texts.SELECT_COUNTRIES,
- reply_markup=get_countries_keyboard(countries, selected_countries, db_user.language)
+ selection_text,
+ reply_markup=get_countries_keyboard(countries, selected_countries, db_user.language),
+ parse_mode="HTML"
)
await state.set_state(SubscriptionStates.selecting_countries)
return
diff --git a/app/handlers/subscription/common.py b/app/handlers/subscription/common.py
index 89bfcccf..6759f758 100644
--- a/app/handlers/subscription/common.py
+++ b/app/handlers/subscription/common.py
@@ -390,9 +390,15 @@ def get_traffic_switch_keyboard(
language: str = "ru",
subscription_end_date: datetime = None,
discount_percent: int = 0,
+ base_traffic_gb: int = None,
) -> InlineKeyboardMarkup:
from app.config import settings
+ # Если базовый трафик не передан, используем текущий
+ # (для обратной совместимости и случаев без докупленного трафика)
+ if base_traffic_gb is None:
+ base_traffic_gb = current_traffic_gb
+
months_multiplier = 1
period_text = ""
if subscription_end_date:
@@ -403,7 +409,8 @@ def get_traffic_switch_keyboard(
packages = settings.get_traffic_packages()
enabled_packages = [pkg for pkg in packages if pkg['enabled']]
- current_price_per_month = settings.get_traffic_price(current_traffic_gb)
+ # Используем базовый трафик для определения цены текущего пакета
+ current_price_per_month = settings.get_traffic_price(base_traffic_gb)
discounted_current_per_month, _ = apply_percentage_discount(
current_price_per_month,
discount_percent,
@@ -422,7 +429,8 @@ def get_traffic_switch_keyboard(
price_diff_per_month = discounted_price_per_month - discounted_current_per_month
total_price_diff = price_diff_per_month * months_multiplier
- if gb == current_traffic_gb:
+ # Сравниваем с базовым трафиком (без докупленного)
+ if gb == base_traffic_gb:
emoji = "✅"
action_text = " (текущий)"
price_text = ""
diff --git a/app/handlers/subscription/countries.py b/app/handlers/subscription/countries.py
index 49f48374..f50f47d8 100644
--- a/app/handlers/subscription/countries.py
+++ b/app/handlers/subscription/countries.py
@@ -646,7 +646,8 @@ async def _get_available_countries(promo_group_id: Optional[int] = None):
"name": server.display_name,
"price_kopeks": server.price_kopeks,
"country_code": server.country_code,
- "is_available": server.is_available and not server.is_full
+ "is_available": server.is_available and not server.is_full,
+ "description": server.description or ""
})
if not countries:
@@ -675,7 +676,8 @@ async def _get_available_countries(promo_group_id: Optional[int] = None):
"uuid": squad["uuid"],
"name": squad_name,
"price_kopeks": 0,
- "is_available": True
+ "is_available": True,
+ "description": ""
})
await cache.set(cache_key_value, countries, 300)
@@ -684,7 +686,7 @@ async def _get_available_countries(promo_group_id: Optional[int] = None):
except Exception as e:
logger.error(f"Ошибка получения списка стран: {e}")
fallback_countries = [
- {"uuid": "default-free", "name": "🆓 Бесплатный сервер", "price_kopeks": 0, "is_available": True},
+ {"uuid": "default-free", "name": "🆓 Бесплатный сервер", "price_kopeks": 0, "is_available": True, "description": ""},
]
await cache.set(cache_key_value, fallback_countries, 60)
@@ -694,6 +696,36 @@ async def _get_countries_info(squad_uuids):
countries = await _get_available_countries()
return [c for c in countries if c['uuid'] in squad_uuids]
+
+def _get_preselected_free_countries(countries: List[dict]) -> List[str]:
+ """Получить UUID бесплатных серверов для автоматического предвыбора."""
+ return [
+ c['uuid'] for c in countries
+ if c.get('is_available', True) and c.get('price_kopeks', 0) == 0
+ ]
+
+
+def _build_countries_selection_text(countries: List[dict], base_text: str) -> str:
+ """
+ Формирует текст выбора серверов с описаниями.
+
+ Если у серверов есть description — добавляет их под базовым текстом.
+ """
+ descriptions = []
+ for country in countries:
+ if not country.get('is_available', True):
+ continue
+ desc = country.get('description', '').strip()
+ if desc:
+ name = country.get('name', '')
+ descriptions.append(f"{name}\n{desc}")
+
+ if not descriptions:
+ return base_text
+
+ return f"{base_text}\n\n" + "\n\n".join(descriptions)
+
+
async def handle_add_country_to_subscription(
callback: types.CallbackQuery,
db_user: User,
diff --git a/app/handlers/subscription/devices.py b/app/handlers/subscription/devices.py
index cecfdf22..034e9e68 100644
--- a/app/handlers/subscription/devices.py
+++ b/app/handlers/subscription/devices.py
@@ -355,6 +355,28 @@ async def confirm_change_devices(
).format(count=new_devices_count)
cost_text = texts.t("DEVICE_CHANGE_NO_REFUND", "Возврат средств не производится")
+ # Проверяем количество подключённых устройств для предупреждения
+ devices_warning = ""
+ if new_devices_count < current_devices and db_user.remnawave_uuid:
+ try:
+ service = RemnaWaveService()
+ async with service.get_api_client() as api:
+ response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}')
+ if response and 'response' in response:
+ connected_count = response['response'].get('total', 0)
+ if connected_count > new_devices_count:
+ devices_warning = texts.t(
+ "DEVICE_CHANGE_RESET_WARNING",
+ (
+ "\n⚠️ Внимание!\n"
+ "У вас подключено {connected} устройств.\n"
+ "При уменьшении лимита до {new} все устройства будут сброшены.\n"
+ "Вам нужно будет заново подключить нужные устройства.\n"
+ ),
+ ).format(connected=connected_count, new=new_devices_count)
+ except Exception as e:
+ logger.error(f"Ошибка проверки устройств: {e}")
+
confirm_text = texts.t(
"DEVICE_CHANGE_CONFIRMATION",
(
@@ -372,6 +394,9 @@ async def confirm_change_devices(
cost=cost_text,
)
+ if devices_warning:
+ confirm_text += devices_warning
+
await callback.message.edit_text(
confirm_text,
reply_markup=get_confirm_change_devices_keyboard(new_devices_count, price, db_user.language),
@@ -431,6 +456,28 @@ async def execute_change_devices(
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
+ # При уменьшении лимита - сбросить лишние устройства
+ devices_reset_count = 0
+ if new_devices_count < current_devices and db_user.remnawave_uuid:
+ try:
+ service = RemnaWaveService()
+ async with service.get_api_client() as api:
+ response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}')
+ if response and 'response' in response:
+ devices_list = response['response'].get('devices', [])
+ connected_count = len(devices_list)
+
+ # Если подключённых устройств больше чем новый лимит - сбросить все
+ if connected_count > new_devices_count:
+ logger.info(
+ f"🔧 Сброс устройств при уменьшении лимита: "
+ f"подключено {connected_count}, новый лимит {new_devices_count}"
+ )
+ await api.reset_user_devices(db_user.remnawave_uuid)
+ devices_reset_count = connected_count
+ except Exception as reset_error:
+ logger.error(f"Ошибка сброса устройств при уменьшении лимита: {reset_error}")
+
await db.refresh(db_user)
await db.refresh(subscription)
@@ -466,6 +513,12 @@ async def execute_change_devices(
"DEVICE_CHANGE_RESULT_LINE",
"📱 Было: {old} → Стало: {new}\n",
).format(old=current_devices, new=new_devices_count)
+ if devices_reset_count > 0:
+ success_text += texts.t(
+ "DEVICE_CHANGE_DEVICES_RESET",
+ "\n🔄 Сброшено устройств: {count}\n"
+ "💡 Подключите заново нужные устройства (до {limit} шт.)\n\n",
+ ).format(count=devices_reset_count, limit=new_devices_count)
success_text += texts.t(
"DEVICE_CHANGE_NO_REFUND_INFO",
"ℹ️ Возврат средств не производится",
diff --git a/app/handlers/subscription/modem.py b/app/handlers/subscription/modem.py
new file mode 100644
index 00000000..3af21996
--- /dev/null
+++ b/app/handlers/subscription/modem.py
@@ -0,0 +1,388 @@
+"""
+Хендлеры для управления модемом в подписке.
+
+Модем - это дополнительное устройство, которое можно подключить к подписке
+за отдельную плату. При подключении увеличивается лимит устройств.
+"""
+
+import logging
+from aiogram import Dispatcher, types, F
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.config import settings
+from app.database.models import User
+from app.keyboards.inline import get_back_keyboard, get_insufficient_balance_keyboard
+from app.localization.texts import get_texts
+from app.services.modem_service import (
+ get_modem_service,
+ ModemError,
+ MODEM_WARNING_DAYS_CRITICAL,
+ MODEM_WARNING_DAYS_INFO,
+)
+from app.utils.decorators import error_handler, modem_available
+
+logger = logging.getLogger(__name__)
+
+
+def get_modem_keyboard(language: str, modem_enabled: bool):
+ """Клавиатура управления модемом."""
+ texts = get_texts(language)
+ keyboard = []
+
+ if modem_enabled:
+ keyboard.append([
+ types.InlineKeyboardButton(
+ text=texts.t("MODEM_DISABLE_BUTTON", "Отключить модем"),
+ callback_data="modem_disable"
+ )
+ ])
+ else:
+ keyboard.append([
+ types.InlineKeyboardButton(
+ text=texts.t("MODEM_ENABLE_BUTTON", "Подключить модем"),
+ callback_data="modem_enable"
+ )
+ ])
+
+ keyboard.append([
+ types.InlineKeyboardButton(
+ text=texts.BACK,
+ callback_data="subscription_settings"
+ )
+ ])
+
+ return types.InlineKeyboardMarkup(inline_keyboard=keyboard)
+
+
+def get_modem_confirm_keyboard(language: str):
+ """Клавиатура подтверждения подключения модема."""
+ texts = get_texts(language)
+ return types.InlineKeyboardMarkup(inline_keyboard=[
+ [
+ types.InlineKeyboardButton(
+ text=texts.t("MODEM_CONFIRM_BUTTON", "Подтвердить подключение"),
+ callback_data="modem_confirm"
+ )
+ ],
+ [
+ types.InlineKeyboardButton(
+ text=texts.CANCEL,
+ callback_data="subscription_modem"
+ )
+ ]
+ ])
+
+
+@error_handler
+@modem_available()
+async def handle_modem_menu(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
+):
+ """Показывает меню управления модемом."""
+ texts = get_texts(db_user.language)
+ subscription = db_user.subscription
+ service = get_modem_service()
+
+ modem_enabled = service.get_modem_enabled(subscription)
+ modem_price = settings.get_modem_price_per_month()
+
+ if modem_enabled:
+ status_text = texts.t("MODEM_STATUS_ENABLED", "Подключен")
+ info_text = texts.t(
+ "MODEM_INFO_ENABLED",
+ (
+ "Модем\n\n"
+ "Статус: {status}\n\n"
+ "Модем подключен к вашей подписке.\n"
+ "Ежемесячная плата: {price}\n\n"
+ "При отключении модема возврат средств не производится."
+ ),
+ ).format(
+ status=status_text,
+ price=texts.format_price(modem_price),
+ )
+ else:
+ status_text = texts.t("MODEM_STATUS_DISABLED", "Не подключен")
+ info_text = texts.t(
+ "MODEM_INFO_DISABLED",
+ (
+ "Модем\n\n"
+ "Статус: {status}\n\n"
+ "Подключите модем к вашей подписке.\n"
+ "Ежемесячная плата: {price}\n\n"
+ "При подключении модема будет добавлено дополнительное устройство."
+ ),
+ ).format(
+ status=status_text,
+ price=texts.format_price(modem_price),
+ )
+
+ await callback.message.edit_text(
+ info_text,
+ reply_markup=get_modem_keyboard(db_user.language, modem_enabled),
+ parse_mode="HTML"
+ )
+ await callback.answer()
+
+
+@error_handler
+@modem_available(for_enable=True)
+async def handle_modem_enable(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
+):
+ """Обработчик подключения модема - показывает информацию о цене."""
+ texts = get_texts(db_user.language)
+ subscription = db_user.subscription
+ service = get_modem_service()
+
+ price_info = service.calculate_price(subscription)
+ modem_price_per_month = settings.get_modem_price_per_month()
+
+ has_funds, missing_kopeks = service.check_balance(db_user, price_info.final_price)
+
+ if not has_funds:
+ if price_info.has_discount:
+ required_text = (
+ f"{texts.format_price(price_info.final_price)} "
+ f"(за {price_info.charged_months} мес, скидка {price_info.discount_percent}%)"
+ )
+ else:
+ required_text = (
+ f"{texts.format_price(price_info.final_price)} "
+ f"(за {price_info.charged_months} мес)"
+ )
+
+ message_text = texts.t(
+ "MODEM_INSUFFICIENT_FUNDS",
+ (
+ "Недостаточно средств\n\n"
+ "Стоимость подключения модема: {required}\n"
+ "На балансе: {balance}\n"
+ "Не хватает: {missing}\n\n"
+ "Выберите способ пополнения."
+ ),
+ ).format(
+ required=required_text,
+ balance=texts.format_price(db_user.balance_kopeks),
+ missing=texts.format_price(missing_kopeks),
+ )
+
+ await callback.message.edit_text(
+ message_text,
+ reply_markup=get_insufficient_balance_keyboard(
+ db_user.language,
+ amount_kopeks=missing_kopeks,
+ ),
+ parse_mode="HTML",
+ )
+ await callback.answer()
+ return
+
+ warning_level = service.get_period_warning_level(price_info.remaining_days)
+
+ if warning_level == "critical":
+ warning_text = texts.t(
+ "MODEM_SHORT_PERIOD_WARNING",
+ "\nВнимание! До окончания подписки осталось всего {days} дн.\n"
+ "После продления подписки модем нужно будет оплатить заново!"
+ ).format(days=price_info.remaining_days)
+ elif warning_level == "info":
+ warning_text = texts.t(
+ "MODEM_PERIOD_NOTE",
+ "\nДо окончания подписки: {days} дн.\n"
+ "После продления модем нужно будет оплатить заново."
+ ).format(days=price_info.remaining_days)
+ else:
+ warning_text = ""
+
+ if price_info.has_discount:
+ price_text = texts.t(
+ "MODEM_PRICE_WITH_DISCOUNT",
+ "Стоимость: {base_price} {final_price} (за {months} мес)\n"
+ "Скидка {discount}%: -{discount_amount}"
+ ).format(
+ base_price=texts.format_price(price_info.base_price),
+ final_price=texts.format_price(price_info.final_price),
+ months=price_info.charged_months,
+ discount=price_info.discount_percent,
+ discount_amount=texts.format_price(price_info.discount_amount),
+ )
+ else:
+ price_text = texts.t(
+ "MODEM_PRICE_NO_DISCOUNT",
+ "Стоимость: {price} (за {months} мес)"
+ ).format(
+ price=texts.format_price(price_info.final_price),
+ months=price_info.charged_months,
+ )
+
+ confirm_text = texts.t(
+ "MODEM_CONFIRM_ENABLE_BASE",
+ (
+ "Подтверждение подключения модема\n\n"
+ "{price_text}\n\n"
+ "При подключении модема:\n"
+ "К подписке добавится дополнительное устройство\n"
+ "Ежемесячная плата увеличится на {monthly_price}\n\n"
+ "Подтвердить подключение?"
+ ),
+ ).format(
+ price_text=price_text,
+ monthly_price=texts.format_price(modem_price_per_month),
+ )
+
+ end_date_str = price_info.end_date.strftime("%d.%m.%Y")
+ period_info = texts.t(
+ "MODEM_PERIOD_INFO",
+ "\nМодем действует до: {end_date} ({days} дн.)"
+ ).format(end_date=end_date_str, days=price_info.remaining_days)
+
+ confirm_text += period_info + warning_text
+
+ await callback.message.edit_text(
+ confirm_text,
+ reply_markup=get_modem_confirm_keyboard(db_user.language),
+ parse_mode="HTML"
+ )
+ await callback.answer()
+
+
+@error_handler
+@modem_available(for_enable=True)
+async def handle_modem_confirm(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
+):
+ """Подтверждение и активация модема."""
+ texts = get_texts(db_user.language)
+ subscription = db_user.subscription
+ service = get_modem_service()
+
+ result = await service.enable_modem(db, db_user, subscription)
+
+ if not result.success:
+ error_messages = {
+ ModemError.INSUFFICIENT_FUNDS: texts.t(
+ "MODEM_INSUFFICIENT_FUNDS_SHORT",
+ "Недостаточно средств на балансе"
+ ),
+ ModemError.CHARGE_ERROR: texts.t(
+ "PAYMENT_CHARGE_ERROR",
+ "Ошибка списания средств"
+ ),
+ ModemError.UPDATE_ERROR: texts.ERROR,
+ }
+
+ error_text = error_messages.get(result.error, texts.ERROR)
+
+ if result.error == ModemError.INSUFFICIENT_FUNDS:
+ await callback.message.edit_text(
+ error_text,
+ reply_markup=get_back_keyboard(db_user.language, "modem_enable"),
+ parse_mode="HTML"
+ )
+ else:
+ await callback.answer(error_text, show_alert=True)
+ return
+
+ try:
+ from app.services.admin_notification_service import AdminNotificationService
+ notification_service = AdminNotificationService(callback.bot)
+ await notification_service.send_subscription_update_notification(
+ db, db_user, subscription, "modem", False, True, result.charged_amount
+ )
+ except Exception as e:
+ logger.error(f"Ошибка отправки уведомления о подключении модема: {e}")
+
+ success_text = texts.t(
+ "MODEM_ENABLED_SUCCESS",
+ (
+ "Модем успешно подключен!\n\n"
+ "Модем активирован\n"
+ "Добавлено устройство для модема\n"
+ ),
+ )
+ if result.charged_amount > 0:
+ success_text += texts.t(
+ "MODEM_CHARGED",
+ "Списано: {amount}",
+ ).format(amount=texts.format_price(result.charged_amount))
+
+ await callback.message.edit_text(
+ success_text,
+ reply_markup=get_back_keyboard(db_user.language, "subscription_settings"),
+ parse_mode="HTML"
+ )
+ await callback.answer()
+
+
+@error_handler
+@modem_available(for_disable=True)
+async def handle_modem_disable(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
+):
+ """Отключение модема."""
+ texts = get_texts(db_user.language)
+ subscription = db_user.subscription
+ service = get_modem_service()
+
+ result = await service.disable_modem(db, db_user, subscription)
+
+ if not result.success:
+ await callback.answer(texts.ERROR, show_alert=True)
+ return
+
+ try:
+ from app.services.admin_notification_service import AdminNotificationService
+ notification_service = AdminNotificationService(callback.bot)
+ await notification_service.send_subscription_update_notification(
+ db, db_user, subscription, "modem", True, False, 0
+ )
+ except Exception as e:
+ logger.error(f"Ошибка отправки уведомления об отключении модема: {e}")
+
+ success_text = texts.t(
+ "MODEM_DISABLED_SUCCESS",
+ (
+ "Модем отключен\n\n"
+ "Модем деактивирован\n"
+ "Возврат средств не производится"
+ ),
+ )
+
+ await callback.message.edit_text(
+ success_text,
+ reply_markup=get_back_keyboard(db_user.language, "subscription_settings"),
+ parse_mode="HTML"
+ )
+ await callback.answer()
+
+
+def register_modem_handlers(dp: Dispatcher):
+ """Регистрация обработчиков модема."""
+ dp.callback_query.register(
+ handle_modem_menu,
+ F.data == "subscription_modem"
+ )
+
+ dp.callback_query.register(
+ handle_modem_enable,
+ F.data == "modem_enable"
+ )
+
+ dp.callback_query.register(
+ handle_modem_confirm,
+ F.data == "modem_confirm"
+ )
+
+ dp.callback_query.register(
+ handle_modem_disable,
+ F.data == "modem_disable"
+ )
diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py
index aaf37a3e..152fdd8a 100644
--- a/app/handlers/subscription/purchase.py
+++ b/app/handlers/subscription/purchase.py
@@ -17,6 +17,7 @@ from app.database.crud.discount_offer import (
from app.database.crud.promo_offer_template import get_promo_offer_template_by_id
from app.database.crud.subscription import (
create_trial_subscription,
+ create_pending_trial_subscription,
create_paid_subscription, add_subscription_traffic, add_subscription_devices,
update_subscription_autopay
)
@@ -49,6 +50,7 @@ from app.keyboards.inline import (
)
from app.services.user_cart_service import user_cart_service
from app.localization.texts import get_texts
+from app.utils.decorators import error_handler
from app.services.admin_notification_service import AdminNotificationService
from app.services.remnawave_service import RemnaWaveConfigurationError, RemnaWaveService
from app.services.blacklist_service import blacklist_service
@@ -138,7 +140,9 @@ from .autopay import (
toggle_autopay,
)
from .countries import (
+ _build_countries_selection_text,
_get_available_countries,
+ _get_preselected_free_countries,
_should_show_countries_management,
apply_countries_changes,
countries_continue,
@@ -346,6 +350,15 @@ async def show_subscription_info(
"",
)
+ # Формируем отображение лимита устройств с учётом модема
+ modem_enabled = getattr(subscription, 'modem_enabled', False) or False
+ if modem_enabled and settings.is_modem_enabled():
+ # Показываем лимит без модема + модем
+ visible_device_limit = (subscription.device_limit or 1) - 1
+ device_limit_display = f"{visible_device_limit} + модем"
+ else:
+ device_limit_display = str(subscription.device_limit)
+
message = message_template.format(
full_name=db_user.full_name,
balance=settings.format_price(db_user.balance_kopeks),
@@ -358,7 +371,7 @@ async def show_subscription_info(
traffic=traffic_used_display,
servers=servers_display,
devices_used=devices_used_str,
- device_limit=subscription.device_limit,
+ device_limit=device_limit_display,
)
if show_devices and devices_list:
@@ -489,12 +502,86 @@ async def show_trial_offer(
)
await callback.answer()
+def _get_trial_payment_keyboard(language: str, can_pay_from_balance: bool = False) -> types.InlineKeyboardMarkup:
+ """Создает клавиатуру с методами оплаты для платного триала."""
+ texts = get_texts(language)
+ keyboard = []
+
+ # Кнопка оплаты с баланса (если хватает средств)
+ if can_pay_from_balance:
+ keyboard.append([types.InlineKeyboardButton(
+ text="✅ Оплатить с баланса",
+ callback_data="trial_pay_with_balance"
+ )])
+
+ # Добавляем доступные методы оплаты
+ if settings.TELEGRAM_STARS_ENABLED:
+ keyboard.append([types.InlineKeyboardButton(
+ text="⭐ Telegram Stars",
+ callback_data="trial_payment_stars"
+ )])
+
+ if settings.is_yookassa_enabled():
+ yookassa_methods = []
+ if settings.YOOKASSA_SBP_ENABLED:
+ yookassa_methods.append(types.InlineKeyboardButton(
+ text="🏦 YooKassa (СБП)",
+ callback_data="trial_payment_yookassa_sbp"
+ ))
+ yookassa_methods.append(types.InlineKeyboardButton(
+ text="💳 YooKassa (Карта)",
+ callback_data="trial_payment_yookassa"
+ ))
+ if yookassa_methods:
+ keyboard.append(yookassa_methods)
+
+ if settings.is_cryptobot_enabled():
+ keyboard.append([types.InlineKeyboardButton(
+ text="🪙 CryptoBot",
+ callback_data="trial_payment_cryptobot"
+ )])
+
+ if settings.is_heleket_enabled():
+ keyboard.append([types.InlineKeyboardButton(
+ text="🪙 Heleket",
+ callback_data="trial_payment_heleket"
+ )])
+
+ if settings.is_mulenpay_enabled():
+ mulenpay_name = settings.get_mulenpay_display_name()
+ keyboard.append([types.InlineKeyboardButton(
+ text=f"💳 {mulenpay_name}",
+ callback_data="trial_payment_mulenpay"
+ )])
+
+ if settings.is_pal24_enabled():
+ keyboard.append([types.InlineKeyboardButton(
+ text="💳 PayPalych",
+ callback_data="trial_payment_pal24"
+ )])
+
+ if settings.is_wata_enabled():
+ keyboard.append([types.InlineKeyboardButton(
+ text="💳 WATA",
+ callback_data="trial_payment_wata"
+ )])
+
+ # Кнопка назад
+ keyboard.append([types.InlineKeyboardButton(
+ text=texts.BACK,
+ callback_data="menu_trial"
+ )])
+
+ return types.InlineKeyboardMarkup(inline_keyboard=keyboard)
+
+
async def activate_trial(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
from app.services.admin_notification_service import AdminNotificationService
+ from app.services.trial_activation_service import get_trial_activation_charge_amount
texts = get_texts(db_user.language)
@@ -506,29 +593,51 @@ async def activate_trial(
await callback.answer()
return
- try:
- preview_trial_activation_charge(db_user)
- except TrialPaymentInsufficientFunds as error:
- required_label = settings.format_price(error.required_amount)
- balance_label = settings.format_price(error.balance_amount)
- missing_label = settings.format_price(error.missing_amount)
- message = texts.t(
- "TRIAL_PAYMENT_INSUFFICIENT_FUNDS",
- "⚠️ Недостаточно средств для активации триала.\n"
- "Необходимо: {required}\nНа балансе: {balance}\n"
- "Не хватает: {missing}\n\nПополните баланс и попробуйте снова.",
- ).format(required=required_label, balance=balance_label, missing=missing_label)
+ # Проверяем, платный ли триал
+ trial_price_kopeks = get_trial_activation_charge_amount()
+
+ if trial_price_kopeks > 0:
+ # Платный триал - показываем экран с выбором метода оплаты
+ user_balance_kopeks = getattr(db_user, "balance_kopeks", 0) or 0
+ can_pay_from_balance = user_balance_kopeks >= trial_price_kopeks
+
+ traffic_label = "Безлимит" if settings.TRIAL_TRAFFIC_LIMIT_GB == 0 else f"{settings.TRIAL_TRAFFIC_LIMIT_GB} ГБ"
+
+ message_lines = [
+ texts.t("PAID_TRIAL_HEADER", "⚡ Пробная подписка"),
+ "",
+ f"📅 {texts.t('PERIOD', 'Период')}: {settings.TRIAL_DURATION_DAYS} {texts.t('DAYS', 'дней')}",
+ f"📊 {texts.t('TRAFFIC', 'Трафик')}: {traffic_label}",
+ f"📱 {texts.t('DEVICES', 'Устройства')}: {settings.TRIAL_DEVICE_LIMIT}",
+ "",
+ f"💰 {texts.t('PRICE', 'Стоимость')}: {settings.format_price(trial_price_kopeks)}",
+ f"💳 {texts.t('YOUR_BALANCE', 'Ваш баланс')}: {settings.format_price(user_balance_kopeks)}",
+ "",
+ ]
+
+ if can_pay_from_balance:
+ message_lines.append(texts.t(
+ "PAID_TRIAL_CAN_PAY_BALANCE",
+ "Вы можете оплатить пробную подписку с баланса или выбрать другой способ оплаты."
+ ))
+ else:
+ message_lines.append(texts.t(
+ "PAID_TRIAL_SELECT_PAYMENT",
+ "Выберите подходящий способ оплаты:"
+ ))
+
+ message_text = "\n".join(message_lines)
+ keyboard = _get_trial_payment_keyboard(db_user.language, can_pay_from_balance)
await callback.message.edit_text(
- message,
- reply_markup=get_insufficient_balance_keyboard(
- db_user.language,
- amount_kopeks=error.required_amount,
- ),
+ message_text,
+ reply_markup=keyboard,
+ parse_mode="HTML"
)
await callback.answer()
return
+ # Бесплатный триал - текущее поведение
charged_amount = 0
subscription: Optional[Subscription] = None
remnawave_user = None
@@ -1738,9 +1847,16 @@ async def select_period(
if await _should_show_countries_management(db_user):
countries = await _get_available_countries(db_user.promo_group_id)
+ # Автоматически предвыбираем бесплатные серверы
+ preselected = _get_preselected_free_countries(countries)
+ data['countries'] = preselected
+ await state.set_data(data)
+ # Формируем текст с описаниями сквадов
+ selection_text = _build_countries_selection_text(countries, texts.SELECT_COUNTRIES)
await callback.message.edit_text(
- texts.SELECT_COUNTRIES,
- reply_markup=get_countries_keyboard(countries, [], db_user.language)
+ selection_text,
+ reply_markup=get_countries_keyboard(countries, preselected, db_user.language),
+ parse_mode="HTML"
)
await state.set_state(SubscriptionStates.selecting_countries)
await callback.answer()
@@ -2683,12 +2799,20 @@ async def handle_subscription_settings(
"",
)
+ # Формируем отображение лимита устройств с учётом модема
+ modem_enabled = getattr(subscription, 'modem_enabled', False) or False
+ if modem_enabled and settings.is_modem_enabled():
+ visible_device_limit = (subscription.device_limit or 1) - 1
+ devices_limit_display = f"{visible_device_limit} + модем"
+ else:
+ devices_limit_display = str(subscription.device_limit)
+
settings_text = settings_template.format(
countries_count=len(subscription.connected_squads),
traffic_used=texts.format_traffic(subscription.traffic_used_gb),
traffic_limit=texts.format_traffic(subscription.traffic_limit_gb),
devices_used=devices_used,
- devices_limit=subscription.device_limit,
+ devices_limit=devices_limit_display,
)
show_countries = await _should_show_countries_management(db_user)
@@ -2715,6 +2839,696 @@ async def clear_saved_cart(
await callback.answer("🗑️ Корзина очищена")
+
+# ============== ХЕНДЛЕРЫ ПЛАТНОГО ТРИАЛА ==============
+
+@error_handler
+async def handle_trial_pay_with_balance(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
+):
+ """Обрабатывает оплату триала с баланса."""
+ from app.services.trial_activation_service import get_trial_activation_charge_amount
+ from app.services.admin_notification_service import AdminNotificationService
+
+ texts = get_texts(db_user.language)
+
+ # Проверяем права на триал
+ if db_user.subscription or db_user.has_had_paid_subscription:
+ await callback.message.edit_text(
+ texts.TRIAL_ALREADY_USED,
+ reply_markup=get_back_keyboard(db_user.language)
+ )
+ await callback.answer()
+ return
+
+ trial_price_kopeks = get_trial_activation_charge_amount()
+ if trial_price_kopeks <= 0:
+ await callback.answer("❌ Ошибка: триал бесплатный", show_alert=True)
+ return
+
+ user_balance_kopeks = getattr(db_user, "balance_kopeks", 0) or 0
+ if user_balance_kopeks < trial_price_kopeks:
+ await callback.answer(
+ texts.t("INSUFFICIENT_BALANCE", "❌ Недостаточно средств на балансе"),
+ show_alert=True
+ )
+ return
+
+ # Списываем с баланса
+ success = await subtract_user_balance(
+ db,
+ db_user,
+ trial_price_kopeks,
+ texts.t("TRIAL_PAYMENT_DESCRIPTION", "Оплата пробной подписки"),
+ )
+
+ if not success:
+ await callback.answer(
+ texts.t("PAYMENT_FAILED", "❌ Не удалось списать средства"),
+ show_alert=True
+ )
+ return
+
+ await db.refresh(db_user)
+
+ # Создаем триальную подписку
+ subscription: Optional[Subscription] = None
+ remnawave_user = None
+
+ try:
+ forced_devices = None
+ if not settings.is_devices_selection_enabled():
+ forced_devices = settings.get_disabled_mode_device_limit()
+
+ subscription = await create_trial_subscription(
+ db,
+ db_user.id,
+ device_limit=forced_devices,
+ )
+
+ await db.refresh(db_user)
+
+ subscription_service = SubscriptionService()
+ try:
+ remnawave_user = await subscription_service.create_remnawave_user(
+ db,
+ subscription,
+ )
+ except RemnaWaveConfigurationError as error:
+ logger.error("RemnaWave update skipped due to configuration error: %s", error)
+ # Откатываем подписку и возвращаем деньги
+ await rollback_trial_subscription_activation(db, subscription)
+ from app.database.crud.user import add_user_balance
+ await add_user_balance(
+ db,
+ db_user,
+ trial_price_kopeks,
+ texts.t("TRIAL_REFUND_DESCRIPTION", "Возврат за неудачную активацию триала"),
+ transaction_type=TransactionType.REFUND,
+ )
+ await db.refresh(db_user)
+
+ await callback.message.edit_text(
+ texts.t(
+ "TRIAL_PROVISIONING_FAILED",
+ "Не удалось завершить активацию триала. Средства возвращены на баланс.",
+ ),
+ reply_markup=get_back_keyboard(db_user.language),
+ )
+ await callback.answer()
+ return
+ except Exception as error:
+ logger.error(
+ "Failed to create RemnaWave user for trial subscription %s: %s",
+ getattr(subscription, "id", ""),
+ error,
+ )
+ # Откатываем подписку и возвращаем деньги
+ await rollback_trial_subscription_activation(db, subscription)
+ from app.database.crud.user import add_user_balance
+ await add_user_balance(
+ db,
+ db_user,
+ trial_price_kopeks,
+ texts.t("TRIAL_REFUND_DESCRIPTION", "Возврат за неудачную активацию триала"),
+ transaction_type=TransactionType.REFUND,
+ )
+ await db.refresh(db_user)
+
+ await callback.message.edit_text(
+ texts.t(
+ "TRIAL_PROVISIONING_FAILED",
+ "Не удалось завершить активацию триала. Средства возвращены на баланс.",
+ ),
+ reply_markup=get_back_keyboard(db_user.language),
+ )
+ await callback.answer()
+ return
+
+ # Отправляем уведомление админам
+ try:
+ notification_service = AdminNotificationService(callback.bot)
+ await notification_service.send_trial_activation_notification(
+ db,
+ db_user,
+ subscription,
+ charged_amount_kopeks=trial_price_kopeks,
+ )
+ except Exception as e:
+ logger.error(f"Ошибка отправки уведомления о триале: {e}")
+
+ # Показываем успешное сообщение с ссылкой
+ subscription_link = get_display_subscription_link(subscription)
+ hide_subscription_link = settings.should_hide_subscription_link()
+
+ payment_note = "\n\n" + texts.t(
+ "TRIAL_PAYMENT_CHARGED_NOTE",
+ "💳 С вашего баланса списано {amount}.",
+ ).format(amount=settings.format_price(trial_price_kopeks))
+
+ if remnawave_user and subscription_link:
+ if settings.is_happ_cryptolink_mode():
+ trial_success_text = (
+ f"{texts.TRIAL_ACTIVATED}\n\n"
+ + texts.t(
+ "SUBSCRIPTION_HAPP_LINK_PROMPT",
+ "🔒 Ссылка на подписку создана. Нажмите кнопку \"Подключиться\" ниже, чтобы открыть её в Happ.",
+ )
+ + "\n\n"
+ + texts.t(
+ "SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT",
+ "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве",
+ )
+ )
+ elif hide_subscription_link:
+ trial_success_text = (
+ f"{texts.TRIAL_ACTIVATED}\n\n"
+ + texts.t(
+ "SUBSCRIPTION_LINK_HIDDEN_NOTICE",
+ "ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе \"Моя подписка\".",
+ )
+ + "\n\n"
+ + texts.t(
+ "SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT",
+ "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве",
+ )
+ )
+ else:
+ subscription_import_link = texts.t(
+ "SUBSCRIPTION_IMPORT_LINK_SECTION",
+ "🔗 Ваша ссылка для импорта в VPN приложение:\n{subscription_url}",
+ ).format(subscription_url=subscription_link)
+
+ trial_success_text = (
+ f"{texts.TRIAL_ACTIVATED}\n\n"
+ f"{subscription_import_link}\n\n"
+ f"{texts.t('SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT', '📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве')}"
+ )
+
+ trial_success_text += payment_note
+
+ connect_mode = settings.CONNECT_BUTTON_MODE
+ connect_keyboard = _build_trial_success_keyboard(texts, subscription_link, connect_mode)
+
+ await callback.message.edit_text(
+ trial_success_text,
+ reply_markup=connect_keyboard,
+ parse_mode="HTML",
+ )
+ else:
+ trial_success_text = (
+ f"{texts.TRIAL_ACTIVATED}\n\n⚠️ Ссылка генерируется, попробуйте перейти в раздел 'Моя подписка' через несколько секунд."
+ )
+ trial_success_text += payment_note
+
+ await callback.message.edit_text(
+ trial_success_text,
+ reply_markup=get_back_keyboard(db_user.language),
+ parse_mode="HTML",
+ )
+
+ await callback.answer()
+
+ except Exception as error:
+ logger.error(
+ "Unexpected error during paid trial activation for user %s: %s",
+ db_user.id,
+ error,
+ )
+ # Пытаемся откатить и вернуть деньги
+ if subscription:
+ await rollback_trial_subscription_activation(db, subscription)
+ from app.database.crud.user import add_user_balance
+ await add_user_balance(
+ db,
+ db_user,
+ trial_price_kopeks,
+ texts.t("TRIAL_REFUND_DESCRIPTION", "Возврат за неудачную активацию триала"),
+ transaction_type=TransactionType.REFUND,
+ )
+ await db.refresh(db_user)
+
+ await callback.message.edit_text(
+ texts.t(
+ "TRIAL_ACTIVATION_ERROR",
+ "❌ Произошла ошибка при активации триала. Средства возвращены на баланс.",
+ ),
+ reply_markup=get_back_keyboard(db_user.language),
+ )
+ await callback.answer()
+
+
+def _build_trial_success_keyboard(texts, subscription_link: str, connect_mode: str) -> InlineKeyboardMarkup:
+ """Создает клавиатуру успешной активации триала."""
+
+ if connect_mode == "miniapp_subscription":
+ return InlineKeyboardMarkup(inline_keyboard=[
+ [
+ InlineKeyboardButton(
+ text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
+ web_app=types.WebAppInfo(url=subscription_link),
+ )
+ ],
+ [
+ InlineKeyboardButton(
+ text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
+ callback_data="back_to_menu",
+ )
+ ],
+ ])
+ elif connect_mode == "miniapp_custom":
+ if not settings.MINIAPP_CUSTOM_URL:
+ return get_back_keyboard(texts.language if hasattr(texts, 'language') else 'ru')
+
+ return InlineKeyboardMarkup(inline_keyboard=[
+ [
+ InlineKeyboardButton(
+ text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
+ web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL),
+ )
+ ],
+ [
+ InlineKeyboardButton(
+ text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
+ callback_data="back_to_menu",
+ )
+ ],
+ ])
+ elif connect_mode == "link":
+ rows = [
+ [
+ InlineKeyboardButton(
+ text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
+ url=subscription_link,
+ )
+ ]
+ ]
+ happ_row = get_happ_download_button_row(texts)
+ if happ_row:
+ rows.append(happ_row)
+ rows.append(
+ [
+ InlineKeyboardButton(
+ text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
+ callback_data="back_to_menu",
+ )
+ ]
+ )
+ return InlineKeyboardMarkup(inline_keyboard=rows)
+ elif connect_mode == "happ_cryptolink":
+ rows = [
+ [
+ InlineKeyboardButton(
+ text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
+ callback_data="open_subscription_link",
+ )
+ ]
+ ]
+ happ_row = get_happ_download_button_row(texts)
+ if happ_row:
+ rows.append(happ_row)
+ rows.append(
+ [
+ InlineKeyboardButton(
+ text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
+ callback_data="back_to_menu",
+ )
+ ]
+ )
+ return InlineKeyboardMarkup(inline_keyboard=rows)
+ else:
+ return InlineKeyboardMarkup(
+ inline_keyboard=[
+ [
+ InlineKeyboardButton(
+ text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
+ callback_data="subscription_connect",
+ )
+ ],
+ [
+ InlineKeyboardButton(
+ text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
+ callback_data="back_to_menu",
+ )
+ ],
+ ]
+ )
+
+
+@error_handler
+async def handle_trial_payment_method(
+ callback: types.CallbackQuery,
+ db_user: User,
+ db: AsyncSession
+):
+ """Обрабатывает выбор метода оплаты для платного триала."""
+ from app.services.trial_activation_service import get_trial_activation_charge_amount
+ from app.services.payment_service import PaymentService
+
+ texts = get_texts(db_user.language)
+
+ # Проверяем права на триал
+ if db_user.subscription or db_user.has_had_paid_subscription:
+ await callback.message.edit_text(
+ texts.TRIAL_ALREADY_USED,
+ reply_markup=get_back_keyboard(db_user.language)
+ )
+ await callback.answer()
+ return
+
+ trial_price_kopeks = get_trial_activation_charge_amount()
+ if trial_price_kopeks <= 0:
+ await callback.answer("❌ Ошибка: триал бесплатный", show_alert=True)
+ return
+
+ # Определяем метод оплаты
+ payment_method = callback.data.replace("trial_payment_", "")
+
+ try:
+ payment_service = PaymentService(callback.bot)
+
+ # Получаем случайный сквад для триала
+ from app.database.crud.server_squad import get_random_trial_squad_uuid
+ trial_squad_uuid = await get_random_trial_squad_uuid(db)
+
+ # Создаем pending триальную подписку
+ pending_subscription = await create_pending_trial_subscription(
+ db=db,
+ user_id=db_user.id,
+ duration_days=settings.TRIAL_DURATION_DAYS,
+ traffic_limit_gb=settings.TRIAL_TRAFFIC_LIMIT_GB,
+ device_limit=settings.TRIAL_DEVICE_LIMIT,
+ connected_squads=[trial_squad_uuid] if trial_squad_uuid else [],
+ payment_method=f"trial_{payment_method}",
+ total_price_kopeks=trial_price_kopeks,
+ )
+
+ if not pending_subscription:
+ await callback.answer("❌ Не удалось подготовить заказ. Попробуйте позже.", show_alert=True)
+ return
+
+ traffic_label = "Безлимит" if settings.TRIAL_TRAFFIC_LIMIT_GB == 0 else f"{settings.TRIAL_TRAFFIC_LIMIT_GB} ГБ"
+
+ if payment_method == "stars":
+ # Оплата через Telegram Stars
+ stars_count = settings.rubles_to_stars(settings.kopeks_to_rubles(trial_price_kopeks))
+
+ await callback.bot.send_invoice(
+ chat_id=callback.from_user.id,
+ title=texts.t("PAID_TRIAL_INVOICE_TITLE", "Пробная подписка на {days} дней").format(
+ days=settings.TRIAL_DURATION_DAYS
+ ),
+ description=(
+ f"{texts.t('PERIOD', 'Период')}: {settings.TRIAL_DURATION_DAYS} {texts.t('DAYS', 'дней')}\n"
+ f"{texts.t('DEVICES', 'Устройства')}: {settings.TRIAL_DEVICE_LIMIT}\n"
+ f"{texts.t('TRAFFIC', 'Трафик')}: {traffic_label}"
+ ),
+ payload=f"trial_{pending_subscription.id}",
+ provider_token="",
+ currency="XTR",
+ prices=[types.LabeledPrice(
+ label=texts.t("PAID_TRIAL_STARS_LABEL", "Пробная подписка"),
+ amount=stars_count
+ )],
+ )
+
+ await callback.message.edit_text(
+ texts.t(
+ "PAID_TRIAL_STARS_WAITING",
+ "⭐ Для оплаты пробной подписки нажмите кнопку оплаты в сообщении выше.\n\n"
+ "После успешной оплаты подписка будет активирована автоматически."
+ ),
+ reply_markup=get_back_keyboard(db_user.language),
+ parse_mode="HTML",
+ )
+
+ elif payment_method == "yookassa_sbp":
+ # Оплата через YooKassa СБП
+ payment_result = await payment_service.create_yookassa_sbp_payment(
+ amount_kopeks=trial_price_kopeks,
+ description=texts.t("PAID_TRIAL_PAYMENT_DESC", "Пробная подписка на {days} дней").format(
+ days=settings.TRIAL_DURATION_DAYS
+ ),
+ user_id=db_user.id,
+ metadata={
+ "type": "trial",
+ "subscription_id": pending_subscription.id,
+ "user_id": db_user.id,
+ },
+ )
+
+ if not payment_result or not payment_result.get("confirmation_url"):
+ await callback.answer("❌ Не удалось создать платеж. Попробуйте позже.", show_alert=True)
+ return
+
+ qr_url = payment_result.get("qr_code_url") or payment_result.get("confirmation_url")
+
+ await callback.message.edit_text(
+ texts.t(
+ "PAID_TRIAL_YOOKASSA_SBP",
+ "🏦 Оплата через СБП\n\n"
+ "Отсканируйте QR-код или перейдите по ссылке для оплаты.\n\n"
+ "💰 Сумма: {amount}"
+ ).format(amount=settings.format_price(trial_price_kopeks)),
+ reply_markup=InlineKeyboardMarkup(inline_keyboard=[
+ [InlineKeyboardButton(text="💳 Оплатить", url=qr_url)],
+ [InlineKeyboardButton(text=texts.BACK, callback_data="trial_activate")],
+ ]),
+ parse_mode="HTML",
+ )
+
+ elif payment_method == "yookassa":
+ # Оплата через YooKassa карта
+ payment_result = await payment_service.create_yookassa_payment(
+ amount_kopeks=trial_price_kopeks,
+ description=texts.t("PAID_TRIAL_PAYMENT_DESC", "Пробная подписка на {days} дней").format(
+ days=settings.TRIAL_DURATION_DAYS
+ ),
+ user_id=db_user.id,
+ metadata={
+ "type": "trial",
+ "subscription_id": pending_subscription.id,
+ "user_id": db_user.id,
+ },
+ )
+
+ if not payment_result or not payment_result.get("confirmation_url"):
+ await callback.answer("❌ Не удалось создать платеж. Попробуйте позже.", show_alert=True)
+ return
+
+ await callback.message.edit_text(
+ texts.t(
+ "PAID_TRIAL_YOOKASSA_CARD",
+ "💳 Оплата картой\n\n"
+ "Нажмите кнопку ниже для перехода к оплате.\n\n"
+ "💰 Сумма: {amount}"
+ ).format(amount=settings.format_price(trial_price_kopeks)),
+ reply_markup=InlineKeyboardMarkup(inline_keyboard=[
+ [InlineKeyboardButton(text="💳 Оплатить", url=payment_result["confirmation_url"])],
+ [InlineKeyboardButton(text=texts.BACK, callback_data="trial_activate")],
+ ]),
+ parse_mode="HTML",
+ )
+
+ elif payment_method == "cryptobot":
+ # Оплата через CryptoBot
+ payment_result = await payment_service.create_cryptobot_payment(
+ amount_kopeks=trial_price_kopeks,
+ description=texts.t("PAID_TRIAL_PAYMENT_DESC", "Пробная подписка на {days} дней").format(
+ days=settings.TRIAL_DURATION_DAYS
+ ),
+ user_id=db_user.id,
+ metadata={
+ "type": "trial",
+ "subscription_id": pending_subscription.id,
+ "user_id": db_user.id,
+ },
+ )
+
+ if not payment_result or not payment_result.get("pay_url"):
+ await callback.answer("❌ Не удалось создать платеж. Попробуйте позже.", show_alert=True)
+ return
+
+ await callback.message.edit_text(
+ texts.t(
+ "PAID_TRIAL_CRYPTOBOT",
+ "🪙 Оплата криптовалютой\n\n"
+ "Нажмите кнопку ниже для перехода к оплате.\n\n"
+ "💰 Сумма: {amount}"
+ ).format(amount=settings.format_price(trial_price_kopeks)),
+ reply_markup=InlineKeyboardMarkup(inline_keyboard=[
+ [InlineKeyboardButton(text="🪙 Оплатить", url=payment_result["pay_url"])],
+ [InlineKeyboardButton(
+ text=texts.t("CHECK_PAYMENT", "🔄 Проверить оплату"),
+ callback_data=f"check_trial_cryptobot_{pending_subscription.id}"
+ )],
+ [InlineKeyboardButton(text=texts.BACK, callback_data="trial_activate")],
+ ]),
+ parse_mode="HTML",
+ )
+
+ elif payment_method == "heleket":
+ # Оплата через Heleket
+ payment_result = await payment_service.create_heleket_payment(
+ amount_kopeks=trial_price_kopeks,
+ description=texts.t("PAID_TRIAL_PAYMENT_DESC", "Пробная подписка на {days} дней").format(
+ days=settings.TRIAL_DURATION_DAYS
+ ),
+ user_id=db_user.id,
+ metadata={
+ "type": "trial",
+ "subscription_id": pending_subscription.id,
+ "user_id": db_user.id,
+ },
+ )
+
+ if not payment_result or not payment_result.get("pay_url"):
+ await callback.answer("❌ Не удалось создать платеж. Попробуйте позже.", show_alert=True)
+ return
+
+ await callback.message.edit_text(
+ texts.t(
+ "PAID_TRIAL_HELEKET",
+ "🪙 Оплата криптовалютой (Heleket)\n\n"
+ "Нажмите кнопку ниже для перехода к оплате.\n\n"
+ "💰 Сумма: {amount}"
+ ).format(amount=settings.format_price(trial_price_kopeks)),
+ reply_markup=InlineKeyboardMarkup(inline_keyboard=[
+ [InlineKeyboardButton(text="🪙 Оплатить", url=payment_result["pay_url"])],
+ [InlineKeyboardButton(
+ text=texts.t("CHECK_PAYMENT", "🔄 Проверить оплату"),
+ callback_data=f"check_trial_heleket_{pending_subscription.id}"
+ )],
+ [InlineKeyboardButton(text=texts.BACK, callback_data="trial_activate")],
+ ]),
+ parse_mode="HTML",
+ )
+
+ elif payment_method == "mulenpay":
+ # Оплата через MulenPay
+ payment_result = await payment_service.create_mulenpay_payment(
+ amount_kopeks=trial_price_kopeks,
+ description=texts.t("PAID_TRIAL_PAYMENT_DESC", "Пробная подписка на {days} дней").format(
+ days=settings.TRIAL_DURATION_DAYS
+ ),
+ user_id=db_user.id,
+ metadata={
+ "type": "trial",
+ "subscription_id": pending_subscription.id,
+ "user_id": db_user.id,
+ },
+ )
+
+ if not payment_result or not payment_result.get("pay_url"):
+ await callback.answer("❌ Не удалось создать платеж. Попробуйте позже.", show_alert=True)
+ return
+
+ mulenpay_name = settings.get_mulenpay_display_name()
+ await callback.message.edit_text(
+ texts.t(
+ "PAID_TRIAL_MULENPAY",
+ "💳 Оплата через {name}\n\n"
+ "Нажмите кнопку ниже для перехода к оплате.\n\n"
+ "💰 Сумма: {amount}"
+ ).format(name=mulenpay_name, amount=settings.format_price(trial_price_kopeks)),
+ reply_markup=InlineKeyboardMarkup(inline_keyboard=[
+ [InlineKeyboardButton(text="💳 Оплатить", url=payment_result["pay_url"])],
+ [InlineKeyboardButton(
+ text=texts.t("CHECK_PAYMENT", "🔄 Проверить оплату"),
+ callback_data=f"check_trial_mulenpay_{pending_subscription.id}"
+ )],
+ [InlineKeyboardButton(text=texts.BACK, callback_data="trial_activate")],
+ ]),
+ parse_mode="HTML",
+ )
+
+ elif payment_method == "pal24":
+ # Оплата через PAL24
+ payment_result = await payment_service.create_pal24_payment(
+ amount_kopeks=trial_price_kopeks,
+ description=texts.t("PAID_TRIAL_PAYMENT_DESC", "Пробная подписка на {days} дней").format(
+ days=settings.TRIAL_DURATION_DAYS
+ ),
+ user_id=db_user.id,
+ metadata={
+ "type": "trial",
+ "subscription_id": pending_subscription.id,
+ "user_id": db_user.id,
+ },
+ )
+
+ if not payment_result or not payment_result.get("pay_url"):
+ await callback.answer("❌ Не удалось создать платеж. Попробуйте позже.", show_alert=True)
+ return
+
+ await callback.message.edit_text(
+ texts.t(
+ "PAID_TRIAL_PAL24",
+ "💳 Оплата через PayPalych\n\n"
+ "Нажмите кнопку ниже для перехода к оплате.\n\n"
+ "💰 Сумма: {amount}"
+ ).format(amount=settings.format_price(trial_price_kopeks)),
+ reply_markup=InlineKeyboardMarkup(inline_keyboard=[
+ [InlineKeyboardButton(text="💳 Оплатить", url=payment_result["pay_url"])],
+ [InlineKeyboardButton(
+ text=texts.t("CHECK_PAYMENT", "🔄 Проверить оплату"),
+ callback_data=f"check_trial_pal24_{pending_subscription.id}"
+ )],
+ [InlineKeyboardButton(text=texts.BACK, callback_data="trial_activate")],
+ ]),
+ parse_mode="HTML",
+ )
+
+ elif payment_method == "wata":
+ # Оплата через WATA
+ payment_result = await payment_service.create_wata_payment(
+ amount_kopeks=trial_price_kopeks,
+ description=texts.t("PAID_TRIAL_PAYMENT_DESC", "Пробная подписка на {days} дней").format(
+ days=settings.TRIAL_DURATION_DAYS
+ ),
+ user_id=db_user.id,
+ metadata={
+ "type": "trial",
+ "subscription_id": pending_subscription.id,
+ "user_id": db_user.id,
+ },
+ )
+
+ if not payment_result or not payment_result.get("pay_url"):
+ await callback.answer("❌ Не удалось создать платеж. Попробуйте позже.", show_alert=True)
+ return
+
+ await callback.message.edit_text(
+ texts.t(
+ "PAID_TRIAL_WATA",
+ "💳 Оплата через WATA\n\n"
+ "Нажмите кнопку ниже для перехода к оплате.\n\n"
+ "💰 Сумма: {amount}"
+ ).format(amount=settings.format_price(trial_price_kopeks)),
+ reply_markup=InlineKeyboardMarkup(inline_keyboard=[
+ [InlineKeyboardButton(text="💳 Оплатить", url=payment_result["pay_url"])],
+ [InlineKeyboardButton(
+ text=texts.t("CHECK_PAYMENT", "🔄 Проверить оплату"),
+ callback_data=f"check_trial_wata_{pending_subscription.id}"
+ )],
+ [InlineKeyboardButton(text=texts.BACK, callback_data="trial_activate")],
+ ]),
+ parse_mode="HTML",
+ )
+
+ else:
+ await callback.answer(f"❌ Неизвестный метод оплаты: {payment_method}", show_alert=True)
+ return
+
+ await callback.answer()
+
+ except Exception as error:
+ logger.error(f"Error processing trial payment method {payment_method}: {error}")
+ await callback.answer("❌ Произошла ошибка при создании платежа. Попробуйте позже.", show_alert=True)
+
+
def register_handlers(dp: Dispatcher):
update_traffic_prices()
@@ -2733,6 +3547,17 @@ def register_handlers(dp: Dispatcher):
F.data == "trial_activate"
)
+ # Хендлеры платного триала
+ dp.callback_query.register(
+ handle_trial_pay_with_balance,
+ F.data == "trial_pay_with_balance"
+ )
+
+ dp.callback_query.register(
+ handle_trial_payment_method,
+ F.data.startswith("trial_payment_")
+ )
+
dp.callback_query.register(
start_subscription_purchase,
F.data.in_(["menu_buy", "subscription_upgrade", "subscription_purchase"])
@@ -3001,6 +3826,10 @@ def register_handlers(dp: Dispatcher):
F.data == "device_connection_help"
)
+ # Регистрируем обработчики модема
+ from .modem import register_modem_handlers
+ register_modem_handlers(dp)
+
# Регистрируем обработчик для простой покупки
dp.callback_query.register(
handle_simple_subscription_purchase,
diff --git a/app/handlers/subscription/traffic.py b/app/handlers/subscription/traffic.py
index b86d85bf..0e7f1d92 100644
--- a/app/handlers/subscription/traffic.py
+++ b/app/handlers/subscription/traffic.py
@@ -79,7 +79,12 @@ from app.utils.promo_offer import (
)
from .common import _apply_addon_discount, _get_addon_discount_percent_for_user, _get_period_hint_from_subscription, get_confirm_switch_traffic_keyboard, get_traffic_switch_keyboard, logger
-from .countries import _get_available_countries, _should_show_countries_management
+from .countries import (
+ _build_countries_selection_text,
+ _get_available_countries,
+ _get_preselected_free_countries,
+ _should_show_countries_management,
+)
from .summary import present_subscription_summary
async def handle_add_traffic(
@@ -91,6 +96,17 @@ async def handle_add_traffic(
texts = get_texts(db_user.language)
+ # Проверяем, включена ли функция докупки трафика
+ if not settings.is_traffic_topup_enabled():
+ await callback.answer(
+ texts.t(
+ "TRAFFIC_TOPUP_DISABLED",
+ "⚠️ Функция докупки трафика отключена",
+ ),
+ show_alert=True,
+ )
+ return
+
if settings.is_traffic_fixed():
await callback.answer(
texts.t(
@@ -146,6 +162,43 @@ async def handle_add_traffic(
await callback.answer()
+def _calculate_traffic_reset_price(subscription) -> int:
+ """Рассчитывает цену сброса трафика в зависимости от настроек."""
+ mode = settings.get_traffic_reset_price_mode()
+ base_price = settings.get_traffic_reset_base_price()
+
+ # Если базовая цена не задана, используем цену периода 30 дней
+ if base_price == 0:
+ base_price = PERIOD_PRICES.get(30, 0)
+
+ if mode == "period":
+ # Старое поведение: фиксированная цена = стоимость периода
+ return base_price
+
+ elif mode == "traffic":
+ # Цена = стоимость текущего пакета трафика
+ traffic_price = settings.get_traffic_price(subscription.traffic_limit_gb)
+ return max(traffic_price, base_price)
+
+ elif mode == "traffic_with_purchased":
+ # Цена = стоимость базового трафика + докупленного
+ # Базовый трафик = текущий лимит - докупленный
+ purchased_gb = getattr(subscription, 'purchased_traffic_gb', 0) or 0
+ base_traffic_gb = subscription.traffic_limit_gb - purchased_gb
+
+ # Получаем цену базового трафика
+ base_traffic_price = settings.get_traffic_price(base_traffic_gb) if base_traffic_gb > 0 else 0
+
+ # Получаем цену докупленного трафика
+ purchased_traffic_price = settings.get_traffic_price(purchased_gb) if purchased_gb > 0 else 0
+
+ total_price = base_traffic_price + purchased_traffic_price
+ return max(total_price, base_price)
+
+ # Fallback на базовую цену
+ return base_price
+
+
async def handle_reset_traffic(
callback: types.CallbackQuery,
db_user: User,
@@ -168,19 +221,40 @@ async def handle_reset_traffic(
await callback.answer("⌛ У вас безлимитный трафик", show_alert=True)
return
- reset_price = PERIOD_PRICES[30]
+ reset_price = _calculate_traffic_reset_price(subscription)
- if db_user.balance_kopeks < reset_price:
- await callback.answer("⌛ Недостаточно средств на балансе", show_alert=True)
- return
+ # Формируем информацию о расчете цены
+ purchased_gb = getattr(subscription, 'purchased_traffic_gb', 0) or 0
+ price_info = ""
+ if purchased_gb > 0 and settings.get_traffic_reset_price_mode() == "traffic_with_purchased":
+ base_traffic_gb = subscription.traffic_limit_gb - purchased_gb
+ price_info = (
+ f"\n\n💡 Расчет цены:\n"
+ f"• Базовый трафик: {texts.format_traffic(base_traffic_gb)}\n"
+ f"• Докупленный: {texts.format_traffic(purchased_gb)}"
+ )
+
+ # Проверяем достаточно ли средств
+ has_enough_balance = db_user.balance_kopeks >= reset_price
+ missing_kopeks = max(0, reset_price - db_user.balance_kopeks)
+
+ # Формируем текст о балансе
+ balance_info = f"\n\n💰 На балансе: {texts.format_price(db_user.balance_kopeks)}"
+ if not has_enough_balance:
+ balance_info += f"\n⚠️ Не хватает: {texts.format_price(missing_kopeks)}"
await callback.message.edit_text(
f"🔄 Сброс трафика\n\n"
f"Использовано: {texts.format_traffic(subscription.traffic_used_gb)}\n"
f"Лимит: {texts.format_traffic(subscription.traffic_limit_gb)}\n\n"
- f"Стоимость сброса: {texts.format_price(reset_price)}\n\n"
+ f"Стоимость сброса: {texts.format_price(reset_price)}{price_info}{balance_info}\n\n"
"После сброса счетчик использованного трафика станет равным 0.",
- reply_markup=get_reset_traffic_confirm_keyboard(reset_price, db_user.language)
+ reply_markup=get_reset_traffic_confirm_keyboard(
+ reset_price,
+ db_user.language,
+ has_enough_balance=has_enough_balance,
+ missing_kopeks=missing_kopeks,
+ )
)
await callback.answer()
@@ -199,7 +273,7 @@ async def confirm_reset_traffic(
texts = get_texts(db_user.language)
subscription = db_user.subscription
- reset_price = PERIOD_PRICES[30]
+ reset_price = _calculate_traffic_reset_price(subscription)
if db_user.balance_kopeks < reset_price:
missing_kopeks = reset_price - db_user.balance_kopeks
@@ -348,9 +422,16 @@ async def select_traffic(
if await _should_show_countries_management(db_user):
countries = await _get_available_countries(db_user.promo_group_id)
+ # Автоматически предвыбираем бесплатные серверы
+ preselected = _get_preselected_free_countries(countries)
+ data['countries'] = preselected
+ await state.set_data(data)
+ # Формируем текст с описаниями сквадов
+ selection_text = _build_countries_selection_text(countries, texts.SELECT_COUNTRIES)
await callback.message.edit_text(
- texts.SELECT_COUNTRIES,
- reply_markup=get_countries_keyboard(countries, [], db_user.language)
+ selection_text,
+ reply_markup=get_countries_keyboard(countries, preselected, db_user.language),
+ parse_mode="HTML"
)
await state.set_state(SubscriptionStates.selecting_countries)
await callback.answer()
@@ -388,7 +469,7 @@ async def add_traffic(
texts = get_texts(db_user.language)
subscription = db_user.subscription
- base_price = settings.get_traffic_price(traffic_gb)
+ base_price = settings.get_traffic_topup_price(traffic_gb)
if base_price == 0 and traffic_gb != 0:
await callback.answer("⚠️ Цена для этого пакета не настроена", show_alert=True)
@@ -458,8 +539,13 @@ async def add_traffic(
if traffic_gb == 0:
subscription.traffic_limit_gb = 0
+ # При переходе на безлимит сбрасываем докупленный трафик
+ subscription.purchased_traffic_gb = 0
else:
await add_subscription_traffic(db, subscription, traffic_gb)
+ # Записываем докупленный трафик для корректного расчета цены сброса
+ current_purchased = getattr(subscription, 'purchased_traffic_gb', 0) or 0
+ subscription.purchased_traffic_gb = current_purchased + traffic_gb
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
@@ -535,6 +621,10 @@ async def handle_switch_traffic(
return
current_traffic = subscription.traffic_limit_gb
+ # Вычисляем базовый трафик (без докупленного) для корректного расчёта цен
+ purchased_traffic = getattr(subscription, 'purchased_traffic_gb', 0) or 0
+ base_traffic = current_traffic - purchased_traffic
+
period_hint_days = _get_period_hint_from_subscription(subscription)
traffic_discount_percent = _get_addon_discount_percent_for_user(
db_user,
@@ -542,18 +632,25 @@ async def handle_switch_traffic(
period_hint_days,
)
+ # Показываем информацию о докупленном трафике, если он есть
+ purchased_info = ""
+ if purchased_traffic > 0:
+ purchased_info = f"\n📦 Базовый пакет: {texts.format_traffic(base_traffic)}\n➕ Докуплено: {texts.format_traffic(purchased_traffic)}"
+
await callback.message.edit_text(
f"🔄 Переключение лимита трафика\n\n"
- f"Текущий лимит: {texts.format_traffic(current_traffic)}\n"
+ f"Текущий лимит: {texts.format_traffic(current_traffic)}{purchased_info}\n"
f"Выберите новый лимит трафика:\n\n"
f"💡 Важно:\n"
f"• При увеличении - доплата за разницу\n"
- f"• При уменьшении - возврат средств не производится",
+ f"• При уменьшении - возврат средств не производится\n"
+ f"• Докупленный трафик будет сброшен",
reply_markup=get_traffic_switch_keyboard(
current_traffic,
db_user.language,
subscription.end_date,
traffic_discount_percent,
+ base_traffic_gb=base_traffic,
),
parse_mode="HTML"
)
@@ -571,11 +668,16 @@ async def confirm_switch_traffic(
current_traffic = subscription.traffic_limit_gb
+ # Вычисляем базовый трафик (без докупленного) для корректного расчёта цены
+ purchased_traffic = getattr(subscription, 'purchased_traffic_gb', 0) or 0
+ base_traffic = current_traffic - purchased_traffic
+
if new_traffic_gb == current_traffic:
await callback.answer("ℹ️ Лимит трафика не изменился", show_alert=True)
return
- old_price_per_month = settings.get_traffic_price(current_traffic)
+ # Используем базовый трафик для определения текущей цены пакета
+ old_price_per_month = settings.get_traffic_price(base_traffic)
new_price_per_month = settings.get_traffic_price(new_traffic_gb)
months_remaining = get_remaining_months(subscription.end_date)
@@ -692,6 +794,8 @@ async def execute_switch_traffic(
)
subscription.traffic_limit_gb = new_traffic_gb
+ # Сбрасываем докупленный трафик при переключении пакета
+ subscription.purchased_traffic_gb = 0
subscription.updated_at = datetime.utcnow()
await db.commit()
diff --git a/app/handlers/tickets.py b/app/handlers/tickets.py
index 48395e17..df164c16 100644
--- a/app/handlers/tickets.py
+++ b/app/handlers/tickets.py
@@ -836,7 +836,7 @@ async def handle_ticket_reply(
)
texts = get_texts(db_user.language)
-
+
await message.answer(
texts.t("TICKET_REPLY_SENT", "✅ Ваш ответ отправлен!"),
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
@@ -850,9 +850,13 @@ async def handle_ticket_reply(
)]
])
)
-
+
await state.clear()
-
+
+ # Уведомить админов об ответе пользователя
+ logger.info(f"Attempting to notify admins about ticket reply #{ticket_id}")
+ await notify_admins_about_ticket_reply(ticket, reply_text, db)
+
except Exception as e:
logger.error(f"Error adding ticket reply: {e}")
texts = get_texts(db_user.language)
@@ -1015,6 +1019,54 @@ async def notify_admins_about_new_ticket(ticket: Ticket, db: AsyncSession):
logger.error(f"Error notifying admins about new ticket: {e}")
+async def notify_admins_about_ticket_reply(ticket: Ticket, reply_text: str, db: AsyncSession):
+ """Уведомить админов об ответе пользователя на тикет"""
+ logger.info(f"notify_admins_about_ticket_reply called for ticket #{ticket.id}")
+ try:
+ from app.config import settings
+ if not settings.is_admin_notifications_enabled():
+ logger.info(f"Admin notifications disabled. Reply to ticket #{ticket.id}")
+ return
+
+ title = (ticket.title or '').strip()
+ if len(title) > 60:
+ title = title[:57] + "..."
+
+ # Загрузим пользователя
+ try:
+ user = await get_user_by_id(db, ticket.user_id)
+ except Exception:
+ user = None
+ full_name = user.full_name if user else "Unknown"
+ telegram_id_display = user.telegram_id if user else "—"
+ username_display = (user.username or "отсутствует") if user else "отсутствует"
+
+ # Обрезаем текст ответа для уведомления
+ reply_preview = reply_text[:150] + "..." if len(reply_text) > 150 else reply_text
+
+ notification_text = (
+ f"💬 ОТВЕТ НА ТИКЕТ\n\n"
+ f"🆔 ID тикета: {ticket.id}\n"
+ f"📝 Заголовок: {title or '—'}\n"
+ f"👤 Пользователь: {full_name}\n"
+ f"🆔 Telegram ID: {telegram_id_display}\n"
+ f"📱 Username: @{username_display}\n\n"
+ f"📩 Сообщение:\n{reply_preview}\n"
+ )
+
+ from app.services.maintenance_service import maintenance_service
+ bot = maintenance_service._bot or None
+ if bot is None:
+ logger.warning("Bot instance is not available for admin notifications")
+ return
+
+ service = AdminNotificationService(bot)
+ result = await service.send_ticket_event_notification(notification_text, None)
+ logger.info(f"Ticket #{ticket.id} reply notification sent: {result}")
+ except Exception as e:
+ logger.error(f"Error notifying admins about ticket reply: {e}")
+
+
def register_handlers(dp: Dispatcher):
"""Регистрация обработчиков тикетов"""
diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py
index 5e126b31..84001a7f 100644
--- a/app/keyboards/inline.py
+++ b/app/keyboards/inline.py
@@ -510,6 +510,7 @@ def get_main_menu_keyboard(
# Добавляем кнопку докупки трафика для лимитированных подписок
if (
settings.BUY_TRAFFIC_BUTTON_VISIBLE
+ and settings.is_traffic_topup_enabled()
and subscription
and not subscription.is_trial
and (subscription.traffic_limit_gb or 0) > 0
@@ -1699,7 +1700,7 @@ def get_add_traffic_keyboard(
if months_multiplier > 1:
period_text = f" (за {months_multiplier} мес)"
- packages = settings.get_traffic_packages()
+ packages = settings.get_traffic_topup_packages()
enabled_packages = [pkg for pkg in packages if pkg['enabled']]
if not enabled_packages:
@@ -1855,27 +1856,45 @@ def get_confirm_change_devices_keyboard(new_devices_count: int, price: int, lang
])
-def get_reset_traffic_confirm_keyboard(price_kopeks: int, language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
+def get_reset_traffic_confirm_keyboard(
+ price_kopeks: int,
+ language: str = DEFAULT_LANGUAGE,
+ has_enough_balance: bool = True,
+ missing_kopeks: int = 0,
+) -> InlineKeyboardMarkup:
from app.config import settings
if settings.is_traffic_fixed():
return get_back_keyboard(language)
texts = get_texts(language)
- return InlineKeyboardMarkup(inline_keyboard=[
- [
+ buttons = []
+
+ if has_enough_balance:
+ # Достаточно средств - показываем кнопку сброса
+ buttons.append([
InlineKeyboardButton(
text=f"✅ Сбросить за {settings.format_price(price_kopeks)}",
callback_data="confirm_reset_traffic"
)
- ],
- [
+ ])
+ else:
+ # Не хватает средств - показываем кнопку пополнения
+ buttons.append([
InlineKeyboardButton(
- text=texts.t("PENDING_CANCEL_BUTTON", "⌛ Отмена"),
- callback_data="menu_subscription",
+ text=texts.t("TOPUP_BALANCE_BUTTON", "💳 Пополнить баланс"),
+ callback_data=f"topup_amount_{missing_kopeks}"
)
- ]
+ ])
+
+ buttons.append([
+ InlineKeyboardButton(
+ text=texts.BACK,
+ callback_data="subscription_settings",
+ )
])
+
+ return InlineKeyboardMarkup(inline_keyboard=buttons)
def get_manage_countries_keyboard(
countries: List[dict],
@@ -2386,6 +2405,14 @@ def get_updated_subscription_settings_keyboard(language: str = DEFAULT_LANGUAGE,
)
])
+ if settings.is_modem_enabled():
+ keyboard.append([
+ InlineKeyboardButton(
+ text=texts.t("MODEM_BUTTON", "📡 Модем"),
+ callback_data="subscription_modem"
+ )
+ ])
+
keyboard.append([
InlineKeyboardButton(
text=texts.t("MANAGE_DEVICES_BUTTON", "🔧 Управление устройствами"),
diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json
index 373c150d..57e1ca8c 100644
--- a/app/localization/locales/en.json
+++ b/app/localization/locales/en.json
@@ -1520,6 +1520,7 @@
"TRAFFIC_5GB": "📊 5 GB - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}",
"TRAFFIC_ALREADY_UNLIMITED": "⚠ You already have unlimited traffic",
"TRAFFIC_FIXED_MODE": "⚠️ Traffic is fixed in the current mode and cannot be changed",
+ "TRAFFIC_TOPUP_DISABLED": "⚠️ Traffic top-up feature is disabled",
"TRAFFIC_INSUFFICIENT_BALANCE": "⚠️ Insufficient balance!\nRequired: {required} (for {months} mo)\nYou have: {balance}",
"TRAFFIC_NO_CHANGE": "ℹ️ Traffic limit was not changed",
"TRAFFIC_PACKAGES_NOT_CONFIGURED": "⚠️ Traffic packages are not configured",
@@ -1619,5 +1620,36 @@
"POLL_COMPLETED": "🙏 Thanks for completing the poll!",
"POLL_REWARD_GRANTED": "Reward {amount} has been credited to your balance.",
"ADMIN_USERS_BULK_BAN": "🛑 Bulk Ban",
- "ADMIN_USERS_BLACKLIST": "🔐 Blacklist"
+ "ADMIN_USERS_BLACKLIST": "🔐 Blacklist",
+ "CONTEST_START_ANNOUNCEMENT": "Game starts",
+ "CONTEST_PRIZE": "Prize",
+ "DAYS": "days subscription",
+ "CONTEST_WINNERS": "Winners",
+ "CONTEST_ATTEMPTS": "Attempts/user",
+ "CONTEST_ELIGIBILITY": "Only users with active or trial subscription can participate",
+ "REMINDER": "Reminder",
+ "CONTEST_REMINDER_TEXT": "Don't forget to participate in contests to get bonuses",
+ "MODEM_BUTTON": "📡 Modem",
+ "MODEM_ENABLE_BUTTON": "✅ Connect modem",
+ "MODEM_DISABLE_BUTTON": "❌ Disconnect modem",
+ "MODEM_CONFIRM_BUTTON": "✅ Confirm connection",
+ "MODEM_STATUS_ENABLED": "✅ Connected",
+ "MODEM_STATUS_DISABLED": "❌ Not connected",
+ "MODEM_PAID_ONLY": "⚠️ Modem is only available for paid subscriptions",
+ "MODEM_DISABLED": "⚠️ Modem feature is disabled",
+ "MODEM_ALREADY_ENABLED": "ℹ️ Modem is already connected",
+ "MODEM_NOT_ENABLED": "ℹ️ Modem is not connected",
+ "MODEM_INFO_ENABLED": "📡 Modem\n\nStatus: {status}\n\nModem is connected to your subscription.\nMonthly fee: {price}\n\nNo refunds are provided when disconnecting the modem.",
+ "MODEM_INFO_DISABLED": "📡 Modem\n\nStatus: {status}\n\nConnect a modem to your subscription.\nMonthly fee: {price}\n\nWhen connecting a modem, an additional device will be added.",
+ "MODEM_INSUFFICIENT_FUNDS": "⚠️ Insufficient funds\n\nModem connection cost: {required}\nBalance: {balance}\nMissing: {missing}\n\nChoose a top-up method.",
+ "MODEM_CONFIRM_ENABLE": "📡 Confirm modem connection\n\nCost: {price} (for {months} months)\n\nWhen connecting a modem:\n• An additional device will be added to your subscription\n• Monthly fee will increase by {monthly_price}\n\nConfirm connection?",
+ "MODEM_ENABLED_SUCCESS": "✅ Modem successfully connected!\n\n📡 Modem activated\n📱 Device added for modem\n",
+ "MODEM_CHARGED": "💰 Charged: {amount}",
+ "MODEM_DISABLED_SUCCESS": "✅ Modem disconnected\n\n📡 Modem deactivated\nℹ️ No refunds are provided",
+ "MODEM_PERIOD_INFO": "\n📅 Modem valid until: {end_date} ({days} days)",
+ "MODEM_SHORT_PERIOD_WARNING": "\n⚠️ Warning! Only {days} days left until subscription ends.\nAfter renewal, modem will need to be paid again!",
+ "MODEM_PERIOD_NOTE": "\nℹ️ Days until subscription ends: {days}\nAfter renewal, modem will need to be paid again.",
+ "MODEM_PRICE_WITH_DISCOUNT": "Cost: {base_price} {final_price} (for {months} months)\n🎁 Discount {discount}%: -{discount_amount}",
+ "MODEM_PRICE_NO_DISCOUNT": "Cost: {price} (for {months} months)",
+ "MODEM_CONFIRM_ENABLE_BASE": "📡 Confirm modem connection\n\n{price_text}\n\nWhen connecting a modem:\n• An additional device will be added to your subscription\n• Monthly fee will increase by {monthly_price}\n\nConfirm connection?"
}
diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json
index e1fd94a1..0d5f2f65 100644
--- a/app/localization/locales/ru.json
+++ b/app/localization/locales/ru.json
@@ -1535,6 +1535,7 @@
"TRAFFIC_5GB": "📊 5 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}",
"TRAFFIC_ALREADY_UNLIMITED": "⚠ У вас уже безлимитный трафик",
"TRAFFIC_FIXED_MODE": "⚠️ В текущем режиме трафик фиксированный и не может быть изменен",
+ "TRAFFIC_TOPUP_DISABLED": "⚠️ Функция докупки трафика отключена",
"TRAFFIC_INSUFFICIENT_BALANCE": "⚠️ Недостаточно средств!\nТребуется: {required} (за {months} мес)\nУ вас: {balance}",
"TRAFFIC_NO_CHANGE": "ℹ️ Лимит трафика не изменился",
"TRAFFIC_PACKAGES_NOT_CONFIGURED": "⚠️ Пакеты трафика не настроены",
@@ -1634,5 +1635,36 @@
"POLL_COMPLETED": "🙏 Спасибо за участие в опросе!",
"POLL_REWARD_GRANTED": "Награда {amount} зачислена на ваш баланс.",
"ADMIN_USERS_BULK_BAN": "🛑 Массовый бан",
- "ADMIN_USERS_BLACKLIST": "🔐 Черный список"
+ "ADMIN_USERS_BLACKLIST": "🔐 Черный список",
+ "CONTEST_START_ANNOUNCEMENT": "Стартует игра",
+ "CONTEST_PRIZE": "Приз",
+ "DAYS": "дн. подписки",
+ "CONTEST_WINNERS": "Победителей",
+ "CONTEST_ATTEMPTS": "Попыток/польз",
+ "CONTEST_ELIGIBILITY": "Участвовать могут только с активной или триальной подпиской",
+ "REMINDER": "Напоминание",
+ "CONTEST_REMINDER_TEXT": "Не забудьте участвовать в конкурсах для получения бонусов",
+ "MODEM_BUTTON": "📡 Модем",
+ "MODEM_ENABLE_BUTTON": "✅ Подключить модем",
+ "MODEM_DISABLE_BUTTON": "❌ Отключить модем",
+ "MODEM_CONFIRM_BUTTON": "✅ Подтвердить подключение",
+ "MODEM_STATUS_ENABLED": "✅ Подключен",
+ "MODEM_STATUS_DISABLED": "❌ Не подключен",
+ "MODEM_PAID_ONLY": "⚠️ Модем доступен только для платных подписок",
+ "MODEM_DISABLED": "⚠️ Функция модема отключена",
+ "MODEM_ALREADY_ENABLED": "ℹ️ Модем уже подключен",
+ "MODEM_NOT_ENABLED": "ℹ️ Модем не подключен",
+ "MODEM_INFO_ENABLED": "📡 Модем\n\nСтатус: {status}\n\nМодем подключен к вашей подписке.\nЕжемесячная плата: {price}\n\nПри отключении модема возврат средств не производится.",
+ "MODEM_INFO_DISABLED": "📡 Модем\n\nСтатус: {status}\n\nПодключите модем к вашей подписке.\nЕжемесячная плата: {price}\n\nПри подключении модема будет добавлено дополнительное устройство.",
+ "MODEM_INSUFFICIENT_FUNDS": "⚠️ Недостаточно средств\n\nСтоимость подключения модема: {required}\nНа балансе: {balance}\nНе хватает: {missing}\n\nВыберите способ пополнения.",
+ "MODEM_CONFIRM_ENABLE": "📡 Подтверждение подключения модема\n\nСтоимость: {price} (за {months} мес)\n\nПри подключении модема:\n• К подписке добавится дополнительное устройство\n• Ежемесячная плата увеличится на {monthly_price}\n\nПодтвердить подключение?",
+ "MODEM_ENABLED_SUCCESS": "✅ Модем успешно подключен!\n\n📡 Модем активирован\n📱 Добавлено устройство для модема\n",
+ "MODEM_CHARGED": "💰 Списано: {amount}",
+ "MODEM_DISABLED_SUCCESS": "✅ Модем отключен\n\n📡 Модем деактивирован\nℹ️ Возврат средств не производится",
+ "MODEM_PERIOD_INFO": "\n📅 Модем действует до: {end_date} ({days} дн.)",
+ "MODEM_SHORT_PERIOD_WARNING": "\n⚠️ Внимание! До окончания подписки осталось всего {days} дн.\nПосле продления подписки модем нужно будет оплатить заново!",
+ "MODEM_PERIOD_NOTE": "\nℹ️ До окончания подписки: {days} дн.\nПосле продления модем нужно будет оплатить заново.",
+ "MODEM_PRICE_WITH_DISCOUNT": "Стоимость: {base_price} {final_price} (за {months} мес)\n🎁 Скидка {discount}%: -{discount_amount}",
+ "MODEM_PRICE_NO_DISCOUNT": "Стоимость: {price} (за {months} мес)",
+ "MODEM_CONFIRM_ENABLE_BASE": "📡 Подтверждение подключения модема\n\n{price_text}\n\nПри подключении модема:\n• К подписке добавится дополнительное устройство\n• Ежемесячная плата увеличится на {monthly_price}\n\nПодтвердить подключение?"
}
diff --git a/app/localization/locales/ua.json b/app/localization/locales/ua.json
index f3f09dc5..5639c0b7 100644
--- a/app/localization/locales/ua.json
+++ b/app/localization/locales/ua.json
@@ -1454,6 +1454,7 @@
"TRAFFIC_5GB": "📊 5 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}",
"TRAFFIC_ALREADY_UNLIMITED": "⚠ У вас вже безлімітний трафік",
"TRAFFIC_FIXED_MODE": "⚠️ У поточному режимі трафік фіксований і не може бути змінений",
+ "TRAFFIC_TOPUP_DISABLED": "⚠️ Функція докупки трафіку вимкнена",
"TRAFFIC_INSUFFICIENT_BALANCE": "⚠️ Недостатньо коштів!\nПотрібно: {required} (за {months} міс)\nУ вас: {balance}",
"TRAFFIC_NO_CHANGE": "ℹ️ Ліміт трафіку не змінився",
"TRAFFIC_PACKAGES_NOT_CONFIGURED": "⚠️ Пакети трафіку не налаштовані",
@@ -1551,5 +1552,28 @@
"POLL_EMPTY": "Опитування поки недоступне.",
"POLL_ERROR": "Не вдалося обробити опитування. Спробуйте пізніше.",
"POLL_COMPLETED": "🙏 Дякуємо за участь в опитуванні!",
- "POLL_REWARD_GRANTED": "Нагороду {amount} зараховано на ваш баланс."
+ "POLL_REWARD_GRANTED": "Нагороду {amount} зараховано на ваш баланс.",
+ "MODEM_BUTTON": "📡 Модем",
+ "MODEM_ENABLE_BUTTON": "✅ Підключити модем",
+ "MODEM_DISABLE_BUTTON": "❌ Відключити модем",
+ "MODEM_CONFIRM_BUTTON": "✅ Підтвердити підключення",
+ "MODEM_STATUS_ENABLED": "✅ Підключено",
+ "MODEM_STATUS_DISABLED": "❌ Не підключено",
+ "MODEM_PAID_ONLY": "⚠️ Модем доступний тільки для платних підписок",
+ "MODEM_DISABLED": "⚠️ Функція модема вимкнена",
+ "MODEM_ALREADY_ENABLED": "ℹ️ Модем вже підключено",
+ "MODEM_NOT_ENABLED": "ℹ️ Модем не підключено",
+ "MODEM_INFO_ENABLED": "📡 Модем\n\nСтатус: {status}\n\nМодем підключено до вашої підписки.\nЩомісячна плата: {price}\n\nПри відключенні модема повернення коштів не здійснюється.",
+ "MODEM_INFO_DISABLED": "📡 Модем\n\nСтатус: {status}\n\nПідключіть модем до вашої підписки.\nЩомісячна плата: {price}\n\nПри підключенні модема буде додано додатковий пристрій.",
+ "MODEM_INSUFFICIENT_FUNDS": "⚠️ Недостатньо коштів\n\nВартість підключення модема: {required}\nНа балансі: {balance}\nНе вистачає: {missing}\n\nОберіть спосіб поповнення.",
+ "MODEM_CONFIRM_ENABLE": "📡 Підтвердження підключення модема\n\nВартість: {price} (за {months} міс)\n\nПри підключенні модема:\n• До підписки додасться додатковий пристрій\n• Щомісячна плата збільшиться на {monthly_price}\n\nПідтвердити підключення?",
+ "MODEM_ENABLED_SUCCESS": "✅ Модем успішно підключено!\n\n📡 Модем активовано\n📱 Додано пристрій для модема\n",
+ "MODEM_CHARGED": "💰 Списано: {amount}",
+ "MODEM_DISABLED_SUCCESS": "✅ Модем відключено\n\n📡 Модем деактивовано\nℹ️ Повернення коштів не здійснюється",
+ "MODEM_PERIOD_INFO": "\n📅 Модем діє до: {end_date} ({days} дн.)",
+ "MODEM_SHORT_PERIOD_WARNING": "\n⚠️ Увага! До закінчення підписки залишилось лише {days} дн.\nПісля продовження підписки модем потрібно буде оплатити знову!",
+ "MODEM_PERIOD_NOTE": "\nℹ️ До закінчення підписки: {days} дн.\nПісля продовження модем потрібно буде оплатити знову.",
+ "MODEM_PRICE_WITH_DISCOUNT": "Вартість: {base_price} {final_price} (за {months} міс)\n🎁 Знижка {discount}%: -{discount_amount}",
+ "MODEM_PRICE_NO_DISCOUNT": "Вартість: {price} (за {months} міс)",
+ "MODEM_CONFIRM_ENABLE_BASE": "📡 Підтвердження підключення модема\n\n{price_text}\n\nПри підключенні модема:\n• До підписки додасться додатковий пристрій\n• Щомісячна плата збільшиться на {monthly_price}\n\nПідтвердити підключення?"
}
diff --git a/app/localization/locales/zh.json b/app/localization/locales/zh.json
index de5ed06f..279fcbca 100644
--- a/app/localization/locales/zh.json
+++ b/app/localization/locales/zh.json
@@ -1452,6 +1452,7 @@
"TRAFFIC_5GB":"📊5GB-{settings.format_price(settings.PRICE_TRAFFIC_5GB)}",
"TRAFFIC_ALREADY_UNLIMITED":"⚠您已拥有无限流量",
"TRAFFIC_FIXED_MODE":"⚠️当前模式下流量是固定的,无法更改",
+"TRAFFIC_TOPUP_DISABLED":"⚠️流量购买功能已禁用",
"TRAFFIC_INSUFFICIENT_BALANCE":"⚠️资金不足!\n需要:{required}(共{months}个月)\n您有:{balance}",
"TRAFFIC_NO_CHANGE":"ℹ️流量限制未更改",
"TRAFFIC_PACKAGES_NOT_CONFIGURED":"⚠️未配置流量包",
@@ -1779,6 +1780,7 @@
"TRAFFIC_5GB":"📊5GB-{settings.format_price(settings.PRICE_TRAFFIC_5GB)}",
"TRAFFIC_ALREADY_UNLIMITED":"⚠您已拥有无限流量",
"TRAFFIC_FIXED_MODE":"⚠️当前模式下流量是固定的,无法更改",
+"TRAFFIC_TOPUP_DISABLED":"⚠️流量购买功能已禁用",
"TRAFFIC_INSUFFICIENT_BALANCE":"⚠️资金不足!\n需要:{required}(共{months}个月)\n您有:{balance}",
"TRAFFIC_NO_CHANGE":"ℹ️流量限制未更改",
"TRAFFIC_PACKAGES_NOT_CONFIGURED":"⚠️未配置流量包",
@@ -1878,5 +1880,28 @@
"POLL_COMPLETED":"🙏感谢您参与民意调查!",
"POLL_REWARD_GRANTED":"奖励{amount}已存入您的余额。",
"DEVICE_GUIDE_WINDOWS":"💻Windows",
-"REFERRAL_LIST_ITEM_ACTIVITY_LONG_AGO":"🕐活跃:很久以前"
+"REFERRAL_LIST_ITEM_ACTIVITY_LONG_AGO":"🕐活跃:很久以前",
+"MODEM_BUTTON":"📡 调制解调器",
+"MODEM_ENABLE_BUTTON":"✅ 连接调制解调器",
+"MODEM_DISABLE_BUTTON":"❌ 断开调制解调器",
+"MODEM_CONFIRM_BUTTON":"✅ 确认连接",
+"MODEM_STATUS_ENABLED":"✅ 已连接",
+"MODEM_STATUS_DISABLED":"❌ 未连接",
+"MODEM_PAID_ONLY":"⚠️ 调制解调器仅适用于付费订阅",
+"MODEM_DISABLED":"⚠️ 调制解调器功能已禁用",
+"MODEM_ALREADY_ENABLED":"ℹ️ 调制解调器已连接",
+"MODEM_NOT_ENABLED":"ℹ️ 调制解调器未连接",
+"MODEM_INFO_ENABLED":"📡 调制解调器\n\n状态:{status}\n\n调制解调器已连接到您的订阅。\n月费:{price}\n\n断开调制解调器时不提供退款。",
+"MODEM_INFO_DISABLED":"📡 调制解调器\n\n状态:{status}\n\n将调制解调器连接到您的订阅。\n月费:{price}\n\n连接调制解调器时将添加额外设备。",
+"MODEM_INSUFFICIENT_FUNDS":"⚠️ 余额不足\n\n调制解调器连接费用:{required}\n余额:{balance}\n差额:{missing}\n\n请选择充值方式。",
+"MODEM_CONFIRM_ENABLE":"📡 确认连接调制解调器\n\n费用:{price}({months}个月)\n\n连接调制解调器时:\n• 将向您的订阅添加额外设备\n• 月费将增加{monthly_price}\n\n确认连接?",
+"MODEM_ENABLED_SUCCESS":"✅ 调制解调器连接成功!\n\n📡 调制解调器已激活\n📱 已添加调制解调器设备\n",
+"MODEM_CHARGED":"💰 已扣款:{amount}",
+"MODEM_DISABLED_SUCCESS":"✅ 调制解调器已断开\n\n📡 调制解调器已停用\nℹ️ 不提供退款",
+"MODEM_PERIOD_INFO":"\n📅 调制解调器有效期至:{end_date}({days}天)",
+"MODEM_SHORT_PERIOD_WARNING":"\n⚠️ 注意!距离订阅结束仅剩{days}天。\n续订后需重新支付调制解调器费用!",
+"MODEM_PERIOD_NOTE":"\nℹ️ 距离订阅结束:{days}天\n续订后需重新支付调制解调器费用。",
+"MODEM_PRICE_WITH_DISCOUNT":"费用:{base_price} {final_price}({months}个月)\n🎁 折扣{discount}%:-{discount_amount}",
+"MODEM_PRICE_NO_DISCOUNT":"费用:{price}({months}个月)",
+"MODEM_CONFIRM_ENABLE_BASE":"📡 确认连接调制解调器\n\n{price_text}\n\n连接调制解调器时:\n• 将向您的订阅添加额外设备\n• 月费将增加{monthly_price}\n\n确认连接?"
}
diff --git a/app/middlewares/channel_checker.py b/app/middlewares/channel_checker.py
index 74d6dc02..43a68d47 100644
--- a/app/middlewares/channel_checker.py
+++ b/app/middlewares/channel_checker.py
@@ -108,8 +108,8 @@ class ChannelCheckerMiddleware(BaseMiddleware):
elif member.status in self.BAD_MEMBER_STATUS:
logger.info(f"❌ Пользователь {telegram_id} не подписан на канал (статус: {member.status})")
- if telegram_id and settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE:
- await self._deactivate_trial_subscription(telegram_id)
+ if telegram_id and (settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE or settings.CHANNEL_REQUIRED_FOR_ALL):
+ await self._deactivate_subscription_on_unsubscribe(telegram_id)
await self._capture_start_payload(state, event, bot)
@@ -253,8 +253,8 @@ class ChannelCheckerMiddleware(BaseMiddleware):
finally:
break
- async def _deactivate_trial_subscription(self, telegram_id: int) -> None:
- if not settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE:
+ async def _deactivate_subscription_on_unsubscribe(self, telegram_id: int) -> None:
+ if not settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE and not settings.CHANNEL_REQUIRED_FOR_ALL:
logger.debug(
"ℹ️ Пропускаем деактивацию подписки пользователя %s: отключение при отписке выключено",
telegram_id,
@@ -272,19 +272,29 @@ class ChannelCheckerMiddleware(BaseMiddleware):
break
subscription = user.subscription
- if (not subscription.is_trial or
- subscription.status != SubscriptionStatus.ACTIVE.value):
+
+ if subscription.status != SubscriptionStatus.ACTIVE.value:
logger.debug(
- "ℹ️ Подписка пользователя %s не требует деактивации (trial=%s, status=%s)",
+ "ℹ️ Подписка пользователя %s не активна (status=%s) — пропускаем деактивацию",
telegram_id,
- subscription.is_trial,
subscription.status,
)
break
+ if settings.CHANNEL_REQUIRED_FOR_ALL:
+ pass
+ elif not subscription.is_trial:
+ logger.debug(
+ "ℹ️ Подписка пользователя %s платная, CHANNEL_REQUIRED_FOR_ALL=False — пропускаем деактивацию",
+ telegram_id,
+ )
+ break
+
await deactivate_subscription(db, subscription)
+ sub_type = "Триальная" if subscription.is_trial else "Платная"
logger.info(
- "🚫 Триальная подписка пользователя %s отключена после отписки от канала",
+ "🚫 %s подписка пользователя %s отключена после отписки от канала",
+ sub_type,
telegram_id,
)
diff --git a/app/services/admin_notification_service.py b/app/services/admin_notification_service.py
index dc9ab2d3..95224e34 100644
--- a/app/services/admin_notification_service.py
+++ b/app/services/admin_notification_service.py
@@ -1510,6 +1510,7 @@ class AdminNotificationService:
except Exception:
runtime_enabled = True
if not (self._is_enabled() and runtime_enabled):
+ logger.info(f"Ticket notification skipped: _is_enabled={self._is_enabled()}, runtime_enabled={runtime_enabled}")
return False
return await self._send_message(text, reply_markup=keyboard, ticket_event=True)
diff --git a/app/services/campaign_service.py b/app/services/campaign_service.py
index b541dfd4..c645b0fd 100644
--- a/app/services/campaign_service.py
+++ b/app/services/campaign_service.py
@@ -147,6 +147,9 @@ class AdvertisingCampaignService:
duration_days=duration_days,
traffic_limit_gb=traffic_limit or 0,
device_limit=device_limit,
+ connected_squads=squads,
+ update_server_counters=True,
+ is_trial=True,
squad_uuid=squad_uuid,
)
diff --git a/app/services/contest_rotation_service.py b/app/services/contest_rotation_service.py
index 534572a7..95ac7500 100644
--- a/app/services/contest_rotation_service.py
+++ b/app/services/contest_rotation_service.py
@@ -1,6 +1,5 @@
import asyncio
import logging
-import random
from datetime import datetime, timedelta, time, timezone
from typing import Dict, List, Optional
from zoneinfo import ZoneInfo
@@ -18,17 +17,19 @@ from app.database.crud.contest import (
)
from app.database.database import AsyncSessionLocal
from app.database.models import ContestTemplate, SubscriptionStatus, User
+from app.services.contests.enums import GameType, PrizeType, RoundStatus
+from app.services.contests.games import get_game_strategy
logger = logging.getLogger(__name__)
-# Slugs for games
-GAME_QUEST = "quest_buttons"
-GAME_LOCKS = "lock_hack"
-GAME_CIPHER = "letter_cipher"
-GAME_SERVER = "server_lottery"
-GAME_BLITZ = "blitz_reaction"
-GAME_EMOJI = "emoji_guess"
-GAME_ANAGRAM = "anagram"
+# Legacy aliases for backward compatibility
+GAME_QUEST = GameType.QUEST_BUTTONS.value
+GAME_LOCKS = GameType.LOCK_HACK.value
+GAME_CIPHER = GameType.LETTER_CIPHER.value
+GAME_SERVER = GameType.SERVER_LOTTERY.value
+GAME_BLITZ = GameType.BLITZ_REACTION.value
+GAME_EMOJI = GameType.EMOJI_GUESS.value
+GAME_ANAGRAM = GameType.ANAGRAM.value
DEFAULT_TEMPLATES = [
@@ -36,7 +37,8 @@ DEFAULT_TEMPLATES = [
"slug": GAME_QUEST,
"name": "Квест-кнопки",
"description": "Найди секретную кнопку 3×3",
- "prize_days": 1,
+ "prize_type": "days",
+ "prize_value": "1",
"max_winners": 3,
"attempts_per_user": 1,
"times_per_day": 2,
@@ -48,7 +50,8 @@ DEFAULT_TEMPLATES = [
"slug": GAME_LOCKS,
"name": "Кнопочный взлом",
"description": "Найди взломанную кнопку среди 20 замков",
- "prize_days": 5,
+ "prize_type": "days",
+ "prize_value": "5",
"max_winners": 1,
"attempts_per_user": 1,
"times_per_day": 2,
@@ -60,7 +63,8 @@ DEFAULT_TEMPLATES = [
"slug": GAME_CIPHER,
"name": "Шифр букв",
"description": "Расшифруй слово по номерам",
- "prize_days": 1,
+ "prize_type": "days",
+ "prize_value": "1",
"max_winners": 1,
"attempts_per_user": 1,
"times_per_day": 2,
@@ -72,7 +76,8 @@ DEFAULT_TEMPLATES = [
"slug": GAME_SERVER,
"name": "Сервер-лотерея",
"description": "Угадай доступный сервер",
- "prize_days": 7,
+ "prize_type": "days",
+ "prize_value": "7",
"max_winners": 1,
"attempts_per_user": 1,
"times_per_day": 1,
@@ -84,7 +89,8 @@ DEFAULT_TEMPLATES = [
"slug": GAME_BLITZ,
"name": "Блиц-реакция",
"description": "Нажми кнопку за 10 секунд",
- "prize_days": 1,
+ "prize_type": "days",
+ "prize_value": "1",
"max_winners": 1,
"attempts_per_user": 1,
"times_per_day": 2,
@@ -96,7 +102,8 @@ DEFAULT_TEMPLATES = [
"slug": GAME_EMOJI,
"name": "Угадай сервис по эмодзи",
"description": "Определи сервис по эмодзи",
- "prize_days": 1,
+ "prize_type": "days",
+ "prize_value": "1",
"max_winners": 1,
"attempts_per_user": 1,
"times_per_day": 1,
@@ -108,7 +115,8 @@ DEFAULT_TEMPLATES = [
"slug": GAME_ANAGRAM,
"name": "Анаграмма дня",
"description": "Собери слово из букв",
- "prize_days": 1,
+ "prize_type": "days",
+ "prize_value": "1",
"max_winners": 1,
"attempts_per_user": 1,
"times_per_day": 1,
@@ -239,38 +247,12 @@ class ContestRotationService:
return ZoneInfo("UTC")
def _build_payload_for_template(self, tpl: ContestTemplate) -> Dict:
- payload = tpl.payload or {}
- if tpl.slug == GAME_QUEST:
- rows = payload.get("rows", 3)
- cols = payload.get("cols", 3)
- total = rows * cols
- secret_idx = random.randint(0, total - 1)
- return {"rows": rows, "cols": cols, "secret_idx": secret_idx}
- if tpl.slug == GAME_LOCKS:
- total = payload.get("buttons", 20)
- secret_idx = random.randint(0, max(0, total - 1))
- return {"total": total, "secret_idx": secret_idx}
- if tpl.slug == GAME_CIPHER:
- words = payload.get("words") or ["VPN"]
- word = random.choice(words)
- codes = [str(ord(ch.upper()) - 64) for ch in word if ch.isalpha()]
- return {"question": "-".join(codes), "answer": word.upper()}
- if tpl.slug == GAME_SERVER:
- flags = payload.get("flags") or ["🇸🇪","🇸🇬","🇺🇸","🇷🇺","🇩🇪","🇯🇵","🇧🇷","🇦🇺","🇨🇦","🇫🇷"]
- secret_idx = random.randint(0, len(flags) - 1)
- return {"flags": flags, "secret_idx": secret_idx}
- if tpl.slug == GAME_BLITZ:
- return {"timeout_seconds": payload.get("timeout_seconds", 10)}
- if tpl.slug == GAME_EMOJI:
- pairs = payload.get("pairs") or [{"question": "🔐📡🌐", "answer": "VPN"}]
- pair = random.choice(pairs)
- return pair
- if tpl.slug == GAME_ANAGRAM:
- words = payload.get("words") or ["SERVER"]
- word = random.choice(words).upper()
- shuffled = "".join(random.sample(word, len(word)))
- return {"letters": shuffled, "answer": word}
- return payload
+ """Build round-specific payload using game strategy."""
+ strategy = get_game_strategy(tpl.slug)
+ if strategy:
+ return strategy.build_payload(tpl.payload or {})
+ # Fallback for unknown game types
+ return tpl.payload or {}
async def _announce_round_start(
self,
@@ -281,11 +263,28 @@ class ContestRotationService:
if not self.bot:
return
+ from app.localization.texts import get_texts
+ texts = get_texts("ru") # Default to ru for announcements
+
+ # Format prize display based on prize_type
+ prize_type = tpl.prize_type or PrizeType.DAYS.value
+ prize_value = tpl.prize_value or "1"
+
+ if prize_type == PrizeType.DAYS.value:
+ prize_display = f"{prize_value} {texts.t('DAYS', 'дн. подписки')}"
+ elif prize_type == PrizeType.BALANCE.value:
+ prize_display = f"{prize_value} коп."
+ elif prize_type == PrizeType.CUSTOM.value:
+ prize_display = prize_value
+ else:
+ prize_display = prize_value
+
text = (
- f"🎲 Стартует игра: {tpl.name}\n"
- f"Приз: {tpl.prize_days} дн. подписки • Победителей: {tpl.max_winners}\n"
- f"Попыток/польз: {tpl.attempts_per_user}\n\n"
- "Участвовать могут только с активной или триальной подпиской."
+ f"🎲 {texts.t('CONTEST_START_ANNOUNCEMENT', 'Стартует игра')}: {tpl.name}\n"
+ f"{texts.t('CONTEST_PRIZE', 'Приз')}: {prize_display} • {texts.t('CONTEST_WINNERS', 'Победителей')}: {tpl.max_winners}\n"
+ f"{texts.t('CONTEST_ATTEMPTS', 'Попыток/польз')}: {tpl.attempts_per_user}\n\n"
+ f"{texts.t('CONTEST_ELIGIBILITY', 'Участвовать могут только с активной или триальной подпиской')}.\n"
+ f"💡 {texts.t('REMINDER', 'Напоминание')}: {texts.t('CONTEST_REMINDER_TEXT', 'Не забудьте участвовать в конкурсах для получения бонусов')}!"
)
await asyncio.gather(
diff --git a/app/services/contests/__init__.py b/app/services/contests/__init__.py
new file mode 100644
index 00000000..831ae551
--- /dev/null
+++ b/app/services/contests/__init__.py
@@ -0,0 +1,14 @@
+"""Contest services module."""
+
+from app.services.contests.enums import GameType, RoundStatus, PrizeType
+from app.services.contests.games import get_game_strategy, BaseGameStrategy
+from app.services.contests.attempt_service import ContestAttemptService
+
+__all__ = [
+ "GameType",
+ "RoundStatus",
+ "PrizeType",
+ "get_game_strategy",
+ "BaseGameStrategy",
+ "ContestAttemptService",
+]
diff --git a/app/services/contests/attempt_service.py b/app/services/contests/attempt_service.py
new file mode 100644
index 00000000..97bc6cfd
--- /dev/null
+++ b/app/services/contests/attempt_service.py
@@ -0,0 +1,316 @@
+"""Service for atomic contest attempt operations."""
+
+import logging
+from dataclasses import dataclass
+from typing import Optional
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.config import settings
+from app.database.crud.contest import create_attempt, get_attempt, update_attempt
+from app.database.crud.subscription import extend_subscription, get_subscription_by_user_id
+from app.database.crud.user import get_user_by_id
+from app.database.models import ContestAttempt, ContestRound, ContestTemplate
+from app.services.contests.enums import PrizeType
+from app.services.contests.games import get_game_strategy
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class AttemptResult:
+ """Result of processing a contest attempt."""
+
+ success: bool
+ is_winner: bool
+ message: str
+ already_played: bool = False
+ round_finished: bool = False
+
+
+class ContestAttemptService:
+ """Service for processing contest attempts with atomic operations."""
+
+ async def process_button_attempt(
+ self,
+ db: AsyncSession,
+ round_obj: ContestRound,
+ user_id: int,
+ pick: str,
+ language: str,
+ ) -> AttemptResult:
+ """
+ Process a button-based game attempt atomically.
+
+ Args:
+ db: Database session
+ round_obj: Contest round
+ user_id: User ID
+ pick: User's pick (button callback data)
+ language: User's language
+
+ Returns:
+ AttemptResult with outcome details
+ """
+ tpl = round_obj.template
+ if not tpl:
+ return AttemptResult(
+ success=False,
+ is_winner=False,
+ message="Конкурс не найден",
+ )
+
+ # Check if user already played
+ existing_attempt = await get_attempt(db, round_obj.id, user_id)
+ if existing_attempt:
+ return AttemptResult(
+ success=False,
+ is_winner=False,
+ message="У вас уже была попытка",
+ already_played=True,
+ )
+
+ # Get game strategy and check answer
+ strategy = get_game_strategy(tpl.slug)
+ if not strategy:
+ return AttemptResult(
+ success=False,
+ is_winner=False,
+ message="Тип игры не поддерживается",
+ )
+
+ check_result = strategy.check_answer(pick, round_obj.payload or {}, language)
+ is_winner = check_result.is_correct
+
+ # Atomic winner check with row lock
+ is_winner = await self._atomic_winner_check(db, round_obj.id, is_winner)
+
+ # Create attempt record
+ await create_attempt(
+ db,
+ round_id=round_obj.id,
+ user_id=user_id,
+ answer=str(pick),
+ is_winner=is_winner,
+ )
+
+ logger.info(
+ "Contest attempt: user %s, round %s, pick '%s', winner %s",
+ user_id, round_obj.id, pick, is_winner
+ )
+
+ if is_winner:
+ prize_msg = await self._award_prize(db, user_id, tpl, language)
+ return AttemptResult(
+ success=True,
+ is_winner=True,
+ message=f"🎉 Победа! {prize_msg}" if prize_msg else "🎉 Победа!",
+ )
+
+ return AttemptResult(
+ success=True,
+ is_winner=False,
+ message=check_result.response_text or "Неудача",
+ )
+
+ async def process_text_attempt(
+ self,
+ db: AsyncSession,
+ round_obj: ContestRound,
+ user_id: int,
+ text_answer: str,
+ language: str,
+ ) -> AttemptResult:
+ """
+ Process a text-input game attempt atomically.
+
+ Args:
+ db: Database session
+ round_obj: Contest round
+ user_id: User ID
+ text_answer: User's text answer
+ language: User's language
+
+ Returns:
+ AttemptResult with outcome details
+ """
+ tpl = round_obj.template
+ if not tpl:
+ return AttemptResult(
+ success=False,
+ is_winner=False,
+ message="Конкурс не найден",
+ )
+
+ # For text games, attempt should already exist (created in render phase)
+ attempt = await get_attempt(db, round_obj.id, user_id)
+ if not attempt:
+ return AttemptResult(
+ success=False,
+ is_winner=False,
+ message="Сначала начните игру",
+ )
+
+ # Check if already answered
+ if attempt.answer is not None:
+ return AttemptResult(
+ success=False,
+ is_winner=False,
+ message="У вас уже была попытка",
+ already_played=True,
+ )
+
+ # Get game strategy and check answer
+ strategy = get_game_strategy(tpl.slug)
+ if not strategy:
+ return AttemptResult(
+ success=False,
+ is_winner=False,
+ message="Тип игры не поддерживается",
+ )
+
+ check_result = strategy.check_answer(text_answer, round_obj.payload or {}, language)
+ is_winner = check_result.is_correct
+
+ # Atomic winner check with row lock
+ is_winner = await self._atomic_winner_check(db, round_obj.id, is_winner)
+
+ # Update attempt with answer
+ await update_attempt(db, attempt, answer=text_answer.strip().upper(), is_winner=is_winner)
+
+ logger.info(
+ "Contest text attempt: user %s, round %s, answer '%s', winner %s",
+ user_id, round_obj.id, text_answer, is_winner
+ )
+
+ if is_winner:
+ prize_msg = await self._award_prize(db, user_id, tpl, language)
+ return AttemptResult(
+ success=True,
+ is_winner=True,
+ message=f"🎉 Победа! {prize_msg}" if prize_msg else "🎉 Победа!",
+ )
+
+ return AttemptResult(
+ success=True,
+ is_winner=False,
+ message=check_result.response_text or "Неверно, попробуй в следующем раунде",
+ )
+
+ async def create_pending_attempt(
+ self,
+ db: AsyncSession,
+ round_id: int,
+ user_id: int,
+ ) -> Optional[ContestAttempt]:
+ """
+ Create a pending attempt for text-input games.
+ This blocks re-entry while user is answering.
+
+ Args:
+ db: Database session
+ round_id: Round ID
+ user_id: User ID
+
+ Returns:
+ Created attempt or None if already exists
+ """
+ existing = await get_attempt(db, round_id, user_id)
+ if existing:
+ return None
+
+ return await create_attempt(
+ db,
+ round_id=round_id,
+ user_id=user_id,
+ answer=None,
+ is_winner=False,
+ )
+
+ async def _atomic_winner_check(
+ self,
+ db: AsyncSession,
+ round_id: int,
+ is_winner: bool,
+ ) -> bool:
+ """
+ Atomically check and increment winner count.
+ Uses SELECT FOR UPDATE to prevent race conditions.
+
+ Args:
+ db: Database session
+ round_id: Round ID
+ is_winner: Whether user answered correctly
+
+ Returns:
+ True if user is a winner, False if max winners reached
+ """
+ if not is_winner:
+ return False
+
+ stmt = select(ContestRound).where(ContestRound.id == round_id).with_for_update()
+ result = await db.execute(stmt)
+ round_obj = result.scalar_one()
+
+ if round_obj.winners_count >= round_obj.max_winners:
+ return False
+
+ round_obj.winners_count += 1
+ await db.commit()
+ return True
+
+ async def _award_prize(
+ self,
+ db: AsyncSession,
+ user_id: int,
+ template: ContestTemplate,
+ language: str,
+ ) -> str:
+ """
+ Award prize to winner.
+
+ Args:
+ db: Database session
+ user_id: Winner user ID
+ template: Contest template with prize info
+ language: User's language
+
+ Returns:
+ Prize notification message
+ """
+ from app.localization.texts import get_texts
+ texts = get_texts(language)
+
+ prize_type = template.prize_type or PrizeType.DAYS.value
+ prize_value = template.prize_value or "1"
+
+ if prize_type == PrizeType.DAYS.value:
+ subscription = await get_subscription_by_user_id(db, user_id)
+ if not subscription:
+ return ""
+ days = int(prize_value) if prize_value.isdigit() else 1
+ await extend_subscription(db, subscription, days)
+ return texts.t("CONTEST_PRIZE_GRANTED", "Бонус {days} дней зачислен!").format(days=days)
+
+ elif prize_type == PrizeType.BALANCE.value:
+ user = await get_user_by_id(db, user_id)
+ if not user:
+ return ""
+ kopeks = int(prize_value) if prize_value.isdigit() else 0
+ if kopeks > 0:
+ user.balance_kopeks += kopeks
+ await db.commit()
+ return texts.t(
+ "CONTEST_BALANCE_GRANTED",
+ "Бонус {amount} зачислен!"
+ ).format(amount=settings.format_price(kopeks))
+
+ elif prize_type == PrizeType.CUSTOM.value:
+ return f"🎁 {prize_value}"
+
+ return ""
+
+
+# Singleton instance
+contest_attempt_service = ContestAttemptService()
diff --git a/app/services/contests/enums.py b/app/services/contests/enums.py
new file mode 100644
index 00000000..070477a8
--- /dev/null
+++ b/app/services/contests/enums.py
@@ -0,0 +1,45 @@
+"""Enum classes for contest system."""
+
+from enum import Enum
+
+
+class GameType(str, Enum):
+ """Types of daily contest games."""
+
+ QUEST_BUTTONS = "quest_buttons"
+ LOCK_HACK = "lock_hack"
+ LETTER_CIPHER = "letter_cipher"
+ SERVER_LOTTERY = "server_lottery"
+ BLITZ_REACTION = "blitz_reaction"
+ EMOJI_GUESS = "emoji_guess"
+ ANAGRAM = "anagram"
+
+ @classmethod
+ def is_text_input(cls, game_type: "GameType") -> bool:
+ """Check if game requires text input from user."""
+ return game_type in {cls.LETTER_CIPHER, cls.EMOJI_GUESS, cls.ANAGRAM}
+
+ @classmethod
+ def is_button_pick(cls, game_type: "GameType") -> bool:
+ """Check if game uses button selection."""
+ return game_type in {
+ cls.QUEST_BUTTONS,
+ cls.LOCK_HACK,
+ cls.SERVER_LOTTERY,
+ cls.BLITZ_REACTION,
+ }
+
+
+class RoundStatus(str, Enum):
+ """Contest round status."""
+
+ ACTIVE = "active"
+ FINISHED = "finished"
+
+
+class PrizeType(str, Enum):
+ """Types of prizes for contests."""
+
+ DAYS = "days"
+ BALANCE = "balance"
+ CUSTOM = "custom"
diff --git a/app/services/contests/games.py b/app/services/contests/games.py
new file mode 100644
index 00000000..81d92ee0
--- /dev/null
+++ b/app/services/contests/games.py
@@ -0,0 +1,473 @@
+"""Game strategies for different contest types."""
+
+import random
+from abc import ABC, abstractmethod
+from dataclasses import dataclass
+from typing import Any, Dict, List, Optional
+
+from aiogram import types
+
+from app.services.contests.enums import GameType
+
+
+@dataclass
+class GameRenderResult:
+ """Result of rendering a game."""
+
+ text: str
+ keyboard: types.InlineKeyboardMarkup
+ requires_text_input: bool = False
+
+
+@dataclass
+class AnswerCheckResult:
+ """Result of checking user's answer."""
+
+ is_correct: bool
+ response_text: str
+
+
+class BaseGameStrategy(ABC):
+ """Base class for game strategies."""
+
+ game_type: GameType
+
+ @abstractmethod
+ def build_payload(self, template_payload: Dict[str, Any]) -> Dict[str, Any]:
+ """Build round-specific payload from template config."""
+ pass
+
+ @abstractmethod
+ def render(
+ self,
+ round_id: int,
+ payload: Dict[str, Any],
+ language: str,
+ back_callback: str = "contests_menu",
+ ) -> GameRenderResult:
+ """Render game UI for user."""
+ pass
+
+ @abstractmethod
+ def check_answer(
+ self,
+ user_answer: str,
+ payload: Dict[str, Any],
+ language: str,
+ ) -> AnswerCheckResult:
+ """Check if user's answer is correct."""
+ pass
+
+ def _get_back_button(self, language: str, callback: str) -> types.InlineKeyboardButton:
+ from app.localization.texts import get_texts
+ texts = get_texts(language)
+ return types.InlineKeyboardButton(text=texts.BACK, callback_data=callback)
+
+ def _get_texts(self, language: str):
+ from app.localization.texts import get_texts
+ return get_texts(language)
+
+
+class QuestButtonsStrategy(BaseGameStrategy):
+ """3x3 grid game - find the secret button."""
+
+ game_type = GameType.QUEST_BUTTONS
+
+ def build_payload(self, template_payload: Dict[str, Any]) -> Dict[str, Any]:
+ rows = template_payload.get("rows", 3)
+ cols = template_payload.get("cols", 3)
+ total = rows * cols
+ secret_idx = random.randint(0, total - 1)
+ return {"rows": rows, "cols": cols, "secret_idx": secret_idx}
+
+ def render(
+ self,
+ round_id: int,
+ payload: Dict[str, Any],
+ language: str,
+ back_callback: str = "contests_menu",
+ ) -> GameRenderResult:
+ texts = self._get_texts(language)
+ rows = payload.get("rows", 3)
+ cols = payload.get("cols", 3)
+
+ keyboard_rows = []
+ for r in range(rows):
+ row_buttons = []
+ for c in range(cols):
+ idx = r * cols + c
+ row_buttons.append(
+ types.InlineKeyboardButton(
+ text="🎛",
+ callback_data=f"contest_pick_{round_id}_quest_{idx}",
+ )
+ )
+ keyboard_rows.append(row_buttons)
+ keyboard_rows.append([self._get_back_button(language, back_callback)])
+
+ return GameRenderResult(
+ text=texts.t("CONTEST_QUEST_PROMPT", "Выбери один из узлов 3×3:"),
+ keyboard=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
+ )
+
+ def check_answer(
+ self,
+ user_answer: str,
+ payload: Dict[str, Any],
+ language: str,
+ ) -> AnswerCheckResult:
+ secret_idx = payload.get("secret_idx")
+ try:
+ if user_answer.startswith("quest_"):
+ idx = int(user_answer.split("_")[1])
+ is_correct = secret_idx is not None and idx == secret_idx
+ else:
+ is_correct = False
+ except (ValueError, IndexError):
+ is_correct = False
+
+ responses = ["Пусто", "Ложный сервер", "Найди другой узел"]
+ return AnswerCheckResult(
+ is_correct=is_correct,
+ response_text="" if is_correct else random.choice(responses),
+ )
+
+
+class LockHackStrategy(BaseGameStrategy):
+ """20 locks game - find the hacked one."""
+
+ game_type = GameType.LOCK_HACK
+
+ def build_payload(self, template_payload: Dict[str, Any]) -> Dict[str, Any]:
+ total = template_payload.get("buttons", 20)
+ secret_idx = random.randint(0, max(0, total - 1))
+ return {"total": total, "secret_idx": secret_idx}
+
+ def render(
+ self,
+ round_id: int,
+ payload: Dict[str, Any],
+ language: str,
+ back_callback: str = "contests_menu",
+ ) -> GameRenderResult:
+ texts = self._get_texts(language)
+ total = payload.get("total", 20)
+
+ keyboard_rows = []
+ row = []
+ for i in range(total):
+ row.append(
+ types.InlineKeyboardButton(
+ text="🔒",
+ callback_data=f"contest_pick_{round_id}_locks_{i}",
+ )
+ )
+ if len(row) == 5:
+ keyboard_rows.append(row)
+ row = []
+ if row:
+ keyboard_rows.append(row)
+ keyboard_rows.append([self._get_back_button(language, back_callback)])
+
+ return GameRenderResult(
+ text=texts.t("CONTEST_LOCKS_PROMPT", "Найди взломанную кнопку среди замков:"),
+ keyboard=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
+ )
+
+ def check_answer(
+ self,
+ user_answer: str,
+ payload: Dict[str, Any],
+ language: str,
+ ) -> AnswerCheckResult:
+ secret_idx = payload.get("secret_idx")
+ try:
+ if user_answer.startswith("locks_"):
+ idx = int(user_answer.split("_")[1])
+ is_correct = secret_idx is not None and idx == secret_idx
+ else:
+ is_correct = False
+ except (ValueError, IndexError):
+ is_correct = False
+
+ responses = ["Заблокировано", "Попробуй ещё", "Нет доступа"]
+ return AnswerCheckResult(
+ is_correct=is_correct,
+ response_text="" if is_correct else random.choice(responses),
+ )
+
+
+class ServerLotteryStrategy(BaseGameStrategy):
+ """Flag lottery game - pick the correct server flag."""
+
+ game_type = GameType.SERVER_LOTTERY
+
+ DEFAULT_FLAGS = ["🇸🇪", "🇸🇬", "🇺🇸", "🇷🇺", "🇩🇪", "🇯🇵", "🇧🇷", "🇦🇺", "🇨🇦", "🇫🇷"]
+
+ def build_payload(self, template_payload: Dict[str, Any]) -> Dict[str, Any]:
+ flags = template_payload.get("flags") or self.DEFAULT_FLAGS
+ secret_idx = random.randint(0, len(flags) - 1)
+ return {"flags": flags, "secret_idx": secret_idx}
+
+ def render(
+ self,
+ round_id: int,
+ payload: Dict[str, Any],
+ language: str,
+ back_callback: str = "contests_menu",
+ ) -> GameRenderResult:
+ texts = self._get_texts(language)
+ flags = payload.get("flags") or []
+ shuffled_flags = flags.copy()
+ random.shuffle(shuffled_flags)
+
+ keyboard_rows = []
+ row = []
+ for flag in shuffled_flags:
+ row.append(
+ types.InlineKeyboardButton(
+ text=flag,
+ callback_data=f"contest_pick_{round_id}_{flag}",
+ )
+ )
+ if len(row) == 5:
+ keyboard_rows.append(row)
+ row = []
+ if row:
+ keyboard_rows.append(row)
+ keyboard_rows.append([self._get_back_button(language, back_callback)])
+
+ return GameRenderResult(
+ text=texts.t("CONTEST_SERVER_PROMPT", "Выбери сервер:"),
+ keyboard=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
+ )
+
+ def check_answer(
+ self,
+ user_answer: str,
+ payload: Dict[str, Any],
+ language: str,
+ ) -> AnswerCheckResult:
+ secret_idx = payload.get("secret_idx")
+ flags = payload.get("flags") or []
+ correct_flag = flags[secret_idx] if secret_idx is not None and secret_idx < len(flags) else ""
+ is_correct = user_answer == correct_flag
+
+ responses = ["Сервер перегружен", "Нет ответа", "Попробуй завтра"]
+ return AnswerCheckResult(
+ is_correct=is_correct,
+ response_text="" if is_correct else random.choice(responses),
+ )
+
+
+class BlitzReactionStrategy(BaseGameStrategy):
+ """Blitz reaction game - press button quickly."""
+
+ game_type = GameType.BLITZ_REACTION
+
+ def build_payload(self, template_payload: Dict[str, Any]) -> Dict[str, Any]:
+ return {"timeout_seconds": template_payload.get("timeout_seconds", 10)}
+
+ def render(
+ self,
+ round_id: int,
+ payload: Dict[str, Any],
+ language: str,
+ back_callback: str = "contests_menu",
+ ) -> GameRenderResult:
+ texts = self._get_texts(language)
+
+ keyboard = types.InlineKeyboardMarkup(
+ inline_keyboard=[
+ [
+ types.InlineKeyboardButton(
+ text=texts.t("CONTEST_BLITZ_BUTTON", "Я здесь!"),
+ callback_data=f"contest_pick_{round_id}_blitz",
+ )
+ ],
+ [self._get_back_button(language, back_callback)],
+ ]
+ )
+
+ return GameRenderResult(
+ text=texts.t("CONTEST_BLITZ_PROMPT", "⚡️ Блиц! Нажми «Я здесь!»"),
+ keyboard=keyboard,
+ )
+
+ def check_answer(
+ self,
+ user_answer: str,
+ payload: Dict[str, Any],
+ language: str,
+ ) -> AnswerCheckResult:
+ is_correct = user_answer == "blitz"
+ return AnswerCheckResult(
+ is_correct=is_correct,
+ response_text="" if is_correct else "Время вышло",
+ )
+
+
+class LetterCipherStrategy(BaseGameStrategy):
+ """Letter cipher game - decode word from letter codes."""
+
+ game_type = GameType.LETTER_CIPHER
+
+ DEFAULT_WORDS = ["VPN", "SERVER", "PROXY", "XRAY"]
+
+ def build_payload(self, template_payload: Dict[str, Any]) -> Dict[str, Any]:
+ words = template_payload.get("words") or self.DEFAULT_WORDS
+ word = random.choice(words)
+ codes = [str(ord(ch.upper()) - 64) for ch in word if ch.isalpha()]
+ return {"question": "-".join(codes), "answer": word.upper()}
+
+ def render(
+ self,
+ round_id: int,
+ payload: Dict[str, Any],
+ language: str,
+ back_callback: str = "contests_menu",
+ ) -> GameRenderResult:
+ texts = self._get_texts(language)
+ question = payload.get("question", "")
+ from app.keyboards.inline import get_back_keyboard
+
+ return GameRenderResult(
+ text=texts.t("CONTEST_CIPHER_PROMPT", "Расшифруй: {q}").format(q=question),
+ keyboard=get_back_keyboard(language),
+ requires_text_input=True,
+ )
+
+ def check_answer(
+ self,
+ user_answer: str,
+ payload: Dict[str, Any],
+ language: str,
+ ) -> AnswerCheckResult:
+ correct = (payload.get("answer") or "").upper()
+ is_correct = correct and user_answer.strip().upper() == correct
+
+ return AnswerCheckResult(
+ is_correct=is_correct,
+ response_text="" if is_correct else "Неверно, попробуй в следующем раунде",
+ )
+
+
+class EmojiGuessStrategy(BaseGameStrategy):
+ """Emoji guess game - guess service by emoji."""
+
+ game_type = GameType.EMOJI_GUESS
+
+ def build_payload(self, template_payload: Dict[str, Any]) -> Dict[str, Any]:
+ pairs = template_payload.get("pairs") or [{"question": "🔐📡🌐", "answer": "VPN"}]
+ pair = random.choice(pairs)
+ return pair
+
+ def render(
+ self,
+ round_id: int,
+ payload: Dict[str, Any],
+ language: str,
+ back_callback: str = "contests_menu",
+ ) -> GameRenderResult:
+ texts = self._get_texts(language)
+ question = payload.get("question", "🤔")
+ emoji_list = question.split()
+ random.shuffle(emoji_list)
+ shuffled_question = " ".join(emoji_list)
+ from app.keyboards.inline import get_back_keyboard
+
+ return GameRenderResult(
+ text=texts.t("CONTEST_EMOJI_PROMPT", "Угадай сервис по эмодзи: {q}").format(
+ q=shuffled_question
+ ),
+ keyboard=get_back_keyboard(language),
+ requires_text_input=True,
+ )
+
+ def check_answer(
+ self,
+ user_answer: str,
+ payload: Dict[str, Any],
+ language: str,
+ ) -> AnswerCheckResult:
+ correct = (payload.get("answer") or "").upper()
+ is_correct = correct and user_answer.strip().upper() == correct
+
+ return AnswerCheckResult(
+ is_correct=is_correct,
+ response_text="" if is_correct else "Неверно, попробуй в следующем раунде",
+ )
+
+
+class AnagramStrategy(BaseGameStrategy):
+ """Anagram game - unscramble letters to form a word."""
+
+ game_type = GameType.ANAGRAM
+
+ DEFAULT_WORDS = ["SERVER", "XRAY", "VPN"]
+
+ def build_payload(self, template_payload: Dict[str, Any]) -> Dict[str, Any]:
+ words = template_payload.get("words") or self.DEFAULT_WORDS
+ word = random.choice(words).upper()
+ shuffled = "".join(random.sample(word, len(word)))
+ return {"letters": shuffled, "answer": word}
+
+ def render(
+ self,
+ round_id: int,
+ payload: Dict[str, Any],
+ language: str,
+ back_callback: str = "contests_menu",
+ ) -> GameRenderResult:
+ texts = self._get_texts(language)
+ letters = payload.get("letters", "")
+ from app.keyboards.inline import get_back_keyboard
+
+ return GameRenderResult(
+ text=texts.t("CONTEST_ANAGRAM_PROMPT", "Составь слово: {letters}").format(
+ letters=letters
+ ),
+ keyboard=get_back_keyboard(language),
+ requires_text_input=True,
+ )
+
+ def check_answer(
+ self,
+ user_answer: str,
+ payload: Dict[str, Any],
+ language: str,
+ ) -> AnswerCheckResult:
+ correct = (payload.get("answer") or "").upper()
+ is_correct = correct and user_answer.strip().upper() == correct
+
+ return AnswerCheckResult(
+ is_correct=is_correct,
+ response_text="" if is_correct else "Неверно, попробуй в следующем раунде",
+ )
+
+
+# Registry of game strategies
+_GAME_STRATEGIES: Dict[GameType, BaseGameStrategy] = {
+ GameType.QUEST_BUTTONS: QuestButtonsStrategy(),
+ GameType.LOCK_HACK: LockHackStrategy(),
+ GameType.SERVER_LOTTERY: ServerLotteryStrategy(),
+ GameType.BLITZ_REACTION: BlitzReactionStrategy(),
+ GameType.LETTER_CIPHER: LetterCipherStrategy(),
+ GameType.EMOJI_GUESS: EmojiGuessStrategy(),
+ GameType.ANAGRAM: AnagramStrategy(),
+}
+
+
+def get_game_strategy(game_type: GameType | str) -> Optional[BaseGameStrategy]:
+ """Get game strategy by type."""
+ if isinstance(game_type, str):
+ try:
+ game_type = GameType(game_type)
+ except ValueError:
+ return None
+ return _GAME_STRATEGIES.get(game_type)
+
+
+def get_all_game_types() -> List[GameType]:
+ """Get list of all supported game types."""
+ return list(_GAME_STRATEGIES.keys())
diff --git a/app/services/menu_layout/constants.py b/app/services/menu_layout/constants.py
index 8bd96980..b76d75e8 100644
--- a/app/services/menu_layout/constants.py
+++ b/app/services/menu_layout/constants.py
@@ -128,7 +128,7 @@ DEFAULT_MENU_CONFIG: Dict[str, Any] = {
"action": "buy_traffic",
"enabled": True,
"visibility": "subscribers",
- "conditions": {"has_traffic_limit": True},
+ "conditions": {"has_traffic_limit": True, "traffic_topup_enabled": True},
"dynamic_text": False,
},
"balance": {
diff --git a/app/services/menu_layout/service.py b/app/services/menu_layout/service.py
index 84b39853..5ce989b0 100644
--- a/app/services/menu_layout/service.py
+++ b/app/services/menu_layout/service.py
@@ -715,6 +715,11 @@ class MenuLayoutService:
if is_trial or traffic_limit <= 0:
return False
+ # traffic_topup_enabled - функция докупки трафика включена
+ if conditions.get("traffic_topup_enabled") is True:
+ if not settings.is_traffic_topup_enabled():
+ return False
+
# is_admin
if conditions.get("is_admin") is True:
if not context.is_admin:
diff --git a/app/services/modem_service.py b/app/services/modem_service.py
new file mode 100644
index 00000000..295475c4
--- /dev/null
+++ b/app/services/modem_service.py
@@ -0,0 +1,392 @@
+"""
+Сервис для управления модемом в подписке.
+
+Модем - это дополнительное устройство, которое можно подключить к подписке
+за отдельную плату. При подключении увеличивается лимит устройств.
+"""
+
+import logging
+from dataclasses import dataclass
+from datetime import datetime
+from enum import Enum
+from typing import Optional, Tuple
+
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.config import settings
+from app.database.models import Subscription, User, TransactionType
+from app.database.crud.transaction import create_transaction
+from app.database.crud.user import subtract_user_balance
+from app.services.subscription_service import SubscriptionService
+from app.utils.pricing_utils import get_remaining_months, calculate_prorated_price
+
+logger = logging.getLogger(__name__)
+
+
+class ModemError(Enum):
+ """Типы ошибок при работе с модемом."""
+ NO_SUBSCRIPTION = "no_subscription"
+ TRIAL_SUBSCRIPTION = "trial_subscription"
+ MODEM_DISABLED = "modem_disabled"
+ ALREADY_ENABLED = "already_enabled"
+ NOT_ENABLED = "not_enabled"
+ INSUFFICIENT_FUNDS = "insufficient_funds"
+ CHARGE_ERROR = "charge_error"
+ UPDATE_ERROR = "update_error"
+
+
+@dataclass
+class ModemAvailabilityResult:
+ """Результат проверки доступности модема."""
+ available: bool
+ error: Optional[ModemError] = None
+ modem_enabled: bool = False
+
+
+@dataclass
+class ModemPriceResult:
+ """Результат расчёта цены модема."""
+ base_price: int
+ final_price: int
+ discount_percent: int
+ discount_amount: int
+ charged_months: int
+ remaining_days: int
+ end_date: datetime
+
+ @property
+ def has_discount(self) -> bool:
+ return self.discount_percent > 0
+
+
+@dataclass
+class ModemEnableResult:
+ """Результат подключения модема."""
+ success: bool
+ error: Optional[ModemError] = None
+ charged_amount: int = 0
+ new_device_limit: int = 0
+
+
+@dataclass
+class ModemDisableResult:
+ """Результат отключения модема."""
+ success: bool
+ error: Optional[ModemError] = None
+ new_device_limit: int = 0
+
+
+# Константы для предупреждений о сроке действия
+MODEM_WARNING_DAYS_CRITICAL = 7
+MODEM_WARNING_DAYS_INFO = 30
+
+
+class ModemService:
+ """
+ Сервис для управления модемом в подписке.
+
+ Инкапсулирует всю бизнес-логику:
+ - Проверки доступности
+ - Расчёт цен и скидок
+ - Подключение/отключение модема
+ - Синхронизация с RemnaWave
+ """
+
+ def __init__(self):
+ self._subscription_service = SubscriptionService()
+
+ @staticmethod
+ def is_modem_feature_enabled() -> bool:
+ """Проверяет, включена ли функция модема в настройках."""
+ return settings.is_modem_enabled()
+
+ @staticmethod
+ def get_modem_enabled(subscription: Optional[Subscription]) -> bool:
+ """Безопасно получает статус модема из подписки."""
+ if subscription is None:
+ return False
+ return getattr(subscription, 'modem_enabled', False) or False
+
+ def check_availability(
+ self,
+ user: User,
+ for_enable: bool = False,
+ for_disable: bool = False
+ ) -> ModemAvailabilityResult:
+ """
+ Проверяет доступность модема для пользователя.
+
+ Args:
+ user: Пользователь
+ for_enable: Проверка для подключения (модем должен быть отключен)
+ for_disable: Проверка для отключения (модем должен быть включен)
+
+ Returns:
+ ModemAvailabilityResult с результатом проверки
+ """
+ subscription = user.subscription
+ modem_enabled = self.get_modem_enabled(subscription)
+
+ if not subscription:
+ return ModemAvailabilityResult(
+ available=False,
+ error=ModemError.NO_SUBSCRIPTION,
+ modem_enabled=modem_enabled
+ )
+
+ if subscription.is_trial:
+ return ModemAvailabilityResult(
+ available=False,
+ error=ModemError.TRIAL_SUBSCRIPTION,
+ modem_enabled=modem_enabled
+ )
+
+ if not self.is_modem_feature_enabled():
+ return ModemAvailabilityResult(
+ available=False,
+ error=ModemError.MODEM_DISABLED,
+ modem_enabled=modem_enabled
+ )
+
+ if for_enable and modem_enabled:
+ return ModemAvailabilityResult(
+ available=False,
+ error=ModemError.ALREADY_ENABLED,
+ modem_enabled=modem_enabled
+ )
+
+ if for_disable and not modem_enabled:
+ return ModemAvailabilityResult(
+ available=False,
+ error=ModemError.NOT_ENABLED,
+ modem_enabled=modem_enabled
+ )
+
+ return ModemAvailabilityResult(
+ available=True,
+ modem_enabled=modem_enabled
+ )
+
+ def calculate_price(self, subscription: Subscription) -> ModemPriceResult:
+ """
+ Рассчитывает стоимость подключения модема.
+
+ Использует пропорциональную цену на основе оставшегося времени подписки
+ и применяет скидки в зависимости от периода.
+
+ Args:
+ subscription: Подписка пользователя
+
+ Returns:
+ ModemPriceResult с детализацией цены
+ """
+ modem_price_per_month = settings.get_modem_price_per_month()
+
+ base_price, charged_months = calculate_prorated_price(
+ modem_price_per_month,
+ subscription.end_date,
+ )
+
+ now = datetime.utcnow()
+ remaining_days = max(0, (subscription.end_date - now).days)
+
+ discount_percent = settings.get_modem_period_discount(charged_months)
+ if discount_percent > 0:
+ discount_amount = base_price * discount_percent // 100
+ final_price = base_price - discount_amount
+ else:
+ discount_amount = 0
+ final_price = base_price
+
+ return ModemPriceResult(
+ base_price=base_price,
+ final_price=final_price,
+ discount_percent=discount_percent,
+ discount_amount=discount_amount,
+ charged_months=charged_months,
+ remaining_days=remaining_days,
+ end_date=subscription.end_date
+ )
+
+ def check_balance(self, user: User, price: int) -> Tuple[bool, int]:
+ """
+ Проверяет достаточность баланса.
+
+ Args:
+ user: Пользователь
+ price: Требуемая сумма
+
+ Returns:
+ Tuple[достаточно ли средств, недостающая сумма]
+ """
+ if price <= 0:
+ return True, 0
+
+ if user.balance_kopeks >= price:
+ return True, 0
+
+ missing = price - user.balance_kopeks
+ return False, missing
+
+ async def enable_modem(
+ self,
+ db: AsyncSession,
+ user: User,
+ subscription: Subscription
+ ) -> ModemEnableResult:
+ """
+ Подключает модем к подписке.
+
+ Выполняет:
+ 1. Расчёт цены
+ 2. Проверку баланса
+ 3. Списание средств
+ 4. Создание транзакции
+ 5. Обновление подписки
+ 6. Синхронизацию с RemnaWave
+
+ Args:
+ db: Сессия базы данных
+ user: Пользователь
+ subscription: Подписка
+
+ Returns:
+ ModemEnableResult с результатом операции
+ """
+ price_info = self.calculate_price(subscription)
+ price = price_info.final_price
+
+ has_funds, _ = self.check_balance(user, price)
+ if not has_funds:
+ return ModemEnableResult(
+ success=False,
+ error=ModemError.INSUFFICIENT_FUNDS
+ )
+
+ try:
+ if price > 0:
+ success = await subtract_user_balance(
+ db, user, price,
+ "Подключение модема"
+ )
+
+ if not success:
+ return ModemEnableResult(
+ success=False,
+ error=ModemError.CHARGE_ERROR
+ )
+
+ await create_transaction(
+ db=db,
+ user_id=user.id,
+ type=TransactionType.SUBSCRIPTION_PAYMENT,
+ amount_kopeks=price,
+ description=f"Подключение модема на {price_info.charged_months} мес"
+ )
+
+ subscription.modem_enabled = True
+ subscription.device_limit = (subscription.device_limit or 1) + 1
+ subscription.updated_at = datetime.utcnow()
+
+ await db.commit()
+
+ await self._subscription_service.update_remnawave_user(db, subscription)
+
+ await db.refresh(user)
+ await db.refresh(subscription)
+
+ logger.info(
+ f"Пользователь {user.telegram_id} подключил модем, списано: {price / 100}₽"
+ )
+
+ return ModemEnableResult(
+ success=True,
+ charged_amount=price,
+ new_device_limit=subscription.device_limit
+ )
+
+ except Exception as e:
+ logger.error(f"Ошибка подключения модема для пользователя {user.telegram_id}: {e}")
+ await db.rollback()
+ return ModemEnableResult(
+ success=False,
+ error=ModemError.UPDATE_ERROR
+ )
+
+ async def disable_modem(
+ self,
+ db: AsyncSession,
+ user: User,
+ subscription: Subscription
+ ) -> ModemDisableResult:
+ """
+ Отключает модем от подписки.
+
+ Возврат средств не производится.
+
+ Args:
+ db: Сессия базы данных
+ user: Пользователь
+ subscription: Подписка
+
+ Returns:
+ ModemDisableResult с результатом операции
+ """
+ try:
+ subscription.modem_enabled = False
+ if subscription.device_limit and subscription.device_limit > 1:
+ subscription.device_limit = subscription.device_limit - 1
+ subscription.updated_at = datetime.utcnow()
+
+ await db.commit()
+
+ await self._subscription_service.update_remnawave_user(db, subscription)
+
+ await db.refresh(user)
+ await db.refresh(subscription)
+
+ logger.info(f"Пользователь {user.telegram_id} отключил модем")
+
+ return ModemDisableResult(
+ success=True,
+ new_device_limit=subscription.device_limit
+ )
+
+ except Exception as e:
+ logger.error(f"Ошибка отключения модема для пользователя {user.telegram_id}: {e}")
+ await db.rollback()
+ return ModemDisableResult(
+ success=False,
+ error=ModemError.UPDATE_ERROR
+ )
+
+ @staticmethod
+ def get_period_warning_level(remaining_days: int) -> Optional[str]:
+ """
+ Определяет уровень предупреждения о сроке действия.
+
+ Args:
+ remaining_days: Оставшиеся дни подписки
+
+ Returns:
+ "critical" если <= 7 дней
+ "info" если <= 30 дней
+ None если больше 30 дней
+ """
+ if remaining_days <= MODEM_WARNING_DAYS_CRITICAL:
+ return "critical"
+ if remaining_days <= MODEM_WARNING_DAYS_INFO:
+ return "info"
+ return None
+
+
+# Singleton instance для использования в хендлерах
+_modem_service: Optional[ModemService] = None
+
+
+def get_modem_service() -> ModemService:
+ """Возвращает singleton экземпляр ModemService."""
+ global _modem_service
+ if _modem_service is None:
+ _modem_service = ModemService()
+ return _modem_service
diff --git a/app/services/nalogo_queue_service.py b/app/services/nalogo_queue_service.py
new file mode 100644
index 00000000..57fb3813
--- /dev/null
+++ b/app/services/nalogo_queue_service.py
@@ -0,0 +1,285 @@
+"""Фоновый сервис для обработки очереди чеков NaloGO.
+
+При временной недоступности сервиса nalog.ru (503), чеки сохраняются в Redis
+и отправляются позже этим сервисом.
+"""
+
+import asyncio
+import logging
+from datetime import datetime, timedelta
+from typing import Optional
+
+from aiogram import Bot
+
+from app.config import settings
+from app.services.nalogo_service import NaloGoService
+
+logger = logging.getLogger(__name__)
+
+
+class NalogoQueueService:
+ """Сервис фоновой обработки очереди чеков NaloGO."""
+
+ def __init__(self, nalogo_service: Optional[NaloGoService] = None):
+ self._nalogo_service = nalogo_service
+ self._bot: Optional[Bot] = None
+ self._task: Optional[asyncio.Task] = None
+ self._running = False
+ self._last_notification_time: Optional[datetime] = None
+ self._notification_cooldown = timedelta(hours=1) # Не чаще раза в час
+ self._had_pending_receipts = False # Флаг для отслеживания успешной разгрузки
+
+ def set_nalogo_service(self, service: NaloGoService) -> None:
+ """Установить сервис NaloGO."""
+ self._nalogo_service = service
+
+ def set_bot(self, bot: Bot) -> None:
+ """Установить бота для отправки уведомлений."""
+ self._bot = bot
+
+ def is_running(self) -> bool:
+ """Проверка, запущен ли сервис."""
+ return self._running and self._task is not None and not self._task.done()
+
+ @property
+ def _check_interval(self) -> int:
+ """Интервал проверки очереди в секундах."""
+ return getattr(settings, "NALOGO_QUEUE_CHECK_INTERVAL", 300)
+
+ @property
+ def _receipt_delay(self) -> int:
+ """Задержка между отправкой чеков в секундах."""
+ return getattr(settings, "NALOGO_QUEUE_RECEIPT_DELAY", 3)
+
+ @property
+ def _max_attempts(self) -> int:
+ """Максимальное количество попыток отправки чека."""
+ return getattr(settings, "NALOGO_QUEUE_MAX_ATTEMPTS", 10)
+
+ async def start(self) -> None:
+ """Запустить фоновую обработку очереди."""
+ if not self._nalogo_service or not self._nalogo_service.configured:
+ logger.info("NaloGO не настроен, сервис очереди чеков не запущен")
+ return
+
+ if self.is_running():
+ logger.warning("Сервис очереди чеков уже запущен")
+ return
+
+ self._running = True
+ self._task = asyncio.create_task(self._process_queue_loop())
+ logger.info(
+ f"Сервис очереди чеков NaloGO запущен "
+ f"(интервал: {self._check_interval}с, задержка между чеками: {self._receipt_delay}с)"
+ )
+
+ async def stop(self) -> None:
+ """Остановить фоновую обработку."""
+ self._running = False
+ if self._task and not self._task.done():
+ self._task.cancel()
+ try:
+ await self._task
+ except asyncio.CancelledError:
+ pass
+ self._task = None
+ logger.info("Сервис очереди чеков NaloGO остановлен")
+
+ async def _send_admin_notification(self, message: str, skip_cooldown: bool = False) -> None:
+ """Отправить уведомление админам о чеках."""
+ if not self._bot:
+ return
+
+ chat_id = settings.get_admin_notifications_chat_id()
+ if not chat_id:
+ return
+
+ topic_id = settings.ADMIN_NOTIFICATIONS_NALOG_TOPIC_ID
+
+ # Проверяем cooldown (можно пропустить для важных уведомлений)
+ if not skip_cooldown:
+ now = datetime.now()
+ if self._last_notification_time:
+ if now - self._last_notification_time < self._notification_cooldown:
+ logger.debug("Уведомление о чеках пропущено (cooldown)")
+ return
+
+ try:
+ await self._bot.send_message(
+ chat_id=chat_id,
+ message_thread_id=topic_id,
+ text=message,
+ parse_mode="HTML",
+ )
+ self._last_notification_time = datetime.now()
+ logger.info("Отправлено уведомление о чеках NaloGO")
+ except Exception as error:
+ logger.error(f"Ошибка отправки уведомления о чеках: {error}")
+
+ async def _process_queue_loop(self) -> None:
+ """Основной цикл обработки очереди."""
+ while self._running:
+ try:
+ await self._process_pending_receipts()
+ except Exception as error:
+ logger.error(f"Ошибка в цикле обработки очереди чеков: {error}")
+
+ await asyncio.sleep(self._check_interval)
+
+ async def _process_pending_receipts(self) -> None:
+ """Обработать все ожидающие чеки в очереди."""
+ if not self._nalogo_service:
+ return
+
+ queue_length = await self._nalogo_service.get_queue_length()
+ if queue_length == 0:
+ return
+
+ logger.info(f"Начинаем обработку очереди чеков: {queue_length} шт.")
+ self._had_pending_receipts = True
+
+ processed = 0
+ failed = 0
+ skipped = 0
+ total_processed_amount = 0.0
+ service_unavailable = False
+
+ while True:
+ receipt_data = await self._nalogo_service.pop_receipt_from_queue()
+ if not receipt_data:
+ break
+
+ attempts = receipt_data.get("attempts", 0)
+ payment_id = receipt_data.get("payment_id", "unknown")
+ amount = receipt_data.get("amount", 0)
+
+ # Проверяем количество попыток
+ if attempts >= self._max_attempts:
+ logger.error(
+ f"Чек {payment_id} превысил лимит попыток ({self._max_attempts}), "
+ f"удален из очереди"
+ )
+ skipped += 1
+ continue
+
+ # Пытаемся отправить чек
+ try:
+ receipt_uuid = await self._nalogo_service.create_receipt(
+ name=receipt_data.get("name", "Интернет-сервис - Пополнение баланса"),
+ amount=amount,
+ quantity=receipt_data.get("quantity", 1),
+ client_info=receipt_data.get("client_info"),
+ payment_id=payment_id,
+ queue_on_failure=False, # Не добавлять в очередь повторно автоматически
+ )
+
+ if receipt_uuid:
+ processed += 1
+ total_processed_amount += amount
+ logger.info(
+ f"Чек из очереди успешно создан: {receipt_uuid} "
+ f"(payment_id={payment_id}, попытка {attempts + 1})"
+ )
+ else:
+ # Вернуть в очередь с увеличенным счетчиком попыток
+ await self._nalogo_service.requeue_receipt(receipt_data)
+ failed += 1
+ service_unavailable = True
+ logger.warning(
+ f"Не удалось создать чек из очереди (payment_id={payment_id}), "
+ f"возвращен в очередь (попытка {attempts + 1}/{self._max_attempts})"
+ )
+ # Если сервис недоступен, прекращаем попытки до следующего цикла
+ break
+
+ except Exception as error:
+ await self._nalogo_service.requeue_receipt(receipt_data)
+ failed += 1
+ logger.error(
+ f"Ошибка при создании чека из очереди (payment_id={payment_id}): {error}"
+ )
+ # Прекращаем попытки при ошибке
+ break
+
+ # Задержка между чеками чтобы не долбить API
+ await asyncio.sleep(self._receipt_delay)
+
+ if processed > 0 or failed > 0 or skipped > 0:
+ logger.info(
+ f"Обработка очереди завершена: "
+ f"успешно={processed}, неудачно={failed}, пропущено={skipped}"
+ )
+
+ # Проверяем остаток в очереди
+ remaining = await self._nalogo_service.get_queue_length()
+
+ # Отправляем уведомление если есть проблемы
+ if service_unavailable or failed > 0:
+ if remaining > 0:
+ queued = await self._nalogo_service.get_queued_receipts()
+ total_queued_amount = sum(r.get("amount", 0) for r in queued)
+
+ message = (
+ f"⚠️ Проблема с отправкой чеков NaloGO\n\n"
+ f"Сервис nalog.ru временно недоступен.\n\n"
+ f"📋 В очереди: {remaining} чек(ов)\n"
+ f"💰 На сумму: {total_queued_amount:,.2f} ₽\n\n"
+ f"Чеки будут отправлены автоматически когда сервис восстановится."
+ )
+ await self._send_admin_notification(message)
+
+ # Уведомление об успешной разгрузке очереди
+ elif remaining == 0 and self._had_pending_receipts and processed > 0:
+ self._had_pending_receipts = False
+ message = (
+ f"✅ Очередь чеков NaloGO разгружена\n\n"
+ f"Все отложенные чеки успешно отправлены!\n\n"
+ f"📋 Отправлено: {processed} чек(ов)\n"
+ f"💰 На сумму: {total_processed_amount:,.2f} ₽"
+ )
+ await self._send_admin_notification(message, skip_cooldown=True)
+
+ async def force_process(self) -> dict:
+ """Принудительно обработать очередь (для ручного запуска)."""
+ if not self._nalogo_service:
+ return {"error": "NaloGO сервис не настроен"}
+
+ queue_length = await self._nalogo_service.get_queue_length()
+ if queue_length == 0:
+ return {"message": "Очередь пуста", "processed": 0}
+
+ await self._process_pending_receipts()
+ new_length = await self._nalogo_service.get_queue_length()
+
+ return {
+ "message": "Обработка завершена",
+ "was_in_queue": queue_length,
+ "remaining": new_length,
+ "processed": queue_length - new_length,
+ }
+
+ async def get_status(self) -> dict:
+ """Получить статус сервиса и очереди."""
+ queue_length = 0
+ total_amount = 0.0
+ queued_receipts = []
+
+ if self._nalogo_service:
+ queue_length = await self._nalogo_service.get_queue_length()
+ if queue_length > 0:
+ queued_receipts = await self._nalogo_service.get_queued_receipts()
+ total_amount = sum(r.get("amount", 0) for r in queued_receipts)
+
+ return {
+ "running": self.is_running(),
+ "check_interval_seconds": self._check_interval,
+ "receipt_delay_seconds": self._receipt_delay,
+ "queue_length": queue_length,
+ "total_amount": total_amount,
+ "max_attempts": self._max_attempts,
+ "queued_receipts": queued_receipts[:10], # Показываем только первые 10
+ }
+
+
+# Глобальный экземпляр сервиса
+nalogo_queue_service = NalogoQueueService()
diff --git a/app/services/nalogo_service.py b/app/services/nalogo_service.py
index dacb9c29..7a49f310 100644
--- a/app/services/nalogo_service.py
+++ b/app/services/nalogo_service.py
@@ -1,4 +1,5 @@
import logging
+from datetime import datetime
from typing import Optional, Dict, Any
from decimal import Decimal
@@ -6,9 +7,12 @@ from nalogo import Client
from nalogo.dto.income import IncomeClient, IncomeType
from app.config import settings
+from app.utils.cache import cache
logger = logging.getLogger(__name__)
+NALOGO_QUEUE_KEY = "nalogo:receipt_queue"
+
class NaloGoService:
"""Сервис для работы с API NaloGO (налоговая служба самозанятых)."""
@@ -49,6 +53,53 @@ class NaloGoService:
)
self.configured = False
+ @staticmethod
+ def _is_service_unavailable(error: Exception) -> bool:
+ """Проверяет, является ли ошибка временной недоступностью сервиса."""
+ error_str = str(error).lower()
+ error_type = type(error).__name__.lower()
+ return (
+ "503" in error_str
+ or "service temporarily unavailable" in error_str
+ or "service unavailable" in error_str
+ or "ведутся работы" in error_str
+ or ("health" in error_str and "false" in error_str)
+ # Таймауты и сетевые ошибки — временные проблемы
+ or "timeout" in error_type
+ or "timeout" in error_str
+ or "readtimeout" in error_type
+ or "connecttimeout" in error_type
+ or "connectionerror" in error_type
+ or "connecterror" in error_type
+ )
+
+ async def _queue_receipt(
+ self,
+ name: str,
+ amount: float,
+ quantity: int,
+ client_info: Optional[Dict[str, Any]],
+ payment_id: Optional[str] = None,
+ ) -> bool:
+ """Добавить чек в очередь для отложенной отправки."""
+ receipt_data = {
+ "name": name,
+ "amount": amount,
+ "quantity": quantity,
+ "client_info": client_info,
+ "payment_id": payment_id,
+ "created_at": datetime.now().isoformat(),
+ "attempts": 0,
+ }
+ success = await cache.lpush(NALOGO_QUEUE_KEY, receipt_data)
+ if success:
+ queue_len = await cache.llen(NALOGO_QUEUE_KEY)
+ logger.info(
+ f"Чек добавлен в очередь (payment_id={payment_id}, "
+ f"сумма={amount}₽, в очереди: {queue_len})"
+ )
+ return success
+
async def authenticate(self) -> bool:
"""Аутентификация в сервисе NaloGO."""
if not self.configured:
@@ -60,10 +111,24 @@ class NaloGoService:
logger.info("Успешная аутентификация в NaloGO")
return True
except Exception as error:
- logger.error("Ошибка аутентификации в NaloGO: %s", error, exc_info=True)
+ if self._is_service_unavailable(error):
+ logger.warning(
+ "NaloGO временно недоступен (техработы): %s",
+ str(error)[:200]
+ )
+ else:
+ logger.error("Ошибка аутентификации в NaloGO: %s", error, exc_info=True)
return False
- async def create_receipt(self, name: str, amount: float, quantity: int = 1, client_info: Optional[Dict[str, Any]] = None) -> Optional[str]:
+ async def create_receipt(
+ self,
+ name: str,
+ amount: float,
+ quantity: int = 1,
+ client_info: Optional[Dict[str, Any]] = None,
+ payment_id: Optional[str] = None,
+ queue_on_failure: bool = True,
+ ) -> Optional[str]:
"""Создание чека о доходе.
Args:
@@ -71,6 +136,8 @@ class NaloGoService:
amount: Сумма в рублях
quantity: Количество
client_info: Информация о клиенте (опционально)
+ payment_id: ID платежа для логирования
+ queue_on_failure: Добавить в очередь при временной недоступности
Returns:
UUID чека или None при ошибке
@@ -84,6 +151,9 @@ class NaloGoService:
if not hasattr(self.client, '_access_token') or not self.client._access_token:
auth_success = await self.authenticate()
if not auth_success:
+ # Если сервис недоступен — добавляем в очередь
+ if queue_on_failure:
+ await self._queue_receipt(name, amount, quantity, client_info, payment_id)
return None
income_api = self.client.income()
@@ -114,5 +184,30 @@ class NaloGoService:
return None
except Exception as error:
- logger.error("Ошибка создания чека в NaloGO: %s", error, exc_info=True)
+ if self._is_service_unavailable(error):
+ logger.warning(
+ "NaloGO временно недоступен, чек будет отправлен позже "
+ f"(payment_id={payment_id}, сумма={amount}₽)"
+ )
+ if queue_on_failure:
+ await self._queue_receipt(name, amount, quantity, client_info, payment_id)
+ else:
+ logger.error("Ошибка создания чека в NaloGO: %s", error, exc_info=True)
return None
+
+ async def get_queue_length(self) -> int:
+ """Получить количество чеков в очереди."""
+ return await cache.llen(NALOGO_QUEUE_KEY)
+
+ async def get_queued_receipts(self) -> list:
+ """Получить список чеков в очереди (без удаления)."""
+ return await cache.lrange(NALOGO_QUEUE_KEY)
+
+ async def pop_receipt_from_queue(self) -> Optional[Dict[str, Any]]:
+ """Извлечь следующий чек из очереди."""
+ return await cache.rpop(NALOGO_QUEUE_KEY)
+
+ async def requeue_receipt(self, receipt_data: Dict[str, Any]) -> bool:
+ """Вернуть чек обратно в очередь (при неудачной отправке)."""
+ receipt_data["attempts"] = receipt_data.get("attempts", 0) + 1
+ return await cache.lpush(NALOGO_QUEUE_KEY, receipt_data)
diff --git a/app/services/payment/cloudpayments.py b/app/services/payment/cloudpayments.py
index 11cf9565..c55201aa 100644
--- a/app/services/payment/cloudpayments.py
+++ b/app/services/payment/cloudpayments.py
@@ -13,6 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PaymentMethod, TransactionType
from app.services.subscription_auto_purchase_service import (
+ auto_activate_subscription_after_topup,
auto_purchase_saved_cart_after_topup,
)
from app.services.cloudpayments_service import CloudPaymentsAPIError, CloudPaymentsService
@@ -259,11 +260,19 @@ class CloudPaymentsPaymentMixin:
logger.exception("Ошибка отправки уведомления CloudPayments: %s", error)
# Auto-purchase if enabled
+ auto_purchase_success = False
try:
- await auto_purchase_saved_cart_after_topup(db, user)
+ auto_purchase_success = await auto_purchase_saved_cart_after_topup(db, user)
except Exception as error:
logger.exception("Ошибка автопокупки после CloudPayments: %s", error)
+ # Умная автоактивация если автопокупка не сработала
+ if not auto_purchase_success:
+ try:
+ await auto_activate_subscription_after_topup(db, user)
+ except Exception as error:
+ logger.exception("Ошибка умной автоактивации после CloudPayments: %s", error)
+
return True
async def process_cloudpayments_fail_webhook(
diff --git a/app/services/payment/cryptobot.py b/app/services/payment/cryptobot.py
index 8ed31bb4..17c00784 100644
--- a/app/services/payment/cryptobot.py
+++ b/app/services/payment/cryptobot.py
@@ -14,6 +14,7 @@ from app.config import settings
from app.database.database import AsyncSessionLocal
from app.database.models import PaymentMethod, TransactionType
from app.services.subscription_auto_purchase_service import (
+ auto_activate_subscription_after_topup,
auto_purchase_saved_cart_after_topup,
)
from app.services.subscription_renewal_service import (
@@ -376,6 +377,22 @@ class CryptoBotPaymentMixin:
if auto_purchase_success:
has_saved_cart = False
+ # Умная автоактивация если автопокупка не сработала
+ if not auto_purchase_success:
+ try:
+ await auto_activate_subscription_after_topup(
+ db,
+ user,
+ bot=bot_instance,
+ )
+ except Exception as auto_activate_error:
+ logger.error(
+ "Ошибка умной автоактивации для пользователя %s: %s",
+ user.id,
+ auto_activate_error,
+ exc_info=True,
+ )
+
if has_saved_cart and bot_instance:
from app.localization.texts import get_texts
diff --git a/app/services/payment/mulenpay.py b/app/services/payment/mulenpay.py
index 27dea36d..f732c802 100644
--- a/app/services/payment/mulenpay.py
+++ b/app/services/payment/mulenpay.py
@@ -12,6 +12,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PaymentMethod, TransactionType
from app.services.subscription_auto_purchase_service import (
+ auto_activate_subscription_after_topup,
auto_purchase_saved_cart_after_topup,
)
from app.utils.user_utils import format_referrer_info
@@ -396,11 +397,23 @@ class MulenPayPaymentMixin:
if auto_purchase_success:
has_saved_cart = False
+ # Умная автоактивация если автопокупка не сработала
+ if not auto_purchase_success:
+ try:
+ await auto_activate_subscription_after_topup(db, user)
+ except Exception as auto_activate_error:
+ logger.error(
+ "Ошибка умной автоактивации для пользователя %s: %s",
+ user.id,
+ auto_activate_error,
+ exc_info=True,
+ )
+
if has_saved_cart and getattr(self, "bot", None):
# Если у пользователя есть сохраненная корзина,
# отправляем ему уведомление с кнопкой вернуться к оформлению
from app.localization.texts import get_texts
-
+
texts = get_texts(user.language)
cart_message = texts.t(
"BALANCE_TOPUP_CART_REMINDER_DETAILED",
diff --git a/app/services/payment/pal24.py b/app/services/payment/pal24.py
index 460e0e31..20ba7eff 100644
--- a/app/services/payment/pal24.py
+++ b/app/services/payment/pal24.py
@@ -14,6 +14,7 @@ from app.config import settings
from app.database.models import PaymentMethod, TransactionType
from app.services.pal24_service import Pal24APIError
from app.services.subscription_auto_purchase_service import (
+ auto_activate_subscription_after_topup,
auto_purchase_saved_cart_after_topup,
)
from app.utils.user_utils import format_referrer_info
@@ -499,6 +500,18 @@ class Pal24PaymentMixin:
if auto_purchase_success:
has_saved_cart = False
+ # Умная автоактивация если автопокупка не сработала
+ if not auto_purchase_success:
+ try:
+ await auto_activate_subscription_after_topup(db, user)
+ except Exception as auto_activate_error:
+ logger.error(
+ "Ошибка умной автоактивации для пользователя %s: %s",
+ user.id,
+ auto_activate_error,
+ exc_info=True,
+ )
+
if has_saved_cart and getattr(self, "bot", None):
from app.localization.texts import get_texts
diff --git a/app/services/payment/platega.py b/app/services/payment/platega.py
index 910c1e90..76985c36 100644
--- a/app/services/payment/platega.py
+++ b/app/services/payment/platega.py
@@ -14,6 +14,7 @@ from app.config import settings
from app.database.models import PaymentMethod, TransactionType
from app.services.platega_service import PlategaService
from app.services.subscription_auto_purchase_service import (
+ auto_activate_subscription_after_topup,
auto_purchase_saved_cart_after_topup,
)
from app.utils.user_utils import format_referrer_info
@@ -485,6 +486,18 @@ class PlategaPaymentMixin:
if auto_purchase_success:
has_saved_cart = False
+ # Умная автоактивация если автопокупка не сработала
+ if not auto_purchase_success:
+ try:
+ await auto_activate_subscription_after_topup(db, user)
+ except Exception as auto_activate_error:
+ logger.error(
+ "Ошибка умной автоактивации для пользователя %s: %s",
+ user.id,
+ auto_activate_error,
+ exc_info=True,
+ )
+
if has_saved_cart and getattr(self, "bot", None):
from app.localization.texts import get_texts
diff --git a/app/services/payment/stars.py b/app/services/payment/stars.py
index 108c5af2..e50cd2ed 100644
--- a/app/services/payment/stars.py
+++ b/app/services/payment/stars.py
@@ -21,6 +21,7 @@ from app.database.crud.user import get_user_by_id
from app.database.models import PaymentMethod, TransactionType
from app.external.telegram_stars import TelegramStarsService
from app.services.subscription_auto_purchase_service import (
+ auto_activate_subscription_after_topup,
auto_purchase_saved_cart_after_topup,
)
from app.utils.user_utils import format_referrer_info
@@ -534,6 +535,22 @@ class TelegramStarsMixin:
if auto_purchase_success:
has_saved_cart = False
+ # Умная автоактивация если автопокупка не сработала
+ if not auto_purchase_success:
+ try:
+ await auto_activate_subscription_after_topup(
+ db,
+ user,
+ bot=getattr(self, "bot", None),
+ )
+ except Exception as auto_activate_error:
+ logger.error(
+ "Ошибка умной автоактивации для пользователя %s: %s",
+ user.id,
+ auto_activate_error,
+ exc_info=True,
+ )
+
if has_saved_cart and getattr(self, "bot", None):
texts = get_texts(user.language)
cart_message = texts.t(
diff --git a/app/services/payment/wata.py b/app/services/payment/wata.py
index 7072d096..3791d42e 100644
--- a/app/services/payment/wata.py
+++ b/app/services/payment/wata.py
@@ -13,6 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PaymentMethod, TransactionType
from app.services.subscription_auto_purchase_service import (
+ auto_activate_subscription_after_topup,
auto_purchase_saved_cart_after_topup,
)
from app.services.wata_service import WataAPIError, WataService
@@ -569,6 +570,18 @@ class WataPaymentMixin:
if auto_purchase_success:
has_saved_cart = False
+ # Умная автоактивация если автопокупка не сработала
+ if not auto_purchase_success:
+ try:
+ await auto_activate_subscription_after_topup(db, user)
+ except Exception as auto_activate_error:
+ logger.error(
+ "Ошибка умной автоактивации для пользователя %s: %s",
+ user.id,
+ auto_activate_error,
+ exc_info=True,
+ )
+
if has_saved_cart and getattr(self, "bot", None):
from app.localization.texts import get_texts
diff --git a/app/services/payment/yookassa.py b/app/services/payment/yookassa.py
index beed9443..66982e15 100644
--- a/app/services/payment/yookassa.py
+++ b/app/services/payment/yookassa.py
@@ -17,6 +17,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PaymentMethod, TransactionType
from app.services.subscription_auto_purchase_service import (
+ auto_activate_subscription_after_topup,
auto_purchase_saved_cart_after_topup,
)
from app.utils.user_utils import format_referrer_info
@@ -741,6 +742,22 @@ class YooKassaPaymentMixin:
if auto_purchase_success:
has_saved_cart = False
+ # Умная автоактивация если автопокупка не сработала
+ if not auto_purchase_success:
+ try:
+ await auto_activate_subscription_after_topup(
+ db,
+ user,
+ bot=getattr(self, "bot", None),
+ )
+ except Exception as auto_activate_error:
+ logger.error(
+ "Ошибка умной автоактивации для пользователя %s: %s",
+ user.id,
+ auto_activate_error,
+ exc_info=True,
+ )
+
if has_saved_cart and getattr(self, "bot", None):
# Если у пользователя есть сохраненная корзина,
# отправляем ему уведомление с кнопкой вернуться к оформлению
@@ -1029,13 +1046,13 @@ class YooKassaPaymentMixin:
receipt_uuid = await self.nalogo_service.create_receipt(
name=receipt_name,
amount=amount_rubles,
- quantity=1
+ quantity=1,
+ payment_id=payment.yookassa_payment_id,
)
if receipt_uuid:
logger.info(f"Чек NaloGO создан для платежа {payment.yookassa_payment_id}: {receipt_uuid}")
- else:
- logger.warning(f"Не удалось создать чек NaloGO для платежа {payment.yookassa_payment_id}")
+ # При временной недоступности чек добавляется в очередь автоматически
except Exception as error:
logger.error(
diff --git a/app/services/reporting_service.py b/app/services/reporting_service.py
index af0697be..cf6376ae 100644
--- a/app/services/reporting_service.py
+++ b/app/services/reporting_service.py
@@ -18,6 +18,7 @@ from app.config import settings
from app.database.crud.subscription import get_subscriptions_statistics
from app.database.database import AsyncSessionLocal
from app.database.models import (
+ PaymentMethod,
Subscription,
SubscriptionConversion,
SubscriptionStatus,
@@ -499,6 +500,11 @@ class ReportingService:
Transaction.created_at >= start_utc,
Transaction.created_at < end_utc,
self._exclude_referral_deposits_condition(),
+ # Исключаем ручные (админские) пополнения из статистики
+ or_(
+ Transaction.payment_method.is_(None),
+ Transaction.payment_method != PaymentMethod.MANUAL.value,
+ ),
)
async def _get_top_referrers(
diff --git a/app/services/subscription_auto_purchase_service.py b/app/services/subscription_auto_purchase_service.py
index 4e438c57..3e8390ee 100644
--- a/app/services/subscription_auto_purchase_service.py
+++ b/app/services/subscription_auto_purchase_service.py
@@ -624,4 +624,282 @@ async def auto_purchase_saved_cart_after_topup(
return True
-__all__ = ["auto_purchase_saved_cart_after_topup"]
+async def auto_activate_subscription_after_topup(
+ db: AsyncSession,
+ user: User,
+ *,
+ bot: Optional[Bot] = None,
+) -> bool:
+ """
+ Умная автоактивация после пополнения баланса.
+
+ Работает БЕЗ сохранённой корзины:
+ - Если подписка активна — ничего не делает
+ - Если подписка истекла — продлевает с теми же параметрами
+ - Если подписки нет — создаёт новую с дефолтными параметрами
+
+ Выбирает максимальный период, который можно оплатить из баланса.
+ """
+ from datetime import datetime
+ from app.database.crud.subscription import get_subscription_by_user_id, create_paid_subscription
+ from app.database.crud.server_squad import get_server_ids_by_uuids, get_available_server_squads
+ from app.database.crud.transaction import create_transaction
+ from app.database.crud.user import subtract_user_balance
+ from app.database.models import TransactionType, PaymentMethod
+ from app.services.subscription_service import SubscriptionService
+ from app.services.subscription_renewal_service import SubscriptionRenewalService
+ from app.services.admin_notification_service import AdminNotificationService
+
+ if not settings.is_auto_activate_after_topup_enabled():
+ return False
+
+ if not user or not getattr(user, "id", None):
+ return False
+
+ subscription = await get_subscription_by_user_id(db, user.id)
+
+ # Если подписка активна — ничего не делаем
+ if subscription and subscription.status == "ACTIVE" and subscription.end_date > datetime.utcnow():
+ logger.info(
+ "🔁 Автоактивация: у пользователя %s уже активная подписка, пропускаем",
+ user.telegram_id,
+ )
+ return False
+
+ # Определяем параметры подписки
+ if subscription:
+ device_limit = subscription.device_limit or settings.DEFAULT_DEVICE_LIMIT
+ traffic_limit_gb = subscription.traffic_limit_gb or 0
+ connected_squads = subscription.connected_squads or []
+ else:
+ device_limit = settings.DEFAULT_DEVICE_LIMIT
+ traffic_limit_gb = 0
+ connected_squads = []
+
+ # Если серверы не выбраны — берём бесплатные по умолчанию
+ if not connected_squads:
+ available_servers = await get_available_server_squads(db, promo_group_id=user.promo_group_id)
+ connected_squads = [
+ s.squad_uuid for s in available_servers
+ if s.is_available and s.price_kopeks == 0
+ ]
+ if not connected_squads and available_servers:
+ connected_squads = [available_servers[0].squad_uuid]
+
+ server_ids = await get_server_ids_by_uuids(db, connected_squads) if connected_squads else []
+
+ balance = user.balance_kopeks
+ available_periods = sorted([int(p) for p in settings.AVAILABLE_SUBSCRIPTION_PERIODS], reverse=True)
+
+ if not available_periods:
+ logger.warning("🔁 Автоактивация: нет доступных периодов подписки")
+ return False
+
+ subscription_service = SubscriptionService()
+
+ # Найти максимальный период <= баланса
+ best_period = None
+ best_price = 0
+
+ for period in available_periods:
+ try:
+ price, _ = await subscription_service.calculate_subscription_price_with_months(
+ period,
+ traffic_limit_gb,
+ server_ids,
+ device_limit,
+ db,
+ user=user
+ )
+ if price <= balance:
+ best_period = period
+ best_price = price
+ break
+ except Exception as calc_error:
+ logger.warning(
+ "🔁 Автоактивация: ошибка расчёта цены для периода %s: %s",
+ period,
+ calc_error,
+ )
+ continue
+
+ if not best_period:
+ logger.info(
+ "🔁 Автоактивация: у пользователя %s недостаточно средств (%s) для любого периода",
+ user.telegram_id,
+ balance,
+ )
+ return False
+
+ texts = get_texts(getattr(user, "language", "ru"))
+
+ try:
+ if subscription:
+ # Продление существующей подписки
+ renewal_service = SubscriptionRenewalService()
+ pricing = await renewal_service.calculate_pricing(
+ db, user, subscription, best_period
+ )
+
+ old_end_date = subscription.end_date
+ result = await renewal_service.finalize(
+ db, user, subscription,
+ pricing,
+ description=f"Автоматическое продление на {best_period} дней",
+ payment_method=PaymentMethod.BALANCE,
+ )
+
+ logger.info(
+ "✅ Автоактивация: подписка пользователя %s продлена на %s дней за %s коп.",
+ user.telegram_id,
+ best_period,
+ best_price,
+ )
+
+ # Уведомление пользователю
+ if bot:
+ try:
+ period_label = format_period_description(best_period, getattr(user, "language", "ru"))
+ new_end_date = result.subscription.end_date
+ end_date_str = new_end_date.strftime("%d.%m.%Y") if new_end_date else "—"
+
+ message = texts.t(
+ "AUTO_PURCHASE_SUBSCRIPTION_EXTENDED",
+ "✅ Подписка автоматически продлена на {period}.",
+ ).format(period=period_label)
+
+ details = texts.t(
+ "AUTO_PURCHASE_SUBSCRIPTION_EXTENDED_DETAILS",
+ "⏰ Новая дата окончания: {date}.",
+ ).format(date=end_date_str)
+
+ hint = texts.t(
+ "AUTO_PURCHASE_SUBSCRIPTION_HINT",
+ "Перейдите в раздел «Моя подписка», чтобы получить ссылку.",
+ )
+
+ keyboard = InlineKeyboardMarkup(
+ inline_keyboard=[
+ [InlineKeyboardButton(
+ text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"),
+ callback_data="menu_subscription",
+ )],
+ ]
+ )
+
+ await bot.send_message(
+ chat_id=user.telegram_id,
+ text=f"{message}\n{details}\n\n{hint}",
+ reply_markup=keyboard,
+ parse_mode="HTML",
+ )
+ except Exception as notify_error:
+ logger.warning(
+ "⚠️ Автоактивация: не удалось уведомить пользователя %s: %s",
+ user.telegram_id,
+ notify_error,
+ )
+
+ else:
+ # Создание новой подписки
+ new_subscription = await create_paid_subscription(
+ db,
+ user.id,
+ best_period,
+ traffic_limit_gb=traffic_limit_gb,
+ device_limit=device_limit,
+ connected_squads=connected_squads,
+ update_server_counters=True
+ )
+
+ await subtract_user_balance(
+ db, user, best_price,
+ f"Активация подписки на {best_period} дней"
+ )
+
+ await subscription_service.create_remnawave_user(db, new_subscription)
+
+ await create_transaction(
+ db=db,
+ user_id=user.id,
+ type=TransactionType.SUBSCRIPTION_PAYMENT,
+ amount_kopeks=best_price,
+ description=f"Активация подписки на {best_period} дней",
+ payment_method=PaymentMethod.BALANCE,
+ )
+
+ logger.info(
+ "✅ Автоактивация: новая подписка на %s дней создана для пользователя %s за %s коп.",
+ best_period,
+ user.telegram_id,
+ best_price,
+ )
+
+ # Уведомление пользователю
+ if bot:
+ try:
+ period_label = format_period_description(best_period, getattr(user, "language", "ru"))
+
+ message = texts.t(
+ "AUTO_PURCHASE_SUBSCRIPTION_SUCCESS",
+ "✅ Подписка на {period} автоматически оформлена после пополнения баланса.",
+ ).format(period=period_label)
+
+ hint = texts.t(
+ "AUTO_PURCHASE_SUBSCRIPTION_HINT",
+ "Перейдите в раздел «Моя подписка», чтобы получить ссылку.",
+ )
+
+ keyboard = InlineKeyboardMarkup(
+ inline_keyboard=[
+ [InlineKeyboardButton(
+ text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"),
+ callback_data="menu_subscription",
+ )],
+ ]
+ )
+
+ await bot.send_message(
+ chat_id=user.telegram_id,
+ text=f"{message}\n\n{hint}",
+ reply_markup=keyboard,
+ parse_mode="HTML",
+ )
+
+ # Уведомление админам
+ try:
+ notification_service = AdminNotificationService(bot)
+ await notification_service.send_subscription_purchase_notification(
+ db,
+ user,
+ new_subscription,
+ None, # transaction
+ best_period,
+ False, # was_trial_conversion
+ )
+ except Exception as admin_error:
+ logger.warning(
+ "⚠️ Автоактивация: не удалось уведомить админов: %s",
+ admin_error,
+ )
+
+ except Exception as notify_error:
+ logger.warning(
+ "⚠️ Автоактивация: не удалось уведомить пользователя %s: %s",
+ user.telegram_id,
+ notify_error,
+ )
+
+ return True
+
+ except Exception as e:
+ logger.error(
+ "❌ Автоактивация: ошибка для пользователя %s: %s",
+ user.telegram_id,
+ e,
+ exc_info=True,
+ )
+ return False
+
+
+__all__ = ["auto_purchase_saved_cart_after_topup", "auto_activate_subscription_after_topup"]
diff --git a/app/services/tribute_service.py b/app/services/tribute_service.py
index 3a6d8c07..7cba2ae0 100644
--- a/app/services/tribute_service.py
+++ b/app/services/tribute_service.py
@@ -15,6 +15,7 @@ from app.database.crud.user import get_user_by_telegram_id
from app.external.tribute import TributeService as TributeAPI
from app.services.payment_service import PaymentService
from app.services.subscription_auto_purchase_service import (
+ auto_activate_subscription_after_topup,
auto_purchase_saved_cart_after_topup,
)
from app.utils.user_utils import format_referrer_info
@@ -314,6 +315,18 @@ class TributeService:
if auto_purchase_success:
has_saved_cart = False
+ # Умная автоактивация если автопокупка не сработала
+ if not auto_purchase_success:
+ try:
+ await auto_activate_subscription_after_topup(session, user)
+ except Exception as auto_activate_error:
+ logger.error(
+ "Ошибка умной автоактивации для пользователя %s: %s",
+ user.id,
+ auto_activate_error,
+ exc_info=True,
+ )
+
if has_saved_cart and self.bot:
# Если у пользователя есть сохраненная корзина,
# отправляем ему уведомление с кнопкой вернуться к оформлению
diff --git a/app/services/user_service.py b/app/services/user_service.py
index 0f723631..1dcb94ce 100644
--- a/app/services/user_service.py
+++ b/app/services/user_service.py
@@ -395,7 +395,11 @@ class UserService:
old_balance = user.balance_kopeks
if amount_kopeks > 0:
- await add_user_balance(db, user, amount_kopeks, description=description)
+ await add_user_balance(
+ db, user, amount_kopeks,
+ description=description,
+ payment_method=PaymentMethod.MANUAL
+ )
logger.info(f"Админ {admin_id} пополнил баланс пользователя {user_id} на {amount_kopeks/100}₽")
success = True
else:
diff --git a/app/utils/cache.py b/app/utils/cache.py
index 88eaa1cf..5bbc916f 100644
--- a/app/utils/cache.py
+++ b/app/utils/cache.py
@@ -159,7 +159,7 @@ class CacheService:
async def get_hash(self, name: str, key: str = None) -> Optional[Union[dict, str]]:
if not self._connected:
return None
-
+
try:
if key:
value = await self.redis_client.hget(name, key)
@@ -171,6 +171,56 @@ class CacheService:
logger.error(f"Ошибка получения хеша {name}: {e}")
return None
+ async def lpush(self, key: str, value: Any) -> bool:
+ """Добавить элемент в начало списка (очереди)."""
+ if not self._connected:
+ return False
+
+ try:
+ serialized = json.dumps(value, default=str)
+ await self.redis_client.lpush(key, serialized)
+ return True
+ except Exception as e:
+ logger.error(f"Ошибка добавления в очередь {key}: {e}")
+ return False
+
+ async def rpop(self, key: str) -> Optional[Any]:
+ """Извлечь элемент из конца списка (FIFO очередь)."""
+ if not self._connected:
+ return None
+
+ try:
+ value = await self.redis_client.rpop(key)
+ if value:
+ return json.loads(value)
+ return None
+ except Exception as e:
+ logger.error(f"Ошибка извлечения из очереди {key}: {e}")
+ return None
+
+ async def llen(self, key: str) -> int:
+ """Получить длину списка (очереди)."""
+ if not self._connected:
+ return 0
+
+ try:
+ return await self.redis_client.llen(key)
+ except Exception as e:
+ logger.error(f"Ошибка получения длины очереди {key}: {e}")
+ return 0
+
+ async def lrange(self, key: str, start: int = 0, end: int = -1) -> list:
+ """Получить элементы списка без удаления."""
+ if not self._connected:
+ return []
+
+ try:
+ items = await self.redis_client.lrange(key, start, end)
+ return [json.loads(item) for item in items]
+ except Exception as e:
+ logger.error(f"Ошибка чтения очереди {key}: {e}")
+ return []
+
cache = CacheService()
diff --git a/app/utils/decorators.py b/app/utils/decorators.py
index 9d044654..3c4a67d7 100644
--- a/app/utils/decorators.py
+++ b/app/utils/decorators.py
@@ -173,7 +173,7 @@ def typing_action(func: Callable) -> Callable:
def rate_limit(rate: float = 1.0, key: str = None):
def decorator(func: Callable) -> Callable:
-
+
@functools.wraps(func)
async def wrapper(
event: types.Update,
@@ -181,7 +181,98 @@ def rate_limit(rate: float = 1.0, key: str = None):
**kwargs
) -> Any:
return await func(event, *args, **kwargs)
-
+
return wrapper
-
+
+ return decorator
+
+
+def modem_available(for_enable: bool = False, for_disable: bool = False):
+ """
+ Декоратор для проверки доступности модема.
+
+ Проверяет:
+ - Наличие подписки
+ - Подписка не триальная
+ - Функция модема включена в настройках
+ - (опционально) Модем ещё не подключен (for_enable=True)
+ - (опционально) Модем уже подключен (for_disable=True)
+
+ Args:
+ for_enable: Проверять, что модем ещё не подключен
+ for_disable: Проверять, что модем подключен
+
+ Usage:
+ @modem_available()
+ async def handle_modem_menu(callback, db_user, db): ...
+
+ @modem_available(for_enable=True)
+ async def handle_modem_enable(callback, db_user, db): ...
+ """
+ def decorator(func: Callable) -> Callable:
+
+ @functools.wraps(func)
+ async def wrapper(
+ event: types.Update,
+ *args,
+ **kwargs
+ ) -> Any:
+ db_user = kwargs.get('db_user')
+
+ if not db_user:
+ logger.warning("modem_available: нет db_user в kwargs")
+ return
+
+ from app.services.modem_service import get_modem_service, ModemError
+
+ service = get_modem_service()
+ result = service.check_availability(
+ db_user,
+ for_enable=for_enable,
+ for_disable=for_disable
+ )
+
+ if not result.available:
+ texts = get_texts(db_user.language if db_user else 'ru')
+
+ error_messages = {
+ ModemError.NO_SUBSCRIPTION: texts.t(
+ "MODEM_PAID_ONLY",
+ "Модем доступен только для платных подписок"
+ ),
+ ModemError.TRIAL_SUBSCRIPTION: texts.t(
+ "MODEM_PAID_ONLY",
+ "Модем доступен только для платных подписок"
+ ),
+ ModemError.MODEM_DISABLED: texts.t(
+ "MODEM_DISABLED",
+ "Функция модема отключена"
+ ),
+ ModemError.ALREADY_ENABLED: texts.t(
+ "MODEM_ALREADY_ENABLED",
+ "Модем уже подключен"
+ ),
+ ModemError.NOT_ENABLED: texts.t(
+ "MODEM_NOT_ENABLED",
+ "Модем не подключен"
+ ),
+ }
+
+ error_text = error_messages.get(result.error, texts.ERROR)
+
+ try:
+ if isinstance(event, types.CallbackQuery):
+ await event.answer(error_text, show_alert=True)
+ elif isinstance(event, types.Message):
+ await event.answer(error_text)
+ except TelegramBadRequest as e:
+ if "query is too old" not in str(e).lower():
+ raise
+
+ return None
+
+ return await func(event, *args, **kwargs)
+
+ return wrapper
+
return decorator
diff --git a/app/webapi/routes/contests.py b/app/webapi/routes/contests.py
index 1ce2109c..0c7f96fe 100644
--- a/app/webapi/routes/contests.py
+++ b/app/webapi/routes/contests.py
@@ -82,7 +82,8 @@ def _serialize_template(tpl: ContestTemplate) -> ContestTemplateResponse:
name=tpl.name,
slug=tpl.slug,
description=tpl.description,
- prize_days=tpl.prize_days,
+ prize_type=tpl.prize_type,
+ prize_value=tpl.prize_value,
max_winners=tpl.max_winners,
attempts_per_user=tpl.attempts_per_user,
times_per_day=tpl.times_per_day,
diff --git a/app/webapi/routes/subscriptions.py b/app/webapi/routes/subscriptions.py
index 6979ec79..637d7b03 100644
--- a/app/webapi/routes/subscriptions.py
+++ b/app/webapi/routes/subscriptions.py
@@ -30,6 +30,7 @@ from ..schemas.subscriptions import (
SubscriptionCreateRequest,
SubscriptionDevicesRequest,
SubscriptionExtendRequest,
+ SubscriptionModemRequest,
SubscriptionResponse,
SubscriptionSquadRequest,
SubscriptionTrafficRequest,
@@ -52,6 +53,7 @@ def _serialize_subscription(subscription: Subscription) -> SubscriptionResponse:
traffic_limit_gb=subscription.traffic_limit_gb,
traffic_used_gb=subscription.traffic_used_gb,
device_limit=subscription.device_limit,
+ modem_enabled=getattr(subscription, 'modem_enabled', False) or False,
autopay_enabled=subscription.autopay_enabled,
autopay_days_before=subscription.autopay_days_before,
subscription_url=subscription.subscription_url,
@@ -302,3 +304,41 @@ async def remove_subscription_squad_endpoint(
subscription = await remove_subscription_squad(db, subscription, squad_uuid)
subscription = await _get_subscription(db, subscription.id)
return _serialize_subscription(subscription)
+
+
+@router.post("/{subscription_id}/modem", response_model=SubscriptionResponse)
+async def set_subscription_modem(
+ subscription_id: int,
+ payload: SubscriptionModemRequest,
+ _: Any = Security(require_api_token),
+ db: AsyncSession = Depends(get_db_session),
+) -> SubscriptionResponse:
+ """Включить или выключить модем для подписки."""
+ subscription = await _get_subscription(db, subscription_id)
+
+ if subscription.is_trial:
+ raise HTTPException(status.HTTP_400_BAD_REQUEST, "Modem is not available for trial subscriptions")
+
+ if not settings.is_modem_enabled():
+ raise HTTPException(status.HTTP_400_BAD_REQUEST, "Modem feature is disabled")
+
+ current_modem = getattr(subscription, 'modem_enabled', False) or False
+
+ if payload.enabled == current_modem:
+ return _serialize_subscription(subscription)
+
+ if payload.enabled:
+ subscription.modem_enabled = True
+ subscription.device_limit = (subscription.device_limit or 1) + 1
+ else:
+ subscription.modem_enabled = False
+ if subscription.device_limit and subscription.device_limit > 1:
+ subscription.device_limit = subscription.device_limit - 1
+
+ await db.commit()
+
+ subscription_service = SubscriptionService()
+ await subscription_service.update_remnawave_user(db, subscription)
+
+ subscription = await _get_subscription(db, subscription.id)
+ return _serialize_subscription(subscription)
diff --git a/app/webapi/routes/users.py b/app/webapi/routes/users.py
index 7e6b444f..029fc08f 100644
--- a/app/webapi/routes/users.py
+++ b/app/webapi/routes/users.py
@@ -59,6 +59,7 @@ def _serialize_subscription(subscription: Optional[Subscription]) -> Optional[Su
traffic_limit_gb=subscription.traffic_limit_gb,
traffic_used_gb=subscription.traffic_used_gb,
device_limit=subscription.device_limit,
+ modem_enabled=getattr(subscription, 'modem_enabled', False) or False,
autopay_enabled=subscription.autopay_enabled,
autopay_days_before=subscription.autopay_days_before,
subscription_url=subscription.subscription_url,
diff --git a/app/webapi/schemas/contests.py b/app/webapi/schemas/contests.py
index 139cd125..cbc6a2d1 100644
--- a/app/webapi/schemas/contests.py
+++ b/app/webapi/schemas/contests.py
@@ -11,7 +11,8 @@ class ContestTemplateResponse(BaseModel):
name: str
slug: str
description: Optional[str] = None
- prize_days: int
+ prize_type: str
+ prize_value: str
max_winners: int
attempts_per_user: int
times_per_day: int
@@ -30,7 +31,8 @@ class ContestTemplateListResponse(BaseModel):
class ContestTemplateUpdateRequest(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
- prize_days: Optional[int] = Field(None, ge=1)
+ prize_type: Optional[str] = None
+ prize_value: Optional[str] = None
max_winners: Optional[int] = Field(None, ge=1)
attempts_per_user: Optional[int] = Field(None, ge=1)
times_per_day: Optional[int] = Field(None, ge=1)
diff --git a/app/webapi/schemas/subscriptions.py b/app/webapi/schemas/subscriptions.py
index 3e1a4ee0..b8e3f27a 100644
--- a/app/webapi/schemas/subscriptions.py
+++ b/app/webapi/schemas/subscriptions.py
@@ -17,6 +17,7 @@ class SubscriptionResponse(BaseModel):
traffic_limit_gb: int
traffic_used_gb: float
device_limit: int
+ modem_enabled: bool = False
autopay_enabled: bool
autopay_days_before: Optional[int] = None
subscription_url: Optional[str] = None
@@ -51,3 +52,7 @@ class SubscriptionDevicesRequest(BaseModel):
class SubscriptionSquadRequest(BaseModel):
squad_uuid: str
+
+
+class SubscriptionModemRequest(BaseModel):
+ enabled: bool
diff --git a/app/webapi/schemas/users.py b/app/webapi/schemas/users.py
index 6f005365..cf8bb0e8 100644
--- a/app/webapi/schemas/users.py
+++ b/app/webapi/schemas/users.py
@@ -25,6 +25,7 @@ class SubscriptionSummary(BaseModel):
traffic_limit_gb: int
traffic_used_gb: float
device_limit: int
+ modem_enabled: bool = False
autopay_enabled: bool
autopay_days_before: Optional[int] = None
subscription_url: Optional[str] = None
diff --git a/main.py b/main.py
index fa714e12..1e0e4cf4 100644
--- a/main.py
+++ b/main.py
@@ -34,6 +34,7 @@ from app.services.external_admin_service import ensure_external_admin_token
from app.services.broadcast_service import broadcast_service
from app.services.referral_contest_service import referral_contest_service
from app.services.contest_rotation_service import contest_rotation_service
+from app.services.nalogo_queue_service import nalogo_queue_service
from app.utils.startup_timeline import StartupTimeline
from app.utils.timezone import TimezoneAwareFormatter
@@ -271,6 +272,11 @@ async def main():
payment_service = PaymentService(bot)
auto_payment_verification_service.set_payment_service(payment_service)
+ # Настройка сервиса очереди чеков NaloGO
+ if payment_service.nalogo_service:
+ nalogo_queue_service.set_nalogo_service(payment_service.nalogo_service)
+ nalogo_queue_service.set_bot(bot)
+
verification_providers: list[str] = []
auto_verification_active = False
async with timeline.stage(
@@ -331,6 +337,27 @@ async def main():
if auto_verification_active:
stage.log("Фоновая автопроверка запущена")
+ async with timeline.stage(
+ "Очередь чеков NaloGO",
+ "🧾",
+ success_message="Сервис очереди чеков запущен",
+ ) as stage:
+ if settings.is_nalogo_enabled():
+ try:
+ await nalogo_queue_service.start()
+ if nalogo_queue_service.is_running():
+ queue_len = await payment_service.nalogo_service.get_queue_length()
+ if queue_len > 0:
+ stage.log(f"В очереди ожидает {queue_len} чек(ов)")
+ stage.success("Фоновая обработка чеков активна")
+ else:
+ stage.skip("Сервис не запущен")
+ except Exception as e:
+ stage.warning(f"Ошибка запуска очереди чеков: {e}")
+ logger.error(f"❌ Ошибка запуска очереди чеков NaloGO: {e}")
+ else:
+ stage.skip("NaloGO отключен настройками")
+
async with timeline.stage(
"Внешняя админка",
"🛡️",
@@ -646,6 +673,12 @@ async def main():
except Exception as e:
logger.error(f"Ошибка остановки ротации игр: {e}")
+ logger.info("ℹ️ Остановка очереди чеков NaloGO...")
+ try:
+ await nalogo_queue_service.stop()
+ except Exception as e:
+ logger.error(f"Ошибка остановки очереди чеков NaloGO: {e}")
+
logger.info("ℹ️ Остановка сервиса бекапов...")
try:
await backup_service.stop_auto_backup()
diff --git a/migrations/alembic/versions/a1b2c3d4e5f6_add_purchased_traffic_gb_to_subscriptions.py b/migrations/alembic/versions/a1b2c3d4e5f6_add_purchased_traffic_gb_to_subscriptions.py
new file mode 100644
index 00000000..88a656ee
--- /dev/null
+++ b/migrations/alembic/versions/a1b2c3d4e5f6_add_purchased_traffic_gb_to_subscriptions.py
@@ -0,0 +1,28 @@
+"""add purchased_traffic_gb to subscriptions
+
+Revision ID: a1b2c3d4e5f6
+Revises: f4a5b6c7d8e9
+Create Date: 2024-12-25 14:30:00.000000
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = 'a1b2c3d4e5f6'
+down_revision = 'f4a5b6c7d8e9'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ # Добавляем колонку purchased_traffic_gb для отслеживания докупленного трафика
+ op.add_column('subscriptions', sa.Column('purchased_traffic_gb', sa.Integer(), nullable=True, server_default='0'))
+
+ # Устанавливаем NOT NULL после добавления значения по умолчанию
+ op.alter_column('subscriptions', 'purchased_traffic_gb', nullable=False, server_default=None)
+
+
+def downgrade():
+ op.drop_column('subscriptions', 'purchased_traffic_gb')
diff --git a/migrations/alembic/versions/f4a5b6c7d8e9_add_modem_enabled_to_subscriptions.py b/migrations/alembic/versions/f4a5b6c7d8e9_add_modem_enabled_to_subscriptions.py
new file mode 100644
index 00000000..4623aa7d
--- /dev/null
+++ b/migrations/alembic/versions/f4a5b6c7d8e9_add_modem_enabled_to_subscriptions.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 = "f4a5b6c7d8e9"
+down_revision: Union[str, None] = "e3c1e0b5b4a7"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.add_column("subscriptions", sa.Column("modem_enabled", sa.Boolean(), nullable=True, server_default="false"))
+
+
+def downgrade() -> None:
+ op.drop_column("subscriptions", "modem_enabled")
diff --git a/tests/services/test_modem_service.py b/tests/services/test_modem_service.py
new file mode 100644
index 00000000..731959c5
--- /dev/null
+++ b/tests/services/test_modem_service.py
@@ -0,0 +1,418 @@
+"""
+Тесты для ModemService - управление модемом в подписке.
+"""
+
+import pytest
+from datetime import datetime, timedelta
+from unittest.mock import AsyncMock, MagicMock, patch
+from types import SimpleNamespace
+
+from app.services.modem_service import (
+ ModemService,
+ ModemError,
+ ModemAvailabilityResult,
+ ModemPriceResult,
+ ModemEnableResult,
+ ModemDisableResult,
+ get_modem_service,
+ MODEM_WARNING_DAYS_CRITICAL,
+ MODEM_WARNING_DAYS_INFO,
+)
+
+
+def create_mock_settings():
+ """Создаёт мок настроек приложения."""
+ settings = MagicMock()
+ settings.is_modem_enabled.return_value = True
+ settings.get_modem_price_per_month.return_value = 10000 # 100 рублей
+ settings.get_modem_period_discount.return_value = 0
+ return settings
+
+
+def create_sample_user():
+ """Создаёт пример пользователя."""
+ user = SimpleNamespace(
+ id=1,
+ telegram_id=123456789,
+ balance_kopeks=50000, # 500 рублей
+ language="ru",
+ subscription=None,
+ )
+ return user
+
+
+def create_sample_subscription():
+ """Создаёт пример подписки."""
+ subscription = SimpleNamespace(
+ id=1,
+ user_id=1,
+ is_trial=False,
+ modem_enabled=False,
+ device_limit=2,
+ end_date=datetime.utcnow() + timedelta(days=30),
+ updated_at=datetime.utcnow(),
+ )
+ return subscription
+
+
+def create_trial_subscription():
+ """Создаёт триальную подписку."""
+ subscription = SimpleNamespace(
+ id=2,
+ user_id=1,
+ is_trial=True,
+ modem_enabled=False,
+ device_limit=1,
+ end_date=datetime.utcnow() + timedelta(days=7),
+ updated_at=datetime.utcnow(),
+ )
+ return subscription
+
+
+def create_modem_service(monkeypatch):
+ """Создаёт ModemService с замоканными настройками."""
+ mock_settings = create_mock_settings()
+ monkeypatch.setattr('app.services.modem_service.settings', mock_settings)
+ return ModemService(), mock_settings
+
+
+class TestModemServiceAvailability:
+ """Тесты проверки доступности модема."""
+
+ def test_check_availability_no_subscription(self, monkeypatch):
+ """Модем недоступен без подписки."""
+ modem_service, _ = create_modem_service(monkeypatch)
+ sample_user = create_sample_user()
+ sample_user.subscription = None
+
+ result = modem_service.check_availability(sample_user)
+
+ assert not result.available
+ assert result.error == ModemError.NO_SUBSCRIPTION
+ assert not result.modem_enabled
+
+ def test_check_availability_trial_subscription(self, monkeypatch):
+ """Модем недоступен для триальной подписки."""
+ modem_service, _ = create_modem_service(monkeypatch)
+ sample_user = create_sample_user()
+ trial_subscription = create_trial_subscription()
+ sample_user.subscription = trial_subscription
+
+ result = modem_service.check_availability(sample_user)
+
+ assert not result.available
+ assert result.error == ModemError.TRIAL_SUBSCRIPTION
+ assert not result.modem_enabled
+
+ def test_check_availability_modem_disabled_in_settings(self, monkeypatch):
+ """Модем недоступен, если отключён в настройках."""
+ modem_service, mock_settings = create_modem_service(monkeypatch)
+ sample_user = create_sample_user()
+ sample_subscription = create_sample_subscription()
+ sample_user.subscription = sample_subscription
+ mock_settings.is_modem_enabled.return_value = False
+
+ result = modem_service.check_availability(sample_user)
+
+ assert not result.available
+ assert result.error == ModemError.MODEM_DISABLED
+
+ def test_check_availability_success(self, monkeypatch):
+ """Модем доступен для платной подписки."""
+ modem_service, _ = create_modem_service(monkeypatch)
+ sample_user = create_sample_user()
+ sample_subscription = create_sample_subscription()
+ sample_user.subscription = sample_subscription
+
+ result = modem_service.check_availability(sample_user)
+
+ assert result.available
+ assert result.error is None
+ assert not result.modem_enabled
+
+ def test_check_availability_for_enable_already_enabled(self, monkeypatch):
+ """Нельзя подключить уже подключенный модем."""
+ modem_service, _ = create_modem_service(monkeypatch)
+ sample_user = create_sample_user()
+ sample_subscription = create_sample_subscription()
+ sample_subscription.modem_enabled = True
+ sample_user.subscription = sample_subscription
+
+ result = modem_service.check_availability(sample_user, for_enable=True)
+
+ assert not result.available
+ assert result.error == ModemError.ALREADY_ENABLED
+ assert result.modem_enabled
+
+ def test_check_availability_for_disable_not_enabled(self, monkeypatch):
+ """Нельзя отключить неподключенный модем."""
+ modem_service, _ = create_modem_service(monkeypatch)
+ sample_user = create_sample_user()
+ sample_subscription = create_sample_subscription()
+ sample_subscription.modem_enabled = False
+ sample_user.subscription = sample_subscription
+
+ result = modem_service.check_availability(sample_user, for_disable=True)
+
+ assert not result.available
+ assert result.error == ModemError.NOT_ENABLED
+ assert not result.modem_enabled
+
+
+class TestModemServicePricing:
+ """Тесты расчёта цены модема."""
+
+ def test_calculate_price_one_month(self, monkeypatch):
+ """Расчёт цены на 1 месяц."""
+ modem_service, _ = create_modem_service(monkeypatch)
+ sample_subscription = create_sample_subscription()
+ sample_subscription.end_date = datetime.utcnow() + timedelta(days=30)
+
+ result = modem_service.calculate_price(sample_subscription)
+
+ assert result.base_price == 10000
+ assert result.final_price == 10000
+ assert result.charged_months == 1
+ assert result.discount_percent == 0
+ assert not result.has_discount
+
+ def test_calculate_price_three_months(self, monkeypatch):
+ """Расчёт цены на 3 месяца."""
+ modem_service, _ = create_modem_service(monkeypatch)
+ sample_subscription = create_sample_subscription()
+ sample_subscription.end_date = datetime.utcnow() + timedelta(days=90)
+
+ result = modem_service.calculate_price(sample_subscription)
+
+ assert result.base_price == 30000 # 3 * 10000
+ assert result.charged_months == 3
+
+ def test_calculate_price_with_discount(self, monkeypatch):
+ """Расчёт цены со скидкой."""
+ modem_service, mock_settings = create_modem_service(monkeypatch)
+ sample_subscription = create_sample_subscription()
+ sample_subscription.end_date = datetime.utcnow() + timedelta(days=90)
+ mock_settings.get_modem_period_discount.return_value = 10 # 10% скидка
+
+ result = modem_service.calculate_price(sample_subscription)
+
+ assert result.base_price == 30000
+ assert result.discount_percent == 10
+ assert result.discount_amount == 3000
+ assert result.final_price == 27000
+ assert result.has_discount
+
+
+class TestModemServiceBalance:
+ """Тесты проверки баланса."""
+
+ def test_check_balance_sufficient(self, monkeypatch):
+ """Баланса достаточно."""
+ modem_service, _ = create_modem_service(monkeypatch)
+ sample_user = create_sample_user()
+ sample_user.balance_kopeks = 50000
+
+ has_funds, missing = modem_service.check_balance(sample_user, 10000)
+
+ assert has_funds
+ assert missing == 0
+
+ def test_check_balance_insufficient(self, monkeypatch):
+ """Баланса недостаточно."""
+ modem_service, _ = create_modem_service(monkeypatch)
+ sample_user = create_sample_user()
+ sample_user.balance_kopeks = 5000
+
+ has_funds, missing = modem_service.check_balance(sample_user, 10000)
+
+ assert not has_funds
+ assert missing == 5000
+
+ def test_check_balance_zero_price(self, monkeypatch):
+ """Нулевая цена - всегда достаточно."""
+ modem_service, _ = create_modem_service(monkeypatch)
+ sample_user = create_sample_user()
+ sample_user.balance_kopeks = 0
+
+ has_funds, missing = modem_service.check_balance(sample_user, 0)
+
+ assert has_funds
+ assert missing == 0
+
+
+class TestModemServicePeriodWarning:
+ """Тесты предупреждений о сроке действия."""
+
+ def test_warning_critical(self, monkeypatch):
+ """Критическое предупреждение при <= 7 днях."""
+ modem_service, _ = create_modem_service(monkeypatch)
+ assert modem_service.get_period_warning_level(7) == "critical"
+ assert modem_service.get_period_warning_level(5) == "critical"
+ assert modem_service.get_period_warning_level(1) == "critical"
+
+ def test_warning_info(self, monkeypatch):
+ """Информационное предупреждение при <= 30 днях."""
+ modem_service, _ = create_modem_service(monkeypatch)
+ assert modem_service.get_period_warning_level(30) == "info"
+ assert modem_service.get_period_warning_level(15) == "info"
+ assert modem_service.get_period_warning_level(8) == "info"
+
+ def test_warning_none(self, monkeypatch):
+ """Нет предупреждения при > 30 днях."""
+ modem_service, _ = create_modem_service(monkeypatch)
+ assert modem_service.get_period_warning_level(31) is None
+ assert modem_service.get_period_warning_level(60) is None
+ assert modem_service.get_period_warning_level(90) is None
+
+
+class TestModemServiceEnable:
+ """Тесты подключения модема."""
+
+ async def test_enable_modem_success(self, monkeypatch):
+ """Успешное подключение модема."""
+ modem_service, _ = create_modem_service(monkeypatch)
+ sample_user = create_sample_user()
+ sample_subscription = create_sample_subscription()
+ sample_user.subscription = sample_subscription
+ sample_user.balance_kopeks = 50000
+
+ mock_db = AsyncMock()
+ mock_subtract = AsyncMock(return_value=True)
+ mock_create_transaction = AsyncMock()
+ mock_update_remnawave = AsyncMock()
+
+ monkeypatch.setattr(
+ 'app.services.modem_service.subtract_user_balance',
+ mock_subtract
+ )
+ monkeypatch.setattr(
+ 'app.services.modem_service.create_transaction',
+ mock_create_transaction
+ )
+ modem_service._subscription_service.update_remnawave_user = mock_update_remnawave
+
+ result = await modem_service.enable_modem(
+ mock_db, sample_user, sample_subscription
+ )
+
+ assert result.success
+ assert result.error is None
+ assert result.charged_amount == 10000
+ assert sample_subscription.modem_enabled is True
+ assert sample_subscription.device_limit == 3 # было 2, стало 3
+
+ async def test_enable_modem_insufficient_funds(self, monkeypatch):
+ """Недостаточно средств для подключения."""
+ modem_service, _ = create_modem_service(monkeypatch)
+ sample_user = create_sample_user()
+ sample_subscription = create_sample_subscription()
+ sample_user.subscription = sample_subscription
+ sample_user.balance_kopeks = 1000 # недостаточно
+
+ mock_db = AsyncMock()
+
+ result = await modem_service.enable_modem(
+ mock_db, sample_user, sample_subscription
+ )
+
+ assert not result.success
+ assert result.error == ModemError.INSUFFICIENT_FUNDS
+
+ async def test_enable_modem_charge_error(self, monkeypatch):
+ """Ошибка списания средств."""
+ modem_service, _ = create_modem_service(monkeypatch)
+ sample_user = create_sample_user()
+ sample_subscription = create_sample_subscription()
+ sample_user.subscription = sample_subscription
+ sample_user.balance_kopeks = 50000
+
+ mock_db = AsyncMock()
+ mock_subtract = AsyncMock(return_value=False) # ошибка списания
+
+ monkeypatch.setattr(
+ 'app.services.modem_service.subtract_user_balance',
+ mock_subtract
+ )
+
+ result = await modem_service.enable_modem(
+ mock_db, sample_user, sample_subscription
+ )
+
+ assert not result.success
+ assert result.error == ModemError.CHARGE_ERROR
+
+
+class TestModemServiceDisable:
+ """Тесты отключения модема."""
+
+ async def test_disable_modem_success(self, monkeypatch):
+ """Успешное отключение модема."""
+ modem_service, _ = create_modem_service(monkeypatch)
+ sample_user = create_sample_user()
+ sample_subscription = create_sample_subscription()
+ sample_subscription.modem_enabled = True
+ sample_subscription.device_limit = 3
+ sample_user.subscription = sample_subscription
+
+ mock_db = AsyncMock()
+ mock_update_remnawave = AsyncMock()
+ modem_service._subscription_service.update_remnawave_user = mock_update_remnawave
+
+ result = await modem_service.disable_modem(
+ mock_db, sample_user, sample_subscription
+ )
+
+ assert result.success
+ assert result.error is None
+ assert sample_subscription.modem_enabled is False
+ assert sample_subscription.device_limit == 2 # было 3, стало 2
+
+
+class TestModemServiceSingleton:
+ """Тесты singleton паттерна."""
+
+ def test_get_modem_service_returns_same_instance(self, monkeypatch):
+ """get_modem_service возвращает один и тот же экземпляр."""
+ # Сбрасываем глобальный экземпляр
+ import app.services.modem_service as modem_module
+ modem_module._modem_service = None
+
+ mock_settings = create_mock_settings()
+ monkeypatch.setattr('app.services.modem_service.settings', mock_settings)
+
+ service1 = get_modem_service()
+ service2 = get_modem_service()
+
+ assert service1 is service2
+
+
+class TestModemEnabledGetter:
+ """Тесты безопасного получения статуса модема."""
+
+ def test_get_modem_enabled_true(self, monkeypatch):
+ """Модем включён."""
+ modem_service, _ = create_modem_service(monkeypatch)
+ sample_subscription = create_sample_subscription()
+ sample_subscription.modem_enabled = True
+
+ assert modem_service.get_modem_enabled(sample_subscription) is True
+
+ def test_get_modem_enabled_false(self, monkeypatch):
+ """Модем выключен."""
+ modem_service, _ = create_modem_service(monkeypatch)
+ sample_subscription = create_sample_subscription()
+ sample_subscription.modem_enabled = False
+
+ assert modem_service.get_modem_enabled(sample_subscription) is False
+
+ def test_get_modem_enabled_none_subscription(self, monkeypatch):
+ """Подписка None."""
+ modem_service, _ = create_modem_service(monkeypatch)
+ assert modem_service.get_modem_enabled(None) is False
+
+ def test_get_modem_enabled_no_attribute(self, monkeypatch):
+ """У подписки нет атрибута modem_enabled."""
+ modem_service, _ = create_modem_service(monkeypatch)
+ subscription = SimpleNamespace(id=1) # без modem_enabled
+
+ assert modem_service.get_modem_enabled(subscription) is False