From e15aed6c19cb31ca738523aa59caa682c0154dcb Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 15:23:27 +0300 Subject: [PATCH 01/47] Update main.py --- main.py | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/main.py b/main.py index 07e992bd..aa1ff426 100644 --- a/main.py +++ b/main.py @@ -36,6 +36,7 @@ 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.services.traffic_monitoring_service import traffic_monitoring_scheduler +from app.services.daily_subscription_service import daily_subscription_service from app.utils.startup_timeline import StartupTimeline from app.utils.timezone import TimezoneAwareFormatter from app.utils.log_handlers import LevelFilterHandler, ExcludePaymentFilter @@ -174,6 +175,7 @@ async def main(): maintenance_task = None version_check_task = None traffic_monitoring_task = None + daily_subscription_task = None polling_task = None web_api_server = None telegram_webhook_enabled = False @@ -240,6 +242,7 @@ async def main(): maintenance_service.set_bot(bot) broadcast_service.set_bot(bot) traffic_monitoring_scheduler.set_bot(bot) + daily_subscription_service.set_bot(bot) from app.services.admin_notification_service import AdminNotificationService @@ -597,6 +600,21 @@ async def main(): traffic_monitoring_task = None stage.skip("Мониторинг трафика отключен настройками") + async with timeline.stage( + "Суточные подписки", + "💳", + success_message="Сервис суточных подписок запущен", + ) as stage: + if daily_subscription_service.is_enabled(): + daily_subscription_task = asyncio.create_task( + daily_subscription_service.start_monitoring() + ) + interval_minutes = daily_subscription_service.get_check_interval_minutes() + stage.log(f"Интервал проверки: {interval_minutes} мин") + else: + daily_subscription_task = None + stage.skip("Суточные подписки отключены настройками") + async with timeline.stage( "Сервис проверки версий", "📄", @@ -661,6 +679,7 @@ async def main(): f"Мониторинг: {'Включен' if monitoring_task else 'Отключен'}", f"Техработы: {'Включен' if maintenance_task else 'Отключен'}", f"Мониторинг трафика: {'Включен' if traffic_monitoring_task else 'Отключен'}", + f"Суточные подписки: {'Включен' if daily_subscription_task else 'Отключен'}", f"Проверка версий: {'Включен' if version_check_task else 'Отключен'}", f"Отчеты: {'Включен' if reporting_service.is_running() else 'Отключен'}", ] @@ -715,6 +734,16 @@ async def main(): traffic_monitoring_scheduler.start_monitoring() ) + if daily_subscription_task and daily_subscription_task.done(): + exception = daily_subscription_task.exception() + if exception: + logger.error(f"Сервис суточных подписок завершился с ошибкой: {exception}") + if daily_subscription_service.is_enabled(): + logger.info("🔄 Перезапуск сервиса суточных подписок...") + daily_subscription_task = asyncio.create_task( + daily_subscription_service.start_monitoring() + ) + if auto_verification_active and not auto_payment_verification_service.is_running(): logger.warning( "Сервис автопроверки пополнений остановился, пробуем перезапустить..." @@ -784,6 +813,15 @@ async def main(): except asyncio.CancelledError: pass + if daily_subscription_task and not daily_subscription_task.done(): + logger.info("ℹ️ Остановка сервиса суточных подписок...") + daily_subscription_service.stop_monitoring() + daily_subscription_task.cancel() + try: + await daily_subscription_task + except asyncio.CancelledError: + pass + logger.info("ℹ️ Остановка сервиса отчетов...") try: await reporting_service.stop() From 49bd69f987b46b277e5cdc93bab05a0457cd1beb Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 15:24:17 +0300 Subject: [PATCH 02/47] Add files via upload --- app/services/daily_subscription_service.py | 277 +++++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100644 app/services/daily_subscription_service.py diff --git a/app/services/daily_subscription_service.py b/app/services/daily_subscription_service.py new file mode 100644 index 00000000..92e075da --- /dev/null +++ b/app/services/daily_subscription_service.py @@ -0,0 +1,277 @@ +""" +Сервис для автоматического списания суточных подписок. +Проверяет подписки с суточным тарифом и списывает плату раз в сутки. +""" +import logging +import asyncio +from datetime import datetime +from typing import Optional + +from aiogram import Bot + +from app.config import settings +from app.database.database import get_db +from app.database.crud.subscription import ( + get_daily_subscriptions_for_charge, + update_daily_charge_time, + suspend_daily_subscription_insufficient_balance, +) +from app.database.crud.user import subtract_user_balance, get_user_by_id +from app.database.crud.transaction import create_transaction +from app.database.models import TransactionType, PaymentMethod +from app.localization.texts import get_texts + + +logger = logging.getLogger(__name__) + + +class DailySubscriptionService: + """ + Сервис автоматического списания для суточных подписок. + """ + + def __init__(self): + self._running = False + self._bot: Optional[Bot] = None + self._check_interval_minutes = 30 # Проверка каждые 30 минут + + def set_bot(self, bot: Bot): + """Устанавливает бота для отправки уведомлений.""" + self._bot = bot + + def is_enabled(self) -> bool: + """Проверяет, включен ли сервис суточных подписок.""" + return getattr(settings, 'DAILY_SUBSCRIPTIONS_ENABLED', True) + + def get_check_interval_minutes(self) -> int: + """Возвращает интервал проверки в минутах.""" + return getattr(settings, 'DAILY_SUBSCRIPTIONS_CHECK_INTERVAL_MINUTES', 30) + + async def process_daily_charges(self) -> dict: + """ + Обрабатывает суточные списания. + + Returns: + dict: Статистика обработки + """ + stats = { + "checked": 0, + "charged": 0, + "suspended": 0, + "errors": 0, + } + + try: + async for db in get_db(): + subscriptions = await get_daily_subscriptions_for_charge(db) + stats["checked"] = len(subscriptions) + + for subscription in subscriptions: + try: + result = await self._process_single_charge(db, subscription) + if result == "charged": + stats["charged"] += 1 + elif result == "suspended": + stats["suspended"] += 1 + elif result == "error": + stats["errors"] += 1 + except Exception as e: + logger.error( + f"Ошибка обработки суточной подписки {subscription.id}: {e}", + exc_info=True + ) + stats["errors"] += 1 + + except Exception as e: + logger.error(f"Ошибка при получении подписок для списания: {e}", exc_info=True) + + return stats + + async def _process_single_charge(self, db, subscription) -> str: + """ + Обрабатывает списание для одной подписки. + + Returns: + str: "charged", "suspended", "error", "skipped" + """ + user = subscription.user + if not user: + user = await get_user_by_id(db, subscription.user_id) + + if not user: + logger.warning(f"Пользователь не найден для подписки {subscription.id}") + return "error" + + tariff = subscription.tariff + if not tariff: + logger.warning(f"Тариф не найден для подписки {subscription.id}") + return "error" + + daily_price = tariff.daily_price_kopeks + if daily_price <= 0: + logger.warning(f"Некорректная суточная цена для тарифа {tariff.id}") + return "error" + + # Проверяем баланс + if user.balance_kopeks < daily_price: + # Недостаточно средств - приостанавливаем подписку + await suspend_daily_subscription_insufficient_balance(db, subscription) + + # Уведомляем пользователя + if self._bot: + await self._notify_insufficient_balance(user, subscription, daily_price) + + logger.info( + f"Подписка {subscription.id} приостановлена: недостаточно средств " + f"(баланс: {user.balance_kopeks}, требуется: {daily_price})" + ) + return "suspended" + + # Списываем средства + description = f"Суточная оплата тарифа «{tariff.name}»" + + try: + deducted = await subtract_user_balance( + db, + user, + daily_price, + description, + ) + + if not deducted: + logger.warning(f"Не удалось списать средства для подписки {subscription.id}") + return "error" + + # Создаём транзакцию + await create_transaction( + db=db, + user_id=user.id, + type=TransactionType.SUBSCRIPTION_PAYMENT, + amount_kopeks=daily_price, + description=description, + payment_method=PaymentMethod.BALANCE, + ) + + # Обновляем время последнего списания + await update_daily_charge_time(db, subscription) + + logger.info( + f"✅ Суточное списание: подписка {subscription.id}, " + f"сумма {daily_price} коп., пользователь {user.telegram_id}" + ) + + # Уведомляем пользователя + if self._bot: + await self._notify_daily_charge(user, subscription, daily_price) + + return "charged" + + except Exception as e: + logger.error( + f"Ошибка при списании средств для подписки {subscription.id}: {e}", + exc_info=True + ) + return "error" + + async def _notify_daily_charge(self, user, subscription, amount_kopeks: int): + """Уведомляет пользователя о суточном списании.""" + if not self._bot: + return + + try: + texts = get_texts(getattr(user, "language", "ru")) + amount_rubles = amount_kopeks / 100 + balance_rubles = user.balance_kopeks / 100 + + message = ( + f"💳 Суточное списание\n\n" + f"Списано: {amount_rubles:.2f} ₽\n" + f"Остаток баланса: {balance_rubles:.2f} ₽\n\n" + f"Следующее списание через 24 часа." + ) + + await self._bot.send_message( + chat_id=user.telegram_id, + text=message, + parse_mode="HTML", + ) + except Exception as e: + logger.warning(f"Не удалось отправить уведомление о списании: {e}") + + async def _notify_insufficient_balance(self, user, subscription, required_amount: int): + """Уведомляет пользователя о недостатке средств.""" + if not self._bot: + return + + try: + from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton + + texts = get_texts(getattr(user, "language", "ru")) + required_rubles = required_amount / 100 + balance_rubles = user.balance_kopeks / 100 + + message = ( + f"⚠️ Подписка приостановлена\n\n" + f"Недостаточно средств для суточной оплаты.\n\n" + f"Требуется: {required_rubles:.2f} ₽\n" + f"Баланс: {balance_rubles:.2f} ₽\n\n" + f"Пополните баланс, чтобы возобновить подписку." + ) + + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton( + text="💳 Пополнить баланс", + callback_data="menu_balance" + )], + [InlineKeyboardButton( + text="📱 Моя подписка", + callback_data="menu_subscription" + )], + ] + ) + + await self._bot.send_message( + chat_id=user.telegram_id, + text=message, + reply_markup=keyboard, + parse_mode="HTML", + ) + except Exception as e: + logger.warning(f"Не удалось отправить уведомление о недостатке средств: {e}") + + async def start_monitoring(self): + """Запускает периодическую проверку суточных подписок.""" + self._running = True + interval_minutes = self.get_check_interval_minutes() + + logger.info( + f"🔄 Запуск сервиса суточных подписок (интервал: {interval_minutes} мин)" + ) + + while self._running: + try: + stats = await self.process_daily_charges() + + if stats["charged"] > 0 or stats["suspended"] > 0: + logger.info( + f"📊 Суточные списания: проверено={stats['checked']}, " + f"списано={stats['charged']}, приостановлено={stats['suspended']}, " + f"ошибок={stats['errors']}" + ) + except Exception as e: + logger.error(f"Ошибка в цикле проверки суточных подписок: {e}", exc_info=True) + + await asyncio.sleep(interval_minutes * 60) + + def stop_monitoring(self): + """Останавливает периодическую проверку.""" + self._running = False + logger.info("⏹️ Сервис суточных подписок остановлен") + + +# Глобальный экземпляр сервиса +daily_subscription_service = DailySubscriptionService() + + +__all__ = ["DailySubscriptionService", "daily_subscription_service"] From 9e4fa9defe3e71b4a36c8a560055efd6a3017592 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 15:24:59 +0300 Subject: [PATCH 03/47] Add files via upload --- app/database/models.py | 39 ++++++++- app/database/universal_migration.py | 130 ++++++++++++++++++++++++++++ 2 files changed, 168 insertions(+), 1 deletion(-) diff --git a/app/database/models.py b/app/database/models.py index 9f47209b..4e847922 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -769,6 +769,10 @@ class Tariff(Base): # Максимальный лимит трафика после докупки (0 = без ограничений) max_topup_traffic_gb = Column(Integer, default=0, nullable=False) + # Суточный тариф - ежедневное списание + is_daily = Column(Boolean, default=False, nullable=False) # Является ли тариф суточным + daily_price_kopeks = Column(Integer, default=0, nullable=False) # Цена за день в копейках + created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) @@ -835,6 +839,10 @@ class Tariff(Base): and not self.is_unlimited_traffic ) + def get_daily_price_rubles(self) -> float: + """Возвращает суточную цену в рублях.""" + return self.daily_price_kopeks / 100 if self.daily_price_kopeks else 0 + def __repr__(self): return f"" @@ -991,6 +999,10 @@ class Subscription(Base): # Тариф (для режима продаж "Тарифы") tariff_id = Column(Integer, ForeignKey("tariffs.id", ondelete="SET NULL"), nullable=True, index=True) + # Суточная подписка + is_daily_paused = Column(Boolean, default=False, nullable=False) # Приостановлена ли суточная подписка пользователем + last_daily_charge_at = Column(DateTime, nullable=True) # Время последнего суточного списания + user = relationship("User", back_populates="subscription") tariff = relationship("Tariff", back_populates="subscriptions") discount_offers = relationship("DiscountOffer", back_populates="subscription") @@ -1123,10 +1135,35 @@ class Subscription(Base): self.status = SubscriptionStatus.ACTIVE.value def add_traffic(self, gb: int): - if self.traffic_limit_gb == 0: + if self.traffic_limit_gb == 0: return self.traffic_limit_gb += gb + @property + def is_daily_tariff(self) -> bool: + """Проверяет, является ли тариф подписки суточным.""" + if self.tariff: + return getattr(self.tariff, 'is_daily', False) + return False + + @property + def daily_price_kopeks(self) -> int: + """Возвращает суточную цену тарифа в копейках.""" + if self.tariff: + return getattr(self.tariff, 'daily_price_kopeks', 0) + return 0 + + @property + def can_charge_daily(self) -> bool: + """Проверяет, можно ли списать суточную оплату.""" + if not self.is_daily_tariff: + return False + if self.is_daily_paused: + return False + if self.status != SubscriptionStatus.ACTIVE.value: + return False + return True + class Transaction(Base): __tablename__ = "transactions" diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index 2c5afdfd..18469e7d 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -5413,6 +5413,122 @@ async def add_tariff_traffic_topup_columns() -> bool: return False +async def add_tariff_daily_columns() -> bool: + """Добавляет колонки для суточных тарифов.""" + try: + columns_added = 0 + + # Колонка is_daily + if not await check_column_exists('tariffs', 'is_daily'): + async with engine.begin() as conn: + db_type = await get_database_type() + + if db_type == 'sqlite': + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN is_daily INTEGER DEFAULT 0 NOT NULL" + )) + elif db_type == 'postgresql': + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN is_daily BOOLEAN DEFAULT FALSE NOT NULL" + )) + else: # MySQL + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN is_daily TINYINT(1) DEFAULT 0 NOT NULL" + )) + + logger.info("✅ Колонка is_daily добавлена в tariffs") + columns_added += 1 + else: + logger.info("ℹ️ Колонка is_daily уже существует в tariffs") + + # Колонка daily_price_kopeks + if not await check_column_exists('tariffs', 'daily_price_kopeks'): + async with engine.begin() as conn: + db_type = await get_database_type() + + if db_type == 'sqlite': + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN daily_price_kopeks INTEGER DEFAULT 0 NOT NULL" + )) + elif db_type == 'postgresql': + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN daily_price_kopeks INTEGER DEFAULT 0 NOT NULL" + )) + else: # MySQL + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN daily_price_kopeks INT DEFAULT 0 NOT NULL" + )) + + logger.info("✅ Колонка daily_price_kopeks добавлена в tariffs") + columns_added += 1 + else: + logger.info("ℹ️ Колонка daily_price_kopeks уже существует в tariffs") + + return True + + except Exception as error: + logger.error(f"❌ Ошибка добавления колонок суточного тарифа: {error}") + return False + + +async def add_subscription_daily_columns() -> bool: + """Добавляет колонки для суточных подписок.""" + try: + columns_added = 0 + + # Колонка is_daily_paused + if not await check_column_exists('subscriptions', 'is_daily_paused'): + 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 is_daily_paused INTEGER DEFAULT 0 NOT NULL" + )) + elif db_type == 'postgresql': + await conn.execute(text( + "ALTER TABLE subscriptions ADD COLUMN is_daily_paused BOOLEAN DEFAULT FALSE NOT NULL" + )) + else: # MySQL + await conn.execute(text( + "ALTER TABLE subscriptions ADD COLUMN is_daily_paused TINYINT(1) DEFAULT 0 NOT NULL" + )) + + logger.info("✅ Колонка is_daily_paused добавлена в subscriptions") + columns_added += 1 + else: + logger.info("ℹ️ Колонка is_daily_paused уже существует в subscriptions") + + # Колонка last_daily_charge_at + if not await check_column_exists('subscriptions', 'last_daily_charge_at'): + 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 last_daily_charge_at DATETIME NULL" + )) + elif db_type == 'postgresql': + await conn.execute(text( + "ALTER TABLE subscriptions ADD COLUMN last_daily_charge_at TIMESTAMP NULL" + )) + else: # MySQL + await conn.execute(text( + "ALTER TABLE subscriptions ADD COLUMN last_daily_charge_at DATETIME NULL" + )) + + logger.info("✅ Колонка last_daily_charge_at добавлена в subscriptions") + columns_added += 1 + else: + logger.info("ℹ️ Колонка last_daily_charge_at уже существует в subscriptions") + + return True + + except Exception as error: + logger.error(f"❌ Ошибка добавления колонок суточной подписки: {error}") + return False + + async def run_universal_migration(): logger.info("=== НАЧАЛО УНИВЕРСАЛЬНОЙ МИГРАЦИИ ===") @@ -5921,6 +6037,20 @@ async def run_universal_migration(): else: logger.warning("⚠️ Проблемы с колонками докупки трафика в tariffs") + logger.info("=== ДОБАВЛЕНИЕ КОЛОНОК СУТОЧНЫХ ТАРИФОВ ===") + daily_tariff_columns_ready = await add_tariff_daily_columns() + if daily_tariff_columns_ready: + logger.info("✅ Колонки суточных тарифов в tariffs готовы") + else: + logger.warning("⚠️ Проблемы с колонками суточных тарифов в tariffs") + + logger.info("=== ДОБАВЛЕНИЕ КОЛОНОК СУТОЧНЫХ ПОДПИСОК ===") + daily_subscription_columns_ready = await add_subscription_daily_columns() + if daily_subscription_columns_ready: + logger.info("✅ Колонки суточных подписок в subscriptions готовы") + else: + logger.warning("⚠️ Проблемы с колонками суточных подписок в subscriptions") + logger.info("=== ОБНОВЛЕНИЕ ВНЕШНИХ КЛЮЧЕЙ ===") fk_updated = await fix_foreign_keys_for_user_deletion() if fk_updated: From 472ef3749073c1da11bebe36c7b2591534495724 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 15:25:25 +0300 Subject: [PATCH 04/47] Add files via upload --- app/database/crud/subscription.py | 164 +++++++++++++++++++++++++++++- app/database/crud/tariff.py | 10 ++ 2 files changed, 172 insertions(+), 2 deletions(-) diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index 37df85f2..8786ffff 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -1756,7 +1756,167 @@ async def activate_pending_subscription( await db.commit() await db.refresh(pending_subscription) - + logger.info(f"Подписка пользователя {user_id} активирована, ID: {pending_subscription.id}") - + return pending_subscription + + +# ==================== СУТОЧНЫЕ ПОДПИСКИ ==================== + + +async def get_daily_subscriptions_for_charge(db: AsyncSession) -> List[Subscription]: + """ + Получает все суточные подписки, которые нужно обработать для списания. + + Критерии: + - Тариф подписки суточный (is_daily=True) + - Подписка активна + - Подписка не приостановлена пользователем + - Прошло более 24 часов с последнего списания (или списания ещё не было) + """ + from app.database.models import Tariff + + now = datetime.utcnow() + one_day_ago = now - timedelta(hours=24) + + query = ( + select(Subscription) + .join(Tariff, Subscription.tariff_id == Tariff.id) + .options( + selectinload(Subscription.user), + selectinload(Subscription.tariff), + ) + .where( + and_( + Tariff.is_daily.is_(True), + Tariff.is_active.is_(True), + Subscription.status == SubscriptionStatus.ACTIVE.value, + Subscription.is_daily_paused.is_(False), + # Списания ещё не было ИЛИ прошло более 24 часов + ( + (Subscription.last_daily_charge_at.is_(None)) | + (Subscription.last_daily_charge_at < one_day_ago) + ), + ) + ) + ) + + result = await db.execute(query) + subscriptions = result.scalars().all() + + logger.info( + f"🔍 Найдено {len(subscriptions)} суточных подписок для списания" + ) + + return list(subscriptions) + + +async def pause_daily_subscription( + db: AsyncSession, + subscription: Subscription, +) -> Subscription: + """Приостанавливает суточную подписку (списание не будет происходить).""" + if not subscription.is_daily_tariff: + logger.warning( + f"Попытка приостановить не-суточную подписку {subscription.id}" + ) + return subscription + + subscription.is_daily_paused = True + await db.commit() + await db.refresh(subscription) + + logger.info( + f"⏸️ Суточная подписка {subscription.id} приостановлена пользователем {subscription.user_id}" + ) + + return subscription + + +async def resume_daily_subscription( + db: AsyncSession, + subscription: Subscription, +) -> Subscription: + """Возобновляет суточную подписку (списание продолжится).""" + if not subscription.is_daily_tariff: + logger.warning( + f"Попытка возобновить не-суточную подписку {subscription.id}" + ) + return subscription + + subscription.is_daily_paused = False + await db.commit() + await db.refresh(subscription) + + logger.info( + f"▶️ Суточная подписка {subscription.id} возобновлена пользователем {subscription.user_id}" + ) + + return subscription + + +async def update_daily_charge_time( + db: AsyncSession, + subscription: Subscription, + charge_time: datetime = None, +) -> Subscription: + """Обновляет время последнего суточного списания.""" + subscription.last_daily_charge_at = charge_time or datetime.utcnow() + await db.commit() + await db.refresh(subscription) + + return subscription + + +async def suspend_daily_subscription_insufficient_balance( + db: AsyncSession, + subscription: Subscription, +) -> Subscription: + """ + Приостанавливает подписку из-за недостатка баланса. + Отличается от pause_daily_subscription тем, что меняет статус на DISABLED. + """ + subscription.status = SubscriptionStatus.DISABLED.value + await db.commit() + await db.refresh(subscription) + + logger.info( + f"⚠️ Суточная подписка {subscription.id} приостановлена: недостаточно средств (user_id={subscription.user_id})" + ) + + return subscription + + +async def get_subscription_with_tariff( + db: AsyncSession, + user_id: int, +) -> Optional[Subscription]: + """Получает подписку пользователя с загруженным тарифом.""" + result = await db.execute( + select(Subscription) + .options( + selectinload(Subscription.user), + selectinload(Subscription.tariff), + ) + .where(Subscription.user_id == user_id) + .order_by(Subscription.created_at.desc()) + .limit(1) + ) + subscription = result.scalar_one_or_none() + + if subscription: + subscription = await check_and_update_subscription_status(db, subscription) + + return subscription + + +async def toggle_daily_subscription_pause( + db: AsyncSession, + subscription: Subscription, +) -> Subscription: + """Переключает состояние паузы суточной подписки.""" + if subscription.is_daily_paused: + return await resume_daily_subscription(db, subscription) + else: + return await pause_daily_subscription(db, subscription) diff --git a/app/database/crud/tariff.py b/app/database/crud/tariff.py index 04c4f193..b607608c 100644 --- a/app/database/crud/tariff.py +++ b/app/database/crud/tariff.py @@ -170,6 +170,8 @@ async def create_tariff( traffic_topup_enabled: bool = False, traffic_topup_packages: Optional[Dict[str, int]] = None, max_topup_traffic_gb: int = 0, + is_daily: bool = False, + daily_price_kopeks: int = 0, ) -> Tariff: """Создает новый тариф.""" normalized_prices = _normalize_period_prices(period_prices) @@ -188,6 +190,8 @@ async def create_tariff( traffic_topup_enabled=traffic_topup_enabled, traffic_topup_packages=traffic_topup_packages or {}, max_topup_traffic_gb=max(0, max_topup_traffic_gb), + is_daily=is_daily, + daily_price_kopeks=max(0, daily_price_kopeks), ) db.add(tariff) @@ -236,6 +240,8 @@ async def update_tariff( traffic_topup_enabled: Optional[bool] = None, traffic_topup_packages: Optional[Dict[str, int]] = None, max_topup_traffic_gb: Optional[int] = None, + is_daily: Optional[bool] = None, + daily_price_kopeks: Optional[int] = None, ) -> Tariff: """Обновляет существующий тариф.""" if name is not None: @@ -267,6 +273,10 @@ async def update_tariff( tariff.traffic_topup_packages = traffic_topup_packages if max_topup_traffic_gb is not None: tariff.max_topup_traffic_gb = max(0, max_topup_traffic_gb) + if is_daily is not None: + tariff.is_daily = is_daily + if daily_price_kopeks is not None: + tariff.daily_price_kopeks = max(0, daily_price_kopeks) # Обновляем промогруппы если указаны if promo_group_ids is not None: From 538c002f8f9cf34a146618aee6b1a7e761a60503 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 15:25:47 +0300 Subject: [PATCH 05/47] Add files via upload --- app/config.py | 4 ++++ app/states.py | 1 + 2 files changed, 5 insertions(+) diff --git a/app/config.py b/app/config.py index b97779ff..fd169be7 100644 --- a/app/config.py +++ b/app/config.py @@ -242,6 +242,10 @@ class Settings(BaseSettings): TRAFFIC_MONITORING_INTERVAL_HOURS: int = 24 # Интервал проверки в часах (по умолчанию - раз в сутки) SUSPICIOUS_NOTIFICATIONS_TOPIC_ID: Optional[int] = None + # Настройки суточных подписок + DAILY_SUBSCRIPTIONS_ENABLED: bool = True # Включить автоматическое списание для суточных тарифов + DAILY_SUBSCRIPTIONS_CHECK_INTERVAL_MINUTES: int = 30 # Интервал проверки в минутах + AUTOPAY_WARNING_DAYS: str = "3,1" ENABLE_AUTOPAY: bool = False diff --git a/app/states.py b/app/states.py index 88ebce08..7e608051 100644 --- a/app/states.py +++ b/app/states.py @@ -182,6 +182,7 @@ class AdminStates(StatesGroup): editing_tariff_promo_groups = State() editing_tariff_traffic_topup_packages = State() editing_tariff_max_topup_traffic = State() + editing_tariff_daily_price = State() class SupportStates(StatesGroup): From 785eabaa4770a10294fd04a9d4ad927450bd12ac Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 15:26:20 +0300 Subject: [PATCH 06/47] Update tariffs.py --- app/handlers/admin/tariffs.py | 156 ++++++++++++++++++++++++++++++++++ 1 file changed, 156 insertions(+) diff --git a/app/handlers/admin/tariffs.py b/app/handlers/admin/tariffs.py index 71434a7b..73e25e30 100644 --- a/app/handlers/admin/tariffs.py +++ b/app/handlers/admin/tariffs.py @@ -199,6 +199,20 @@ def get_tariff_view_keyboard( InlineKeyboardButton(text="👥 Промогруппы", callback_data=f"admin_tariff_edit_promo:{tariff.id}"), ]) + # Суточный режим + is_daily = getattr(tariff, 'is_daily', False) + if is_daily: + buttons.append([ + InlineKeyboardButton(text="🔄 ❌ Отключить суточный режим", callback_data=f"admin_tariff_toggle_daily:{tariff.id}"), + ]) + buttons.append([ + InlineKeyboardButton(text="💰 Суточная цена", callback_data=f"admin_tariff_edit_daily_price:{tariff.id}"), + ]) + else: + buttons.append([ + InlineKeyboardButton(text="🔄 Включить суточный режим", callback_data=f"admin_tariff_toggle_daily:{tariff.id}"), + ]) + # Переключение триала if tariff.is_trial_available: buttons.append([ @@ -287,6 +301,14 @@ def format_tariff_info(tariff: Tariff, language: str, subs_count: int = 0) -> st # Форматируем докупку трафика traffic_topup_display = _format_traffic_topup_packages(tariff) + # Форматируем суточный тариф + is_daily = getattr(tariff, 'is_daily', False) + daily_price_kopeks = getattr(tariff, 'daily_price_kopeks', 0) + if is_daily: + daily_status = f"✅ Включен ({_format_price_kopeks(daily_price_kopeks)}/день)" + else: + daily_status = "❌ Отключен" + return f"""📦 Тариф: {tariff.name} {status} @@ -300,6 +322,8 @@ def format_tariff_info(tariff: Tariff, language: str, subs_count: int = 0) -> st • Триал: {trial_status} • Дней триала: {trial_days_display} +🔄 Суточный режим: {daily_status} + Докупка трафика: {traffic_topup_display} @@ -496,6 +520,133 @@ async def toggle_trial_tariff( ) +@admin_required +@error_handler +async def toggle_daily_tariff( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + """Переключает суточный режим тарифа.""" + tariff_id = int(callback.data.split(":")[1]) + tariff = await get_tariff_by_id(db, tariff_id) + + if not tariff: + await callback.answer("Тариф не найден", show_alert=True) + return + + is_daily = getattr(tariff, 'is_daily', False) + + if is_daily: + # Отключаем суточный режим + tariff = await update_tariff(db, tariff, is_daily=False, daily_price_kopeks=0) + await callback.answer("Суточный режим отключен", show_alert=True) + else: + # Включаем суточный режим (с ценой по умолчанию) + tariff = await update_tariff(db, tariff, is_daily=True, daily_price_kopeks=5000) # 50 руб по умолчанию + await callback.answer( + f"Суточный режим включен. Цена: 50 ₽/день\n" + "Настройте цену через кнопку «💰 Суточная цена»", + show_alert=True + ) + + subs_count = await get_tariff_subscriptions_count(db, tariff_id) + + await callback.message.edit_text( + format_tariff_info(tariff, db_user.language, subs_count), + reply_markup=get_tariff_view_keyboard(tariff, db_user.language), + parse_mode="HTML" + ) + + +@admin_required +@error_handler +async def start_edit_daily_price( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Начинает редактирование суточной цены.""" + texts = get_texts(db_user.language) + + tariff_id = int(callback.data.split(":")[1]) + tariff = await get_tariff_by_id(db, tariff_id) + + if not tariff: + await callback.answer("Тариф не найден", show_alert=True) + return + + current_price = getattr(tariff, 'daily_price_kopeks', 0) + current_rubles = current_price / 100 if current_price else 0 + + await state.set_state(AdminStates.editing_tariff_daily_price) + await state.update_data(tariff_id=tariff_id, language=db_user.language) + + await callback.message.edit_text( + f"💰 Редактирование суточной цены\n\n" + f"Тариф: {tariff.name}\n" + f"Текущая цена: {_format_price_kopeks(current_price)}/день\n\n" + "Введите новую цену за день в рублях.\n" + "Пример: 50 или 99.90", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text=texts.CANCEL, callback_data=f"admin_tariff_view:{tariff_id}")] + ]), + parse_mode="HTML" + ) + await callback.answer() + + +@admin_required +@error_handler +async def process_daily_price_input( + message: types.Message, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Обрабатывает ввод суточной цены.""" + texts = get_texts(db_user.language) + data = await state.get_data() + tariff_id = data.get("tariff_id") + + if not tariff_id: + await state.clear() + return + + tariff = await get_tariff_by_id(db, tariff_id) + if not tariff: + await message.answer("Тариф не найден") + await state.clear() + return + + try: + price_rubles = float(message.text.strip().replace(",", ".")) + if price_rubles < 0: + raise ValueError("Цена не может быть отрицательной") + + price_kopeks = int(price_rubles * 100) + except ValueError: + await message.answer( + "❌ Некорректная цена. Введите число.\n" + "Пример: 50 или 99.90", + parse_mode="HTML" + ) + return + + tariff = await update_tariff(db, tariff, daily_price_kopeks=price_kopeks) + await state.clear() + + subs_count = await get_tariff_subscriptions_count(db, tariff_id) + + await message.answer( + f"✅ Суточная цена установлена: {_format_price_kopeks(price_kopeks)}/день\n\n" + + format_tariff_info(tariff, db_user.language, subs_count), + reply_markup=get_tariff_view_keyboard(tariff, db_user.language), + parse_mode="HTML" + ) + + # ============ СОЗДАНИЕ ТАРИФА ============ @admin_required @@ -2318,3 +2469,8 @@ def register_handlers(dp: Dispatcher): dp.callback_query.register(start_edit_tariff_promo_groups, F.data.startswith("admin_tariff_edit_promo:")) dp.callback_query.register(toggle_tariff_promo_group, F.data.startswith("admin_tariff_toggle_promo:")) dp.callback_query.register(clear_tariff_promo_groups, F.data.startswith("admin_tariff_clear_promo:")) + + # Суточный режим + dp.callback_query.register(toggle_daily_tariff, F.data.startswith("admin_tariff_toggle_daily:")) + dp.callback_query.register(start_edit_daily_price, F.data.startswith("admin_tariff_edit_daily_price:")) + dp.message.register(process_daily_price_input, AdminStates.editing_tariff_daily_price) From 22884b5cddff8433c58cfb52c2d08856746b7cb1 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 15:27:05 +0300 Subject: [PATCH 07/47] Update purchase.py --- app/handlers/subscription/purchase.py | 118 +++++++++++++++++++++++++- 1 file changed, 117 insertions(+), 1 deletion(-) diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py index 3a1680a2..281ca051 100644 --- a/app/handlers/subscription/purchase.py +++ b/app/handlers/subscription/purchase.py @@ -3016,7 +3016,12 @@ async def handle_subscription_settings( await callback.message.edit_text( settings_text, - reply_markup=get_updated_subscription_settings_keyboard(db_user.language, show_countries, tariff=tariff), + reply_markup=get_updated_subscription_settings_keyboard( + db_user.language, + show_countries, + tariff=tariff, + subscription=subscription + ), parse_mode="HTML" ) await callback.answer() @@ -3037,6 +3042,112 @@ async def clear_saved_cart( await callback.answer("🗑️ Корзина очищена") +# ============== ХЕНДЛЕР ПАУЗЫ СУТОЧНОЙ ПОДПИСКИ ============== + +async def handle_toggle_daily_subscription_pause( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + """Переключает паузу суточной подписки.""" + from app.database.crud.subscription import toggle_daily_subscription_pause + from app.database.crud.tariff import get_tariff_by_id + + texts = get_texts(db_user.language) + subscription = db_user.subscription + + if not subscription: + await callback.answer( + texts.t("NO_SUBSCRIPTION_ERROR", "❌ У вас нет активной подписки"), + show_alert=True + ) + return + + # Проверяем что это суточный тариф + tariff = None + if subscription.tariff_id: + tariff = await get_tariff_by_id(db, subscription.tariff_id) + + if not tariff or not getattr(tariff, 'is_daily', False): + await callback.answer( + texts.t("NOT_DAILY_TARIFF_ERROR", "❌ Эта функция доступна только для суточных тарифов"), + show_alert=True + ) + return + + # Переключаем статус паузы + was_paused = subscription.is_daily_paused + subscription = await toggle_daily_subscription_pause(db, subscription) + + if was_paused: + # Была пауза, теперь возобновили + message = texts.t( + "DAILY_SUBSCRIPTION_RESUMED", + "▶️ Суточная подписка возобновлена.\n\nСписание будет произведено в ближайший цикл проверки." + ) + else: + # Была активна, теперь на паузе + message = texts.t( + "DAILY_SUBSCRIPTION_PAUSED", + "⏸️ Суточная подписка приостановлена.\n\nСписания не будут производиться до возобновления." + ) + + await callback.answer(message, show_alert=True) + + # Обновляем клавиатуру настроек + show_countries = await _should_show_countries_management(db_user) + + settings_template = texts.t( + "SUBSCRIPTION_SETTINGS_OVERVIEW", + ( + "⚙️ Настройки подписки\n\n" + "📊 Текущие параметры:\n" + "🌐 Стран: {countries_count}\n" + "📈 Трафик: {traffic_used} / {traffic_limit}\n" + "📱 Устройства: {devices_used} / {devices_limit}\n\n" + "Выберите что хотите изменить:" + ), + ) + + show_devices = settings.is_devices_selection_enabled() + if not show_devices: + settings_template = settings_template.replace( + "\n📱 Устройства: {devices_used} / {devices_limit}", + "", + ) + + if show_devices: + devices_used = await get_current_devices_count(db_user) + else: + devices_used = 0 + + 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=devices_limit_display, + ) + + await callback.message.edit_text( + settings_text, + reply_markup=get_updated_subscription_settings_keyboard( + db_user.language, + show_countries, + tariff=tariff, + subscription=subscription + ), + parse_mode="HTML" + ) + + # ============== ХЕНДЛЕРЫ ПЛАТНОГО ТРИАЛА ============== @error_handler @@ -3993,6 +4104,11 @@ def register_handlers(dp: Dispatcher): F.data == "subscription_settings" ) + dp.callback_query.register( + handle_toggle_daily_subscription_pause, + F.data == "toggle_daily_subscription_pause" + ) + dp.callback_query.register( handle_no_traffic_packages, F.data == "no_traffic_packages" From 42978e2a373c919f0dd3e7e7e0aaf07c8f0db77b Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 15:27:37 +0300 Subject: [PATCH 08/47] Update inline.py --- app/keyboards/inline.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 2b4e4ca5..1979df1f 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -2514,6 +2514,7 @@ def get_updated_subscription_settings_keyboard( language: str = DEFAULT_LANGUAGE, show_countries_management: bool = True, tariff=None, # Тариф подписки (если есть - ограничиваем настройки) + subscription=None, # Подписка (для проверки суточной паузы) ) -> InlineKeyboardMarkup: from app.config import settings @@ -2523,6 +2524,17 @@ def get_updated_subscription_settings_keyboard( # Если подписка на тарифе - отключаем страны, модем, трафик has_tariff = tariff is not None + # Кнопка паузы/возобновления суточной подписки + if tariff and getattr(tariff, 'is_daily', False) and subscription: + is_paused = getattr(subscription, 'is_daily_paused', False) + if is_paused: + button_text = texts.t("RESUME_DAILY_SUBSCRIPTION_BUTTON", "▶️ Возобновить суточный тариф") + else: + button_text = texts.t("PAUSE_DAILY_SUBSCRIPTION_BUTTON", "⏸️ Приостановить суточный тариф") + keyboard.append([ + InlineKeyboardButton(text=button_text, callback_data="toggle_daily_subscription_pause") + ]) + if show_countries_management and not has_tariff: keyboard.append([ InlineKeyboardButton(text=texts.t("ADD_COUNTRIES_BUTTON", "🌐 Добавить страны"), callback_data="subscription_add_countries") From 24666dd15596a0eb8af18ad902fe76ab7a10e0ff Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 15:48:20 +0300 Subject: [PATCH 09/47] Update tariffs.py --- app/handlers/admin/tariffs.py | 192 ++++++++++++++++++++++++++-------- 1 file changed, 147 insertions(+), 45 deletions(-) diff --git a/app/handlers/admin/tariffs.py b/app/handlers/admin/tariffs.py index 73e25e30..888cccb6 100644 --- a/app/handlers/admin/tariffs.py +++ b/app/handlers/admin/tariffs.py @@ -183,10 +183,17 @@ def get_tariff_view_keyboard( InlineKeyboardButton(text="📊 Трафик", callback_data=f"admin_tariff_edit_traffic:{tariff.id}"), InlineKeyboardButton(text="📱 Устройства", callback_data=f"admin_tariff_edit_devices:{tariff.id}"), ]) - buttons.append([ - InlineKeyboardButton(text="💰 Цены", callback_data=f"admin_tariff_edit_prices:{tariff.id}"), - InlineKeyboardButton(text="🎚️ Уровень", callback_data=f"admin_tariff_edit_tier:{tariff.id}"), - ]) + # Цены за периоды только для обычных тарифов (не суточных) + is_daily = getattr(tariff, 'is_daily', False) + if not is_daily: + buttons.append([ + InlineKeyboardButton(text="💰 Цены", callback_data=f"admin_tariff_edit_prices:{tariff.id}"), + InlineKeyboardButton(text="🎚️ Уровень", callback_data=f"admin_tariff_edit_tier:{tariff.id}"), + ]) + else: + buttons.append([ + InlineKeyboardButton(text="🎚️ Уровень", callback_data=f"admin_tariff_edit_tier:{tariff.id}"), + ]) buttons.append([ InlineKeyboardButton(text="📱💰 Цена за устройство", callback_data=f"admin_tariff_edit_device_price:{tariff.id}"), InlineKeyboardButton(text="⏰ Дни триала", callback_data=f"admin_tariff_edit_trial_days:{tariff.id}"), @@ -199,19 +206,13 @@ def get_tariff_view_keyboard( InlineKeyboardButton(text="👥 Промогруппы", callback_data=f"admin_tariff_edit_promo:{tariff.id}"), ]) - # Суточный режим - is_daily = getattr(tariff, 'is_daily', False) + # Суточный режим - только для уже суточных тарифов показываем настройки + # Новые тарифы делаются суточными только при создании if is_daily: - buttons.append([ - InlineKeyboardButton(text="🔄 ❌ Отключить суточный режим", callback_data=f"admin_tariff_toggle_daily:{tariff.id}"), - ]) buttons.append([ InlineKeyboardButton(text="💰 Суточная цена", callback_data=f"admin_tariff_edit_daily_price:{tariff.id}"), ]) - else: - buttons.append([ - InlineKeyboardButton(text="🔄 Включить суточный режим", callback_data=f"admin_tariff_toggle_daily:{tariff.id}"), - ]) + # Примечание: отключение суточного режима убрано - это необратимое решение при создании # Переключение триала if tariff.is_trial_available: @@ -304,14 +305,18 @@ def format_tariff_info(tariff: Tariff, language: str, subs_count: int = 0) -> st # Форматируем суточный тариф is_daily = getattr(tariff, 'is_daily', False) daily_price_kopeks = getattr(tariff, 'daily_price_kopeks', 0) + + # Формируем блок цен в зависимости от типа тарифа if is_daily: - daily_status = f"✅ Включен ({_format_price_kopeks(daily_price_kopeks)}/день)" + price_block = f"💰 Суточная цена: {_format_price_kopeks(daily_price_kopeks)}/день" + tariff_type = "🔄 Суточный" else: - daily_status = "❌ Отключен" + price_block = f"Цены:\n{prices_display}" + tariff_type = "📅 Периодный" return f"""📦 Тариф: {tariff.name} -{status} +{status} | {tariff_type} 🎚️ Уровень: {tariff.tier_level} 📊 Порядок: {tariff.display_order} @@ -322,13 +327,10 @@ def format_tariff_info(tariff: Tariff, language: str, subs_count: int = 0) -> st • Триал: {trial_status} • Дней триала: {trial_days_display} -🔄 Суточный режим: {daily_status} - Докупка трафика: {traffic_topup_display} -Цены: -{prices_display} +{price_block} Серверы: {squads_display} Промогруппы: {promo_display} @@ -605,46 +607,73 @@ async def process_daily_price_input( db: AsyncSession, state: FSMContext, ): - """Обрабатывает ввод суточной цены.""" + """Обрабатывает ввод суточной цены (создание и редактирование).""" texts = get_texts(db_user.language) data = await state.get_data() tariff_id = data.get("tariff_id") - if not tariff_id: - await state.clear() - return - - tariff = await get_tariff_by_id(db, tariff_id) - if not tariff: - await message.answer("Тариф не найден") - await state.clear() - return - + # Парсим цену try: price_rubles = float(message.text.strip().replace(",", ".")) - if price_rubles < 0: - raise ValueError("Цена не может быть отрицательной") + if price_rubles <= 0: + raise ValueError("Цена должна быть положительной") price_kopeks = int(price_rubles * 100) except ValueError: await message.answer( - "❌ Некорректная цена. Введите число.\n" + "❌ Некорректная цена. Введите положительное число.\n" "Пример: 50 или 99.90", parse_mode="HTML" ) return - tariff = await update_tariff(db, tariff, daily_price_kopeks=price_kopeks) - await state.clear() + # Проверяем - это создание или редактирование + is_creating = data.get("tariff_is_daily") and not tariff_id - subs_count = await get_tariff_subscriptions_count(db, tariff_id) + if is_creating: + # Создаем новый суточный тариф + tariff = await create_tariff( + db, + name=data['tariff_name'], + traffic_limit_gb=data['tariff_traffic'], + device_limit=data['tariff_devices'], + tier_level=data['tariff_tier'], + period_prices={}, + is_active=True, + is_daily=True, + daily_price_kopeks=price_kopeks, + ) + await state.clear() - await message.answer( - f"✅ Суточная цена установлена: {_format_price_kopeks(price_kopeks)}/день\n\n" - + format_tariff_info(tariff, db_user.language, subs_count), - reply_markup=get_tariff_view_keyboard(tariff, db_user.language), - parse_mode="HTML" - ) + await message.answer( + f"✅ Суточный тариф создан!\n\n" + + format_tariff_info(tariff, db_user.language, 0), + reply_markup=get_tariff_view_keyboard(tariff, db_user.language), + parse_mode="HTML" + ) + else: + # Редактируем существующий тариф + if not tariff_id: + await state.clear() + return + + tariff = await get_tariff_by_id(db, tariff_id) + if not tariff: + await message.answer("Тариф не найден") + await state.clear() + return + + tariff = await update_tariff(db, tariff, daily_price_kopeks=price_kopeks) + await state.clear() + + subs_count = await get_tariff_subscriptions_count(db, tariff_id) + + await message.answer( + f"✅ Суточная цена установлена: {_format_price_kopeks(price_kopeks)}/день\n\n" + + format_tariff_info(tariff, db_user.language, subs_count), + reply_markup=get_tariff_view_keyboard(tariff, db_user.language), + parse_mode="HTML" + ) # ============ СОЗДАНИЕ ТАРИФА ============ @@ -811,17 +840,51 @@ async def process_tariff_tier( data = await state.get_data() await state.update_data(tariff_tier=tier) - await state.set_state(AdminStates.creating_tariff_prices) traffic_display = _format_traffic(data['tariff_traffic']) + # Шаг 5/6: Выбор типа тарифа await message.answer( "📦 Создание тарифа\n\n" f"Название: {data['tariff_name']}\n" f"Трафик: {traffic_display}\n" f"Устройств: {data['tariff_devices']}\n" f"Уровень: {tier}\n\n" - "Шаг 5/6: Введите цены на периоды\n\n" + "Шаг 5/6: Выберите тип тарифа", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="📅 Периодный (месяцы)", callback_data="tariff_type_periodic")], + [InlineKeyboardButton(text="🔄 Суточный (оплата за день)", callback_data="tariff_type_daily")], + [InlineKeyboardButton(text=texts.CANCEL, callback_data="admin_tariffs")] + ]), + parse_mode="HTML" + ) + + +@admin_required +@error_handler +async def select_tariff_type_periodic( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Выбирает периодный тип тарифа.""" + texts = get_texts(db_user.language) + data = await state.get_data() + + await state.update_data(tariff_is_daily=False) + await state.set_state(AdminStates.creating_tariff_prices) + + traffic_display = _format_traffic(data['tariff_traffic']) + + await callback.message.edit_text( + "📦 Создание тарифа\n\n" + f"Название: {data['tariff_name']}\n" + f"Трафик: {traffic_display}\n" + f"Устройств: {data['tariff_devices']}\n" + f"Уровень: {data['tariff_tier']}\n" + f"Тип: 📅 Периодный\n\n" + "Шаг 6/6: Введите цены на периоды\n\n" "Формат: дней:цена_в_копейках\n" "Несколько периодов через запятую\n\n" "Пример:\n30:9900, 90:24900, 180:44900, 360:79900", @@ -830,6 +893,43 @@ async def process_tariff_tier( ]), parse_mode="HTML" ) + await callback.answer() + + +@admin_required +@error_handler +async def select_tariff_type_daily( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Выбирает суточный тип тарифа.""" + from app.states import AdminStates + + texts = get_texts(db_user.language) + data = await state.get_data() + + await state.update_data(tariff_is_daily=True) + await state.set_state(AdminStates.editing_tariff_daily_price) + + traffic_display = _format_traffic(data['tariff_traffic']) + + await callback.message.edit_text( + "📦 Создание суточного тарифа\n\n" + f"Название: {data['tariff_name']}\n" + f"Трафик: {traffic_display}\n" + f"Устройств: {data['tariff_devices']}\n" + f"Уровень: {data['tariff_tier']}\n" + f"Тип: 🔄 Суточный\n\n" + "Шаг 6/6: Введите суточную цену в рублях\n\n" + "Пример: 50 (50 ₽/день), 99.90 (99.90 ₽/день)", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text=texts.CANCEL, callback_data="admin_tariffs")] + ]), + parse_mode="HTML" + ) + await callback.answer() @admin_required @@ -2411,6 +2511,8 @@ def register_handlers(dp: Dispatcher): dp.message.register(process_tariff_traffic, AdminStates.creating_tariff_traffic) dp.message.register(process_tariff_devices, AdminStates.creating_tariff_devices) dp.message.register(process_tariff_tier, AdminStates.creating_tariff_tier) + dp.callback_query.register(select_tariff_type_periodic, F.data == "tariff_type_periodic") + dp.callback_query.register(select_tariff_type_daily, F.data == "tariff_type_daily") dp.message.register(process_tariff_prices, AdminStates.creating_tariff_prices) # Редактирование названия From 7c4471a5101715d596e2956ba9aae79114f38102 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 15:48:51 +0300 Subject: [PATCH 10/47] Update inline.py --- app/keyboards/inline.py | 46 +++++++++++++++++++++++------------------ 1 file changed, 26 insertions(+), 20 deletions(-) diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 1979df1f..7559d9de 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -980,23 +980,38 @@ def get_subscription_keyboard( InlineKeyboardButton(text=texts.MENU_BUY_SUBSCRIPTION, callback_data="subscription_upgrade") ]) else: - # Ряд: [Продлить] [Автоплатеж] - keyboard.append([ - InlineKeyboardButton(text=texts.MENU_EXTEND_SUBSCRIPTION, callback_data="subscription_extend"), - InlineKeyboardButton( - text=texts.t("AUTOPAY_BUTTON", "💳 Автоплатеж"), - callback_data="subscription_autopay", - ) - ]) + # Проверяем, является ли тариф суточным + tariff = getattr(subscription, 'tariff', None) if subscription else None + is_daily_tariff = tariff and getattr(tariff, 'is_daily', False) - # Ряд: [Настройки] [Тариф] (если режим тарифов) + if is_daily_tariff: + # Для суточного тарифа показываем кнопку паузы/возобновления + is_paused = getattr(subscription, 'is_daily_paused', False) + if is_paused: + pause_text = texts.t("RESUME_DAILY_BUTTON", "▶️ Возобновить подписку") + else: + pause_text = texts.t("PAUSE_DAILY_BUTTON", "⏸️ Приостановить подписку") + keyboard.append([ + InlineKeyboardButton(text=pause_text, callback_data="toggle_daily_subscription_pause") + ]) + else: + # Для обычного тарифа: [Продлить] [Автоплатеж] + keyboard.append([ + InlineKeyboardButton(text=texts.MENU_EXTEND_SUBSCRIPTION, callback_data="subscription_extend"), + InlineKeyboardButton( + text=texts.t("AUTOPAY_BUTTON", "💳 Автоплатеж"), + callback_data="subscription_autopay", + ) + ]) + + # Ряд: [Настройки] [Тариф] (если режим тарифов и не суточный) settings_row = [ InlineKeyboardButton( text=texts.t("SUBSCRIPTION_SETTINGS_BUTTON", "⚙️ Настройки"), callback_data="subscription_settings", ) ] - if settings.is_tariffs_mode() and subscription: + if settings.is_tariffs_mode() and subscription and not is_daily_tariff: settings_row.append( InlineKeyboardButton( text=texts.t("CHANGE_TARIFF_BUTTON", "📦 Тариф"), @@ -2524,16 +2539,7 @@ def get_updated_subscription_settings_keyboard( # Если подписка на тарифе - отключаем страны, модем, трафик has_tariff = tariff is not None - # Кнопка паузы/возобновления суточной подписки - if tariff and getattr(tariff, 'is_daily', False) and subscription: - is_paused = getattr(subscription, 'is_daily_paused', False) - if is_paused: - button_text = texts.t("RESUME_DAILY_SUBSCRIPTION_BUTTON", "▶️ Возобновить суточный тариф") - else: - button_text = texts.t("PAUSE_DAILY_SUBSCRIPTION_BUTTON", "⏸️ Приостановить суточный тариф") - keyboard.append([ - InlineKeyboardButton(text=button_text, callback_data="toggle_daily_subscription_pause") - ]) + # Для суточных тарифов кнопка паузы теперь в главном меню подписки if show_countries_management and not has_tariff: keyboard.append([ From 6fc3dd0e357ea172baaa2ec431d0c4c437807959 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 15:49:25 +0300 Subject: [PATCH 11/47] Update purchase.py --- app/handlers/subscription/purchase.py | 67 +++++---------------------- 1 file changed, 12 insertions(+), 55 deletions(-) diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py index 281ca051..085521cc 100644 --- a/app/handlers/subscription/purchase.py +++ b/app/handlers/subscription/purchase.py @@ -335,12 +335,15 @@ async def show_subscription_info( # Получаем название тарифа для режима тарифов tariff_line = "" + tariff = None if settings.is_tariffs_mode() and subscription.tariff_id: try: from app.database.crud.tariff import get_tariff_by_id tariff = await get_tariff_by_id(db, subscription.tariff_id) if tariff: tariff_line = f"\n📦 Тариф: {tariff.name}" + # Прикрепляем тариф к подписке для использования в клавиатуре + subscription.tariff = tariff except Exception as e: logger.warning(f"Ошибка получения тарифа: {e}") @@ -3075,77 +3078,31 @@ async def handle_toggle_daily_subscription_pause( ) return + # Прикрепляем тариф к подписке для CRUD функций + subscription.tariff = tariff + # Переключаем статус паузы - was_paused = subscription.is_daily_paused + was_paused = getattr(subscription, 'is_daily_paused', False) subscription = await toggle_daily_subscription_pause(db, subscription) if was_paused: # Была пауза, теперь возобновили message = texts.t( "DAILY_SUBSCRIPTION_RESUMED", - "▶️ Суточная подписка возобновлена.\n\nСписание будет произведено в ближайший цикл проверки." + "▶️ Подписка возобновлена!" ) else: # Была активна, теперь на паузе message = texts.t( "DAILY_SUBSCRIPTION_PAUSED", - "⏸️ Суточная подписка приостановлена.\n\nСписания не будут производиться до возобновления." + "⏸️ Подписка приостановлена!" ) await callback.answer(message, show_alert=True) - # Обновляем клавиатуру настроек - show_countries = await _should_show_countries_management(db_user) - - settings_template = texts.t( - "SUBSCRIPTION_SETTINGS_OVERVIEW", - ( - "⚙️ Настройки подписки\n\n" - "📊 Текущие параметры:\n" - "🌐 Стран: {countries_count}\n" - "📈 Трафик: {traffic_used} / {traffic_limit}\n" - "📱 Устройства: {devices_used} / {devices_limit}\n\n" - "Выберите что хотите изменить:" - ), - ) - - show_devices = settings.is_devices_selection_enabled() - if not show_devices: - settings_template = settings_template.replace( - "\n📱 Устройства: {devices_used} / {devices_limit}", - "", - ) - - if show_devices: - devices_used = await get_current_devices_count(db_user) - else: - devices_used = 0 - - 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=devices_limit_display, - ) - - await callback.message.edit_text( - settings_text, - reply_markup=get_updated_subscription_settings_keyboard( - db_user.language, - show_countries, - tariff=tariff, - subscription=subscription - ), - parse_mode="HTML" - ) + # Возвращаемся в меню подписки - вызываем show_subscription_info + await db.refresh(db_user) + await show_subscription_info(callback, db_user, db) # ============== ХЕНДЛЕРЫ ПЛАТНОГО ТРИАЛА ============== From 2b1a20e3737f96bca990cd938163a87aa7836e0f Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 15:59:22 +0300 Subject: [PATCH 12/47] Update purchase.py --- app/handlers/subscription/purchase.py | 60 ++++++++++++++++++++++++++- 1 file changed, 58 insertions(+), 2 deletions(-) diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py index 085521cc..79fb4ce6 100644 --- a/app/handlers/subscription/purchase.py +++ b/app/handlers/subscription/purchase.py @@ -333,8 +333,9 @@ async def show_subscription_info( else texts.t("SUBSCRIPTION_NO_SERVERS", "Нет серверов") ) - # Получаем название тарифа для режима тарифов + # Получаем информацию о тарифе для режима тарифов tariff_line = "" + tariff_info_block = "" tariff = None if settings.is_tariffs_mode() and subscription.tariff_id: try: @@ -344,6 +345,60 @@ async def show_subscription_info( tariff_line = f"\n📦 Тариф: {tariff.name}" # Прикрепляем тариф к подписке для использования в клавиатуре subscription.tariff = tariff + + # Формируем блок информации о тарифе + is_daily = getattr(tariff, 'is_daily', False) + tariff_type_str = "🔄 Суточный" if is_daily else "📅 Периодный" + + tariff_info_lines = [ + f"📦 {tariff.name}", + f"Тип: {tariff_type_str}", + f"Трафик: {tariff.traffic_limit_gb} ГБ" if tariff.traffic_limit_gb > 0 else "Трафик: ∞ Безлимит", + f"Устройства: {tariff.device_limit}", + ] + + if is_daily: + # Для суточного тарифа показываем цену и прогресс-бар + daily_price = getattr(tariff, 'daily_price_kopeks', 0) / 100 + tariff_info_lines.append(f"Цена: {daily_price:.2f} ₽/день") + + # Прогресс-бар до следующего списания + last_charge = getattr(subscription, 'last_daily_charge_at', None) + is_paused = getattr(subscription, 'is_daily_paused', False) + + if is_paused: + tariff_info_lines.append("") + tariff_info_lines.append("⏸️ Подписка приостановлена") + elif last_charge: + from datetime import timedelta + next_charge = last_charge + timedelta(hours=24) + now = datetime.utcnow() + + if next_charge > now: + time_until = next_charge - now + hours_left = time_until.seconds // 3600 + minutes_left = (time_until.seconds % 3600) // 60 + + # Процент оставшегося времени (24 часа = 100%) + total_seconds = 24 * 3600 + remaining_seconds = time_until.total_seconds() + percent = min(100, max(0, (remaining_seconds / total_seconds) * 100)) + + # Генерируем прогресс-бар + bar_length = 10 + filled = int(bar_length * percent / 100) + empty = bar_length - filled + progress_bar = "▓" * filled + "░" * empty + + tariff_info_lines.append("") + tariff_info_lines.append(f"⏳ До списания: {hours_left}ч {minutes_left}мин") + tariff_info_lines.append(f"[{progress_bar}] {percent:.0f}%") + else: + tariff_info_lines.append("") + tariff_info_lines.append("⏳ Первое списание скоро") + + tariff_info_block = "\n
" + "\n".join(tariff_info_lines) + "
" + except Exception as e: logger.warning(f"Ошибка получения тарифа: {e}") @@ -351,7 +406,7 @@ async def show_subscription_info( "SUBSCRIPTION_OVERVIEW_TEMPLATE", """👤 {full_name} 💰 Баланс: {balance} -📱 Подписка: {status_emoji} {status_display}{warning} +📱 Подписка: {status_emoji} {status_display}{warning}{tariff_info_block} 📱 Информация о подписке 🎭 Тип: {subscription_type}{tariff_line} @@ -383,6 +438,7 @@ async def show_subscription_info( status_emoji=status_emoji, status_display=status_display, warning=warning_text, + tariff_info_block=tariff_info_block, subscription_type=subscription_type, tariff_line=tariff_line, end_date=format_local_datetime(subscription.end_date, "%d.%m.%Y %H:%M"), From 4967c7ff7dd246abfaf7c9f3671287969bd03d07 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 16:05:31 +0300 Subject: [PATCH 13/47] Update daily_subscription_service.py --- app/services/daily_subscription_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/daily_subscription_service.py b/app/services/daily_subscription_service.py index 92e075da..c1265595 100644 --- a/app/services/daily_subscription_service.py +++ b/app/services/daily_subscription_service.py @@ -149,7 +149,7 @@ class DailySubscriptionService: type=TransactionType.SUBSCRIPTION_PAYMENT, amount_kopeks=daily_price, description=description, - payment_method=PaymentMethod.BALANCE, + payment_method=PaymentMethod.MANUAL, ) # Обновляем время последнего списания From 1c23538b715df8a90b929d5285bf4304a9ae5a52 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 16:14:14 +0300 Subject: [PATCH 14/47] Update tariff_purchase.py --- app/handlers/subscription/tariff_purchase.py | 520 +++++++++++++++++-- 1 file changed, 483 insertions(+), 37 deletions(-) diff --git a/app/handlers/subscription/tariff_purchase.py b/app/handlers/subscription/tariff_purchase.py index 322228c4..bb7a9a41 100644 --- a/app/handlers/subscription/tariff_purchase.py +++ b/app/handlers/subscription/tariff_purchase.py @@ -1,5 +1,6 @@ """Покупка подписки по тарифам.""" import logging +from datetime import timedelta from typing import List, Optional from aiogram import Dispatcher, types, F @@ -98,19 +99,27 @@ def format_tariffs_list_text( traffic = "∞" if traffic_gb == 0 else f"{traffic_gb}ГБ" # Цена - prices = tariff.period_prices or {} + is_daily = getattr(tariff, 'is_daily', False) price_text = "" discount_icon = "" - if prices: - min_period = min(prices.keys(), key=int) - min_price = prices[min_period] - discount_percent = 0 - if db_user: - discount_percent = _get_user_period_discount(db_user, int(min_period)) - if discount_percent > 0: - min_price = _apply_promo_discount(min_price, discount_percent) - discount_icon = "🔥" - price_text = f"от {_format_price_kopeks(min_price, compact=True)}{discount_icon}" + + if is_daily: + # Для суточных тарифов показываем цену за день + daily_price = getattr(tariff, 'daily_price_kopeks', 0) + price_text = f"🔄 {_format_price_kopeks(daily_price, compact=True)}/день" + else: + # Для периодных тарифов показываем минимальную цену + prices = tariff.period_prices or {} + if prices: + min_period = min(prices.keys(), key=int) + min_price = prices[min_period] + discount_percent = 0 + if db_user: + discount_percent = _get_user_period_discount(db_user, int(min_period)) + if discount_percent > 0: + min_price = _apply_promo_discount(min_price, discount_percent) + discount_icon = "🔥" + price_text = f"от {_format_price_kopeks(min_price, compact=True)}{discount_icon}" # Компактный формат: Название — 250ГБ/10📱 от 179₽🔥 lines.append(f"{tariff.name} — {traffic}/{tariff.device_limit}📱 {price_text}") @@ -257,11 +266,58 @@ def format_tariff_info_for_user( if discount_percent > 0: text += f"\n🎁 Ваша скидка: {discount_percent}%\n" - text += "\nВыберите период подписки:" + # Для суточных тарифов не показываем выбор периода + is_daily = getattr(tariff, 'is_daily', False) + if not is_daily: + text += "\nВыберите период подписки:" return text +def get_daily_tariff_confirm_keyboard( + tariff_id: int, + language: str, +) -> InlineKeyboardMarkup: + """Создает клавиатуру подтверждения покупки суточного тарифа.""" + texts = get_texts(language) + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton( + text="✅ Подтвердить покупку", + callback_data=f"daily_tariff_confirm:{tariff_id}" + ) + ], + [ + InlineKeyboardButton( + text=texts.BACK, + callback_data="tariff_list" + ) + ] + ]) + + +def get_daily_tariff_insufficient_balance_keyboard( + tariff_id: int, + language: str, +) -> InlineKeyboardMarkup: + """Создает клавиатуру при недостаточном балансе для суточного тарифа.""" + texts = get_texts(language) + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton( + text="💳 Пополнить баланс", + callback_data="balance_topup" + ) + ], + [ + InlineKeyboardButton( + text=texts.BACK, + callback_data="tariff_list" + ) + ] + ]) + + @error_handler async def show_tariffs_list( callback: types.CallbackQuery, @@ -324,11 +380,48 @@ async def select_tariff( await callback.answer("Тариф недоступен", show_alert=True) return - await callback.message.edit_text( - format_tariff_info_for_user(tariff, db_user.language), - reply_markup=get_tariff_periods_keyboard(tariff, db_user.language, db_user=db_user), - parse_mode="HTML" - ) + # Проверяем, суточный ли это тариф + is_daily = getattr(tariff, 'is_daily', False) + + if is_daily: + # Для суточного тарифа показываем подтверждение без выбора периода + daily_price = getattr(tariff, 'daily_price_kopeks', 0) + user_balance = db_user.balance_kopeks or 0 + traffic = _format_traffic(tariff.traffic_limit_gb) + + if user_balance >= daily_price: + await callback.message.edit_text( + f"✅ Подтверждение покупки\n\n" + f"📦 Тариф: {tariff.name}\n" + f"📊 Трафик: {traffic}\n" + f"📱 Устройств: {tariff.device_limit}\n" + f"🔄 Тип: Суточный\n\n" + f"💰 Цена: {_format_price_kopeks(daily_price)}/день\n\n" + f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n\n" + f"ℹ️ Средства будут списываться автоматически раз в сутки.\n" + f"Вы можете приостановить подписку в любой момент.", + reply_markup=get_daily_tariff_confirm_keyboard(tariff_id, db_user.language), + parse_mode="HTML" + ) + else: + missing = daily_price - user_balance + await callback.message.edit_text( + f"❌ Недостаточно средств\n\n" + f"📦 Тариф: {tariff.name}\n" + f"🔄 Тип: Суточный\n" + f"💰 Цена: {_format_price_kopeks(daily_price)}/день\n\n" + f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n" + f"⚠️ Не хватает: {_format_price_kopeks(missing)}", + reply_markup=get_daily_tariff_insufficient_balance_keyboard(tariff_id, db_user.language), + parse_mode="HTML" + ) + else: + # Для обычного тарифа показываем выбор периода + await callback.message.edit_text( + format_tariff_info_for_user(tariff, db_user.language), + reply_markup=get_tariff_periods_keyboard(tariff, db_user.language, db_user=db_user), + parse_mode="HTML" + ) await state.update_data(selected_tariff_id=tariff_id) await callback.answer() @@ -546,6 +639,160 @@ async def confirm_tariff_purchase( await callback.answer("Произошла ошибка при оформлении подписки", show_alert=True) +# ==================== Покупка суточного тарифа ==================== + +@error_handler +async def confirm_daily_tariff_purchase( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Подтверждает покупку суточного тарифа.""" + from datetime import datetime + + tariff_id = int(callback.data.split(":")[1]) + tariff = await get_tariff_by_id(db, tariff_id) + + if not tariff or not tariff.is_active: + await callback.answer("Тариф недоступен", show_alert=True) + return + + is_daily = getattr(tariff, 'is_daily', False) + if not is_daily: + await callback.answer("Это не суточный тариф", show_alert=True) + return + + daily_price = getattr(tariff, 'daily_price_kopeks', 0) + if daily_price <= 0: + await callback.answer("Некорректная цена тарифа", show_alert=True) + return + + # Проверяем баланс + user_balance = db_user.balance_kopeks or 0 + if user_balance < daily_price: + await callback.answer("Недостаточно средств на балансе", show_alert=True) + return + + texts = get_texts(db_user.language) + + try: + # Списываем первый день сразу + success = await subtract_user_balance( + db, db_user, daily_price, + f"Покупка суточного тарифа {tariff.name} (первый день)" + ) + if not success: + await callback.answer("Ошибка списания баланса", show_alert=True) + return + + # Получаем список серверов из тарифа + squads = tariff.allowed_squads or [] + + # Проверяем есть ли уже подписка + existing_subscription = await get_subscription_by_user_id(db, db_user.id) + + if existing_subscription: + # Обновляем существующую подписку на суточный тариф + existing_subscription.tariff_id = tariff.id + existing_subscription.traffic_limit_gb = tariff.traffic_limit_gb + existing_subscription.device_limit = tariff.device_limit + existing_subscription.connected_squads = squads + existing_subscription.status = "active" + existing_subscription.is_daily_paused = False + existing_subscription.last_daily_charge_at = datetime.utcnow() + # Для суточного тарифа ставим далёкую дату окончания + existing_subscription.end_date = datetime.utcnow() + timedelta(days=365 * 10) + + await db.commit() + await db.refresh(existing_subscription) + subscription = existing_subscription + else: + # Создаем новую подписку + subscription = await create_paid_subscription( + db=db, + user_id=db_user.id, + duration_days=365 * 10, # Суточные подписки не имеют фиксированного срока + traffic_limit_gb=tariff.traffic_limit_gb, + device_limit=tariff.device_limit, + connected_squads=squads, + tariff_id=tariff.id, + ) + # Устанавливаем время последнего списания + subscription.last_daily_charge_at = datetime.utcnow() + subscription.is_daily_paused = False + await db.commit() + await db.refresh(subscription) + + # Обновляем пользователя в Remnawave + try: + subscription_service = SubscriptionService() + await subscription_service.create_remnawave_user( + db, + subscription, + reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT, + reset_reason="покупка суточного тарифа", + ) + except Exception as e: + logger.error(f"Ошибка обновления Remnawave: {e}") + + # Создаем транзакцию + await create_transaction( + db, + user_id=db_user.id, + type=TransactionType.SUBSCRIPTION_PAYMENT, + amount_kopeks=-daily_price, + description=f"Покупка суточного тарифа {tariff.name} (первый день)", + ) + + # Отправляем уведомление админу + try: + admin_notification_service = AdminNotificationService(callback.bot) + await admin_notification_service.send_subscription_purchase_notification( + db, + db_user, + subscription, + None, + 1, # 1 день + was_trial_conversion=False, + amount_kopeks=daily_price, + ) + except Exception as e: + logger.error(f"Ошибка отправки уведомления админу: {e}") + + # Очищаем корзину после успешной покупки + try: + await user_cart_service.delete_user_cart(db_user.id) + logger.info(f"Корзина очищена после покупки суточного тарифа для пользователя {db_user.telegram_id}") + except Exception as e: + logger.error(f"Ошибка очистки корзины: {e}") + + await state.clear() + + traffic = _format_traffic(tariff.traffic_limit_gb) + + await callback.message.edit_text( + f"🎉 Суточная подписка оформлена!\n\n" + f"📦 Тариф: {tariff.name}\n" + f"📊 Трафик: {traffic}\n" + f"📱 Устройств: {tariff.device_limit}\n" + f"🔄 Тип: Суточный\n" + f"💰 Списано: {_format_price_kopeks(daily_price)}\n\n" + f"ℹ️ Следующее списание через 24 часа.\n" + f"Перейдите в раздел «Подписка» для подключения.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")], + [InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")] + ]), + parse_mode="HTML" + ) + await callback.answer("Подписка оформлена!", show_alert=True) + + except Exception as e: + logger.error(f"Ошибка при покупке суточного тарифа: {e}", exc_info=True) + await callback.answer("Произошла ошибка при оформлении подписки", show_alert=True) + + # ==================== Продление по тарифу ==================== def get_tariff_extend_keyboard( @@ -880,19 +1127,27 @@ def format_tariff_switch_list_text( traffic_gb = tariff.traffic_limit_gb traffic = "∞" if traffic_gb == 0 else f"{traffic_gb}ГБ" - prices = tariff.period_prices or {} + # Проверяем суточный ли тариф + is_daily = getattr(tariff, 'is_daily', False) price_text = "" discount_icon = "" - if prices: - min_period = min(prices.keys(), key=int) - min_price = prices[min_period] - discount_percent = 0 - if db_user: - discount_percent = _get_user_period_discount(db_user, int(min_period)) - if discount_percent > 0: - min_price = _apply_promo_discount(min_price, discount_percent) - discount_icon = "🔥" - price_text = f"от {_format_price_kopeks(min_price, compact=True)}{discount_icon}" + + if is_daily: + # Для суточных тарифов показываем цену за день + daily_price = getattr(tariff, 'daily_price_kopeks', 0) + price_text = f"🔄 {_format_price_kopeks(daily_price, compact=True)}/день" + else: + prices = tariff.period_prices or {} + if prices: + min_period = min(prices.keys(), key=int) + min_price = prices[min_period] + discount_percent = 0 + if db_user: + discount_percent = _get_user_period_discount(db_user, int(min_period)) + if discount_percent > 0: + min_price = _apply_promo_discount(min_price, discount_percent) + discount_icon = "🔥" + price_text = f"от {_format_price_kopeks(min_price, compact=True)}{discount_icon}" lines.append(f"{tariff.name} — {traffic}/{tariff.device_limit}📱 {price_text}") @@ -1105,23 +1360,77 @@ async def select_tariff_switch( traffic = _format_traffic(tariff.traffic_limit_gb) - info_text = f"""📦 {tariff.name} + # Проверяем, суточный ли это тариф + is_daily = getattr(tariff, 'is_daily', False) + + if is_daily: + # Для суточного тарифа показываем подтверждение без выбора периода + daily_price = getattr(tariff, 'daily_price_kopeks', 0) + user_balance = db_user.balance_kopeks or 0 + + if user_balance >= daily_price: + await callback.message.edit_text( + f"✅ Подтверждение смены тарифа\n\n" + f"📦 Новый тариф: {tariff.name}\n" + f"📊 Трафик: {traffic}\n" + f"📱 Устройств: {tariff.device_limit}\n" + f"🔄 Тип: Суточный\n\n" + f"💰 Цена: {_format_price_kopeks(daily_price)}/день\n\n" + f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n\n" + f"ℹ️ Средства будут списываться автоматически раз в сутки.\n" + f"Вы можете приостановить подписку в любой момент.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton( + text="✅ Подтвердить смену", + callback_data=f"daily_tariff_switch_confirm:{tariff_id}" + )], + [InlineKeyboardButton( + text=get_texts(db_user.language).BACK, + callback_data="tariff_switch" + )] + ]), + parse_mode="HTML" + ) + else: + missing = daily_price - user_balance + await callback.message.edit_text( + f"❌ Недостаточно средств\n\n" + f"📦 Тариф: {tariff.name}\n" + f"🔄 Тип: Суточный\n" + f"💰 Цена: {_format_price_kopeks(daily_price)}/день\n\n" + f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n" + f"⚠️ Не хватает: {_format_price_kopeks(missing)}", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton( + text="💳 Пополнить баланс", + callback_data="balance_topup" + )], + [InlineKeyboardButton( + text=get_texts(db_user.language).BACK, + callback_data="tariff_switch" + )] + ]), + parse_mode="HTML" + ) + else: + # Для обычного тарифа показываем выбор периода + info_text = f"""📦 {tariff.name} Параметры нового тарифа: • Трафик: {traffic} • Устройств: {tariff.device_limit} """ - if tariff.description: - info_text += f"\n📝 {tariff.description}\n" + if tariff.description: + info_text += f"\n📝 {tariff.description}\n" - info_text += "\n⚠️ Оплачивается полная стоимость тарифа.\nВыберите период:" + info_text += "\n⚠️ Оплачивается полная стоимость тарифа.\nВыберите период:" - await callback.message.edit_text( - info_text, - reply_markup=get_tariff_switch_periods_keyboard(tariff, db_user.language, db_user=db_user), - parse_mode="HTML" - ) + await callback.message.edit_text( + info_text, + reply_markup=get_tariff_switch_periods_keyboard(tariff, db_user.language, db_user=db_user), + parse_mode="HTML" + ) await state.update_data(switch_tariff_id=tariff_id) await callback.answer() @@ -1356,6 +1665,137 @@ async def confirm_tariff_switch( await callback.answer("Произошла ошибка при переключении тарифа", show_alert=True) +# ==================== Смена на суточный тариф ==================== + +@error_handler +async def confirm_daily_tariff_switch( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Подтверждает смену на суточный тариф.""" + from datetime import datetime + + tariff_id = int(callback.data.split(":")[1]) + tariff = await get_tariff_by_id(db, tariff_id) + + if not tariff or not tariff.is_active: + await callback.answer("Тариф недоступен", show_alert=True) + return + + is_daily = getattr(tariff, 'is_daily', False) + if not is_daily: + await callback.answer("Это не суточный тариф", show_alert=True) + return + + daily_price = getattr(tariff, 'daily_price_kopeks', 0) + if daily_price <= 0: + await callback.answer("Некорректная цена тарифа", show_alert=True) + return + + # Проверяем баланс + user_balance = db_user.balance_kopeks or 0 + if user_balance < daily_price: + await callback.answer("Недостаточно средств на балансе", show_alert=True) + return + + # Проверяем наличие подписки + subscription = await get_subscription_by_user_id(db, db_user.id) + if not subscription: + await callback.answer("У вас нет активной подписки", show_alert=True) + return + + texts = get_texts(db_user.language) + + try: + # Списываем первый день сразу + success = await subtract_user_balance( + db, db_user, daily_price, + f"Смена на суточный тариф {tariff.name} (первый день)" + ) + if not success: + await callback.answer("Ошибка списания баланса", show_alert=True) + return + + # Получаем список серверов из тарифа + squads = tariff.allowed_squads or [] + + # Обновляем подписку на суточный тариф + subscription.tariff_id = tariff.id + subscription.traffic_limit_gb = tariff.traffic_limit_gb + subscription.device_limit = tariff.device_limit + subscription.connected_squads = squads + subscription.status = "active" + subscription.is_daily_paused = False + subscription.last_daily_charge_at = datetime.utcnow() + # Для суточного тарифа ставим далёкую дату окончания + subscription.end_date = datetime.utcnow() + timedelta(days=365 * 10) + + await db.commit() + await db.refresh(subscription) + + # Обновляем пользователя в Remnawave + try: + subscription_service = SubscriptionService() + await subscription_service.create_remnawave_user( + db, + subscription, + reset_traffic=True, + reset_reason="смена на суточный тариф", + ) + except Exception as e: + logger.error(f"Ошибка обновления Remnawave: {e}") + + # Создаем транзакцию + await create_transaction( + db, + user_id=db_user.id, + type=TransactionType.SUBSCRIPTION_PAYMENT, + amount_kopeks=-daily_price, + description=f"Смена на суточный тариф {tariff.name} (первый день)", + ) + + # Отправляем уведомление админу + try: + admin_notification_service = AdminNotificationService(callback.bot) + await admin_notification_service.send_subscription_purchase_notification( + db, + db_user, + subscription, + None, + 1, # 1 день + was_trial_conversion=False, + amount_kopeks=daily_price, + ) + except Exception as e: + logger.error(f"Ошибка отправки уведомления админу: {e}") + + await state.clear() + + traffic = _format_traffic(tariff.traffic_limit_gb) + + await callback.message.edit_text( + f"🎉 Тариф успешно изменён!\n\n" + f"📦 Новый тариф: {tariff.name}\n" + f"📊 Трафик: {traffic}\n" + f"📱 Устройств: {tariff.device_limit}\n" + f"🔄 Тип: Суточный\n" + f"💰 Списано: {_format_price_kopeks(daily_price)}\n\n" + f"ℹ️ Следующее списание через 24 часа.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")], + [InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")] + ]), + parse_mode="HTML" + ) + await callback.answer("Тариф изменён!", show_alert=True) + + except Exception as e: + logger.error(f"Ошибка при смене на суточный тариф: {e}", exc_info=True) + await callback.answer("Произошла ошибка при смене тарифа", show_alert=True) + + # ==================== Мгновенное переключение тарифов (без выбора периода) ==================== def _get_tariff_monthly_price(tariff: Tariff) -> int: @@ -1874,6 +2314,9 @@ def register_tariff_purchase_handlers(dp: Dispatcher): # Подтверждение покупки dp.callback_query.register(confirm_tariff_purchase, F.data.startswith("tariff_confirm:")) + # Подтверждение покупки суточного тарифа + dp.callback_query.register(confirm_daily_tariff_purchase, F.data.startswith("daily_tariff_confirm:")) + # Продление по тарифу dp.callback_query.register(select_tariff_extend_period, F.data.startswith("tariff_extend:")) dp.callback_query.register(confirm_tariff_extend, F.data.startswith("tariff_ext_confirm:")) @@ -1884,6 +2327,9 @@ def register_tariff_purchase_handlers(dp: Dispatcher): dp.callback_query.register(select_tariff_switch_period, F.data.startswith("tariff_sw_period:")) dp.callback_query.register(confirm_tariff_switch, F.data.startswith("tariff_sw_confirm:")) + # Смена на суточный тариф + dp.callback_query.register(confirm_daily_tariff_switch, F.data.startswith("daily_tariff_switch_confirm:")) + # Мгновенное переключение тарифов (без выбора периода) dp.callback_query.register(show_instant_switch_list, F.data == "instant_switch") dp.callback_query.register(preview_instant_switch, F.data.startswith("instant_sw_preview:")) From 78c44a1e00cb1db46308db1c2e2d0bf5aaf38562 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 16:19:17 +0300 Subject: [PATCH 15/47] Update tariff_purchase.py --- app/handlers/subscription/tariff_purchase.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/handlers/subscription/tariff_purchase.py b/app/handlers/subscription/tariff_purchase.py index bb7a9a41..8366d5f8 100644 --- a/app/handlers/subscription/tariff_purchase.py +++ b/app/handlers/subscription/tariff_purchase.py @@ -699,6 +699,7 @@ async def confirm_daily_tariff_purchase( existing_subscription.device_limit = tariff.device_limit existing_subscription.connected_squads = squads existing_subscription.status = "active" + existing_subscription.is_trial = False # Сбрасываем триальный статус existing_subscription.is_daily_paused = False existing_subscription.last_daily_charge_at = datetime.utcnow() # Для суточного тарифа ставим далёкую дату окончания @@ -1727,6 +1728,7 @@ async def confirm_daily_tariff_switch( subscription.device_limit = tariff.device_limit subscription.connected_squads = squads subscription.status = "active" + subscription.is_trial = False # Сбрасываем триальный статус subscription.is_daily_paused = False subscription.last_daily_charge_at = datetime.utcnow() # Для суточного тарифа ставим далёкую дату окончания From 8c5a385f147e18c3044f69b7b1d3bac4f1084ddf Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 16:29:31 +0300 Subject: [PATCH 16/47] Update tariff_purchase.py --- app/handlers/subscription/tariff_purchase.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/handlers/subscription/tariff_purchase.py b/app/handlers/subscription/tariff_purchase.py index 8366d5f8..402c7ae8 100644 --- a/app/handlers/subscription/tariff_purchase.py +++ b/app/handlers/subscription/tariff_purchase.py @@ -702,18 +702,18 @@ async def confirm_daily_tariff_purchase( existing_subscription.is_trial = False # Сбрасываем триальный статус existing_subscription.is_daily_paused = False existing_subscription.last_daily_charge_at = datetime.utcnow() - # Для суточного тарифа ставим далёкую дату окончания - existing_subscription.end_date = datetime.utcnow() + timedelta(days=365 * 10) + # Для суточного тарифа ставим срок на 1 день + existing_subscription.end_date = datetime.utcnow() + timedelta(days=1) await db.commit() await db.refresh(existing_subscription) subscription = existing_subscription else: - # Создаем новую подписку + # Создаем новую подписку на 1 день subscription = await create_paid_subscription( db=db, user_id=db_user.id, - duration_days=365 * 10, # Суточные подписки не имеют фиксированного срока + duration_days=1, traffic_limit_gb=tariff.traffic_limit_gb, device_limit=tariff.device_limit, connected_squads=squads, @@ -1731,8 +1731,8 @@ async def confirm_daily_tariff_switch( subscription.is_trial = False # Сбрасываем триальный статус subscription.is_daily_paused = False subscription.last_daily_charge_at = datetime.utcnow() - # Для суточного тарифа ставим далёкую дату окончания - subscription.end_date = datetime.utcnow() + timedelta(days=365 * 10) + # Для суточного тарифа ставим срок на 1 день + subscription.end_date = datetime.utcnow() + timedelta(days=1) await db.commit() await db.refresh(subscription) From 62bec70db9b7cf556badbd16a3033a2c693fa5e6 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 16:29:59 +0300 Subject: [PATCH 17/47] Update daily_subscription_service.py --- app/services/daily_subscription_service.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/app/services/daily_subscription_service.py b/app/services/daily_subscription_service.py index c1265595..c2a290b7 100644 --- a/app/services/daily_subscription_service.py +++ b/app/services/daily_subscription_service.py @@ -152,14 +152,27 @@ class DailySubscriptionService: payment_method=PaymentMethod.MANUAL, ) - # Обновляем время последнего списания - await update_daily_charge_time(db, subscription) + # Обновляем время последнего списания и продлеваем подписку + subscription = await update_daily_charge_time(db, subscription) logger.info( f"✅ Суточное списание: подписка {subscription.id}, " f"сумма {daily_price} коп., пользователь {user.telegram_id}" ) + # Синхронизируем с Remnawave (обновляем срок подписки) + try: + from app.services.subscription_service import SubscriptionService + subscription_service = SubscriptionService() + await subscription_service.create_remnawave_user( + db, + subscription, + reset_traffic=False, + reset_reason=None, + ) + except Exception as e: + logger.warning(f"Не удалось обновить Remnawave: {e}") + # Уведомляем пользователя if self._bot: await self._notify_daily_charge(user, subscription, daily_price) From 2090da3603a77ee39c1e0c74714f480c520122a6 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 16:30:24 +0300 Subject: [PATCH 18/47] Update inline.py --- app/keyboards/inline.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 7559d9de..1934f4fc 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -1004,18 +1004,20 @@ def get_subscription_keyboard( ) ]) - # Ряд: [Настройки] [Тариф] (если режим тарифов и не суточный) + # Ряд: [Настройки] [Тариф] (если режим тарифов) settings_row = [ InlineKeyboardButton( text=texts.t("SUBSCRIPTION_SETTINGS_BUTTON", "⚙️ Настройки"), callback_data="subscription_settings", ) ] - if settings.is_tariffs_mode() and subscription and not is_daily_tariff: + if settings.is_tariffs_mode() and subscription: + # Для суточных тарифов переходим на список тарифов, для обычных - мгновенное переключение + tariff_callback = "tariff_switch" if is_daily_tariff else "instant_switch" settings_row.append( InlineKeyboardButton( text=texts.t("CHANGE_TARIFF_BUTTON", "📦 Тариф"), - callback_data="instant_switch" + callback_data=tariff_callback ) ) keyboard.append(settings_row) From 640a80953f851b23503629dd5121fa570212fd2d Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 16:31:00 +0300 Subject: [PATCH 19/47] Update subscription.py --- app/database/crud/subscription.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index 8786ffff..793a998d 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -1861,8 +1861,16 @@ async def update_daily_charge_time( subscription: Subscription, charge_time: datetime = None, ) -> Subscription: - """Обновляет время последнего суточного списания.""" - subscription.last_daily_charge_at = charge_time or datetime.utcnow() + """Обновляет время последнего суточного списания и продлевает подписку на 1 день.""" + now = charge_time or datetime.utcnow() + subscription.last_daily_charge_at = now + + # Продлеваем подписку на 1 день от текущего момента + new_end_date = now + timedelta(days=1) + if subscription.end_date is None or subscription.end_date < new_end_date: + subscription.end_date = new_end_date + logger.info(f"📅 Продлена подписка {subscription.id} до {new_end_date}") + await db.commit() await db.refresh(subscription) From 7aac2fadf742c140c698a5787a9809f5e7971a64 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 16:33:47 +0300 Subject: [PATCH 20/47] Update tariff_purchase.py --- app/handlers/subscription/tariff_purchase.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/app/handlers/subscription/tariff_purchase.py b/app/handlers/subscription/tariff_purchase.py index 402c7ae8..a1bcd449 100644 --- a/app/handlers/subscription/tariff_purchase.py +++ b/app/handlers/subscription/tariff_purchase.py @@ -1369,6 +1369,16 @@ async def select_tariff_switch( daily_price = getattr(tariff, 'daily_price_kopeks', 0) user_balance = db_user.balance_kopeks or 0 + # Проверяем текущую подписку на оставшиеся дни + current_subscription = await get_subscription_by_user_id(db, db_user.id) + days_warning = "" + if current_subscription and current_subscription.end_date: + from datetime import datetime + remaining = current_subscription.end_date - datetime.utcnow() + remaining_days = max(0, remaining.days) + if remaining_days > 1: + days_warning = f"\n\n⚠️ Внимание! У вас осталось {remaining_days} дн. подписки.\nПри смене на суточный тариф они будут утеряны!" + if user_balance >= daily_price: await callback.message.edit_text( f"✅ Подтверждение смены тарифа\n\n" @@ -1377,7 +1387,8 @@ async def select_tariff_switch( f"📱 Устройств: {tariff.device_limit}\n" f"🔄 Тип: Суточный\n\n" f"💰 Цена: {_format_price_kopeks(daily_price)}/день\n\n" - f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n\n" + f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}" + f"{days_warning}\n\n" f"ℹ️ Средства будут списываться автоматически раз в сутки.\n" f"Вы можете приостановить подписку в любой момент.", reply_markup=InlineKeyboardMarkup(inline_keyboard=[ @@ -1400,7 +1411,8 @@ async def select_tariff_switch( f"🔄 Тип: Суточный\n" f"💰 Цена: {_format_price_kopeks(daily_price)}/день\n\n" f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n" - f"⚠️ Не хватает: {_format_price_kopeks(missing)}", + f"⚠️ Не хватает: {_format_price_kopeks(missing)}" + f"{days_warning}", reply_markup=InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton( text="💳 Пополнить баланс", From 92a6231b6f66e0d808b7f58899ceebdf7737f86f Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 16:40:42 +0300 Subject: [PATCH 21/47] Update tariff_purchase.py --- app/handlers/subscription/tariff_purchase.py | 83 +++++++++++++++++++- 1 file changed, 82 insertions(+), 1 deletion(-) diff --git a/app/handlers/subscription/tariff_purchase.py b/app/handlers/subscription/tariff_purchase.py index a1bcd449..9d469177 100644 --- a/app/handlers/subscription/tariff_purchase.py +++ b/app/handlers/subscription/tariff_purchase.py @@ -2131,6 +2131,61 @@ async def preview_instant_switch( texts = get_texts(db_user.language) + # Проверяем, суточный ли новый тариф + is_new_daily = getattr(new_tariff, 'is_daily', False) + daily_warning = "" + if is_new_daily and remaining_days > 1: + daily_warning = texts.t( + "DAILY_SWITCH_WARNING", + f"\n\n⚠️ Внимание! У вас осталось {remaining_days} дн. подписки.\nПри смене на суточный тариф они будут утеряны!" + ).format(days=remaining_days) + + # Для суточного тарифа особая логика показа + if is_new_daily: + daily_price = getattr(new_tariff, 'daily_price_kopeks', 0) + user_balance = db_user.balance_kopeks or 0 + + if user_balance >= daily_price: + await callback.message.edit_text( + f"🔄 Переключение на суточный тариф\n\n" + f"📌 Текущий: {current_tariff.name}\n" + f" • Трафик: {current_traffic}\n" + f" • Устройств: {current_tariff.device_limit}\n\n" + f"📦 Новый: {new_tariff.name}\n" + f" • Трафик: {traffic}\n" + f" • Устройств: {new_tariff.device_limit}\n" + f" • Тип: 🔄 Суточный\n\n" + f"💰 Цена: {_format_price_kopeks(daily_price)}/день\n\n" + f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}" + f"{daily_warning}\n\n" + f"ℹ️ Средства будут списываться автоматически раз в сутки.", + reply_markup=get_instant_switch_confirm_keyboard(tariff_id, db_user.language), + parse_mode="HTML" + ) + else: + missing = daily_price - user_balance + await callback.message.edit_text( + f"❌ Недостаточно средств\n\n" + f"📦 Тариф: {new_tariff.name}\n" + f"🔄 Тип: Суточный\n" + f"💰 Цена: {_format_price_kopeks(daily_price)}/день\n\n" + f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n" + f"⚠️ Не хватает: {_format_price_kopeks(missing)}" + f"{daily_warning}", + reply_markup=get_instant_switch_insufficient_balance_keyboard(tariff_id, db_user.language), + parse_mode="HTML" + ) + + await state.update_data( + switch_tariff_id=tariff_id, + upgrade_cost=0, + is_upgrade=False, + current_tariff_id=current_tariff_id, + remaining_days=remaining_days, + ) + await callback.answer() + return + if is_upgrade: # Upgrade - нужна доплата if user_balance >= upgrade_cost: @@ -2237,13 +2292,39 @@ async def confirm_instant_switch( # Получаем список серверов из нового тарифа squads = new_tariff.allowed_squads or [] + # Проверяем, суточный ли новый тариф + is_new_daily = getattr(new_tariff, 'is_daily', False) + # Обновляем подписку с новыми параметрами тарифа - # НЕ меняем end_date - только параметры тарифа subscription.tariff_id = new_tariff.id subscription.traffic_limit_gb = new_tariff.traffic_limit_gb subscription.device_limit = new_tariff.device_limit subscription.connected_squads = squads + if is_new_daily: + # Для суточного тарифа - сбрасываем на 1 день и настраиваем суточные параметры + daily_price = getattr(new_tariff, 'daily_price_kopeks', 0) + + # Списываем первый день если ещё не списано (upgrade_cost был 0) + if upgrade_cost == 0 and daily_price > 0: + if user_balance >= daily_price: + await subtract_user_balance( + db, db_user, daily_price, + f"Переключение на суточный тариф {new_tariff.name} (первый день)" + ) + await create_transaction( + db, + user_id=db_user.id, + type=TransactionType.SUBSCRIPTION_PAYMENT, + amount_kopeks=-daily_price, + description=f"Переключение на суточный тариф {new_tariff.name} (первый день)", + ) + + subscription.end_date = datetime.utcnow() + timedelta(days=1) + subscription.is_trial = False + subscription.is_daily_paused = False + subscription.last_daily_charge_at = datetime.utcnow() + await db.commit() await db.refresh(subscription) From 891b3887992b21f314f13b34dc47e625901f83dc Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 16:41:36 +0300 Subject: [PATCH 22/47] Add files via upload --- app/localization/locales/en.json | 6 +++++- app/localization/locales/ru.json | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index bd0721ec..045969cb 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -1660,5 +1660,9 @@ "ADMIN_USER_RESTRICTIONS": "⚠️ Restrict", "USER_RESTRICTION_TOPUP_BLOCKED": "🚫 Top-up restricted\n\n{reason}\n\nIf you believe this is an error, you can appeal the decision.", "USER_RESTRICTION_SUBSCRIPTION_BLOCKED": "🚫 Subscription purchase/renewal restricted\n\n{reason}\n\nIf you believe this is an error, you can appeal the decision.", - "USER_RESTRICTION_APPEAL_BUTTON": "🆘 Appeal" + "USER_RESTRICTION_APPEAL_BUTTON": "🆘 Appeal", + + "PAUSE_DAILY_BUTTON": "⏸️ Pause subscription", + "RESUME_DAILY_BUTTON": "▶️ Resume subscription", + "DAILY_SWITCH_WARNING": "⚠️ Warning! You have {days} days left.\nThey will be lost when switching to daily tariff!" } diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index ea9167fa..4214b4a3 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -1677,5 +1677,9 @@ "ADMIN_USER_RESTRICTIONS": "⚠️ Ограничить", "USER_RESTRICTION_TOPUP_BLOCKED": "🚫 Пополнение ограничено\n\n{reason}\n\nЕсли вы считаете это ошибкой, вы можете обжаловать решение.", "USER_RESTRICTION_SUBSCRIPTION_BLOCKED": "🚫 Покупка/продление подписки ограничено\n\n{reason}\n\nЕсли вы считаете это ошибкой, вы можете обжаловать решение.", - "USER_RESTRICTION_APPEAL_BUTTON": "🆘 Обжаловать" + "USER_RESTRICTION_APPEAL_BUTTON": "🆘 Обжаловать", + + "PAUSE_DAILY_BUTTON": "⏸️ Приостановить подписку", + "RESUME_DAILY_BUTTON": "▶️ Возобновить подписку", + "DAILY_SWITCH_WARNING": "⚠️ Внимание! У вас осталось {days} дн. подписки.\nПри смене на суточный тариф они будут утеряны!" } From 030acf07a42ad6388ccc92099493b1c67ccd3e7f Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 16:47:52 +0300 Subject: [PATCH 23/47] Update tariff_purchase.py --- app/handlers/subscription/tariff_purchase.py | 50 +++++++++++++------- 1 file changed, 34 insertions(+), 16 deletions(-) diff --git a/app/handlers/subscription/tariff_purchase.py b/app/handlers/subscription/tariff_purchase.py index 9d469177..e54335d9 100644 --- a/app/handlers/subscription/tariff_purchase.py +++ b/app/handlers/subscription/tariff_purchase.py @@ -2369,24 +2369,42 @@ async def confirm_instant_switch( traffic = _format_traffic(new_tariff.traffic_limit_gb) - if is_upgrade: - cost_text = f"💰 Списано: {_format_price_kopeks(upgrade_cost)}" + # Для суточного тарифа другое сообщение об успехе + if is_new_daily: + daily_price = getattr(new_tariff, 'daily_price_kopeks', 0) + await callback.message.edit_text( + f"🎉 Тариф успешно изменён!\n\n" + f"📦 Новый тариф: {new_tariff.name}\n" + f"📊 Трафик: {traffic}\n" + f"📱 Устройств: {new_tariff.device_limit}\n" + f"🔄 Тип: Суточный\n" + f"💰 Списано: {_format_price_kopeks(daily_price)}\n\n" + f"ℹ️ Следующее списание через 24 часа.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")], + [InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")] + ]), + parse_mode="HTML" + ) else: - cost_text = "💰 Бесплатно" + if is_upgrade: + cost_text = f"💰 Списано: {_format_price_kopeks(upgrade_cost)}" + else: + cost_text = "💰 Бесплатно" - await callback.message.edit_text( - f"🎉 Тариф успешно изменён!\n\n" - f"📦 Новый тариф: {new_tariff.name}\n" - f"📊 Трафик: {traffic}\n" - f"📱 Устройств: {new_tariff.device_limit}\n" - f"⏰ Осталось дней: {remaining_days}\n" - f"{cost_text}", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")], - [InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")] - ]), - parse_mode="HTML" - ) + await callback.message.edit_text( + f"🎉 Тариф успешно изменён!\n\n" + f"📦 Новый тариф: {new_tariff.name}\n" + f"📊 Трафик: {traffic}\n" + f"📱 Устройств: {new_tariff.device_limit}\n" + f"⏰ Осталось дней: {remaining_days}\n" + f"{cost_text}", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")], + [InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")] + ]), + parse_mode="HTML" + ) await callback.answer("Тариф изменён!", show_alert=True) except Exception as e: From 71ff89ca55c257402684dbe7e03fee57b97f7ee3 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 16:56:00 +0300 Subject: [PATCH 24/47] Update monitoring_service.py --- app/services/monitoring_service.py | 43 +++++++++++++++++++++++------- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py index 44b6b5b9..adac5867 100644 --- a/app/services/monitoring_service.py +++ b/app/services/monitoring_service.py @@ -46,6 +46,7 @@ from app.database.models import ( MonitoringLog, SubscriptionStatus, Subscription, + Tariff, User, Ticket, TicketStatus, @@ -694,7 +695,10 @@ class MonitoringService: result = await db.execute( select(Subscription) - .options(selectinload(Subscription.user)) + .options( + selectinload(Subscription.user), + selectinload(Subscription.tariff), + ) .where( and_( Subscription.is_trial == False, @@ -703,7 +707,14 @@ class MonitoringService: ) ) - subscriptions = result.scalars().all() + all_subscriptions = result.scalars().all() + + # Исключаем суточные тарифы - для них отдельная логика + subscriptions = [ + sub for sub in all_subscriptions + if not (sub.tariff and getattr(sub.tariff, 'is_daily', False)) + ] + sent_day1 = 0 sent_wave2 = 0 sent_wave3 = 0 @@ -811,27 +822,41 @@ class MonitoringService: async def _get_expiring_paid_subscriptions(self, db: AsyncSession, days_before: int) -> List[Subscription]: current_time = datetime.utcnow() threshold_date = current_time + timedelta(days=days_before) - + result = await db.execute( select(Subscription) - .options(selectinload(Subscription.user)) + .options( + selectinload(Subscription.user), + selectinload(Subscription.tariff), + ) .where( and_( Subscription.status == SubscriptionStatus.ACTIVE.value, - Subscription.is_trial == False, + Subscription.is_trial == False, Subscription.end_date > current_time, Subscription.end_date <= threshold_date ) ) ) - + logger.debug(f"🔍 Поиск платных подписок, истекающих в ближайшие {days_before} дней") logger.debug(f"📅 Текущее время: {current_time}") logger.debug(f"📅 Пороговая дата: {threshold_date}") - - subscriptions = result.scalars().all() + + all_subscriptions = result.scalars().all() + + # Исключаем суточные тарифы - для них отдельная логика списания + subscriptions = [ + sub for sub in all_subscriptions + if not (sub.tariff and getattr(sub.tariff, 'is_daily', False)) + ] + + excluded_count = len(all_subscriptions) - len(subscriptions) + if excluded_count > 0: + logger.debug(f"🔄 Исключено {excluded_count} суточных подписок из уведомлений") + logger.info(f"📊 Найдено {len(subscriptions)} платных подписок для уведомлений") - + return subscriptions @staticmethod From 0759657185f12828e0ed06f9e2e165d38648d749 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 17:00:05 +0300 Subject: [PATCH 25/47] Update purchase.py --- app/handlers/subscription/purchase.py | 40 +++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py index 79fb4ce6..e4d707ba 100644 --- a/app/handlers/subscription/purchase.py +++ b/app/handlers/subscription/purchase.py @@ -2,6 +2,8 @@ import base64 import json import logging from datetime import datetime, timedelta + +logger = logging.getLogger(__name__) from typing import Dict, List, Any, Tuple, Optional from urllib.parse import quote from aiogram import Dispatcher, types, F @@ -134,7 +136,7 @@ from app.handlers.simple_subscription import ( _get_simple_subscription_payment_keyboard, ) -from .common import _apply_promo_offer_discount, _get_promo_offer_discount_percent, logger, update_traffic_prices +from .common import _apply_promo_offer_discount, _get_promo_offer_discount_percent, update_traffic_prices from .autopay import ( handle_autopay_menu, handle_subscription_cancel, @@ -337,10 +339,14 @@ async def show_subscription_info( tariff_line = "" tariff_info_block = "" tariff = None + print(f"[DEBUG TARIFF] Режим тарифов: {settings.is_tariffs_mode()}, tariff_id: {subscription.tariff_id}") + logger.info(f"🔍 Режим тарифов: {settings.is_tariffs_mode()}, tariff_id: {subscription.tariff_id}") if settings.is_tariffs_mode() and subscription.tariff_id: try: from app.database.crud.tariff import get_tariff_by_id tariff = await get_tariff_by_id(db, subscription.tariff_id) + print(f"[DEBUG TARIFF] Загружен тариф: {tariff.name if tariff else None}") + logger.info(f"🔍 Загружен тариф: {tariff.name if tariff else None}, is_daily: {getattr(tariff, 'is_daily', None) if tariff else None}") if tariff: tariff_line = f"\n📦 Тариф: {tariff.name}" # Прикрепляем тариф к подписке для использования в клавиатуре @@ -398,13 +404,35 @@ async def show_subscription_info( tariff_info_lines.append("⏳ Первое списание скоро") tariff_info_block = "\n
" + "\n".join(tariff_info_lines) + "
" + print(f"[DEBUG TARIFF] Сформирован блок: {len(tariff_info_block)} символов") + logger.info(f"🔍 Сформирован блок тарифа: {len(tariff_info_block)} символов") except Exception as e: - logger.warning(f"Ошибка получения тарифа: {e}") + print(f"[DEBUG TARIFF] ОШИБКА: {e}") + logger.warning(f"Ошибка получения тарифа: {e}", exc_info=True) - message_template = texts.t( - "SUBSCRIPTION_OVERVIEW_TEMPLATE", - """👤 {full_name} + print(f"[DEBUG TARIFF] tariff_line='{tariff_line}', tariff_info_block_len={len(tariff_info_block)}") + # Определяем, суточный ли тариф для выбора шаблона + is_daily_tariff = tariff and getattr(tariff, 'is_daily', False) + + if is_daily_tariff: + # Для суточных тарифов другой шаблон без "Действует до" и "Осталось" + message_template = texts.t( + "SUBSCRIPTION_DAILY_OVERVIEW_TEMPLATE", + """👤 {full_name} +💰 Баланс: {balance} +📱 Подписка: {status_emoji} {status_display}{warning}{tariff_info_block} + +📱 Информация о подписке +🎭 Тип: {subscription_type}{tariff_line} +📈 Трафик: {traffic} +🌍 Серверы: {servers} +📱 Устройства: {devices_used} / {device_limit}""", + ) + else: + message_template = texts.t( + "SUBSCRIPTION_OVERVIEW_TEMPLATE", + """👤 {full_name} 💰 Баланс: {balance} 📱 Подписка: {status_emoji} {status_display}{warning}{tariff_info_block} @@ -415,7 +443,7 @@ async def show_subscription_info( 📈 Трафик: {traffic} 🌍 Серверы: {servers} 📱 Устройства: {devices_used} / {device_limit}""", - ) + ) if not show_devices: message_template = message_template.replace( From 3839f6f7095fc66d080cf22103144e173980209f Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 17:20:13 +0300 Subject: [PATCH 26/47] Add files via upload --- app/handlers/subscription/purchase.py | 8 ------ app/handlers/subscription/tariff_purchase.py | 30 ++++++++++++++++++++ 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py index e4d707ba..892baefc 100644 --- a/app/handlers/subscription/purchase.py +++ b/app/handlers/subscription/purchase.py @@ -339,14 +339,10 @@ async def show_subscription_info( tariff_line = "" tariff_info_block = "" tariff = None - print(f"[DEBUG TARIFF] Режим тарифов: {settings.is_tariffs_mode()}, tariff_id: {subscription.tariff_id}") - logger.info(f"🔍 Режим тарифов: {settings.is_tariffs_mode()}, tariff_id: {subscription.tariff_id}") if settings.is_tariffs_mode() and subscription.tariff_id: try: from app.database.crud.tariff import get_tariff_by_id tariff = await get_tariff_by_id(db, subscription.tariff_id) - print(f"[DEBUG TARIFF] Загружен тариф: {tariff.name if tariff else None}") - logger.info(f"🔍 Загружен тариф: {tariff.name if tariff else None}, is_daily: {getattr(tariff, 'is_daily', None) if tariff else None}") if tariff: tariff_line = f"\n📦 Тариф: {tariff.name}" # Прикрепляем тариф к подписке для использования в клавиатуре @@ -404,14 +400,10 @@ async def show_subscription_info( tariff_info_lines.append("⏳ Первое списание скоро") tariff_info_block = "\n
" + "\n".join(tariff_info_lines) + "
" - print(f"[DEBUG TARIFF] Сформирован блок: {len(tariff_info_block)} символов") - logger.info(f"🔍 Сформирован блок тарифа: {len(tariff_info_block)} символов") except Exception as e: - print(f"[DEBUG TARIFF] ОШИБКА: {e}") logger.warning(f"Ошибка получения тарифа: {e}", exc_info=True) - print(f"[DEBUG TARIFF] tariff_line='{tariff_line}', tariff_info_block_len={len(tariff_info_block)}") # Определяем, суточный ли тариф для выбора шаблона is_daily_tariff = tariff and getattr(tariff, 'is_daily', False) diff --git a/app/handlers/subscription/tariff_purchase.py b/app/handlers/subscription/tariff_purchase.py index e54335d9..266821ab 100644 --- a/app/handlers/subscription/tariff_purchase.py +++ b/app/handlers/subscription/tariff_purchase.py @@ -545,6 +545,12 @@ async def confirm_tariff_purchase( # Получаем список серверов из тарифа squads = tariff.allowed_squads or [] + # Если allowed_squads пустой - значит "все серверы", получаем их + if not squads: + from app.database.crud.server_squad import get_all_server_squads + all_servers, _ = await get_all_server_squads(db, available_only=True) + squads = [s.squad_uuid for s in all_servers if s.squad_uuid] + # Проверяем есть ли уже подписка existing_subscription = await get_subscription_by_user_id(db, db_user.id) @@ -689,6 +695,12 @@ async def confirm_daily_tariff_purchase( # Получаем список серверов из тарифа squads = tariff.allowed_squads or [] + # Если allowed_squads пустой - значит "все серверы", получаем их + if not squads: + from app.database.crud.server_squad import get_all_server_squads + all_servers, _ = await get_all_server_squads(db, available_only=True) + squads = [s.squad_uuid for s in all_servers if s.squad_uuid] + # Проверяем есть ли уже подписка existing_subscription = await get_subscription_by_user_id(db, db_user.id) @@ -1592,6 +1604,12 @@ async def confirm_tariff_switch( # Получаем список серверов из тарифа squads = tariff.allowed_squads or [] + # Если allowed_squads пустой - значит "все серверы", получаем их + if not squads: + from app.database.crud.server_squad import get_all_server_squads + all_servers, _ = await get_all_server_squads(db, available_only=True) + squads = [s.squad_uuid for s in all_servers if s.squad_uuid] + # При смене тарифа пользователь получает ровно тот период, за который заплатил # Старые дни не сохраняются - это смена тарифа, а не продление days_for_new_tariff = period @@ -1734,6 +1752,12 @@ async def confirm_daily_tariff_switch( # Получаем список серверов из тарифа squads = tariff.allowed_squads or [] + # Если allowed_squads пустой - значит "все серверы", получаем их + if not squads: + from app.database.crud.server_squad import get_all_server_squads + all_servers, _ = await get_all_server_squads(db, available_only=True) + squads = [s.squad_uuid for s in all_servers if s.squad_uuid] + # Обновляем подписку на суточный тариф subscription.tariff_id = tariff.id subscription.traffic_limit_gb = tariff.traffic_limit_gb @@ -2292,6 +2316,12 @@ async def confirm_instant_switch( # Получаем список серверов из нового тарифа squads = new_tariff.allowed_squads or [] + # Если allowed_squads пустой - значит "все серверы", получаем их + if not squads: + from app.database.crud.server_squad import get_all_server_squads + all_servers, _ = await get_all_server_squads(db, available_only=True) + squads = [s.squad_uuid for s in all_servers if s.squad_uuid] + # Проверяем, суточный ли новый тариф is_new_daily = getattr(new_tariff, 'is_daily', False) From 0903f42da91664866b083c8db28dafcd010ae07a Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 17:20:55 +0300 Subject: [PATCH 27/47] Add files via upload --- app/localization/locales/en.json | 3 ++- app/localization/locales/ru.json | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index 045969cb..fcfb7d65 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -1424,7 +1424,8 @@ "SUBSCRIPTION_NOT_FOUND": "❌ Subscription not found", "SUBSCRIPTION_NO_ACTIVE_LINK": "⚠ You don't have an active subscription or the link is still being generated", "SUBSCRIPTION_NO_SERVERS": "No servers", - "SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Balance: {balance}\n📱 Subscription: {status_emoji} {status_display}{warning}\n\n📱 Subscription details\n🎭 Type: {subscription_type}\n📅 Valid until: {end_date}\n⏰ Time left: {time_left}\n📈 Traffic: {traffic}\n🌍 Servers: {servers}\n📱 Devices: {devices_used} / {device_limit}", + "SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Balance: {balance}\n📱 Subscription: {status_emoji} {status_display}{warning}{tariff_info_block}\n\n📱 Subscription details\n🎭 Type: {subscription_type}{tariff_line}\n📅 Valid until: {end_date}\n⏰ Time left: {time_left}\n📈 Traffic: {traffic}\n🌍 Servers: {servers}\n📱 Devices: {devices_used} / {device_limit}", + "SUBSCRIPTION_DAILY_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Balance: {balance}\n📱 Subscription: {status_emoji} {status_display}{warning}{tariff_info_block}\n\n📱 Subscription details\n🎭 Type: {subscription_type}{tariff_line}\n📈 Traffic: {traffic}\n🌍 Servers: {servers}\n📱 Devices: {devices_used} / {device_limit}", "SUBSCRIPTION_PROMO_DISCOUNT_HINT": "⚡ Extra {percent}% discount is active and will apply automatically. It stacks with other discounts.", "SUBSCRIPTION_PROMO_DISCOUNT_NOTE": "⚡ Extra discount {percent}%: -{amount}", "SUBSCRIPTION_PROMO_DISCOUNT_TIMER": "⏳ Discount active for {time_left}\n{bar}", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index 4214b4a3..16ed01da 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -1441,7 +1441,8 @@ "SUBSCRIPTION_NOT_FOUND": "❌ Подписка не найдена", "SUBSCRIPTION_NO_ACTIVE_LINK": "⚠ У вас нет активной подписки или ссылка еще генерируется", "SUBSCRIPTION_NO_SERVERS": "Нет серверов", - "SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Баланс: {balance}\n📱 Подписка: {status_emoji} {status_display}{warning}\n\n📱 Информация о подписке\n🎭 Тип: {subscription_type}\n📅 Действует до: {end_date}\n⏰ Осталось: {time_left}\n📈 Трафик: {traffic}\n🌍 Серверы: {servers}\n📱 Устройства: {devices_used} / {device_limit}", + "SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Баланс: {balance}\n📱 Подписка: {status_emoji} {status_display}{warning}{tariff_info_block}\n\n📱 Информация о подписке\n🎭 Тип: {subscription_type}{tariff_line}\n📅 Действует до: {end_date}\n⏰ Осталось: {time_left}\n📈 Трафик: {traffic}\n🌍 Серверы: {servers}\n📱 Устройства: {devices_used} / {device_limit}", + "SUBSCRIPTION_DAILY_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Баланс: {balance}\n📱 Подписка: {status_emoji} {status_display}{warning}{tariff_info_block}\n\n📱 Информация о подписке\n🎭 Тип: {subscription_type}{tariff_line}\n📈 Трафик: {traffic}\n🌍 Серверы: {servers}\n📱 Устройства: {devices_used} / {device_limit}", "SUBSCRIPTION_PROMO_DISCOUNT_HINT": "⚡ Активирована доп. скидка {percent}%. \n\nСуммируется с другими скидками!", "SUBSCRIPTION_PROMO_DISCOUNT_NOTE": "⚡ Доп. скидка {percent}%: -{amount}", "SUBSCRIPTION_PROMO_DISCOUNT_TIMER": "⏳ Скидка действует ещё: {time_left}\n{bar}", From 09bb52f8e5ac6369f93d172acdefb8b9994da4a9 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 17:25:47 +0300 Subject: [PATCH 28/47] Add files via upload --- app/localization/locales/en.json | 4 ++-- app/localization/locales/ru.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index fcfb7d65..c6db1338 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -1424,8 +1424,8 @@ "SUBSCRIPTION_NOT_FOUND": "❌ Subscription not found", "SUBSCRIPTION_NO_ACTIVE_LINK": "⚠ You don't have an active subscription or the link is still being generated", "SUBSCRIPTION_NO_SERVERS": "No servers", - "SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Balance: {balance}\n📱 Subscription: {status_emoji} {status_display}{warning}{tariff_info_block}\n\n📱 Subscription details\n🎭 Type: {subscription_type}{tariff_line}\n📅 Valid until: {end_date}\n⏰ Time left: {time_left}\n📈 Traffic: {traffic}\n🌍 Servers: {servers}\n📱 Devices: {devices_used} / {device_limit}", - "SUBSCRIPTION_DAILY_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Balance: {balance}\n📱 Subscription: {status_emoji} {status_display}{warning}{tariff_info_block}\n\n📱 Subscription details\n🎭 Type: {subscription_type}{tariff_line}\n📈 Traffic: {traffic}\n🌍 Servers: {servers}\n📱 Devices: {devices_used} / {device_limit}", + "SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Balance: {balance}\n📱 Subscription: {status_emoji} {status_display}{warning}{tariff_info_block}\n\n📱 Subscription details\n🎭 Type: {subscription_type}\n📅 Valid until: {end_date}\n⏰ Time left: {time_left}\n📈 Traffic: {traffic}\n🌍 Servers: {servers}\n📱 Devices: {devices_used} / {device_limit}", + "SUBSCRIPTION_DAILY_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Balance: {balance}\n📱 Subscription: {status_emoji} {status_display}{warning}{tariff_info_block}\n\n📱 Subscription details\n🎭 Type: {subscription_type}\n📈 Traffic: {traffic}\n🌍 Servers: {servers}\n📱 Devices: {devices_used} / {device_limit}", "SUBSCRIPTION_PROMO_DISCOUNT_HINT": "⚡ Extra {percent}% discount is active and will apply automatically. It stacks with other discounts.", "SUBSCRIPTION_PROMO_DISCOUNT_NOTE": "⚡ Extra discount {percent}%: -{amount}", "SUBSCRIPTION_PROMO_DISCOUNT_TIMER": "⏳ Discount active for {time_left}\n{bar}", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index 16ed01da..f995f979 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -1441,8 +1441,8 @@ "SUBSCRIPTION_NOT_FOUND": "❌ Подписка не найдена", "SUBSCRIPTION_NO_ACTIVE_LINK": "⚠ У вас нет активной подписки или ссылка еще генерируется", "SUBSCRIPTION_NO_SERVERS": "Нет серверов", - "SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Баланс: {balance}\n📱 Подписка: {status_emoji} {status_display}{warning}{tariff_info_block}\n\n📱 Информация о подписке\n🎭 Тип: {subscription_type}{tariff_line}\n📅 Действует до: {end_date}\n⏰ Осталось: {time_left}\n📈 Трафик: {traffic}\n🌍 Серверы: {servers}\n📱 Устройства: {devices_used} / {device_limit}", - "SUBSCRIPTION_DAILY_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Баланс: {balance}\n📱 Подписка: {status_emoji} {status_display}{warning}{tariff_info_block}\n\n📱 Информация о подписке\n🎭 Тип: {subscription_type}{tariff_line}\n📈 Трафик: {traffic}\n🌍 Серверы: {servers}\n📱 Устройства: {devices_used} / {device_limit}", + "SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Баланс: {balance}\n📱 Подписка: {status_emoji} {status_display}{warning}{tariff_info_block}\n\n📱 Информация о подписке\n🎭 Тип: {subscription_type}\n📅 Действует до: {end_date}\n⏰ Осталось: {time_left}\n📈 Трафик: {traffic}\n🌍 Серверы: {servers}\n📱 Устройства: {devices_used} / {device_limit}", + "SUBSCRIPTION_DAILY_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Баланс: {balance}\n📱 Подписка: {status_emoji} {status_display}{warning}{tariff_info_block}\n\n📱 Информация о подписке\n🎭 Тип: {subscription_type}\n📈 Трафик: {traffic}\n🌍 Серверы: {servers}\n📱 Устройства: {devices_used} / {device_limit}", "SUBSCRIPTION_PROMO_DISCOUNT_HINT": "⚡ Активирована доп. скидка {percent}%. \n\nСуммируется с другими скидками!", "SUBSCRIPTION_PROMO_DISCOUNT_NOTE": "⚡ Доп. скидка {percent}%: -{amount}", "SUBSCRIPTION_PROMO_DISCOUNT_TIMER": "⏳ Скидка действует ещё: {time_left}\n{bar}", From 0f8a367b24d6280c3191f997adcd025525ac42fd Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 17:26:16 +0300 Subject: [PATCH 29/47] Add files via upload --- app/handlers/subscription/purchase.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py index 892baefc..ac2c9122 100644 --- a/app/handlers/subscription/purchase.py +++ b/app/handlers/subscription/purchase.py @@ -336,7 +336,6 @@ async def show_subscription_info( ) # Получаем информацию о тарифе для режима тарифов - tariff_line = "" tariff_info_block = "" tariff = None if settings.is_tariffs_mode() and subscription.tariff_id: @@ -344,7 +343,6 @@ async def show_subscription_info( from app.database.crud.tariff import get_tariff_by_id tariff = await get_tariff_by_id(db, subscription.tariff_id) if tariff: - tariff_line = f"\n📦 Тариф: {tariff.name}" # Прикрепляем тариф к подписке для использования в клавиатуре subscription.tariff = tariff @@ -416,7 +414,7 @@ async def show_subscription_info( 📱 Подписка: {status_emoji} {status_display}{warning}{tariff_info_block} 📱 Информация о подписке -🎭 Тип: {subscription_type}{tariff_line} +🎭 Тип: {subscription_type} 📈 Трафик: {traffic} 🌍 Серверы: {servers} 📱 Устройства: {devices_used} / {device_limit}""", @@ -429,7 +427,7 @@ async def show_subscription_info( 📱 Подписка: {status_emoji} {status_display}{warning}{tariff_info_block} 📱 Информация о подписке -🎭 Тип: {subscription_type}{tariff_line} +🎭 Тип: {subscription_type} 📅 Действует до: {end_date} ⏰ Осталось: {time_left} 📈 Трафик: {traffic} @@ -460,7 +458,6 @@ async def show_subscription_info( warning=warning_text, tariff_info_block=tariff_info_block, subscription_type=subscription_type, - tariff_line=tariff_line, end_date=format_local_datetime(subscription.end_date, "%d.%m.%Y %H:%M"), time_left=time_left_text, traffic=traffic_used_display, From a9e56504a77dcfe9b2f61fb281b2fea5b9453250 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 17:33:11 +0300 Subject: [PATCH 30/47] Update menu.py --- app/handlers/menu.py | 35 +++++++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/app/handlers/menu.py b/app/handlers/menu.py index 092b40cc..d11d4970 100644 --- a/app/handlers/menu.py +++ b/app/handlers/menu.py @@ -1092,7 +1092,7 @@ async def handle_back_to_menu( ) await callback.answer() -def _get_subscription_status(user: User, texts) -> str: +def _get_subscription_status(user: User, texts, is_daily_tariff: bool = False) -> str: subscription = getattr(user, "subscription", None) if not subscription: return texts.t("SUB_STATUS_NONE", "❌ Отсутствует") @@ -1144,6 +1144,10 @@ def _get_subscription_status(user: User, texts) -> str: ) if actual_status == "active": + # Для суточных тарифов не показываем предупреждение об истечении + if is_daily_tariff: + return texts.t("SUB_STATUS_DAILY_ACTIVE", "💎 Активна") + if days_left > 7 and end_date_text: return texts.t( "SUB_STATUS_ACTIVE_LONG", @@ -1185,12 +1189,39 @@ def _insert_random_message(base_text: str, random_message: str, action_prompt: s async def get_main_menu_text(user, texts, db: AsyncSession): + from app.config import settings + + # Загружаем информацию о тарифе если включен режим тарифов + tariff = None + is_daily_tariff = False + tariff_info_block = "" + + subscription = getattr(user, "subscription", None) + if settings.is_tariffs_mode() and subscription and subscription.tariff_id: + try: + from app.database.crud.tariff import get_tariff_by_id + tariff = await get_tariff_by_id(db, subscription.tariff_id) + if tariff: + is_daily_tariff = getattr(tariff, 'is_daily', False) + # Формируем краткий блок информации о тарифе для главного меню + tariff_info_block = f"\n📦 Тариф: {tariff.name}" + except Exception as e: + logger.debug(f"Не удалось загрузить тариф для главного меню: {e}") base_text = texts.MAIN_MENU.format( user_name=user.full_name, - subscription_status=_get_subscription_status(user, texts) + subscription_status=_get_subscription_status(user, texts, is_daily_tariff) ) + # Добавляем информацию о тарифе перед "Выберите действие" + if tariff_info_block: + action_prompt_text = texts.t("MAIN_MENU_ACTION_PROMPT", "Выберите действие:") + if action_prompt_text in base_text: + base_text = base_text.replace( + action_prompt_text, + f"{tariff_info_block}\n\n{action_prompt_text}" + ) + action_prompt = texts.t("MAIN_MENU_ACTION_PROMPT", "Выберите действие:") info_sections: list[str] = [] From d9514ac00e1e3984186329f06b9b114a3399f6cd Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 17:33:52 +0300 Subject: [PATCH 31/47] Add files via upload --- app/localization/locales/en.json | 1 + app/localization/locales/ru.json | 1 + app/localization/locales/ua.json | 4 +++- app/localization/locales/zh.json | 8 ++++++-- 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index c6db1338..1d267f80 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -1456,6 +1456,7 @@ "SUB_STATUS_ACTIVE_LONG": "💎 Active\n📅 until {end_date} ({days} days)", "SUB_STATUS_ACTIVE_TODAY": "💎 Active\n⚠️ expires today!", "SUB_STATUS_ACTIVE_TOMORROW": "💎 Active\n⚠️ expires tomorrow!", + "SUB_STATUS_DAILY_ACTIVE": "💎 Active", "SUB_STATUS_EXPIRED": "🔴 Expired\n📅 {end_date}", "SUB_STATUS_DISABLED": "⚫ Disabled", "SUB_STATUS_PENDING": "⏳ Pending activation", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index f995f979..c8ddc9ac 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -1473,6 +1473,7 @@ "SUB_STATUS_ACTIVE_LONG": "💎 Активна\n📅 до {end_date} ({days} дн.)", "SUB_STATUS_ACTIVE_TODAY": "💎 Активна\n⚠️ истекает сегодня!", "SUB_STATUS_ACTIVE_TOMORROW": "💎 Активна\n⚠️ истекает завтра!", + "SUB_STATUS_DAILY_ACTIVE": "💎 Активна", "SUB_STATUS_EXPIRED": "🔴 Истекла\n📅 {end_date}", "SUB_STATUS_DISABLED": "⚫ Отключена", "SUB_STATUS_PENDING": "⏳ Ожидает активации", diff --git a/app/localization/locales/ua.json b/app/localization/locales/ua.json index baa7ef9a..eb8db8d6 100644 --- a/app/localization/locales/ua.json +++ b/app/localization/locales/ua.json @@ -1355,7 +1355,8 @@ "SUBSCRIPTION_NOT_FOUND": "❌ Підписку не знайдено", "SUBSCRIPTION_NO_ACTIVE_LINK": "⚠ У вас немає активної підписки або посилання ще генерується", "SUBSCRIPTION_NO_SERVERS": "Немає серверів", - "SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Баланс: {balance}\n📱 Підписка: {status_emoji} {status_display}{warning}\n\n📱 Інформація про підписку\n🎭 Тип: {subscription_type}\n📅 Діє до: {end_date}\n⏰ Залишилося: {time_left}\n📈 Трафік: {traffic}\n🌍 Сервери: {servers}\n📱 Пристрої: {devices_used} / {device_limit}", + "SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Баланс: {balance}\n📱 Підписка: {status_emoji} {status_display}{warning}{tariff_info_block}\n\n📱 Інформація про підписку\n🎭 Тип: {subscription_type}\n📅 Діє до: {end_date}\n⏰ Залишилося: {time_left}\n📈 Трафік: {traffic}\n🌍 Сервери: {servers}\n📱 Пристрої: {devices_used} / {device_limit}", + "SUBSCRIPTION_DAILY_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Баланс: {balance}\n📱 Підписка: {status_emoji} {status_display}{warning}{tariff_info_block}\n\n📱 Інформація про підписку\n🎭 Тип: {subscription_type}\n📈 Трафік: {traffic}\n🌍 Сервери: {servers}\n📱 Пристрої: {devices_used} / {device_limit}", "SUBSCRIPTION_PROMO_DISCOUNT_HINT": "⚡ Активовано дод. знижку {percent}%. \n\nСумується з іншими знижками!", "SUBSCRIPTION_PROMO_DISCOUNT_NOTE": "⚡ Дод. знижка {percent}%: -{amount}", "SUBSCRIPTION_PROMO_DISCOUNT_TIMER": "⏳ Знижка діє ще: {time_left}\n{bar}", @@ -1386,6 +1387,7 @@ "SUB_STATUS_ACTIVE_LONG": "💎 Активна\n📅 до {end_date} ({days} дн.)", "SUB_STATUS_ACTIVE_TODAY": "💎 Активна\n⚠️ закінчується сьогодні!", "SUB_STATUS_ACTIVE_TOMORROW": "💎 Активна\n⚠️ закінчується завтра!", + "SUB_STATUS_DAILY_ACTIVE": "💎 Активна", "SUB_STATUS_EXPIRED": "🔴 Закінчилася\n📅 {end_date}", "SUB_STATUS_DISABLED": "⚫ Вимкнена", "SUB_STATUS_PENDING": "⏳ Очікує активації", diff --git a/app/localization/locales/zh.json b/app/localization/locales/zh.json index 8b7f7c27..9b792ce7 100644 --- a/app/localization/locales/zh.json +++ b/app/localization/locales/zh.json @@ -1353,7 +1353,8 @@ "SUBSCRIPTION_NOT_FOUND":"❌未找到订阅", "SUBSCRIPTION_NO_ACTIVE_LINK":"⚠您没有活跃的订阅或链接仍在生成中", "SUBSCRIPTION_NO_SERVERS":"没有服务器", -"SUBSCRIPTION_OVERVIEW_TEMPLATE":"👤{full_name}\n💰余额:{balance}\n📱订阅:{status_emoji}{status_display}{warning}\n\n📱订阅信息\n🎭类型:{subscription_type}\n📅有效期至:{end_date}\n⏰剩余时间:{time_left}\n📈流量:{traffic}\n🌍服务器:{servers}\n📱设备:{devices_used}/{device_limit}", +"SUBSCRIPTION_OVERVIEW_TEMPLATE":"👤{full_name}\n💰余额:{balance}\n📱订阅:{status_emoji}{status_display}{warning}{tariff_info_block}\n\n📱订阅信息\n🎭类型:{subscription_type}\n📅有效期至:{end_date}\n⏰剩余时间:{time_left}\n📈流量:{traffic}\n🌍服务器:{servers}\n📱设备:{devices_used}/{device_limit}", +"SUBSCRIPTION_DAILY_OVERVIEW_TEMPLATE":"👤{full_name}\n💰余额:{balance}\n📱订阅:{status_emoji}{status_display}{warning}{tariff_info_block}\n\n📱订阅信息\n🎭类型:{subscription_type}\n📈流量:{traffic}\n🌍服务器:{servers}\n📱设备:{devices_used}/{device_limit}", "SUBSCRIPTION_PROMO_DISCOUNT_HINT":"⚡已激活额外{percent}%折扣。\n\n可与其他折扣叠加!", "SUBSCRIPTION_PROMO_DISCOUNT_NOTE":"⚡额外{percent}%折扣:-{amount}", "SUBSCRIPTION_PROMO_DISCOUNT_TIMER":"⏳折扣剩余时间:{time_left}\n{bar}", @@ -1384,6 +1385,7 @@ "SUB_STATUS_ACTIVE_LONG":"💎活跃\n📅至{end_date}({days}天)", "SUB_STATUS_ACTIVE_TODAY":"💎活跃\n⚠️今天过期!", "SUB_STATUS_ACTIVE_TOMORROW":"💎活跃\n⚠️明天过期!", +"SUB_STATUS_DAILY_ACTIVE":"💎活跃", "SUB_STATUS_EXPIRED":"🔴已过期\n📅{end_date}", "SUB_STATUS_DISABLED":"⚫已禁用", "SUB_STATUS_PENDING":"⏳等待激活", @@ -1681,7 +1683,8 @@ "SUBSCRIPTION_NOT_FOUND":"❌未找到订阅", "SUBSCRIPTION_NO_ACTIVE_LINK":"⚠您没有活跃的订阅或链接仍在生成中", "SUBSCRIPTION_NO_SERVERS":"没有服务器", -"SUBSCRIPTION_OVERVIEW_TEMPLATE":"👤{full_name}\n💰余额:{balance}\n📱订阅:{status_emoji}{status_display}{warning}\n\n📱订阅信息\n🎭类型:{subscription_type}\n📅有效期至:{end_date}\n⏰剩余时间:{time_left}\n📈流量:{traffic}\n🌍服务器:{servers}\n📱设备:{devices_used}/{device_limit}", +"SUBSCRIPTION_OVERVIEW_TEMPLATE":"👤{full_name}\n💰余额:{balance}\n📱订阅:{status_emoji}{status_display}{warning}{tariff_info_block}\n\n📱订阅信息\n🎭类型:{subscription_type}\n📅有效期至:{end_date}\n⏰剩余时间:{time_left}\n📈流量:{traffic}\n🌍服务器:{servers}\n📱设备:{devices_used}/{device_limit}", +"SUBSCRIPTION_DAILY_OVERVIEW_TEMPLATE":"👤{full_name}\n💰余额:{balance}\n📱订阅:{status_emoji}{status_display}{warning}{tariff_info_block}\n\n📱订阅信息\n🎭类型:{subscription_type}\n📈流量:{traffic}\n🌍服务器:{servers}\n📱设备:{devices_used}/{device_limit}", "SUBSCRIPTION_PROMO_DISCOUNT_HINT":"⚡已激活额外{percent}%折扣。\n\n可与其他折扣叠加!", "SUBSCRIPTION_PROMO_DISCOUNT_NOTE":"⚡额外{percent}%折扣:-{amount}", "SUBSCRIPTION_PROMO_DISCOUNT_TIMER":"⏳折扣剩余时间:{time_left}\n{bar}", @@ -1712,6 +1715,7 @@ "SUB_STATUS_ACTIVE_LONG":"💎活跃\n📅至{end_date}({days}天)", "SUB_STATUS_ACTIVE_TODAY":"💎活跃\n⚠️今天过期!", "SUB_STATUS_ACTIVE_TOMORROW":"💎活跃\n⚠️明天过期!", +"SUB_STATUS_DAILY_ACTIVE":"💎活跃", "SUB_STATUS_EXPIRED":"🔴已过期\n📅{end_date}", "SUB_STATUS_DISABLED":"⚫已禁用", "SUB_STATUS_PENDING":"⏳等待激活", From ae94ed1dcfd10dbf356c9012090456b2971befc7 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 17:36:31 +0300 Subject: [PATCH 32/47] Update subscription_checker.py --- app/middlewares/subscription_checker.py | 60 +++++++++++-------------- 1 file changed, 27 insertions(+), 33 deletions(-) diff --git a/app/middlewares/subscription_checker.py b/app/middlewares/subscription_checker.py index 9d9c523f..18ae8e1c 100644 --- a/app/middlewares/subscription_checker.py +++ b/app/middlewares/subscription_checker.py @@ -2,52 +2,46 @@ import logging from typing import Callable, Dict, Any, Awaitable from datetime import datetime from aiogram import BaseMiddleware -from aiogram.types import TelegramObject, Update, Message, CallbackQuery +from aiogram.types import TelegramObject -from app.database.database import get_db -from app.database.crud.user import get_user_by_telegram_id from app.database.models import SubscriptionStatus logger = logging.getLogger(__name__) class SubscriptionStatusMiddleware(BaseMiddleware): - + """ + Проверяет статус подписки пользователя. + ВАЖНО: Использует db и db_user из data, которые уже загружены в AuthMiddleware. + Не создаёт дополнительных сессий БД. + """ + async def __call__( self, handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], event: TelegramObject, data: Dict[str, Any] ) -> Any: - - telegram_id = None - if isinstance(event, (Message, CallbackQuery)): - telegram_id = event.from_user.id - elif isinstance(event, Update): - if event.message: - telegram_id = event.message.from_user.id - elif event.callback_query: - telegram_id = event.callback_query.from_user.id - - if telegram_id: + # Используем db и user из AuthMiddleware - не создаём новую сессию! + db = data.get('db') + user = data.get('db_user') + + if db and user and user.subscription: try: - async for db in get_db(): - user = await get_user_by_telegram_id(db, telegram_id) - if user and user.subscription: - current_time = datetime.utcnow() - subscription = user.subscription - - if (subscription.status == SubscriptionStatus.ACTIVE.value and - subscription.end_date <= current_time): - - subscription.status = SubscriptionStatus.EXPIRED.value - subscription.updated_at = current_time - await db.commit() - - logger.info(f"⏰ Middleware: Статус подписки пользователя {user.id} изменен на 'expired' (время истекло)") - break - + current_time = datetime.utcnow() + subscription = user.subscription + + if (subscription.status == SubscriptionStatus.ACTIVE.value and + subscription.end_date and + subscription.end_date <= current_time): + + subscription.status = SubscriptionStatus.EXPIRED.value + subscription.updated_at = current_time + await db.commit() + + logger.info(f"⏰ Middleware: Статус подписки пользователя {user.id} изменен на 'expired' (время истекло)") + except Exception as e: - logger.error(f"Ошибка проверки статуса подписки для пользователя {telegram_id}: {e}") - + logger.error(f"Ошибка проверки статуса подписки: {e}") + return await handler(event, data) From 6e8c9bda303decced5cc911591e2ab5239ab3c06 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 17:39:58 +0300 Subject: [PATCH 33/47] Update channel_checker.py --- app/middlewares/channel_checker.py | 56 ++++++++++-------------------- 1 file changed, 18 insertions(+), 38 deletions(-) diff --git a/app/middlewares/channel_checker.py b/app/middlewares/channel_checker.py index cac399fb..67fa2db0 100644 --- a/app/middlewares/channel_checker.py +++ b/app/middlewares/channel_checker.py @@ -1,17 +1,19 @@ import logging from typing import Callable, Dict, Any, Awaitable, Optional +from datetime import datetime from aiogram import BaseMiddleware, Bot, types from aiogram.exceptions import TelegramForbiddenError, TelegramBadRequest from aiogram.fsm.context import FSMContext from aiogram.types import TelegramObject, Update, Message, CallbackQuery from aiogram.enums import ChatMemberStatus +from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database.database import get_db from app.database.crud.campaign import get_campaign_by_start_parameter from app.database.crud.subscription import deactivate_subscription, reactivate_subscription from app.database.crud.user import get_user_by_telegram_id -from app.database.models import SubscriptionStatus +from app.database.models import SubscriptionStatus, User from app.keyboards.inline import get_channel_sub_keyboard from app.localization.loader import DEFAULT_LANGUAGE from app.localization.texts import get_texts @@ -23,6 +25,11 @@ logger = logging.getLogger(__name__) class ChannelCheckerMiddleware(BaseMiddleware): + """ + Middleware для проверки подписки на канал. + ОПТИМИЗИРОВАНО: создаёт максимум одну сессию БД на запрос. + """ + def __init__(self): self.BAD_MEMBER_STATUS = ( ChatMemberStatus.LEFT, @@ -55,10 +62,7 @@ class ChannelCheckerMiddleware(BaseMiddleware): logger.debug("❌ telegram_id не найден, пропускаем") return await handler(event, data) - - # Админам разрешаем пропускать проверку подписки, чтобы не блокировать - # работу панели управления даже при отсутствии подписки. Важно делать - # это до обращения к состоянию, чтобы не выполнять лишние операции. + # Админам разрешаем пропускать проверку подписки if settings.is_admin(telegram_id): logger.debug( "✅ Пользователь %s является администратором — пропускаем проверку подписки", @@ -72,7 +76,6 @@ class ChannelCheckerMiddleware(BaseMiddleware): if state: current_state = await state.get_state() - is_reg_process = is_registration_process(event, current_state) if is_reg_process: @@ -191,10 +194,10 @@ class ChannelCheckerMiddleware(BaseMiddleware): payload = parts[1] - data = await state.get_data() or {} - if data.get("pending_start_payload") != payload: - data["pending_start_payload"] = payload - await state.set_data(data) + state_data = await state.get_data() or {} + if state_data.get("pending_start_payload") != payload: + state_data["pending_start_payload"] = payload + await state.set_data(state_data) logger.debug("💾 Сохранен start payload %s для последующей обработки", payload) if bot and message.from_user: @@ -213,7 +216,7 @@ class ChannelCheckerMiddleware(BaseMiddleware): payload: str, ) -> None: try: - data = await state.get_data() or {} + state_data = await state.get_data() or {} except Exception as error: logger.error( "❌ Не удалось получить данные состояния для уведомления по кампании %s: %s", @@ -222,7 +225,7 @@ class ChannelCheckerMiddleware(BaseMiddleware): ) return - if data.get("campaign_notification_sent"): + if state_data.get("campaign_notification_sent"): return async for db in get_db(): @@ -246,7 +249,6 @@ class ChannelCheckerMiddleware(BaseMiddleware): ) if sent: await state.update_data(campaign_notification_sent=True) - break except Exception as error: logger.error( "❌ Ошибка отправки уведомления о переходе по кампании %s: %s", @@ -259,40 +261,24 @@ class ChannelCheckerMiddleware(BaseMiddleware): async def _deactivate_subscription_on_unsubscribe( self, telegram_id: int, bot: Bot, channel_link: Optional[str] ) -> None: + """Деактивация подписки при отписке от канала.""" if not settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE and not settings.CHANNEL_REQUIRED_FOR_ALL: - logger.debug( - "ℹ️ Пропускаем деактивацию подписки пользователя %s: отключение при отписке выключено", - telegram_id, - ) return async for db in get_db(): try: user = await get_user_by_telegram_id(db, telegram_id) if not user or not user.subscription: - logger.debug( - "⚠️ Пользователь %s отсутствует или не имеет подписки — пропускаем деактивацию", - telegram_id, - ) break subscription = user.subscription if subscription.status != SubscriptionStatus.ACTIVE.value: - logger.debug( - "ℹ️ Подписка пользователя %s не активна (status=%s) — пропускаем деактивацию", - telegram_id, - 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) @@ -324,7 +310,6 @@ class ChannelCheckerMiddleware(BaseMiddleware): ) channel_kb = get_channel_sub_keyboard(channel_link, language=user.language) await bot.send_message(telegram_id, notification_text, reply_markup=channel_kb) - logger.info(f"📨 Уведомление о деактивации отправлено пользователю {telegram_id}") except Exception as notify_error: logger.error( "❌ Не удалось отправить уведомление о деактивации пользователю %s: %s", @@ -341,10 +326,7 @@ class ChannelCheckerMiddleware(BaseMiddleware): break async def _reactivate_subscription_on_subscribe(self, telegram_id: int, bot: Bot) -> None: - """Реактивация подписки после повторной подписки на канал. - - Вызывается только если подписка в статусе DISABLED. - """ + """Реактивация подписки после повторной подписки на канал.""" if not settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE and not settings.CHANNEL_REQUIRED_FOR_ALL: return @@ -356,13 +338,11 @@ class ChannelCheckerMiddleware(BaseMiddleware): subscription = user.subscription - # Реактивируем только DISABLED подписки (деактивированные из-за отписки) - # Тихо выходим если подписка не требует реактивации — без логов + # Реактивируем только DISABLED подписки if subscription.status != SubscriptionStatus.DISABLED.value: break # Проверяем что подписка ещё не истекла - from datetime import datetime if subscription.end_date and subscription.end_date <= datetime.utcnow(): break From c350195bfc9072c93abcc5f41192d3634c800cba Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 17:58:25 +0300 Subject: [PATCH 34/47] Update miniapp.py --- app/webapi/routes/miniapp.py | 129 +++++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index c469335f..b19d8acd 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -197,6 +197,8 @@ from ..schemas.miniapp import ( MiniAppConnectedServer, MiniAppTrafficTopupRequest, MiniAppTrafficTopupResponse, + MiniAppDailySubscriptionToggleRequest, + MiniAppDailySubscriptionToggleResponse, ) @@ -3414,6 +3416,26 @@ async def get_subscription_details( devices_count, devices = await _load_devices_info(user) + # Загружаем данные суточного тарифа + is_daily_tariff = False + is_daily_paused = False + daily_tariff_name = None + daily_price_kopeks = None + daily_price_label = None + daily_next_charge_at = None + + if subscription and getattr(subscription, "tariff_id", None): + tariff = await get_tariff_by_id(db, subscription.tariff_id) + if tariff and getattr(tariff, 'is_daily', False): + is_daily_tariff = True + is_daily_paused = getattr(subscription, 'is_daily_paused', False) + daily_tariff_name = tariff.name + daily_price_kopeks = getattr(tariff, 'daily_price_kopeks', 0) + daily_price_label = settings.format_price(daily_price_kopeks) + "/день" if daily_price_kopeks > 0 else None + # Следующее списание - через 24 часа от последнего обновления подписки или от start_date + if subscription.end_date and not is_daily_paused: + daily_next_charge_at = subscription.end_date + response_user = MiniAppSubscriptionUser( telegram_id=user.telegram_id, username=user.username, @@ -3443,6 +3465,12 @@ async def get_subscription_details( promo_offer_discount_percent=active_discount_percent, promo_offer_discount_expires_at=active_discount_expires_at, promo_offer_discount_source=promo_offer_source, + is_daily_tariff=is_daily_tariff, + is_daily_paused=is_daily_paused, + daily_tariff_name=daily_tariff_name, + daily_price_kopeks=daily_price_kopeks, + daily_price_label=daily_price_label, + daily_next_charge_at=daily_next_charge_at, ) referral_info = await _build_referral_info(db, user) @@ -6340,6 +6368,11 @@ async def _build_tariff_model( is_upgrade = upgrade is_switch_free = cost == 0 + # Суточный тариф + is_daily = getattr(tariff, 'is_daily', False) + daily_price_kopeks = getattr(tariff, 'daily_price_kopeks', 0) if is_daily else 0 + daily_price_label = settings.format_price(daily_price_kopeks) + "/день" if is_daily and daily_price_kopeks > 0 else None + return MiniAppTariff( id=tariff.id, name=tariff.name, @@ -6358,6 +6391,9 @@ async def _build_tariff_model( switch_cost_label=switch_cost_label, is_upgrade=is_upgrade, is_switch_free=is_switch_free, + is_daily=is_daily, + daily_price_kopeks=daily_price_kopeks, + daily_price_label=daily_price_label, ) @@ -6378,6 +6414,11 @@ async def _build_current_tariff_model(db: AsyncSession, tariff, promo_group=None except (TypeError, ValueError): pass + # Суточный тариф + is_daily = getattr(tariff, 'is_daily', False) + daily_price_kopeks = getattr(tariff, 'daily_price_kopeks', 0) if is_daily else 0 + daily_price_label = settings.format_price(daily_price_kopeks) + "/день" if is_daily and daily_price_kopeks > 0 else None + return MiniAppCurrentTariff( id=tariff.id, name=tariff.name, @@ -6389,6 +6430,9 @@ async def _build_current_tariff_model(db: AsyncSession, tariff, promo_group=None device_limit=tariff.device_limit, servers_count=servers_count, monthly_price_kopeks=monthly_price, + is_daily=is_daily, + daily_price_kopeks=daily_price_kopeks, + daily_price_label=daily_price_label, ) @@ -7093,3 +7137,88 @@ async def purchase_traffic_topup_endpoint( new_balance_kopeks=user.balance_kopeks, charged_kopeks=final_price, ) + + +@router.post("/subscription/daily/toggle-pause") +async def toggle_daily_subscription_pause_endpoint( + payload: MiniAppDailySubscriptionToggleRequest, + db: AsyncSession = Depends(get_db_session), +): + """Переключает паузу/активацию суточной подписки.""" + from app.webapi.schemas.miniapp import MiniAppDailySubscriptionToggleResponse + from app.services.subscription_service import SubscriptionService + + user = await _authorize_miniapp_user(payload.init_data, db) + subscription = user.subscription + + if not subscription: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={"code": "no_subscription", "message": "No subscription found"}, + ) + + # Проверяем наличие тарифа + tariff_id = getattr(subscription, 'tariff_id', None) + if not tariff_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"code": "no_tariff", "message": "Subscription has no tariff"}, + ) + + tariff = await get_tariff_by_id(db, tariff_id) + if not tariff or not getattr(tariff, 'is_daily', False): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"code": "not_daily_tariff", "message": "Subscription is not on a daily tariff"}, + ) + + # Переключаем состояние паузы + is_currently_paused = getattr(subscription, 'is_daily_paused', False) + new_paused_state = not is_currently_paused + subscription.is_daily_paused = new_paused_state + + # Если снимаем с паузы и подписка активна, нужно проверить баланс для активации + if not new_paused_state: + daily_price = getattr(tariff, 'daily_price_kopeks', 0) + if daily_price > 0 and user.balance_kopeks < daily_price: + raise HTTPException( + status_code=status.HTTP_402_PAYMENT_REQUIRED, + detail={ + "code": "insufficient_balance", + "message": "Insufficient balance to resume daily subscription", + "required": daily_price, + "balance": user.balance_kopeks, + }, + ) + + await db.commit() + await db.refresh(subscription) + await db.refresh(user) + + # Синхронизация с RemnaWave + try: + service = SubscriptionService() + if new_paused_state: + # При паузе отключаем пользователя в RemnaWave + if user.remnawave_uuid: + await service.disable_remnawave_user(user.remnawave_uuid) + else: + # При возобновлении включаем пользователя в RemnaWave + if user.remnawave_uuid: + await service.enable_remnawave_user(user.remnawave_uuid) + except Exception as e: + logger.error(f"Ошибка синхронизации с RemnaWave при паузе/возобновлении: {e}") + + lang = getattr(user, "language", settings.DEFAULT_LANGUAGE) + if new_paused_state: + message = "Суточная подписка приостановлена" if lang == "ru" else "Daily subscription paused" + else: + message = "Суточная подписка возобновлена" if lang == "ru" else "Daily subscription resumed" + + return MiniAppDailySubscriptionToggleResponse( + success=True, + message=message, + is_paused=new_paused_state, + balance_kopeks=user.balance_kopeks, + balance_label=settings.format_price(user.balance_kopeks), + ) From 0ac2a7a62b198ef11149e943e0ada3e68114cfb8 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 17:58:50 +0300 Subject: [PATCH 35/47] Update miniapp.py --- app/webapi/schemas/miniapp.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/app/webapi/schemas/miniapp.py b/app/webapi/schemas/miniapp.py index 70e7fdf8..152beaf4 100644 --- a/app/webapi/schemas/miniapp.py +++ b/app/webapi/schemas/miniapp.py @@ -47,6 +47,13 @@ class MiniAppSubscriptionUser(BaseModel): promo_offer_discount_percent: int = 0 promo_offer_discount_expires_at: Optional[datetime] = None promo_offer_discount_source: Optional[str] = None + # Суточные тарифы + is_daily_tariff: bool = False + is_daily_paused: bool = False + daily_tariff_name: Optional[str] = None + daily_price_kopeks: Optional[int] = None + daily_price_label: Optional[str] = None + daily_next_charge_at: Optional[datetime] = None # Время следующего списания class MiniAppPromoGroup(BaseModel): @@ -529,6 +536,10 @@ class MiniAppTariff(BaseModel): switch_cost_label: Optional[str] = None # Форматированная стоимость is_upgrade: Optional[bool] = None # True = повышение, False = понижение is_switch_free: Optional[bool] = None # True = бесплатное переключение + # Суточные тарифы + is_daily: bool = False + daily_price_kopeks: int = 0 + daily_price_label: Optional[str] = None class MiniAppTrafficTopupPackage(BaseModel): @@ -561,6 +572,10 @@ class MiniAppCurrentTariff(BaseModel): # Лимит докупки трафика (0 = без лимита) max_topup_traffic_gb: int = 0 available_topup_gb: Optional[int] = None # Сколько еще можно докупить (None = без лимита) + # Суточные тарифы + is_daily: bool = False + daily_price_kopeks: int = 0 + daily_price_label: Optional[str] = None class MiniAppTrafficTopupRequest(BaseModel): @@ -650,6 +665,20 @@ class MiniAppTariffSwitchResponse(BaseModel): balance_label: str = "" +class MiniAppDailySubscriptionToggleRequest(BaseModel): + """Запрос на паузу/возобновление суточной подписки.""" + init_data: str = Field(...) + + +class MiniAppDailySubscriptionToggleResponse(BaseModel): + """Ответ на паузу/возобновление суточной подписки.""" + success: bool = True + message: Optional[str] = None + is_paused: bool = False + balance_kopeks: int = 0 + balance_label: str = "" + + class MiniAppSubscriptionResponse(BaseModel): success: bool = True subscription_id: Optional[int] = None From 97711ac735fbc0632aa08a6dae9e9faabe577ba3 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 18:01:34 +0300 Subject: [PATCH 36/47] Update index.html --- miniapp/index.html | 459 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 445 insertions(+), 14 deletions(-) diff --git a/miniapp/index.html b/miniapp/index.html index 9e97474f..8fb9d4d8 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -2707,6 +2707,168 @@ color: #41464b; } + .status-paused { + background: linear-gradient(135deg, #e2e3e5, #eeeff1); + color: #41464b; + } + + /* Daily Subscription Status Bar */ + .daily-subscription-status { + margin-top: 16px; + padding: 16px; + background: linear-gradient(135deg, rgba(var(--primary-rgb), 0.08), rgba(var(--primary-rgb), 0.04)); + border-radius: var(--radius-lg); + border: 1px solid rgba(var(--primary-rgb), 0.15); + } + + .daily-subscription-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; + } + + .daily-subscription-title { + font-size: 14px; + font-weight: 700; + color: var(--text-primary); + display: flex; + align-items: center; + gap: 8px; + } + + .daily-subscription-title .daily-icon { + font-size: 18px; + } + + .daily-subscription-price { + font-size: 14px; + font-weight: 600; + color: var(--primary); + } + + .daily-subscription-progress { + margin-bottom: 12px; + } + + .daily-progress-bar { + height: 8px; + background: rgba(var(--primary-rgb), 0.15); + border-radius: 4px; + overflow: hidden; + position: relative; + } + + .daily-progress-fill { + height: 100%; + background: linear-gradient(90deg, var(--primary), rgba(var(--primary-rgb), 0.7)); + border-radius: 4px; + transition: width 0.5s ease; + position: relative; + } + + .daily-progress-fill::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent); + animation: shimmer 2s infinite; + } + + .daily-subscription-info { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 13px; + } + + .daily-time-remaining { + color: var(--text-secondary); + } + + .daily-time-remaining strong { + color: var(--text-primary); + font-weight: 600; + } + + .daily-next-charge { + color: var(--text-secondary); + } + + .daily-subscription-actions { + margin-top: 12px; + display: flex; + gap: 8px; + } + + .daily-pause-btn { + flex: 1; + padding: 10px 16px; + border-radius: var(--radius); + border: 2px solid var(--border-color); + background: var(--bg-secondary); + color: var(--text-primary); + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + } + + .daily-pause-btn:hover { + border-color: var(--primary); + background: rgba(var(--primary-rgb), 0.05); + } + + .daily-pause-btn.paused { + background: linear-gradient(135deg, var(--primary), rgba(var(--primary-rgb), 0.8)); + color: white; + border-color: transparent; + } + + .daily-pause-btn.paused:hover { + opacity: 0.9; + } + + .daily-pause-btn:disabled { + opacity: 0.6; + cursor: not-allowed; + } + + .daily-subscription-paused-notice { + margin-top: 12px; + padding: 12px; + background: rgba(var(--warning-rgb), 0.1); + border-radius: var(--radius); + border: 1px solid rgba(var(--warning-rgb), 0.2); + font-size: 13px; + color: var(--text-primary); + display: flex; + align-items: flex-start; + gap: 10px; + } + + .daily-subscription-paused-notice .notice-icon { + font-size: 18px; + line-height: 1; + } + + :root[data-theme="dark"] .daily-subscription-status { + background: linear-gradient(135deg, rgba(var(--primary-rgb), 0.12), rgba(var(--primary-rgb), 0.06)); + border-color: rgba(var(--primary-rgb), 0.25); + } + + :root[data-theme="dark"] .status-paused { + background: linear-gradient(135deg, rgba(100, 116, 139, 0.3), rgba(100, 116, 139, 0.2)); + color: #94a3b8; + } + .status-missing { background: linear-gradient(135deg, #e0e7ff, #eef2ff); color: #1e3a8a; @@ -5569,6 +5731,39 @@ + + +
@@ -6433,6 +6628,16 @@ 'info.promo_group': 'Promo group', 'info.device_limit': 'Device limit', 'info.autopay': 'Auto-pay', + // Daily subscription + 'daily.tariff_name': 'Daily tariff', + 'daily.time_remaining': 'Time left:', + 'daily.next_charge': 'Next charge at', + 'daily.pause': 'Pause', + 'daily.resume': 'Resume', + 'daily.paused_notice': 'Subscription paused. Daily billing disabled. VPN is not working.', + 'daily.status.active': 'Active', + 'daily.status.paused': 'Paused', + 'daily.hours_remaining': 'Hours left', 'button.connect.default': 'Connect to VPN', 'button.connect.happ': 'Connect', 'button.copy': 'Copy subscription link', @@ -6896,6 +7101,16 @@ 'info.promo_group': 'Уровень', 'info.device_limit': 'Лимит устройств', 'info.autopay': 'Автоплатеж', + // Суточная подписка + 'daily.tariff_name': 'Суточный тариф', + 'daily.time_remaining': 'Осталось:', + 'daily.next_charge': 'Следующее списание', + 'daily.pause': 'Приостановить', + 'daily.resume': 'Возобновить', + 'daily.paused_notice': 'Подписка приостановлена. Ежедневное списание отключено. VPN не работает.', + 'daily.status.active': 'Активна', + 'daily.status.paused': 'Приостановлена', + 'daily.hours_remaining': 'Часов осталось', 'button.connect.default': 'Подключиться к VPN', 'button.connect.happ': 'Подключиться', 'button.copy': 'Скопировать ссылку подписки', @@ -9278,6 +9493,9 @@ : autopayLabel; } + // Отображение суточной подписки + renderDailySubscriptionStatus(); + renderSubscriptionMissingCard(); renderSubscriptionPurchaseCard(); renderSubscriptionRenewalCard(); @@ -9295,6 +9513,195 @@ updateActionButtons(); } + // ============================================================ + // Суточная подписка - отображение и управление + // ============================================================ + let dailyTimerInterval = null; + + function renderDailySubscriptionStatus() { + const container = document.getElementById('dailySubscriptionStatus'); + if (!container) return; + + const user = userData?.user; + if (!user) { + container.classList.add('hidden'); + return; + } + + const isDailyTariff = user.is_daily_tariff ?? user.isDailyTariff ?? false; + const isDailyPaused = user.is_daily_paused ?? user.isDailyPaused ?? false; + const dailyTariffName = user.daily_tariff_name ?? user.dailyTariffName ?? ''; + const dailyPriceLabel = user.daily_price_label ?? user.dailyPriceLabel ?? ''; + const dailyNextChargeAt = user.daily_next_charge_at ?? user.dailyNextChargeAt ?? null; + + if (!isDailyTariff) { + container.classList.add('hidden'); + if (dailyTimerInterval) { + clearInterval(dailyTimerInterval); + dailyTimerInterval = null; + } + return; + } + + container.classList.remove('hidden'); + + // Заполняем данные + const tariffNameEl = document.getElementById('dailyTariffName'); + if (tariffNameEl && dailyTariffName) { + tariffNameEl.textContent = dailyTariffName; + } + + const priceEl = document.getElementById('dailyPrice'); + if (priceEl && dailyPriceLabel) { + priceEl.textContent = dailyPriceLabel; + } + + // Настройка кнопки паузы + const pauseBtn = document.getElementById('dailyPauseBtn'); + const pauseBtnIcon = document.getElementById('dailyPauseBtnIcon'); + const pauseBtnText = document.getElementById('dailyPauseBtnText'); + const pausedNotice = document.getElementById('dailyPausedNotice'); + const progressSection = document.getElementById('dailyProgressSection'); + + if (isDailyPaused) { + pauseBtn?.classList.add('paused'); + if (pauseBtnIcon) pauseBtnIcon.textContent = '▶️'; + if (pauseBtnText) pauseBtnText.textContent = t('daily.resume'); + pausedNotice?.classList.remove('hidden'); + progressSection?.classList.add('hidden'); + + // Обновляем статус badge + const statusBadge = document.getElementById('statusBadge'); + if (statusBadge) { + statusBadge.textContent = t('daily.status.paused'); + statusBadge.className = 'status-badge status-paused'; + } + } else { + pauseBtn?.classList.remove('paused'); + if (pauseBtnIcon) pauseBtnIcon.textContent = '⏸️'; + if (pauseBtnText) pauseBtnText.textContent = t('daily.pause'); + pausedNotice?.classList.add('hidden'); + progressSection?.classList.remove('hidden'); + } + + // Обработчик кнопки паузы + if (pauseBtn) { + pauseBtn.onclick = () => toggleDailyPause(); + } + + // Запуск таймера обратного отсчета + startDailyCountdownTimer(dailyNextChargeAt, isDailyPaused); + } + + function startDailyCountdownTimer(nextChargeAt, isPaused) { + if (dailyTimerInterval) { + clearInterval(dailyTimerInterval); + dailyTimerInterval = null; + } + + const timeRemainingEl = document.getElementById('dailyTimeRemaining'); + const progressFill = document.getElementById('dailyProgressFill'); + const nextChargeEl = document.getElementById('dailyNextCharge'); + + if (!nextChargeAt || isPaused) { + if (timeRemainingEl) timeRemainingEl.textContent = '--:--:--'; + if (progressFill) progressFill.style.width = '0%'; + if (nextChargeEl) nextChargeEl.textContent = ''; + return; + } + + const nextChargeDate = new Date(nextChargeAt); + const totalDuration = 24 * 60 * 60 * 1000; // 24 часа в миллисекундах + + function updateTimer() { + const now = new Date(); + const remaining = nextChargeDate - now; + + if (remaining <= 0) { + if (timeRemainingEl) timeRemainingEl.textContent = '00:00:00'; + if (progressFill) progressFill.style.width = '0%'; + clearInterval(dailyTimerInterval); + return; + } + + // Форматируем оставшееся время + const hours = Math.floor(remaining / (1000 * 60 * 60)); + const minutes = Math.floor((remaining % (1000 * 60 * 60)) / (1000 * 60)); + const seconds = Math.floor((remaining % (1000 * 60)) / 1000); + const timeStr = `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; + + if (timeRemainingEl) timeRemainingEl.textContent = timeStr; + + // Обновляем прогресс бар (от 100% до 0%) + const progressPercent = Math.max(0, Math.min(100, (remaining / totalDuration) * 100)); + if (progressFill) progressFill.style.width = `${progressPercent}%`; + + // Время следующего списания + if (nextChargeEl) { + const chargeTime = nextChargeDate.toLocaleTimeString(currentLanguage === 'ru' ? 'ru-RU' : 'en-US', { + hour: '2-digit', + minute: '2-digit' + }); + nextChargeEl.textContent = `${t('daily.next_charge')} ${chargeTime}`; + } + } + + updateTimer(); + dailyTimerInterval = setInterval(updateTimer, 1000); + } + + async function toggleDailyPause() { + const pauseBtn = document.getElementById('dailyPauseBtn'); + if (!pauseBtn || pauseBtn.disabled) return; + + pauseBtn.disabled = true; + + try { + const initData = window.Telegram?.WebApp?.initData || ''; + const response = await fetch(`${apiBaseUrl}/miniapp/subscription/daily/toggle-pause`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ init_data: initData }) + }); + + const result = await response.json(); + + if (!response.ok) { + const errorCode = result?.detail?.code || 'unknown'; + if (errorCode === 'insufficient_balance') { + showToast(t('daily.error.insufficient_balance') || 'Недостаточно средств для возобновления подписки', 'error'); + } else { + showToast(result?.detail?.message || 'Error', 'error'); + } + return; + } + + // Обновляем локальные данные + if (userData?.user) { + userData.user.is_daily_paused = result.is_paused; + userData.user.isDailyPaused = result.is_paused; + } + + // Обновляем баланс + if (result.balance_kopeks !== undefined) { + userData.balance_kopeks = result.balance_kopeks; + userData.balanceKopeks = result.balance_kopeks; + renderBalanceSection(); + } + + // Перерисовываем статус + renderDailySubscriptionStatus(); + + showToast(result.message || 'OK', 'success'); + + } catch (error) { + console.error('Error toggling daily pause:', error); + showToast('Connection error', 'error'); + } finally { + pauseBtn.disabled = false; + } + } + function renderSubscriptionMissingCard() { const card = document.getElementById('subscriptionMissingCard'); if (!card) { @@ -20359,6 +20766,8 @@ // Разные стили для режима смены и покупки if (isInstantSwitchMode) { // Режим мгновенной смены тарифа - используем точные данные с сервера + const isDaily = tariff.is_daily ?? tariff.isDaily ?? false; + const dailyPriceLabel = tariff.daily_price_label ?? tariff.dailyPriceLabel ?? null; const isUpgrade = tariff.is_upgrade ?? tariff.isUpgrade ?? false; const isSwitchFree = tariff.is_switch_free ?? tariff.isSwitchFree ?? !isUpgrade; const upgradeCost = tariff.switch_cost_kopeks ?? tariff.switchCostKopeks ?? 0; @@ -20394,12 +20803,13 @@ const upgradeLabel = preferredLanguage === 'en' ? 'upgrade' : 'доплата'; const freeLabel = preferredLanguage === 'en' ? '✓ Free' : '✓ Бесплатно'; + const dailyLabel = preferredLanguage === 'en' ? 'daily' : 'суточный'; div.className = 'instant-switch-tariff-item'; div.innerHTML = `
- ${isPremium ? '👑 ' : '⚡ '}${escapeHtml(tariff.name)} + ${isDaily ? '🔄 ' : (isPremium ? '👑 ' : '⚡ ')}${escapeHtml(tariff.name)}
${description ? `
${escapeHtml(description)}
` : ''}
@@ -20409,22 +20819,40 @@ ${serverTags ? `
${serverTags}
` : ''}
- - ${isUpgrade - ? `${upgradeLabel}+${upgradeCostLabel}` - : freeLabel} - + ${isDaily ? ` + + ${dailyLabel} + ${dailyPriceLabel || ''} + + ` : ` + + ${isUpgrade + ? `${upgradeLabel}+${upgradeCostLabel}` + : freeLabel} + + `}
`; div.addEventListener('click', () => showInstantSwitchConfirm(tariff, isUpgrade, upgradeCost)); } else { // Обычный режим покупки + const isDaily = tariff.is_daily ?? tariff.isDaily ?? false; + const dailyPriceKopeks = tariff.daily_price_kopeks ?? tariff.dailyPriceKopeks ?? 0; + const dailyPriceLabel = tariff.daily_price_label ?? tariff.dailyPriceLabel ?? null; + let minPrice = null; let minPriceOriginal = null; let maxDiscountPercent = 0; + let priceDisplayLabel = null; + let priceDisplayOriginalLabel = null; + let pricePrefix = preferredLanguage === 'en' ? 'from' : 'от'; - if (periods.length > 0) { + if (isDaily && dailyPriceKopeks > 0) { + // Суточный тариф - показываем цену за день + priceDisplayLabel = dailyPriceLabel || formatPriceFromKopeks(dailyPriceKopeks, tariffsData?.currency || 'RUB') + '/день'; + pricePrefix = ''; + } else if (periods.length > 0) { periods.forEach(p => { const price = p.price_kopeks || p.priceKopeks || 0; const originalPrice = p.original_price_kopeks || p.originalPriceKopeks || price; @@ -20438,14 +20866,16 @@ maxDiscountPercent = discountPct; } }); + priceDisplayLabel = minPrice !== null + ? formatPriceFromKopeks(minPrice, tariffsData?.currency || 'RUB') + : null; + priceDisplayOriginalLabel = minPriceOriginal !== null + ? formatPriceFromKopeks(minPriceOriginal, tariffsData?.currency || 'RUB') + : null; } - const minPriceLabel = minPrice !== null - ? formatPriceFromKopeks(minPrice, tariffsData?.currency || 'RUB') - : null; - const minPriceOriginalLabel = minPriceOriginal !== null - ? formatPriceFromKopeks(minPriceOriginal, tariffsData?.currency || 'RUB') - : null; + const minPriceLabel = priceDisplayLabel; + const minPriceOriginalLabel = priceDisplayOriginalLabel; div.className = 'subscription-settings-toggle' + (selectedTariffId === tariff.id ? ' active' : ''); @@ -20489,10 +20919,11 @@
${minPriceLabel ? `
-
от
+ ${pricePrefix ? `
${pricePrefix}
` : ''} ${minPriceOriginalLabel ? `
${minPriceOriginalLabel}
` : ''}
${minPriceLabel}
${maxDiscountPercent > 0 ? `
-${maxDiscountPercent}%
` : ''} + ${isDaily ? `
🔄 суточный
` : ''}
` : ''}
From bcef64bafac29dfef4bef178cdc32f55e38e7ec9 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 18:11:21 +0300 Subject: [PATCH 37/47] Update miniapp.py --- app/webapi/routes/miniapp.py | 69 +++++++++++++++++++++++++++++------- 1 file changed, 56 insertions(+), 13 deletions(-) diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index b19d8acd..9c45c773 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -6548,21 +6548,35 @@ async def purchase_tariff_endpoint( }, ) - # Получаем цену за выбранный период - base_price_kopeks = tariff.get_price_for_period(payload.period_days) - if base_price_kopeks is None: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail={ - "code": "invalid_period", - "message": "Invalid period for this tariff", - }, - ) + # Получаем цену + is_daily_tariff = getattr(tariff, 'is_daily', False) + if is_daily_tariff: + # Для суточного тарифа берём daily_price_kopeks (первый день) + base_price_kopeks = getattr(tariff, 'daily_price_kopeks', 0) + if base_price_kopeks <= 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "code": "invalid_daily_price", + "message": "Daily tariff has no price configured", + }, + ) + else: + # Для обычного тарифа получаем цену за выбранный период + base_price_kopeks = tariff.get_price_for_period(payload.period_days) + if base_price_kopeks is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "code": "invalid_period", + "message": "Invalid period for this tariff", + }, + ) - # Применяем скидку промогруппы + # Применяем скидку промогруппы (только для обычных тарифов, не для суточных) price_kopeks = base_price_kopeks discount_percent = 0 - if promo_group: + if not is_daily_tariff and promo_group: raw_discounts = getattr(promo_group, 'period_discounts', None) or {} for k, v in raw_discounts.items(): try: @@ -6589,7 +6603,9 @@ async def purchase_tariff_endpoint( subscription = getattr(user, "subscription", None) # Списываем баланс - if discount_percent > 0: + if is_daily_tariff: + description = f"Активация суточного тарифа '{tariff.name}' (первый день)" + elif discount_percent > 0: description = f"Покупка тарифа '{tariff.name}' на {payload.period_days} дней (скидка {discount_percent}%)" else: description = f"Покупка тарифа '{tariff.name}' на {payload.period_days} дней" @@ -6636,6 +6652,16 @@ async def purchase_tariff_endpoint( tariff_id=tariff.id, ) + # Инициализация daily полей при покупке суточного тарифа + is_daily_tariff = getattr(tariff, 'is_daily', False) + if is_daily_tariff: + subscription.is_daily_paused = False + subscription.last_daily_charge_at = datetime.utcnow() + # Для суточного тарифа end_date = сейчас + 1 день (первый день уже оплачен) + subscription.end_date = datetime.utcnow() + timedelta(days=1) + await db.commit() + await db.refresh(subscription) + # Синхронизируем с RemnaWave service = SubscriptionService() await service.update_remnawave_user(db, subscription) @@ -6923,6 +6949,23 @@ async def switch_tariff_endpoint( # Сбрасываем докупленный трафик при смене тарифа subscription.purchased_traffic_gb = 0 + # Обработка daily полей при смене тарифа + new_is_daily = getattr(new_tariff, 'is_daily', False) + old_is_daily = getattr(current_tariff, 'is_daily', False) + + if new_is_daily: + # Переход на суточный тариф + subscription.is_daily_paused = False + subscription.last_daily_charge_at = datetime.utcnow() + # Для суточного тарифа end_date = сейчас + 1 день + subscription.end_date = datetime.utcnow() + timedelta(days=1) + logger.info(f"🔄 Смена на суточный тариф: установлены daily поля, end_date={subscription.end_date}") + elif old_is_daily and not new_is_daily: + # Переход с суточного на обычный тариф - очищаем daily поля + subscription.is_daily_paused = False + subscription.last_daily_charge_at = None + logger.info(f"🔄 Смена с суточного на обычный тариф: очищены daily поля") + await db.commit() await db.refresh(subscription) await db.refresh(user) From 26e242cac72440ea0e15450bddd45416dd728281 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 18:12:14 +0300 Subject: [PATCH 38/47] Update subscription.py --- app/database/crud/subscription.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index 793a998d..da1a56be 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -426,6 +426,24 @@ async def extend_subscription( subscription.connected_squads = connected_squads logger.info(f"🌍 Обновлены сквады: {old_squads} → {connected_squads}") + # Обработка daily полей при смене тарифа + if is_tariff_change and tariff_id is not None: + # Получаем информацию о новом тарифе для проверки is_daily + from app.database.crud.tariff import get_tariff_by_id + new_tariff = await get_tariff_by_id(db, tariff_id) + old_was_daily = getattr(subscription, 'is_daily_paused', False) or getattr(subscription, 'last_daily_charge_at', None) is not None + + if new_tariff and getattr(new_tariff, 'is_daily', False): + # Переход на суточный тариф - сбрасываем флаги + subscription.is_daily_paused = False + subscription.last_daily_charge_at = None # Будет установлено при первом списании + logger.info(f"🔄 Переход на суточный тариф: сброшены daily флаги") + elif old_was_daily: + # Переход с суточного на обычный тариф - очищаем daily поля + subscription.is_daily_paused = False + subscription.last_daily_charge_at = None + logger.info(f"🔄 Переход с суточного тарифа: очищены daily флаги") + # В режиме fixed_with_topup при продлении сбрасываем трафик до фиксированного лимита # Только если не передан traffic_limit_gb И у подписки нет тарифа (классический режим) # Если у подписки есть tariff_id - трафик определяется тарифом, не сбрасываем From df17f2be4a1b15b092eed090b12922805bb32a0a Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 18:13:02 +0300 Subject: [PATCH 39/47] Update index.html --- miniapp/index.html | 65 ++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 57 insertions(+), 8 deletions(-) diff --git a/miniapp/index.html b/miniapp/index.html index 8fb9d4d8..4f03e7aa 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -21114,6 +21114,34 @@ return; } + // Для суточных тарифов показываем специальную информацию + const isDaily = selectedTariffData.is_daily ?? selectedTariffData.isDaily ?? false; + if (isDaily) { + const dailyPriceLabel = selectedTariffData.daily_price_label ?? selectedTariffData.dailyPriceLabel ?? ''; + section.classList.remove('hidden'); + list.innerHTML = ` +
+
+ 🔄 +
+
Суточная подписка
+
Ежедневное списание с баланса
+
+
+
+ Стоимость в день: + ${dailyPriceLabel} +
+
+ 💡 Списание происходит автоматически каждые 24 часа. Можно приостановить в любой момент. +
+
+ `; + summary?.classList.add('hidden'); + updateTariffButton(); + return; + } + const periods = selectedTariffData.periods || []; if (periods.length === 0) { section.classList.add('hidden'); @@ -21233,12 +21261,24 @@ const btn = document.getElementById('tariffsSelectBtn'); if (!btn) return; - if (selectedTariffData && selectedTariffPeriod) { + // Для суточных тарифов не требуем выбора периода + const isDaily = selectedTariffData?.is_daily ?? selectedTariffData?.isDaily ?? false; + + if (selectedTariffData && (selectedTariffPeriod || isDaily)) { btn.disabled = false; - const priceKopeks = selectedTariffPeriod.price_kopeks || selectedTariffPeriod.priceKopeks || - selectedTariffPeriod.final_price || selectedTariffPeriod.finalPrice || 0; - const priceLabel = formatPriceFromKopeks(priceKopeks, tariffsData?.currency || 'RUB'); - btn.textContent = `Купить за ${priceLabel}`; + let priceKopeks, priceLabel; + + if (isDaily) { + // Суточный тариф - используем daily_price + priceKopeks = selectedTariffData.daily_price_kopeks ?? selectedTariffData.dailyPriceKopeks ?? 0; + priceLabel = selectedTariffData.daily_price_label ?? selectedTariffData.dailyPriceLabel ?? formatPriceFromKopeks(priceKopeks, tariffsData?.currency || 'RUB'); + btn.textContent = `Активировать за ${priceLabel}`; + } else { + priceKopeks = selectedTariffPeriod.price_kopeks || selectedTariffPeriod.priceKopeks || + selectedTariffPeriod.final_price || selectedTariffPeriod.finalPrice || 0; + priceLabel = formatPriceFromKopeks(priceKopeks, tariffsData?.currency || 'RUB'); + btn.textContent = `Купить за ${priceLabel}`; + } } else { btn.disabled = true; btn.textContent = t('tariffs.select'); @@ -21246,7 +21286,10 @@ } async function purchaseTariff() { - if (!selectedTariffId || !selectedTariffPeriod) { + const isDaily = selectedTariffData?.is_daily ?? selectedTariffData?.isDaily ?? false; + + // Для суточных тарифов не требуем выбора периода + if (!selectedTariffId || (!selectedTariffPeriod && !isDaily)) { showPopup('Выберите тариф', 'Ошибка'); return; } @@ -21259,7 +21302,10 @@ try { const initData = tg.initData || ''; - const periodDays = selectedTariffPeriod.days || selectedTariffPeriod.period_days || selectedTariffPeriod.periodDays; + // Для суточных тарифов используем period_days=1 + const periodDays = isDaily + ? 1 + : (selectedTariffPeriod.days || selectedTariffPeriod.period_days || selectedTariffPeriod.periodDays); const response = await fetch('/miniapp/subscription/tariff/purchase', { method: 'POST', @@ -21277,7 +21323,10 @@ throw new Error(result?.detail?.message || result?.message || 'Ошибка покупки тарифа'); } - showPopup(result.message || 'Тариф успешно активирован!', 'Успех'); + const successMsg = isDaily + ? (result.message || 'Суточный тариф активирован!') + : (result.message || 'Тариф успешно активирован!'); + showPopup(successMsg, 'Успех'); await refreshSubscriptionData(); } catch (err) { console.error('Tariff purchase failed:', err); From db54b01f04e99664c7596c33b461e288326d072f Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 18:16:59 +0300 Subject: [PATCH 40/47] Update subscription.py --- app/database/crud/subscription.py | 45 +++++++++++++++++-------------- 1 file changed, 25 insertions(+), 20 deletions(-) diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index da1a56be..c6992347 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -328,23 +328,10 @@ async def extend_subscription( if is_tariff_change: logger.info(f"🔄 Обнаружена СМЕНА тарифа: {subscription.tariff_id} → {tariff_id}") - # НОВОЕ: Вычисляем бонусные дни от триала ДО изменения end_date - # Бонусные дни НЕ начисляются при смене тарифа + # Бонусные дни от триала - добавляются ТОЛЬКО когда подписка истекла + # и мы начинаем отсчёт с текущей даты. НЕ начисляются при смене тарифа. + # Если подписка ещё активна - просто добавляем дни к существующей дате окончания. bonus_days = 0 - if not is_tariff_change and subscription.is_trial and settings.TRIAL_ADD_REMAINING_DAYS_TO_PAID: - # Вычисляем остаток триала - if subscription.end_date and subscription.end_date > current_time: - remaining = subscription.end_date - current_time - if remaining.total_seconds() > 0: - bonus_days = max(0, remaining.days) - logger.info( - "🎁 Обнаружен остаток триала: %s дней для подписки %s", - bonus_days, - subscription.id, - ) - - # Применяем продление с учетом бонусных дней - total_days = days + bonus_days if days < 0: subscription.end_date = subscription.end_date + timedelta(days=days) @@ -354,16 +341,34 @@ async def extend_subscription( subscription.end_date, ) elif is_tariff_change: - # При СМЕНЕ тарифа срок начинается с текущей даты + # При СМЕНЕ тарифа срок начинается с текущей даты + бонус от триала + if subscription.is_trial and settings.TRIAL_ADD_REMAINING_DAYS_TO_PAID: + if subscription.end_date and subscription.end_date > current_time: + remaining = subscription.end_date - current_time + if remaining.total_seconds() > 0: + bonus_days = max(0, remaining.days) + logger.info( + "🎁 Обнаружен остаток триала: %s дней для подписки %s", + bonus_days, + subscription.id, + ) + total_days = days + bonus_days subscription.end_date = current_time + timedelta(days=total_days) subscription.start_date = current_time logger.info(f"📅 СМЕНА тарифа: срок начинается с текущей даты + {total_days} дней") elif subscription.end_date > current_time: - subscription.end_date = subscription.end_date + timedelta(days=total_days) - logger.info(f"📅 Подписка активна, добавляем {total_days} дней ({days} + {bonus_days} бонус) к текущей дате окончания") + # Подписка активна - просто добавляем дни к текущей дате окончания + # БЕЗ бонусных дней (они уже учтены в end_date) + subscription.end_date = subscription.end_date + timedelta(days=days) + logger.info(f"📅 Подписка активна, добавляем {days} дней к текущей дате окончания") else: + # Подписка истекла - начинаем с текущей даты + бонус от триала + if subscription.is_trial and settings.TRIAL_ADD_REMAINING_DAYS_TO_PAID: + # Триал истёк, но бонус всё равно не добавляем (триал уже истёк) + pass + total_days = days + bonus_days subscription.end_date = current_time + timedelta(days=total_days) - logger.info(f"📅 Подписка истекла, устанавливаем новую дату окончания на {total_days} дней ({days} + {bonus_days} бонус)") + logger.info(f"📅 Подписка истекла, устанавливаем новую дату окончания на {total_days} дней") # УДАЛЕНО: Автоматическая конвертация триала по длительности # Теперь триал конвертируется ТОЛЬКО после успешного коммита продления From 25f2a9507d4c00f1cade99b7df245853cbd744ab Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 18:26:00 +0300 Subject: [PATCH 41/47] Update index.html --- miniapp/index.html | 42 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/miniapp/index.html b/miniapp/index.html index 4f03e7aa..0471801e 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -2749,6 +2749,16 @@ .daily-subscription-progress { margin-bottom: 12px; + transition: opacity 0.3s ease; + } + + .daily-subscription-progress.paused { + opacity: 0.5; + } + + .daily-subscription-progress.paused .daily-progress-fill { + animation: none; + background: var(--hint-color); } .daily-progress-bar { @@ -6635,6 +6645,7 @@ 'daily.pause': 'Pause', 'daily.resume': 'Resume', 'daily.paused_notice': 'Subscription paused. Daily billing disabled. VPN is not working.', + 'daily.paused_no_charge': 'Paused — no charge', 'daily.status.active': 'Active', 'daily.status.paused': 'Paused', 'daily.hours_remaining': 'Hours left', @@ -7108,6 +7119,7 @@ 'daily.pause': 'Приостановить', 'daily.resume': 'Возобновить', 'daily.paused_notice': 'Подписка приостановлена. Ежедневное списание отключено. VPN не работает.', + 'daily.paused_no_charge': 'Пауза — без списания', 'daily.status.active': 'Активна', 'daily.status.paused': 'Приостановлена', 'daily.hours_remaining': 'Часов осталось', @@ -9568,7 +9580,9 @@ if (pauseBtnIcon) pauseBtnIcon.textContent = '▶️'; if (pauseBtnText) pauseBtnText.textContent = t('daily.resume'); pausedNotice?.classList.remove('hidden'); - progressSection?.classList.add('hidden'); + // Оставляем прогресс секцию видимой, но затемняем + progressSection?.classList.remove('hidden'); + progressSection?.classList.add('paused'); // Обновляем статус badge const statusBadge = document.getElementById('statusBadge'); @@ -9582,6 +9596,7 @@ if (pauseBtnText) pauseBtnText.textContent = t('daily.pause'); pausedNotice?.classList.add('hidden'); progressSection?.classList.remove('hidden'); + progressSection?.classList.remove('paused'); } // Обработчик кнопки паузы @@ -9603,13 +9618,36 @@ const progressFill = document.getElementById('dailyProgressFill'); const nextChargeEl = document.getElementById('dailyNextCharge'); - if (!nextChargeAt || isPaused) { + if (!nextChargeAt) { if (timeRemainingEl) timeRemainingEl.textContent = '--:--:--'; if (progressFill) progressFill.style.width = '0%'; if (nextChargeEl) nextChargeEl.textContent = ''; return; } + // При паузе показываем оставшееся время, но без обновления (статичное) + if (isPaused) { + const nextChargeDate = new Date(nextChargeAt); + const now = new Date(); + const remaining = nextChargeDate - now; + + if (remaining > 0) { + const hours = Math.floor(remaining / (1000 * 60 * 60)); + const minutes = Math.floor((remaining % (1000 * 60 * 60)) / (1000 * 60)); + const seconds = Math.floor((remaining % (1000 * 60)) / 1000); + const timeStr = `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; + if (timeRemainingEl) timeRemainingEl.textContent = timeStr; + const totalDuration = 24 * 60 * 60 * 1000; + const progressPercent = Math.max(0, Math.min(100, (remaining / totalDuration) * 100)); + if (progressFill) progressFill.style.width = `${progressPercent}%`; + } else { + if (timeRemainingEl) timeRemainingEl.textContent = '00:00:00'; + if (progressFill) progressFill.style.width = '0%'; + } + if (nextChargeEl) nextChargeEl.textContent = t('daily.paused_no_charge'); + return; + } + const nextChargeDate = new Date(nextChargeAt); const totalDuration = 24 * 60 * 60 * 1000; // 24 часа в миллисекундах From e987b25e6b08cfff14dcde17c8c21b677e436c47 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 18:26:54 +0300 Subject: [PATCH 42/47] Add files via upload --- app/localization/locales/en.json | 4 +++- app/localization/locales/ru.json | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index 1d267f80..6d394742 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -1666,5 +1666,7 @@ "PAUSE_DAILY_BUTTON": "⏸️ Pause subscription", "RESUME_DAILY_BUTTON": "▶️ Resume subscription", - "DAILY_SWITCH_WARNING": "⚠️ Warning! You have {days} days left.\nThey will be lost when switching to daily tariff!" + "DAILY_SWITCH_WARNING": "⚠️ Warning! You have {days} days left.\nThey will be lost when switching to daily tariff!", + "DAILY_SUBSCRIPTION_PAUSED": "⏸️ Subscription paused", + "DAILY_SUBSCRIPTION_RESUMED": "▶️ Subscription resumed!" } diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index c8ddc9ac..e74c16c7 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -1683,5 +1683,7 @@ "PAUSE_DAILY_BUTTON": "⏸️ Приостановить подписку", "RESUME_DAILY_BUTTON": "▶️ Возобновить подписку", - "DAILY_SWITCH_WARNING": "⚠️ Внимание! У вас осталось {days} дн. подписки.\nПри смене на суточный тариф они будут утеряны!" + "DAILY_SWITCH_WARNING": "⚠️ Внимание! У вас осталось {days} дн. подписки.\nПри смене на суточный тариф они будут утеряны!", + "DAILY_SUBSCRIPTION_PAUSED": "⏸️ Подписка приостановлена", + "DAILY_SUBSCRIPTION_RESUMED": "▶️ Подписка возобновлена!" } From eb40d8f48adf237a68d21a7dd2652574b27aa986 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 18:43:54 +0300 Subject: [PATCH 43/47] Update miniapp.py --- app/webapi/routes/miniapp.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index 9c45c773..8b378b0c 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -6551,6 +6551,8 @@ async def purchase_tariff_endpoint( # Получаем цену is_daily_tariff = getattr(tariff, 'is_daily', False) if is_daily_tariff: + # Для суточного тарифа принудительно 1 день (защита от манипуляций с period_days) + payload.period_days = 1 # Для суточного тарифа берём daily_price_kopeks (первый день) base_price_kopeks = getattr(tariff, 'daily_price_kopeks', 0) if base_price_kopeks <= 0: From 67f60ba41ace2e451911dde740adb4ccee0fef7f Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 18:44:34 +0300 Subject: [PATCH 44/47] Update subscription.py --- app/database/crud/subscription.py | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index c6992347..f881df9f 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -1486,9 +1486,9 @@ async def check_and_update_subscription_status( db: AsyncSession, subscription: Subscription ) -> Subscription: - + current_time = datetime.utcnow() - + logger.info( "🔍 Проверка статуса подписки %s, текущий статус: %s, дата окончания: %s, текущее время: %s", subscription.id, @@ -1496,20 +1496,29 @@ async def check_and_update_subscription_status( format_local_datetime(subscription.end_date), format_local_datetime(current_time), ) - - if (subscription.status == SubscriptionStatus.ACTIVE.value and + + # Для суточных тарифов с паузой не меняем статус на expired + # (время "заморожено" пока пользователь на паузе) + is_daily_paused = getattr(subscription, 'is_daily_paused', False) + if is_daily_paused: + logger.info( + f"⏸️ Суточная подписка {subscription.id} на паузе, пропускаем проверку истечения" + ) + return subscription + + if (subscription.status == SubscriptionStatus.ACTIVE.value and subscription.end_date <= current_time): - + subscription.status = SubscriptionStatus.EXPIRED.value subscription.updated_at = current_time - + await db.commit() await db.refresh(subscription) - + logger.info(f"⏰ Статус подписки пользователя {subscription.user_id} изменен на 'expired'") elif subscription.status == SubscriptionStatus.PENDING.value: logger.info(f"ℹ️ Проверка PENDING подписки {subscription.id}, статус остается без изменений") - + return subscription async def create_subscription_no_commit( From 40fad0d53750303822a63f471fd975b7cb33c90a Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 18:56:24 +0300 Subject: [PATCH 45/47] Update subscription.py --- app/database/crud/subscription.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index f881df9f..df18478b 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -1878,6 +1878,17 @@ async def resume_daily_subscription( return subscription subscription.is_daily_paused = False + + # Восстанавливаем статус ACTIVE если подписка была DISABLED (недостаток средств) + if subscription.status == SubscriptionStatus.DISABLED.value: + subscription.status = SubscriptionStatus.ACTIVE.value + # Обновляем время последнего списания для корректного расчёта следующего + subscription.last_daily_charge_at = datetime.utcnow() + subscription.end_date = datetime.utcnow() + timedelta(days=1) + logger.info( + f"✅ Суточная подписка {subscription.id} восстановлена из DISABLED в ACTIVE" + ) + await db.commit() await db.refresh(subscription) From fab17702c5fc4328b4672a679a28d65ea227d0d9 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 18:57:13 +0300 Subject: [PATCH 46/47] Update miniapp.py --- app/webapi/routes/miniapp.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index 8b378b0c..8353a9b0 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -7222,7 +7222,7 @@ async def toggle_daily_subscription_pause_endpoint( new_paused_state = not is_currently_paused subscription.is_daily_paused = new_paused_state - # Если снимаем с паузы и подписка активна, нужно проверить баланс для активации + # Если снимаем с паузы, нужно проверить баланс для активации if not new_paused_state: daily_price = getattr(tariff, 'daily_price_kopeks', 0) if daily_price > 0 and user.balance_kopeks < daily_price: @@ -7236,6 +7236,17 @@ async def toggle_daily_subscription_pause_endpoint( }, ) + # Восстанавливаем статус ACTIVE если подписка была DISABLED (недостаток средств) + from app.database.models import SubscriptionStatus + if subscription.status == SubscriptionStatus.DISABLED.value: + subscription.status = SubscriptionStatus.ACTIVE.value + # Обновляем время последнего списания для корректного расчёта следующего + subscription.last_daily_charge_at = datetime.utcnow() + subscription.end_date = datetime.utcnow() + timedelta(days=1) + logger.info( + f"✅ Суточная подписка {subscription.id} восстановлена из DISABLED в ACTIVE" + ) + await db.commit() await db.refresh(subscription) await db.refresh(user) From 26f3d4e0528ffe11be7354788fb927b34e985c19 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 12 Jan 2026 18:57:50 +0300 Subject: [PATCH 47/47] Update purchase.py --- app/handlers/subscription/purchase.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py index ac2c9122..488ee8c8 100644 --- a/app/handlers/subscription/purchase.py +++ b/app/handlers/subscription/purchase.py @@ -369,6 +369,17 @@ async def show_subscription_info( if is_paused: tariff_info_lines.append("") tariff_info_lines.append("⏸️ Подписка приостановлена") + # Показываем оставшееся время даже при паузе + if last_charge: + from datetime import timedelta + next_charge = last_charge + timedelta(hours=24) + now = datetime.utcnow() + if next_charge > now: + time_until = next_charge - now + hours_left = time_until.seconds // 3600 + minutes_left = (time_until.seconds % 3600) // 60 + tariff_info_lines.append(f"⏳ Осталось: {hours_left}ч {minutes_left}мин") + tariff_info_lines.append("💤 Списание приостановлено") elif last_charge: from datetime import timedelta next_charge = last_charge + timedelta(hours=24) @@ -3156,6 +3167,20 @@ async def handle_toggle_daily_subscription_pause( # Переключаем статус паузы was_paused = getattr(subscription, 'is_daily_paused', False) + + # При возобновлении проверяем баланс + if was_paused: + daily_price = getattr(tariff, 'daily_price_kopeks', 0) + if daily_price > 0 and db_user.balance_kopeks < daily_price: + await callback.answer( + texts.t( + "INSUFFICIENT_BALANCE_FOR_RESUME", + f"❌ Недостаточно средств для возобновления. Требуется: {settings.format_price(daily_price)}" + ), + show_alert=True + ) + return + subscription = await toggle_daily_subscription_pause(db, subscription) if was_paused: