mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-01-19 19:32:10 +00:00
Merge pull request #2270 from BEDOLAGA-DEV/dev5
Daily Tariffs / Bug fixs / promocode fixs / db dublicate session fix
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:"))
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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!"
|
||||
}
|
||||
|
||||
@@ -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": "▶️ Подписка возобновлена!"
|
||||
}
|
||||
|
||||
@@ -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": "⏳ Очікує активації",
|
||||
|
||||
@@ -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":"⏳等待激活",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
290
app/services/daily_subscription_service.py
Normal file
290
app/services/daily_subscription_service.py
Normal 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"]
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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
38
main.py
@@ -36,6 +36,7 @@ from app.services.referral_contest_service import referral_contest_service
|
||||
from app.services.contest_rotation_service import contest_rotation_service
|
||||
from app.services.nalogo_queue_service import nalogo_queue_service
|
||||
from app.services.traffic_monitoring_service import traffic_monitoring_scheduler
|
||||
from app.services.daily_subscription_service import daily_subscription_service
|
||||
from app.utils.startup_timeline import StartupTimeline
|
||||
from app.utils.timezone import TimezoneAwareFormatter
|
||||
from app.utils.log_handlers import LevelFilterHandler, ExcludePaymentFilter
|
||||
@@ -174,6 +175,7 @@ async def main():
|
||||
maintenance_task = None
|
||||
version_check_task = None
|
||||
traffic_monitoring_task = None
|
||||
daily_subscription_task = None
|
||||
polling_task = None
|
||||
web_api_server = None
|
||||
telegram_webhook_enabled = False
|
||||
@@ -240,6 +242,7 @@ async def main():
|
||||
maintenance_service.set_bot(bot)
|
||||
broadcast_service.set_bot(bot)
|
||||
traffic_monitoring_scheduler.set_bot(bot)
|
||||
daily_subscription_service.set_bot(bot)
|
||||
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
@@ -597,6 +600,21 @@ async def main():
|
||||
traffic_monitoring_task = None
|
||||
stage.skip("Мониторинг трафика отключен настройками")
|
||||
|
||||
async with timeline.stage(
|
||||
"Суточные подписки",
|
||||
"💳",
|
||||
success_message="Сервис суточных подписок запущен",
|
||||
) as stage:
|
||||
if daily_subscription_service.is_enabled():
|
||||
daily_subscription_task = asyncio.create_task(
|
||||
daily_subscription_service.start_monitoring()
|
||||
)
|
||||
interval_minutes = daily_subscription_service.get_check_interval_minutes()
|
||||
stage.log(f"Интервал проверки: {interval_minutes} мин")
|
||||
else:
|
||||
daily_subscription_task = None
|
||||
stage.skip("Суточные подписки отключены настройками")
|
||||
|
||||
async with timeline.stage(
|
||||
"Сервис проверки версий",
|
||||
"📄",
|
||||
@@ -661,6 +679,7 @@ async def main():
|
||||
f"Мониторинг: {'Включен' if monitoring_task else 'Отключен'}",
|
||||
f"Техработы: {'Включен' if maintenance_task else 'Отключен'}",
|
||||
f"Мониторинг трафика: {'Включен' if traffic_monitoring_task else 'Отключен'}",
|
||||
f"Суточные подписки: {'Включен' if daily_subscription_task else 'Отключен'}",
|
||||
f"Проверка версий: {'Включен' if version_check_task else 'Отключен'}",
|
||||
f"Отчеты: {'Включен' if reporting_service.is_running() else 'Отключен'}",
|
||||
]
|
||||
@@ -715,6 +734,16 @@ async def main():
|
||||
traffic_monitoring_scheduler.start_monitoring()
|
||||
)
|
||||
|
||||
if daily_subscription_task and daily_subscription_task.done():
|
||||
exception = daily_subscription_task.exception()
|
||||
if exception:
|
||||
logger.error(f"Сервис суточных подписок завершился с ошибкой: {exception}")
|
||||
if daily_subscription_service.is_enabled():
|
||||
logger.info("🔄 Перезапуск сервиса суточных подписок...")
|
||||
daily_subscription_task = asyncio.create_task(
|
||||
daily_subscription_service.start_monitoring()
|
||||
)
|
||||
|
||||
if auto_verification_active and not auto_payment_verification_service.is_running():
|
||||
logger.warning(
|
||||
"Сервис автопроверки пополнений остановился, пробуем перезапустить..."
|
||||
@@ -784,6 +813,15 @@ async def main():
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
if daily_subscription_task and not daily_subscription_task.done():
|
||||
logger.info("ℹ️ Остановка сервиса суточных подписок...")
|
||||
daily_subscription_service.stop_monitoring()
|
||||
daily_subscription_task.cancel()
|
||||
try:
|
||||
await daily_subscription_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
logger.info("ℹ️ Остановка сервиса отчетов...")
|
||||
try:
|
||||
await reporting_service.stop()
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user