Merge pull request #2270 from BEDOLAGA-DEV/dev5

Daily Tariffs / Bug fixs / promocode fixs / db dublicate session fix
This commit is contained in:
Egor
2026-01-12 19:06:54 +03:00
committed by GitHub
23 changed files with 2784 additions and 241 deletions

View File

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

View File

@@ -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} дней")
# УДАЛЕНО: Автоматическая конвертация триала по длительности
# Теперь триал конвертируется ТОЛЬКО после успешного коммита продления
@@ -426,6 +431,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 - трафик определяется тарифом, не сбрасываем
@@ -1463,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,
@@ -1473,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(
@@ -1756,7 +1788,186 @@ 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
# Восстанавливаем статус 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)
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:
"""Обновляет время последнего суточного списания и продлевает подписку на 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)
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)

View File

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

View File

@@ -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"<Tariff(id={self.id}, name='{self.name}', tier={self.tier_level}, active={self.is_active})>"
@@ -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"

View File

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

View File

@@ -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,6 +206,14 @@ def get_tariff_view_keyboard(
InlineKeyboardButton(text="👥 Промогруппы", callback_data=f"admin_tariff_edit_promo:{tariff.id}"),
])
# Суточный режим - только для уже суточных тарифов показываем настройки
# Новые тарифы делаются суточными только при создании
if is_daily:
buttons.append([
InlineKeyboardButton(text="💰 Суточная цена", callback_data=f"admin_tariff_edit_daily_price:{tariff.id}"),
])
# Примечание: отключение суточного режима убрано - это необратимое решение при создании
# Переключение триала
if tariff.is_trial_available:
buttons.append([
@@ -287,9 +302,21 @@ 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:
price_block = f"<b>💰 Суточная цена:</b> {_format_price_kopeks(daily_price_kopeks)}/день"
tariff_type = "🔄 Суточный"
else:
price_block = f"<b>Цены:</b>\n{prices_display}"
tariff_type = "📅 Периодный"
return f"""📦 <b>Тариф: {tariff.name}</b>
{status}
{status} | {tariff_type}
🎚️ Уровень: {tariff.tier_level}
📊 Порядок: {tariff.display_order}
@@ -303,8 +330,7 @@ def format_tariff_info(tariff: Tariff, language: str, subs_count: int = 0) -> st
<b>Докупка трафика:</b>
{traffic_topup_display}
<b>Цены:</b>
{prices_display}
{price_block}
<b>Серверы:</b> {squads_display}
<b>Промогруппы:</b> {promo_display}
@@ -496,6 +522,160 @@ 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"💰 <b>Редактирование суточной цены</b>\n\n"
f"Тариф: {tariff.name}\n"
f"Текущая цена: {_format_price_kopeks(current_price)}/день\n\n"
"Введите новую цену за день в рублях.\n"
"Пример: <code>50</code> или <code>99.90</code>",
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")
# Парсим цену
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"
"Пример: <code>50</code> или <code>99.90</code>",
parse_mode="HTML"
)
return
# Проверяем - это создание или редактирование
is_creating = data.get("tariff_is_daily") and not 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"✅ <b>Суточный тариф создан!</b>\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"
)
# ============ СОЗДАНИЕ ТАРИФА ============
@admin_required
@@ -660,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(
"📦 <b>Создание тарифа</b>\n\n"
f"Название: <b>{data['tariff_name']}</b>\n"
f"Трафик: <b>{traffic_display}</b>\n"
f"Устройств: <b>{data['tariff_devices']}</b>\n"
f"Уровень: <b>{tier}</b>\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(
"📦 <b>Создание тарифа</b>\n\n"
f"Название: <b>{data['tariff_name']}</b>\n"
f"Трафик: <b>{traffic_display}</b>\n"
f"Устройств: <b>{data['tariff_devices']}</b>\n"
f"Уровень: <b>{data['tariff_tier']}</b>\n"
f"Тип: <b>📅 Периодный</b>\n\n"
"Шаг 6/6: Введите цены на периоды\n\n"
"Формат: <code>дней:цена_в_копейках</code>\n"
"Несколько периодов через запятую\n\n"
"Пример:\n<code>30:9900, 90:24900, 180:44900, 360:79900</code>",
@@ -679,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(
"📦 <b>Создание суточного тарифа</b>\n\n"
f"Название: <b>{data['tariff_name']}</b>\n"
f"Трафик: <b>{traffic_display}</b>\n"
f"Устройств: <b>{data['tariff_devices']}</b>\n"
f"Уровень: <b>{data['tariff_tier']}</b>\n"
f"Тип: <b>🔄 Суточный</b>\n\n"
"Шаг 6/6: Введите суточную цену в рублях\n\n"
"Пример: <i>50</i> (50 ₽/день), <i>99.90</i> (99.90 ₽/день)",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text=texts.CANCEL, callback_data="admin_tariffs")]
]),
parse_mode="HTML"
)
await callback.answer()
@admin_required
@@ -2260,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)
# Редактирование названия
@@ -2318,3 +2571,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)

