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