View File

@@ -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] = []

View File

@@ -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,
@@ -333,31 +335,116 @@ 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:
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}"
except Exception as e:
logger.warning(f"Ошибка получения тарифа: {e}")
# Прикрепляем тариф к подписке для использования в клавиатуре
subscription.tariff = tariff
message_template = texts.t(
"SUBSCRIPTION_OVERVIEW_TEMPLATE",
"""👤 {full_name}
# Формируем блок информации о тарифе
is_daily = getattr(tariff, 'is_daily', False)
tariff_type_str = "🔄 Суточный" if is_daily else "📅 Периодный"
tariff_info_lines = [
f"<b>📦 {tariff.name}</b>",
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("⏸️ <b>Подписка приостановлена</b>")
# Показываем оставшееся время даже при паузе
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)
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<blockquote expandable>" + "\n".join(tariff_info_lines) + "</blockquote>"
except Exception as e:
logger.warning(f"Ошибка получения тарифа: {e}", exc_info=True)
# Определяем, суточный ли тариф для выбора шаблона
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}
📱 Подписка: {status_emoji} {status_display}{warning}{tariff_info_block}
📱 Информация о подписке
🎭 Тип: {subscription_type}{tariff_line}
🎭 Тип: {subscription_type}
📈 Трафик: {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}
📱 Информация о подписке
🎭 Тип: {subscription_type}
📅 Действует до: {end_date}
⏰ Осталось: {time_left}
📈 Трафик: {traffic}
🌍 Серверы: {servers}
📱 Устройства: {devices_used} / {device_limit}""",
)
)
if not show_devices:
message_template = message_template.replace(
@@ -380,8 +467,8 @@ 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"),
time_left=time_left_text,
traffic=traffic_used_display,
@@ -3016,7 +3103,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 +3129,80 @@ 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
# Прикрепляем тариф к подписке для CRUD функций
subscription.tariff = tariff
# Переключаем статус паузы
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:
# Была пауза, теперь возобновили
message = texts.t(
"DAILY_SUBSCRIPTION_RESUMED",
"▶️ Подписка возобновлена!"
)
else:
# Была активна, теперь на паузе
message = texts.t(
"DAILY_SUBSCRIPTION_PAUSED",
"⏸️ Подписка приостановлена!"
)
await callback.answer(message, show_alert=True)
# Возвращаемся в меню подписки - вызываем show_subscription_info
await db.refresh(db_user)
await show_subscription_info(callback, db_user, db)
# ============== ХЕНДЛЕРЫ ПЛАТНОГО ТРИАЛА ==============
@error_handler
@@ -3993,6 +4159,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"

View File

@@ -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"<b>{tariff.name}</b> — {traffic}/{tariff.device_limit}📱 {price_text}")
@@ -257,11 +266,58 @@ def format_tariff_info_for_user(
if discount_percent > 0:
text += f"\n🎁 <b>Ваша скидка: {discount_percent}%</b>\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"✅ <b>Подтверждение покупки</b>\n\n"
f"📦 Тариф: <b>{tariff.name}</b>\n"
f"📊 Трафик: {traffic}\n"
f"📱 Устройств: {tariff.device_limit}\n"
f"🔄 Тип: <b>Суточный</b>\n\n"
f"💰 <b>Цена: {_format_price_kopeks(daily_price)}/день</b>\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"❌ <b>Недостаточно средств</b>\n\n"
f"📦 Тариф: <b>{tariff.name}</b>\n"
f"🔄 Тип: Суточный\n"
f"💰 Цена: {_format_price_kopeks(daily_price)}/день\n\n"
f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n"
f"⚠️ Не хватает: <b>{_format_price_kopeks(missing)}</b>",
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()
@@ -452,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)
@@ -546,6 +645,167 @@ 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 []
# Если 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)
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_trial = False # Сбрасываем триальный статус
existing_subscription.is_daily_paused = False
existing_subscription.last_daily_charge_at = datetime.utcnow()
# Для суточного тарифа ставим срок на 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=1,
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"🎉 <b>Суточная подписка оформлена!</b>\n\n"
f"📦 Тариф: <b>{tariff.name}</b>\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 +1140,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"<b>{tariff.name}</b> — {traffic}/{tariff.device_limit}📱 {price_text}")
@@ -1105,23 +1373,89 @@ async def select_tariff_switch(
traffic = _format_traffic(tariff.traffic_limit_gb)
info_text = f"""📦 <b>{tariff.name}</b>
# Проверяем, суточный ли это тариф
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
# Проверяем текущую подписку на оставшиеся дни
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⚠️ <b>Внимание!</b> У вас осталось {remaining_days} дн. подписки.\nПри смене на суточный тариф они будут утеряны!"
if user_balance >= daily_price:
await callback.message.edit_text(
f"✅ <b>Подтверждение смены тарифа</b>\n\n"
f"📦 Новый тариф: <b>{tariff.name}</b>\n"
f"📊 Трафик: {traffic}\n"
f"📱 Устройств: {tariff.device_limit}\n"
f"🔄 Тип: <b>Суточный</b>\n\n"
f"💰 <b>Цена: {_format_price_kopeks(daily_price)}/день</b>\n\n"
f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}"
f"{days_warning}\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"❌ <b>Недостаточно средств</b>\n\n"
f"📦 Тариф: <b>{tariff.name}</b>\n"
f"🔄 Тип: Суточный\n"
f"💰 Цена: {_format_price_kopeks(daily_price)}/день\n\n"
f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n"
f"⚠️ Не хватает: <b>{_format_price_kopeks(missing)}</b>"
f"{days_warning}",
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"""📦 <b>{tariff.name}</b>
<b>Параметры нового тарифа:</b>
• Трафик: {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()
@@ -1270,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
@@ -1356,6 +1696,144 @@ 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 []
# Если 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
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()
# Для суточного тарифа ставим срок на 1 день
subscription.end_date = datetime.utcnow() + timedelta(days=1)
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"🎉 <b>Тариф успешно изменён!</b>\n\n"
f"📦 Новый тариф: <b>{tariff.name}</b>\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:
@@ -1677,6 +2155,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⚠️ <b>Внимание!</b> У вас осталось {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"🔄 <b>Переключение на суточный тариф</b>\n\n"
f"📌 Текущий: <b>{current_tariff.name}</b>\n"
f" • Трафик: {current_traffic}\n"
f" • Устройств: {current_tariff.device_limit}\n\n"
f"📦 Новый: <b>{new_tariff.name}</b>\n"
f" • Трафик: {traffic}\n"
f" • Устройств: {new_tariff.device_limit}\n"
f" • Тип: 🔄 Суточный\n\n"
f"💰 <b>Цена: {_format_price_kopeks(daily_price)}/день</b>\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"❌ <b>Недостаточно средств</b>\n\n"
f"📦 Тариф: <b>{new_tariff.name}</b>\n"
f"🔄 Тип: Суточный\n"
f"💰 Цена: {_format_price_kopeks(daily_price)}/день\n\n"
f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n"
f"⚠️ Не хватает: <b>{_format_price_kopeks(missing)}</b>"
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:
@@ -1783,13 +2316,45 @@ 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)
# Обновляем подписку с новыми параметрами тарифа
# НЕ меняем 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)
@@ -1834,24 +2399,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"🎉 <b>Тариф успешно изменён!</b>\n\n"
f"📦 Новый тариф: <b>{new_tariff.name}</b>\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"🎉 <b>Тариф успешно изменён!</b>\n\n"
f"📦 Новый тариф: <b>{new_tariff.name}</b>\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"🎉 <b>Тариф успешно изменён!</b>\n\n"
f"📦 Новый тариф: <b>{new_tariff.name}</b>\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:
@@ -1874,6 +2457,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 +2470,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:"))

View File

@@ -980,14 +980,29 @@ 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 = [
@@ -997,10 +1012,12 @@ def get_subscription_keyboard(
)
]
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)
@@ -2514,6 +2531,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 +2541,8 @@ def get_updated_subscription_settings_keyboard(
# Если подписка на тарифе - отключаем страны, модем, трафик
has_tariff = tariff is not None
# Для суточных тарифов кнопка паузы теперь в главном меню подписки
if show_countries_management and not has_tariff:
keyboard.append([
InlineKeyboardButton(text=texts.t("ADD_COUNTRIES_BUTTON", "🌐 Добавить страны"), callback_data="subscription_add_countries")

View File

@@ -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}\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<code>{bar}</code>",
@@ -1455,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",
@@ -1660,5 +1662,11 @@
"ADMIN_USER_RESTRICTIONS": "⚠️ Restrict",
"USER_RESTRICTION_TOPUP_BLOCKED": "🚫 <b>Top-up restricted</b>\n\n{reason}\n\nIf you believe this is an error, you can appeal the decision.",
"USER_RESTRICTION_SUBSCRIPTION_BLOCKED": "🚫 <b>Subscription purchase/renewal restricted</b>\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": "⚠️ <b>Warning!</b> You have {days} days left.\nThey will be lost when switching to daily tariff!",
"DAILY_SUBSCRIPTION_PAUSED": "⏸️ Subscription paused",
"DAILY_SUBSCRIPTION_RESUMED": "▶️ Subscription resumed!"
}

View File

@@ -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}\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<code>{bar}</code>",
@@ -1472,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": "⏳ Ожидает активации",
@@ -1677,5 +1679,11 @@
"ADMIN_USER_RESTRICTIONS": "⚠️ Ограничить",
"USER_RESTRICTION_TOPUP_BLOCKED": "🚫 <b>Пополнение ограничено</b>\n\n{reason}\n\nЕсли вы считаете это ошибкой, вы можете обжаловать решение.",
"USER_RESTRICTION_SUBSCRIPTION_BLOCKED": "🚫 <b>Покупка/продление подписки ограничено</b>\n\n{reason}\n\nЕсли вы считаете это ошибкой, вы можете обжаловать решение.",
"USER_RESTRICTION_APPEAL_BUTTON": "🆘 Обжаловать"
"USER_RESTRICTION_APPEAL_BUTTON": "🆘 Обжаловать",
"PAUSE_DAILY_BUTTON": "⏸️ Приостановить подписку",
"RESUME_DAILY_BUTTON": "▶️ Возобновить подписку",
"DAILY_SWITCH_WARNING": "⚠️ <b>Внимание!</b> У вас осталось {days} дн. подписки.\nПри смене на суточный тариф они будут утеряны!",
"DAILY_SUBSCRIPTION_PAUSED": "⏸️ Подписка приостановлена",
"DAILY_SUBSCRIPTION_RESUMED": "▶️ Подписка возобновлена!"
}

View File

@@ -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<code>{bar}</code>",
@@ -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": "⏳ Очікує активації",

View File

@@ -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<code>{bar}</code>",
@@ -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<code>{bar}</code>",
@@ -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":"⏳等待激活",

View File

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

View File

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

View File

@@ -0,0 +1,290 @@
"""
Сервис для автоматического списания суточных подписок.
Проверяет подписки с суточным тарифом и списывает плату раз в сутки.
"""
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.MANUAL,
)
# Обновляем время последнего списания и продлеваем подписку
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)
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"💳 <b>Суточное списание</b>\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"⚠️ <b>Подписка приостановлена</b>\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"]

View File

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

View File

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

View File

@@ -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,
)
@@ -6504,21 +6548,37 @@ 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:
# Для суточного тарифа принудительно 1 день (защита от манипуляций с period_days)
payload.period_days = 1
# Для суточного тарифа берём 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:
@@ -6545,7 +6605,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} дней"
@@ -6592,6 +6654,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)
@@ -6879,6 +6951,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)
@@ -7093,3 +7182,99 @@ 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,
},
)
# Восстанавливаем статус 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)
# Синхронизация с 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),
)

View File

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

38
main.py
View File

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

View File

@@ -2707,6 +2707,178 @@
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;
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 {
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 +5741,39 @@
</div>
</div>
<!-- Daily Subscription Status Bar -->
<div class="daily-subscription-status hidden" id="dailySubscriptionStatus">
<div class="daily-subscription-header">
<div class="daily-subscription-title">
<span class="daily-icon">🔄</span>
<span id="dailyTariffName" data-i18n="daily.tariff_name">Суточный тариф</span>
</div>
<div class="daily-subscription-price" id="dailyPrice">-/день</div>
</div>
<div class="daily-subscription-progress" id="dailyProgressSection">
<div class="daily-progress-bar">
<div class="daily-progress-fill" id="dailyProgressFill" style="width: 100%"></div>
</div>
</div>
<div class="daily-subscription-info">
<div class="daily-time-remaining">
<span data-i18n="daily.time_remaining">Осталось:</span>
<strong id="dailyTimeRemaining">--:--:--</strong>
</div>
<div class="daily-next-charge" id="dailyNextCharge"></div>
</div>
<div class="daily-subscription-actions">
<button class="daily-pause-btn" id="dailyPauseBtn" type="button">
<span id="dailyPauseBtnIcon">⏸️</span>
<span id="dailyPauseBtnText" data-i18n="daily.pause">Приостановить</span>
</button>
</div>
<div class="daily-subscription-paused-notice hidden" id="dailyPausedNotice">
<span class="notice-icon">⏸️</span>
<span data-i18n="daily.paused_notice">Подписка приостановлена. Ежедневное списание отключено. VPN не работает.</span>
</div>
</div>
<div class="info-list">
<div class="info-item">
<span class="info-label" data-i18n="info.expires">
@@ -6433,6 +6638,17 @@
'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.paused_no_charge': 'Paused — no charge',
'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 +7112,17 @@
'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.paused_no_charge': 'Пауза — без списания',
'daily.status.active': 'Активна',
'daily.status.paused': 'Приостановлена',
'daily.hours_remaining': 'Часов осталось',
'button.connect.default': 'Подключиться к VPN',
'button.connect.happ': 'Подключиться',
'button.copy': 'Скопировать ссылку подписки',
@@ -9278,6 +9505,9 @@
: autopayLabel;
}
// Отображение суточной подписки
renderDailySubscriptionStatus();
renderSubscriptionMissingCard();
renderSubscriptionPurchaseCard();
renderSubscriptionRenewalCard();
@@ -9295,6 +9525,221 @@
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.remove('hidden');
progressSection?.classList.add('paused');
// Обновляем статус 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');
progressSection?.classList.remove('paused');
}
// Обработчик кнопки паузы
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) {
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 часа в миллисекундах
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 +20804,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 +20841,13 @@
const upgradeLabel = preferredLanguage === 'en' ? 'upgrade' : 'доплата';
const freeLabel = preferredLanguage === 'en' ? '✓ Free' : '✓ Бесплатно';
const dailyLabel = preferredLanguage === 'en' ? 'daily' : 'суточный';
div.className = 'instant-switch-tariff-item';
div.innerHTML = `
<div class="instant-switch-tariff-info">
<div class="instant-switch-tariff-name">
${isPremium ? '👑 ' : '⚡ '}${escapeHtml(tariff.name)}
${isDaily ? '🔄 ' : (isPremium ? '👑 ' : '⚡ ')}${escapeHtml(tariff.name)}
</div>
${description ? `<div style="font-size: 11px; color: var(--text-secondary); margin: 4px 0; line-height: 1.3;">${escapeHtml(description)}</div>` : ''}
<div class="instant-switch-tariff-details">
@@ -20409,22 +20857,40 @@
${serverTags ? `<div style="display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px;">${serverTags}</div>` : ''}
</div>
<div class="instant-switch-tariff-cost">
<span class="instant-switch-cost-badge ${isUpgrade ? 'upgrade' : 'free'}">
${isUpgrade
? `<span style="font-size: 10px; display: block; text-transform: uppercase; opacity: 0.8;">${upgradeLabel}</span>+${upgradeCostLabel}`
: freeLabel}
</span>
${isDaily ? `
<span class="instant-switch-cost-badge" style="background: linear-gradient(135deg, #8b5cf6, #6366f1); color: white;">
<span style="font-size: 10px; display: block; text-transform: uppercase; opacity: 0.8;">${dailyLabel}</span>
${dailyPriceLabel || ''}
</span>
` : `
<span class="instant-switch-cost-badge ${isUpgrade ? 'upgrade' : 'free'}">
${isUpgrade
? `<span style="font-size: 10px; display: block; text-transform: uppercase; opacity: 0.8;">${upgradeLabel}</span>+${upgradeCostLabel}`
: freeLabel}
</span>
`}
</div>
`;
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 +20904,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 +20957,11 @@
</div>
${minPriceLabel ? `
<div style="text-align: right; flex-shrink: 0;">
<div style="font-size: 10px; color: var(--text-secondary); text-transform: uppercase;">от</div>
${pricePrefix ? `<div style="font-size: 10px; color: var(--text-secondary); text-transform: uppercase;">${pricePrefix}</div>` : ''}
${minPriceOriginalLabel ? `<div style="font-size: 12px; color: var(--text-secondary); text-decoration: line-through;">${minPriceOriginalLabel}</div>` : ''}
<div style="font-weight: 600; color: var(--primary); font-size: 15px;">${minPriceLabel}</div>
${maxDiscountPercent > 0 ? `<div class="tariff-discount-badge">-${maxDiscountPercent}%</div>` : ''}
${isDaily ? `<div style="font-size: 10px; color: var(--primary); margin-top: 2px;">🔄 суточный</div>` : ''}
</div>
` : ''}
</div>
@@ -20683,6 +21152,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 = `
<div style="padding: 16px; background: linear-gradient(135deg, rgba(139, 92, 246, 0.1), rgba(99, 102, 241, 0.05)); border-radius: 12px; border: 1px solid rgba(139, 92, 246, 0.2);">
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 10px;">
<span style="font-size: 24px;">🔄</span>
<div>
<div style="font-weight: 600; color: var(--text-primary);">Суточная подписка</div>
<div style="font-size: 12px; color: var(--text-secondary);">Ежедневное списание с баланса</div>
</div>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; padding-top: 10px; border-top: 1px solid rgba(139, 92, 246, 0.1);">
<span style="font-size: 13px; color: var(--text-secondary);">Стоимость в день:</span>
<span style="font-weight: 700; color: var(--primary); font-size: 16px;">${dailyPriceLabel}</span>
</div>
<div style="font-size: 11px; color: var(--text-secondary); margin-top: 8px; line-height: 1.4;">
💡 Списание происходит автоматически каждые 24 часа. Можно приостановить в любой момент.
</div>
</div>
`;
summary?.classList.add('hidden');
updateTariffButton();
return;
}
const periods = selectedTariffData.periods || [];
if (periods.length === 0) {
section.classList.add('hidden');
@@ -20802,12 +21299,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');
@@ -20815,7 +21324,10 @@
}
async function purchaseTariff() {
if (!selectedTariffId || !selectedTariffPeriod) {
const isDaily = selectedTariffData?.is_daily ?? selectedTariffData?.isDaily ?? false;
// Для суточных тарифов не требуем выбора периода
if (!selectedTariffId || (!selectedTariffPeriod && !isDaily)) {
showPopup('Выберите тариф', 'Ошибка');
return;
}
@@ -20828,7 +21340,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',
@@ -20846,7 +21361,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);