Merge pull request #2241 from BEDOLAGA-DEV/dev5

Tariffs
This commit is contained in:
Egor
2026-01-07 05:14:30 +03:00
committed by GitHub
22 changed files with 5443 additions and 111 deletions

View File

@@ -131,10 +131,21 @@ REMNAWAVE_USER_USERNAME_TEMPLATE="user_{telegram_id}"
REMNAWAVE_USER_DELETE_MODE=delete
# ========= ПОДПИСКИ =========
# ===== РЕЖИМ ПРОДАЖ =====
# Режим продаж подписок:
# "classic" - классический режим (выбор серверов, трафика, устройств, периода отдельно)
# "tariffs" - режим тарифов (готовые пакеты с фиксированными параметрами)
SALES_MODE=classic
# ===== ТРИАЛ ПОДПИСКА =====
TRIAL_DURATION_DAYS=3
TRIAL_TRAFFIC_LIMIT_GB=10
TRIAL_DEVICE_LIMIT=1
# ID тарифа для триала в режиме тарифов (0 = использовать стандартные настройки триала)
# Если указан ID тарифа, параметры триала берутся из тарифа (traffic_limit_gb, device_limit, allowed_squads)
# Длительность триала всё равно берётся из TRIAL_DURATION_DAYS
TRIAL_TARIFF_ID=0
# Платный триал: если TRIAL_ACTIVATION_PRICE > 0, триал становится платным
# Цена в копейках (1000 = 10 рублей). Пользователь может оплатить триал любым методом оплаты.
# TRIAL_PAYMENT_ENABLED опционален (для обратной совместимости)

View File

@@ -65,6 +65,7 @@ from app.handlers.admin import (
faq as admin_faq,
payments as admin_payments,
trials as admin_trials,
tariffs as admin_tariffs,
)
from app.handlers import contests as user_contests
from app.handlers.stars_payments import register_stars_handlers
@@ -190,6 +191,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
admin_faq.register_handlers(dp)
admin_payments.register_handlers(dp)
admin_trials.register_handlers(dp)
admin_tariffs.register_handlers(dp)
admin_bulk_ban.register_bulk_ban_handlers(dp)
admin_blacklist.register_blacklist_handlers(dp)
common.register_handlers(dp)

View File

@@ -166,7 +166,17 @@ class Settings(BaseSettings):
TRAFFIC_SELECTION_MODE: str = "selectable"
FIXED_TRAFFIC_LIMIT_GB: int = 100
BUY_TRAFFIC_BUTTON_VISIBLE: bool = True
# Режим продаж подписок:
# - classic: классический режим (выбор серверов, трафика, устройств, периода отдельно)
# - tariffs: режим тарифов (готовые пакеты с фиксированными параметрами)
SALES_MODE: str = "classic"
# ID тарифа для триала в режиме тарифов (0 = использовать стандартные настройки триала)
# Если указан ID тарифа, параметры триала берутся из тарифа (traffic_limit_gb, device_limit, allowed_squads)
# Длительность триала всё равно берётся из TRIAL_DURATION_DAYS
TRIAL_TARIFF_ID: int = 0
# Настройки докупки трафика
TRAFFIC_TOPUP_ENABLED: bool = True # Включить/выключить функцию докупки трафика
# Пакеты для докупки трафика (формат: "гб:цена:enabled", пустая строка = использовать TRAFFIC_PACKAGES_CONFIG)
@@ -1191,6 +1201,22 @@ class Settings(BaseSettings):
def is_modem_enabled(self) -> bool:
return bool(self.MODEM_ENABLED)
def is_tariffs_mode(self) -> bool:
"""Проверяет, включен ли режим продаж 'Тарифы'."""
return self.SALES_MODE == "tariffs"
def is_classic_mode(self) -> bool:
"""Проверяет, включен ли классический режим продаж."""
return self.SALES_MODE != "tariffs"
def get_sales_mode(self) -> str:
"""Возвращает текущий режим продаж."""
return self.SALES_MODE if self.SALES_MODE in ("classic", "tariffs") else "classic"
def get_trial_tariff_id(self) -> int:
"""Возвращает ID тарифа для триала (0 = использовать стандартные настройки)."""
return self.TRIAL_TARIFF_ID if self.TRIAL_TARIFF_ID > 0 else 0
def get_modem_price_per_month(self) -> int:
try:
value = int(self.MODEM_PRICE_PER_MONTH)
@@ -1248,11 +1274,12 @@ class Settings(BaseSettings):
return applicable_discount
def is_trial_paid_activation_enabled(self) -> bool:
# Если цена > 0, триал автоматически платный
# (TRIAL_PAYMENT_ENABLED теперь опционален - для обратной совместимости)
if self.TRIAL_ACTIVATION_PRICE > 0:
return True
return bool(self.TRIAL_PAYMENT_ENABLED)
# TRIAL_PAYMENT_ENABLED - главный переключатель платной активации
# Если выключен - триал бесплатный, независимо от цены
if not self.TRIAL_PAYMENT_ENABLED:
return False
# Если включен - проверяем что цена > 0
return self.TRIAL_ACTIVATION_PRICE > 0
def get_trial_activation_price(self) -> int:
try:

View File

@@ -44,23 +44,38 @@ async def create_trial_subscription(
duration_days: int = None,
traffic_limit_gb: int = None,
device_limit: Optional[int] = None,
squad_uuid: str = None
squad_uuid: str = None,
connected_squads: List[str] = None,
tariff_id: Optional[int] = None,
) -> Subscription:
"""Создает триальную подписку.
Args:
connected_squads: Список UUID сквадов (если указан, squad_uuid игнорируется)
tariff_id: ID тарифа (для режима тарифов)
"""
duration_days = duration_days or settings.TRIAL_DURATION_DAYS
traffic_limit_gb = traffic_limit_gb or settings.TRIAL_TRAFFIC_LIMIT_GB
if device_limit is None:
device_limit = settings.TRIAL_DEVICE_LIMIT
if not squad_uuid:
# Если переданы connected_squads, используем их
# Иначе используем squad_uuid или получаем случайный
final_squads = []
if connected_squads:
final_squads = connected_squads
elif squad_uuid:
final_squads = [squad_uuid]
else:
try:
from app.database.crud.server_squad import get_random_trial_squad_uuid
squad_uuid = await get_random_trial_squad_uuid(db)
if squad_uuid:
random_squad = await get_random_trial_squad_uuid(db)
if random_squad:
final_squads = [random_squad]
logger.debug(
"Выбран сквад %s для триальной подписки пользователя %s",
squad_uuid,
random_squad,
user_id,
)
except Exception as error:
@@ -80,40 +95,42 @@ async def create_trial_subscription(
end_date=end_date,
traffic_limit_gb=traffic_limit_gb,
device_limit=device_limit,
connected_squads=[squad_uuid] if squad_uuid else [],
connected_squads=final_squads,
autopay_enabled=settings.is_autopay_enabled_by_default(),
autopay_days_before=settings.DEFAULT_AUTOPAY_DAYS_BEFORE,
tariff_id=tariff_id,
)
db.add(subscription)
await db.commit()
await db.refresh(subscription)
logger.info(f"🎁 Создана триальная подписка для пользователя {user_id}")
logger.info(f"🎁 Создана триальная подписка для пользователя {user_id}" +
(f" с тарифом {tariff_id}" if tariff_id else ""))
if squad_uuid:
if final_squads:
try:
from app.database.crud.server_squad import (
get_server_ids_by_uuids,
add_user_to_servers,
)
server_ids = await get_server_ids_by_uuids(db, [squad_uuid])
server_ids = await get_server_ids_by_uuids(db, final_squads)
if server_ids:
await add_user_to_servers(db, server_ids)
logger.info(
"📈 Обновлен счетчик пользователей для триального сквада %s",
squad_uuid,
"📈 Обновлен счетчик пользователей для триальных сквадов %s",
final_squads,
)
else:
logger.warning(
"⚠️ Не удалось найти серверы для обновления счетчика (сквад %s)",
squad_uuid,
"⚠️ Не удалось найти серверы для обновления счетчика (сквады %s)",
final_squads,
)
except Exception as error:
logger.error(
"⚠️ Ошибка обновления счетчика пользователей для триального сквада %s: %s",
squad_uuid,
"⚠️ Ошибка обновления счетчика пользователей для триальных сквадов %s: %s",
final_squads,
error,
)
@@ -129,6 +146,7 @@ async def create_paid_subscription(
connected_squads: List[str] = None,
update_server_counters: bool = False,
is_trial: bool = False,
tariff_id: Optional[int] = None,
) -> Subscription:
end_date = datetime.utcnow() + timedelta(days=duration_days)
@@ -147,6 +165,7 @@ async def create_paid_subscription(
connected_squads=connected_squads or [],
autopay_enabled=settings.is_autopay_enabled_by_default(),
autopay_days_before=settings.DEFAULT_AUTOPAY_DAYS_BEFORE,
tariff_id=tariff_id,
)
db.add(subscription)
@@ -276,8 +295,24 @@ async def replace_subscription(
async def extend_subscription(
db: AsyncSession,
subscription: Subscription,
days: int
days: int,
*,
tariff_id: Optional[int] = None,
traffic_limit_gb: Optional[int] = None,
device_limit: Optional[int] = None,
connected_squads: Optional[List[str]] = None,
) -> Subscription:
"""Продлевает подписку на указанное количество дней.
Args:
db: Сессия базы данных
subscription: Подписка для продления
days: Количество дней для продления
tariff_id: ID тарифа (опционально, для режима тарифов)
traffic_limit_gb: Лимит трафика ГБ (опционально, для режима тарифов)
device_limit: Лимит устройств (опционально, для режима тарифов)
connected_squads: Список UUID сквадов (опционально, для режима тарифов)
"""
current_time = datetime.utcnow()
logger.info(f"🔄 Продление подписки {subscription.id} на {days} дней")
@@ -320,7 +355,7 @@ async def extend_subscription(
# Логируем статус подписки перед проверкой
logger.info(f"🔄 Продление подписки {subscription.id}, текущий статус: {subscription.status}, дни: {days}")
if days > 0 and subscription.status in (
SubscriptionStatus.EXPIRED.value,
SubscriptionStatus.DISABLED.value,
@@ -339,13 +374,36 @@ async def extend_subscription(
days
)
if settings.RESET_TRAFFIC_ON_PAYMENT:
# Обновляем параметры тарифа, если переданы
if tariff_id is not None:
old_tariff_id = subscription.tariff_id
subscription.tariff_id = tariff_id
logger.info(f"📦 Обновлен тариф подписки: {old_tariff_id}{tariff_id}")
if traffic_limit_gb is not None:
old_traffic = subscription.traffic_limit_gb
subscription.traffic_limit_gb = traffic_limit_gb
subscription.traffic_used_gb = 0.0
subscription.purchased_traffic_gb = 0 # Сбрасываем докупленный трафик вместе с использованным
subscription.purchased_traffic_gb = 0
logger.info(f"📊 Обновлен лимит трафика: {old_traffic} ГБ → {traffic_limit_gb} ГБ")
elif settings.RESET_TRAFFIC_ON_PAYMENT:
subscription.traffic_used_gb = 0.0
subscription.purchased_traffic_gb = 0
logger.info("🔄 Сбрасываем использованный и докупленный трафик согласно настройке RESET_TRAFFIC_ON_PAYMENT")
if device_limit is not None:
old_devices = subscription.device_limit
subscription.device_limit = device_limit
logger.info(f"📱 Обновлен лимит устройств: {old_devices}{device_limit}")
if connected_squads is not None:
old_squads = subscription.connected_squads
subscription.connected_squads = connected_squads
logger.info(f"🌍 Обновлены сквады: {old_squads}{connected_squads}")
# В режиме fixed_with_topup при продлении сбрасываем трафик до фиксированного лимита
if settings.is_traffic_fixed() and days > 0:
# Только если не передан traffic_limit_gb (т.е. не режим тарифов)
if traffic_limit_gb is None and settings.is_traffic_fixed() and days > 0:
fixed_limit = settings.get_fixed_traffic_limit()
old_limit = subscription.traffic_limit_gb
if subscription.traffic_limit_gb != fixed_limit or (subscription.purchased_traffic_gb or 0) > 0:

401
app/database/crud/tariff.py Normal file
View File

@@ -0,0 +1,401 @@
import logging
from typing import Dict, List, Optional
from sqlalchemy import func, select, update, delete
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import Tariff, Subscription, PromoGroup, tariff_promo_groups
logger = logging.getLogger(__name__)
def _normalize_period_prices(period_prices: Optional[Dict[int, int]]) -> Dict[str, int]:
"""Нормализует цены периодов в формат {str: int}."""
if not period_prices:
return {}
normalized: Dict[str, int] = {}
for key, value in period_prices.items():
try:
period = int(key)
price = int(value)
except (TypeError, ValueError):
continue
if period > 0 and price >= 0:
normalized[str(period)] = price
return normalized
async def get_all_tariffs(
db: AsyncSession,
*,
include_inactive: bool = False,
offset: int = 0,
limit: Optional[int] = None,
) -> List[Tariff]:
"""Получает все тарифы с опциональной фильтрацией по активности."""
query = select(Tariff).options(selectinload(Tariff.allowed_promo_groups))
if not include_inactive:
query = query.where(Tariff.is_active.is_(True))
query = query.order_by(Tariff.display_order, Tariff.id)
if offset:
query = query.offset(offset)
if limit is not None:
query = query.limit(limit)
result = await db.execute(query)
return result.scalars().all()
async def get_tariff_by_id(
db: AsyncSession,
tariff_id: int,
*,
with_promo_groups: bool = True,
) -> Optional[Tariff]:
"""Получает тариф по ID."""
query = select(Tariff).where(Tariff.id == tariff_id)
if with_promo_groups:
query = query.options(selectinload(Tariff.allowed_promo_groups))
result = await db.execute(query)
return result.scalars().first()
async def count_tariffs(db: AsyncSession, *, include_inactive: bool = False) -> int:
"""Подсчитывает количество тарифов."""
query = select(func.count(Tariff.id))
if not include_inactive:
query = query.where(Tariff.is_active.is_(True))
result = await db.execute(query)
return int(result.scalar_one())
async def get_trial_tariff(db: AsyncSession) -> Optional[Tariff]:
"""Получает тариф, доступный для триала (is_trial_available=True)."""
query = (
select(Tariff)
.where(Tariff.is_trial_available.is_(True))
.where(Tariff.is_active.is_(True))
.options(selectinload(Tariff.allowed_promo_groups))
.limit(1)
)
result = await db.execute(query)
return result.scalars().first()
async def set_trial_tariff(db: AsyncSession, tariff_id: int) -> Optional[Tariff]:
"""Устанавливает тариф как триальный (снимает флаг с других тарифов)."""
# Снимаем флаг с всех тарифов
await db.execute(
Tariff.__table__.update().values(is_trial_available=False)
)
# Устанавливаем флаг на выбранный тариф
tariff = await get_tariff_by_id(db, tariff_id)
if tariff:
tariff.is_trial_available = True
await db.commit()
await db.refresh(tariff)
return tariff
async def clear_trial_tariff(db: AsyncSession) -> None:
"""Снимает флаг триала со всех тарифов."""
await db.execute(
Tariff.__table__.update().values(is_trial_available=False)
)
await db.commit()
async def get_tariffs_for_user(
db: AsyncSession,
promo_group_id: Optional[int] = None,
) -> List[Tariff]:
"""
Получает тарифы, доступные для пользователя с учетом его промогруппы.
Если у тарифа нет ограничений по промогруппам - он доступен всем.
"""
query = (
select(Tariff)
.options(selectinload(Tariff.allowed_promo_groups))
.where(Tariff.is_active.is_(True))
.order_by(Tariff.display_order, Tariff.id)
)
result = await db.execute(query)
tariffs = result.scalars().all()
# Фильтруем по промогруппе
available_tariffs = []
for tariff in tariffs:
if not tariff.allowed_promo_groups:
# Нет ограничений - доступен всем
available_tariffs.append(tariff)
elif promo_group_id is not None:
# Проверяем, есть ли промогруппа пользователя в списке разрешенных
if any(pg.id == promo_group_id for pg in tariff.allowed_promo_groups):
available_tariffs.append(tariff)
# else: пользователь без промогруппы, а у тарифа есть ограничения - пропускаем
return available_tariffs
async def create_tariff(
db: AsyncSession,
name: str,
*,
description: Optional[str] = None,
display_order: int = 0,
is_active: bool = True,
traffic_limit_gb: int = 100,
device_limit: int = 1,
allowed_squads: Optional[List[str]] = None,
period_prices: Optional[Dict[int, int]] = None,
tier_level: int = 1,
is_trial_available: bool = False,
promo_group_ids: Optional[List[int]] = None,
) -> Tariff:
"""Создает новый тариф."""
normalized_prices = _normalize_period_prices(period_prices)
tariff = Tariff(
name=name.strip(),
description=description.strip() if description else None,
display_order=max(0, display_order),
is_active=is_active,
traffic_limit_gb=max(0, traffic_limit_gb),
device_limit=max(1, device_limit),
allowed_squads=allowed_squads or [],
period_prices=normalized_prices,
tier_level=max(1, tier_level),
is_trial_available=is_trial_available,
)
db.add(tariff)
await db.flush()
# Добавляем промогруппы если указаны
if promo_group_ids:
promo_groups_result = await db.execute(
select(PromoGroup).where(PromoGroup.id.in_(promo_group_ids))
)
promo_groups = promo_groups_result.scalars().all()
tariff.allowed_promo_groups = list(promo_groups)
await db.commit()
await db.refresh(tariff)
logger.info(
"Создан тариф '%s' (id=%s, tier=%s, traffic=%sGB, devices=%s, prices=%s)",
tariff.name,
tariff.id,
tariff.tier_level,
tariff.traffic_limit_gb,
tariff.device_limit,
normalized_prices,
)
return tariff
async def update_tariff(
db: AsyncSession,
tariff: Tariff,
*,
name: Optional[str] = None,
description: Optional[str] = None,
display_order: Optional[int] = None,
is_active: Optional[bool] = None,
traffic_limit_gb: Optional[int] = None,
device_limit: Optional[int] = None,
device_price_kopeks: Optional[int] = ..., # ... = не передан, None = сбросить
allowed_squads: Optional[List[str]] = None,
period_prices: Optional[Dict[int, int]] = None,
tier_level: Optional[int] = None,
is_trial_available: Optional[bool] = None,
promo_group_ids: Optional[List[int]] = None,
) -> Tariff:
"""Обновляет существующий тариф."""
if name is not None:
tariff.name = name.strip()
if description is not None:
tariff.description = description.strip() if description else None
if display_order is not None:
tariff.display_order = max(0, display_order)
if is_active is not None:
tariff.is_active = is_active
if traffic_limit_gb is not None:
tariff.traffic_limit_gb = max(0, traffic_limit_gb)
if device_limit is not None:
tariff.device_limit = max(1, device_limit)
if device_price_kopeks is not ...:
# Если передан device_price_kopeks (включая None) - обновляем
tariff.device_price_kopeks = device_price_kopeks
if allowed_squads is not None:
tariff.allowed_squads = allowed_squads
if period_prices is not None:
tariff.period_prices = _normalize_period_prices(period_prices)
if tier_level is not None:
tariff.tier_level = max(1, tier_level)
if is_trial_available is not None:
tariff.is_trial_available = is_trial_available
# Обновляем промогруппы если указаны
if promo_group_ids is not None:
if promo_group_ids:
promo_groups_result = await db.execute(
select(PromoGroup).where(PromoGroup.id.in_(promo_group_ids))
)
promo_groups = promo_groups_result.scalars().all()
tariff.allowed_promo_groups = list(promo_groups)
else:
tariff.allowed_promo_groups = []
await db.commit()
await db.refresh(tariff)
logger.info(
"Обновлен тариф '%s' (id=%s)",
tariff.name,
tariff.id,
)
return tariff
async def delete_tariff(db: AsyncSession, tariff: Tariff) -> bool:
"""
Удаляет тариф.
Подписки с этим тарифом получат tariff_id = NULL.
"""
tariff_id = tariff.id
tariff_name = tariff.name
# Подсчитываем подписки с этим тарифом
subscriptions_count = await db.execute(
select(func.count(Subscription.id)).where(Subscription.tariff_id == tariff_id)
)
affected_subscriptions = subscriptions_count.scalar_one()
# Удаляем тариф (FK с ondelete=SET NULL автоматически обнулит tariff_id в подписках)
await db.delete(tariff)
await db.commit()
logger.info(
"Удален тариф '%s' (id=%s), затронуто подписок: %s",
tariff_name,
tariff_id,
affected_subscriptions,
)
return True
async def get_tariff_subscriptions_count(db: AsyncSession, tariff_id: int) -> int:
"""Подсчитывает количество подписок на тарифе."""
result = await db.execute(
select(func.count(Subscription.id)).where(Subscription.tariff_id == tariff_id)
)
return int(result.scalar_one())
async def set_tariff_promo_groups(
db: AsyncSession,
tariff: Tariff,
promo_group_ids: List[int],
) -> Tariff:
"""Устанавливает промогруппы для тарифа."""
if promo_group_ids:
promo_groups_result = await db.execute(
select(PromoGroup).where(PromoGroup.id.in_(promo_group_ids))
)
promo_groups = promo_groups_result.scalars().all()
tariff.allowed_promo_groups = list(promo_groups)
else:
tariff.allowed_promo_groups = []
await db.commit()
await db.refresh(tariff)
return tariff
async def add_promo_group_to_tariff(
db: AsyncSession,
tariff: Tariff,
promo_group_id: int,
) -> bool:
"""Добавляет промогруппу к тарифу."""
promo_group = await db.get(PromoGroup, promo_group_id)
if not promo_group:
return False
if promo_group not in tariff.allowed_promo_groups:
tariff.allowed_promo_groups.append(promo_group)
await db.commit()
return True
async def remove_promo_group_from_tariff(
db: AsyncSession,
tariff: Tariff,
promo_group_id: int,
) -> bool:
"""Удаляет промогруппу из тарифа."""
for pg in tariff.allowed_promo_groups:
if pg.id == promo_group_id:
tariff.allowed_promo_groups.remove(pg)
await db.commit()
return True
return False
async def get_tariffs_with_subscriptions_count(
db: AsyncSession,
*,
include_inactive: bool = False,
) -> List[tuple]:
"""Получает тарифы с количеством подписок."""
query = (
select(Tariff, func.count(Subscription.id))
.outerjoin(Subscription, Subscription.tariff_id == Tariff.id)
.group_by(Tariff.id)
.order_by(Tariff.display_order, Tariff.id)
)
if not include_inactive:
query = query.where(Tariff.is_active.is_(True))
result = await db.execute(query)
return result.all()
async def reorder_tariffs(
db: AsyncSession,
tariff_order: List[int],
) -> None:
"""Изменяет порядок отображения тарифов."""
for order, tariff_id in enumerate(tariff_order):
await db.execute(
update(Tariff)
.where(Tariff.id == tariff_id)
.values(display_order=order)
)
await db.commit()
logger.info("Изменен порядок тарифов: %s", tariff_order)

View File

@@ -46,6 +46,25 @@ server_squad_promo_groups = Table(
)
# M2M таблица для связи тарифов с промогруппами (доступ к тарифу)
tariff_promo_groups = Table(
"tariff_promo_groups",
Base.metadata,
Column(
"tariff_id",
Integer,
ForeignKey("tariffs.id", ondelete="CASCADE"),
primary_key=True,
),
Column(
"promo_group_id",
Integer,
ForeignKey("promo_groups.id", ondelete="CASCADE"),
primary_key=True,
),
)
class UserStatus(Enum):
ACTIVE = "active"
BLOCKED = "blocked"
@@ -714,6 +733,82 @@ class UserPromoGroup(Base):
return f"<UserPromoGroup(user_id={self.user_id}, promo_group_id={self.promo_group_id}, assigned_by='{self.assigned_by}')>"
class Tariff(Base):
"""Тарифный план для режима продаж 'Тарифы'."""
__tablename__ = "tariffs"
id = Column(Integer, primary_key=True, index=True)
# Основная информация
name = Column(String(255), nullable=False)
description = Column(Text, nullable=True)
display_order = Column(Integer, default=0, nullable=False)
is_active = Column(Boolean, default=True, nullable=False)
# Параметры тарифа
traffic_limit_gb = Column(Integer, nullable=False, default=100) # 0 = безлимит
device_limit = Column(Integer, nullable=False, default=1)
device_price_kopeks = Column(Integer, nullable=True, default=None) # Цена за доп. устройство (None = нельзя докупить)
# Сквады (серверы) доступные в тарифе
allowed_squads = Column(JSON, default=list) # список UUID сквадов
# Цены на периоды в копейках (JSON: {"14": 30000, "30": 50000, "90": 120000, ...})
period_prices = Column(JSON, nullable=False, default=dict)
# Уровень тарифа (для визуального отображения, 1 = базовый)
tier_level = Column(Integer, default=1, nullable=False)
# Дополнительные настройки
is_trial_available = Column(Boolean, default=False, nullable=False) # Можно ли взять триал на этом тарифе
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
# M2M связь с промогруппами (какие промогруппы имеют доступ к тарифу)
allowed_promo_groups = relationship(
"PromoGroup",
secondary=tariff_promo_groups,
lazy="selectin",
)
# Подписки на этом тарифе
subscriptions = relationship("Subscription", back_populates="tariff")
@property
def is_unlimited_traffic(self) -> bool:
"""Проверяет, безлимитный ли трафик."""
return self.traffic_limit_gb == 0
def get_price_for_period(self, period_days: int) -> Optional[int]:
"""Возвращает цену в копейках для указанного периода."""
prices = self.period_prices or {}
return prices.get(str(period_days))
def get_available_periods(self) -> List[int]:
"""Возвращает список доступных периодов в днях."""
prices = self.period_prices or {}
return sorted([int(p) for p in prices.keys()])
def get_price_rubles(self, period_days: int) -> Optional[float]:
"""Возвращает цену в рублях для указанного периода."""
price_kopeks = self.get_price_for_period(period_days)
if price_kopeks is not None:
return price_kopeks / 100
return None
def is_available_for_promo_group(self, promo_group_id: Optional[int]) -> bool:
"""Проверяет, доступен ли тариф для указанной промогруппы."""
if not self.allowed_promo_groups:
return True # Если нет ограничений - доступен всем
if promo_group_id is None:
return True # Если у пользователя нет группы - доступен
return any(pg.id == promo_group_id for pg in self.allowed_promo_groups)
def __repr__(self):
return f"<Tariff(id={self.id}, name='{self.name}', tier={self.tier_level}, active={self.is_active})>"
class User(Base):
__tablename__ = "users"
@@ -860,10 +955,14 @@ class Subscription(Base):
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
remnawave_short_uuid = Column(String(255), nullable=True)
# Тариф (для режима продаж "Тарифы")
tariff_id = Column(Integer, ForeignKey("tariffs.id", ondelete="SET NULL"), nullable=True, index=True)
user = relationship("User", back_populates="subscription")
tariff = relationship("Tariff", back_populates="subscriptions")
discount_offers = relationship("DiscountOffer", back_populates="subscription")
temporary_accesses = relationship("SubscriptionTemporaryAccess", back_populates="subscription")
@@ -2108,4 +2207,4 @@ class CabinetRefreshToken(Base):
def __repr__(self) -> str:
status = "valid" if self.is_valid else ("revoked" if self.is_revoked else "expired")
return f"<CabinetRefreshToken id={self.id} user_id={self.user_id} status={status}>"
return f"<CabinetRefreshToken id={self.id} user_id={self.user_id} status={status}>"

View File

@@ -5049,6 +5049,203 @@ async def add_transaction_receipt_columns() -> bool:
return False
# =============================================================================
# МИГРАЦИИ ДЛЯ РЕЖИМА ТАРИФОВ
# =============================================================================
async def create_tariffs_table() -> bool:
"""Создаёт таблицу тарифов для режима продаж 'Тарифы'."""
try:
if await check_table_exists('tariffs'):
logger.info(" Таблица tariffs уже существует")
return True
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == 'sqlite':
await conn.execute(text("""
CREATE TABLE tariffs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(255) NOT NULL,
description TEXT,
display_order INTEGER DEFAULT 0 NOT NULL,
is_active BOOLEAN DEFAULT 1 NOT NULL,
traffic_limit_gb INTEGER DEFAULT 100 NOT NULL,
device_limit INTEGER DEFAULT 1 NOT NULL,
allowed_squads JSON DEFAULT '[]',
period_prices JSON DEFAULT '{}' NOT NULL,
tier_level INTEGER DEFAULT 1 NOT NULL,
is_trial_available BOOLEAN DEFAULT 0 NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
"""))
elif db_type == 'postgresql':
await conn.execute(text("""
CREATE TABLE tariffs (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT,
display_order INTEGER DEFAULT 0 NOT NULL,
is_active BOOLEAN DEFAULT TRUE NOT NULL,
traffic_limit_gb INTEGER DEFAULT 100 NOT NULL,
device_limit INTEGER DEFAULT 1 NOT NULL,
allowed_squads JSON DEFAULT '[]',
period_prices JSON DEFAULT '{}' NOT NULL,
tier_level INTEGER DEFAULT 1 NOT NULL,
is_trial_available BOOLEAN DEFAULT FALSE NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
)
"""))
else: # MySQL
await conn.execute(text("""
CREATE TABLE tariffs (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT,
display_order INT DEFAULT 0 NOT NULL,
is_active BOOLEAN DEFAULT TRUE NOT NULL,
traffic_limit_gb INT DEFAULT 100 NOT NULL,
device_limit INT DEFAULT 1 NOT NULL,
allowed_squads JSON DEFAULT (JSON_ARRAY()),
period_prices JSON NOT NULL,
tier_level INT DEFAULT 1 NOT NULL,
is_trial_available BOOLEAN DEFAULT FALSE NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
)
"""))
logger.info("✅ Таблица tariffs создана")
return True
except Exception as error:
logger.error(f"❌ Ошибка создания таблицы tariffs: {error}")
return False
async def create_tariff_promo_groups_table() -> bool:
"""Создаёт связующую таблицу tariff_promo_groups для M2M связи тарифов и промогрупп."""
try:
if await check_table_exists('tariff_promo_groups'):
logger.info(" Таблица tariff_promo_groups уже существует")
return True
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == 'sqlite':
await conn.execute(text("""
CREATE TABLE tariff_promo_groups (
tariff_id INTEGER NOT NULL,
promo_group_id INTEGER NOT NULL,
PRIMARY KEY (tariff_id, promo_group_id),
FOREIGN KEY (tariff_id) REFERENCES tariffs(id) ON DELETE CASCADE,
FOREIGN KEY (promo_group_id) REFERENCES promo_groups(id) ON DELETE CASCADE
)
"""))
elif db_type == 'postgresql':
await conn.execute(text("""
CREATE TABLE tariff_promo_groups (
tariff_id INTEGER NOT NULL REFERENCES tariffs(id) ON DELETE CASCADE,
promo_group_id INTEGER NOT NULL REFERENCES promo_groups(id) ON DELETE CASCADE,
PRIMARY KEY (tariff_id, promo_group_id)
)
"""))
else: # MySQL
await conn.execute(text("""
CREATE TABLE tariff_promo_groups (
tariff_id INT NOT NULL,
promo_group_id INT NOT NULL,
PRIMARY KEY (tariff_id, promo_group_id),
FOREIGN KEY (tariff_id) REFERENCES tariffs(id) ON DELETE CASCADE,
FOREIGN KEY (promo_group_id) REFERENCES promo_groups(id) ON DELETE CASCADE
)
"""))
logger.info("✅ Таблица tariff_promo_groups создана")
return True
except Exception as error:
logger.error(f"❌ Ошибка создания таблицы tariff_promo_groups: {error}")
return False
async def add_subscription_tariff_id_column() -> bool:
"""Добавляет колонку tariff_id в таблицу subscriptions."""
try:
if await check_column_exists('subscriptions', 'tariff_id'):
logger.info(" Колонка tariff_id уже существует в subscriptions")
return True
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == 'sqlite':
await conn.execute(text(
"ALTER TABLE subscriptions ADD COLUMN tariff_id INTEGER REFERENCES tariffs(id)"
))
elif db_type == 'postgresql':
await conn.execute(text(
"ALTER TABLE subscriptions ADD COLUMN tariff_id INTEGER REFERENCES tariffs(id) ON DELETE SET NULL"
))
# Создаём индекс
await conn.execute(text(
"CREATE INDEX IF NOT EXISTS ix_subscriptions_tariff_id ON subscriptions(tariff_id)"
))
else: # MySQL
await conn.execute(text(
"ALTER TABLE subscriptions ADD COLUMN tariff_id INT NULL"
))
await conn.execute(text(
"ALTER TABLE subscriptions ADD CONSTRAINT fk_subscriptions_tariff "
"FOREIGN KEY (tariff_id) REFERENCES tariffs(id) ON DELETE SET NULL"
))
await conn.execute(text(
"CREATE INDEX ix_subscriptions_tariff_id ON subscriptions(tariff_id)"
))
logger.info("✅ Колонка tariff_id добавлена в subscriptions")
return True
except Exception as error:
logger.error(f"❌ Ошибка добавления колонки tariff_id: {error}")
return False
async def add_tariff_device_price_column() -> bool:
"""Добавляет колонку device_price_kopeks в таблицу tariffs."""
try:
if await check_column_exists('tariffs', 'device_price_kopeks'):
logger.info(" Колонка device_price_kopeks уже существует в tariffs")
return True
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 device_price_kopeks INTEGER DEFAULT NULL"
))
elif db_type == 'postgresql':
await conn.execute(text(
"ALTER TABLE tariffs ADD COLUMN device_price_kopeks INTEGER DEFAULT NULL"
))
else: # MySQL
await conn.execute(text(
"ALTER TABLE tariffs ADD COLUMN device_price_kopeks INT DEFAULT NULL"
))
logger.info("✅ Колонка device_price_kopeks добавлена в tariffs")
return True
except Exception as error:
logger.error(f"❌ Ошибка добавления колонки device_price_kopeks: {error}")
return False
async def run_universal_migration():
logger.info("=== НАЧАЛО УНИВЕРСАЛЬНОЙ МИГРАЦИИ ===")
@@ -5526,6 +5723,31 @@ async def run_universal_migration():
else:
logger.warning("⚠️ Проблемы с настройкой доступа серверов к промогруппам")
logger.info("=== СОЗДАНИЕ ТАБЛИЦ ДЛЯ РЕЖИМА ТАРИФОВ ===")
tariffs_table_ready = await create_tariffs_table()
if tariffs_table_ready:
logger.info("✅ Таблица tariffs готова")
else:
logger.warning("⚠️ Проблемы с таблицей tariffs")
tariff_promo_groups_ready = await create_tariff_promo_groups_table()
if tariff_promo_groups_ready:
logger.info("✅ Таблица tariff_promo_groups готова")
else:
logger.warning("⚠️ Проблемы с таблицей tariff_promo_groups")
tariff_id_column_ready = await add_subscription_tariff_id_column()
if tariff_id_column_ready:
logger.info("✅ Колонка tariff_id в subscriptions готова")
else:
logger.warning("⚠️ Проблемы с колонкой tariff_id в subscriptions")
device_price_column_ready = await add_tariff_device_price_column()
if device_price_column_ready:
logger.info("✅ Колонка device_price_kopeks в tariffs готова")
else:
logger.warning("⚠️ Проблемы с колонкой device_price_kopeks в tariffs")
logger.info("=== ОБНОВЛЕНИЕ ВНЕШНИХ КЛЮЧЕЙ ===")
fk_updated = await fix_foreign_keys_for_user_deletion()
if fk_updated:

File diff suppressed because it is too large Load Diff

View File

@@ -44,6 +44,7 @@ from app.database.crud.server_squad import (
get_server_squad_by_id,
get_server_ids_by_uuids,
)
from app.database.crud.tariff import get_all_tariffs, get_tariff_by_id
from app.services.subscription_service import SubscriptionService
from app.utils.subscription_utils import (
resolve_hwid_device_limit_for_payload,
@@ -976,6 +977,15 @@ async def _render_user_subscription_overview(
text += f"<b>Статус:</b> {status_emoji} {'Активна' if subscription.is_active else 'Неактивна'}\n"
text += f"<b>Тип:</b> {type_emoji} {'Триал' if subscription.is_trial else 'Платная'}\n"
# Отображение тарифа
if subscription.tariff_id:
tariff = await get_tariff_by_id(db, subscription.tariff_id)
if tariff:
text += f"<b>Тариф:</b> 📦 {tariff.name}\n"
else:
text += f"<b>Тариф:</b> ID {subscription.tariff_id} (удалён)\n"
text += f"<b>Начало:</b> {format_datetime(subscription.start_date)}\n"
text += f"<b>Окончание:</b> {format_datetime(subscription.end_date)}\n"
text += f"<b>Трафик:</b> {traffic_display}\n"
@@ -1053,6 +1063,15 @@ async def _render_user_subscription_overview(
)
])
# Кнопка смены тарифа в режиме тарифов
if settings.is_tariffs_mode():
keyboard.append([
types.InlineKeyboardButton(
text="📦 Сменить тариф",
callback_data=f"admin_sub_change_tariff_{user_id}"
)
])
if subscription.is_active:
keyboard.append([
types.InlineKeyboardButton(
@@ -5037,6 +5056,234 @@ async def _change_subscription_type(db: AsyncSession, user_id: int, new_type: st
return False
# =============================================================================
# Смена тарифа пользователя администратором
# =============================================================================
@admin_required
@error_handler
async def show_admin_tariff_change(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
"""Показывает список доступных тарифов для смены."""
user_id = int(callback.data.split('_')[-1])
user = await get_user_by_id(db, user_id)
if not user:
await callback.answer("❌ Пользователь не найден", show_alert=True)
return
from app.database.crud.subscription import get_subscription_by_user_id
subscription = await get_subscription_by_user_id(db, user_id)
if not subscription:
await callback.answer("У пользователя нет подписки", show_alert=True)
return
# Получаем все активные тарифы
tariffs = await get_all_tariffs(db, include_inactive=False)
if not tariffs:
await callback.message.edit_text(
"❌ <b>Нет доступных тарифов</b>\n\n"
"Создайте тарифы в разделе управления тарифами.",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_user_subscription_{user_id}")]
])
)
await callback.answer()
return
# Текущий тариф
current_tariff = None
if subscription.tariff_id:
current_tariff = await get_tariff_by_id(db, subscription.tariff_id)
text = "📦 <b>Смена тарифа пользователя</b>\n\n"
user_link = f'<a href="tg://user?id={user.telegram_id}">{user.full_name}</a>'
text += f"👤 {user_link}\n\n"
if current_tariff:
text += f"<b>Текущий тариф:</b> {current_tariff.name}\n\n"
else:
text += "<b>Текущий тариф:</b> не установлен\n\n"
text += "Выберите новый тариф:\n"
keyboard = []
for tariff in tariffs:
# Отмечаем текущий тариф
prefix = "" if current_tariff and tariff.id == current_tariff.id else ""
# Описание тарифа
traffic_str = "♾️" if tariff.traffic_limit_gb == 0 else f"{tariff.traffic_limit_gb} ГБ"
servers_count = len(tariff.allowed_squads) if tariff.allowed_squads else 0
button_text = f"{prefix}{tariff.name} ({tariff.device_limit} устр., {traffic_str}, {servers_count} серв.)"
keyboard.append([
types.InlineKeyboardButton(
text=button_text,
callback_data=f"admin_sub_tariff_select_{tariff.id}_{user_id}"
)
])
keyboard.append([
types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_user_subscription_{user_id}")
])
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
)
await callback.answer()
@admin_required
@error_handler
async def select_admin_tariff_change(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
"""Подтверждение выбора тарифа."""
parts = callback.data.split('_')
tariff_id = int(parts[-2])
user_id = int(parts[-1])
user = await get_user_by_id(db, user_id)
if not user:
await callback.answer("❌ Пользователь не найден", show_alert=True)
return
tariff = await get_tariff_by_id(db, tariff_id)
if not tariff:
await callback.answer("❌ Тариф не найден", show_alert=True)
return
from app.database.crud.subscription import get_subscription_by_user_id
subscription = await get_subscription_by_user_id(db, user_id)
if not subscription:
await callback.answer("У пользователя нет подписки", show_alert=True)
return
# Проверяем, если это тот же тариф
if subscription.tariff_id == tariff_id:
await callback.answer(" Этот тариф уже установлен", show_alert=True)
return
traffic_str = "♾️" if tariff.traffic_limit_gb == 0 else f"{tariff.traffic_limit_gb} ГБ"
servers_count = len(tariff.allowed_squads) if tariff.allowed_squads else 0
text = f"📦 <b>Подтверждение смены тарифа</b>\n\n"
user_link = f'<a href="tg://user?id={user.telegram_id}">{user.full_name}</a>'
text += f"👤 {user_link}\n\n"
text += f"<b>Новый тариф:</b> {tariff.name}\n"
text += f"• Устройства: {tariff.device_limit}\n"
text += f"• Трафик: {traffic_str}\n"
text += f"• Серверы: {servers_count}\n\n"
text += "⚠️ Параметры подписки будут обновлены в соответствии с тарифом.\n"
text += "Дата окончания подписки не изменится."
keyboard = [
[
types.InlineKeyboardButton(
text="✅ Подтвердить",
callback_data=f"admin_sub_tariff_confirm_{tariff_id}_{user_id}"
),
types.InlineKeyboardButton(
text="❌ Отмена",
callback_data=f"admin_sub_change_tariff_{user_id}"
)
]
]
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
)
await callback.answer()
@admin_required
@error_handler
async def confirm_admin_tariff_change(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
"""Применяет смену тарифа."""
parts = callback.data.split('_')
tariff_id = int(parts[-2])
user_id = int(parts[-1])
user = await get_user_by_id(db, user_id)
if not user:
await callback.answer("❌ Пользователь не найден", show_alert=True)
return
tariff = await get_tariff_by_id(db, tariff_id)
if not tariff:
await callback.answer("❌ Тариф не найден", show_alert=True)
return
from app.database.crud.subscription import get_subscription_by_user_id
subscription = await get_subscription_by_user_id(db, user_id)
if not subscription:
await callback.answer("У пользователя нет подписки", show_alert=True)
return
try:
old_tariff_id = subscription.tariff_id
# Обновляем параметры подписки в соответствии с тарифом
subscription.tariff_id = tariff.id
subscription.device_limit = tariff.device_limit
subscription.traffic_limit_gb = tariff.traffic_limit_gb
subscription.connected_squads = tariff.allowed_squads or []
subscription.updated_at = datetime.utcnow()
await db.commit()
# Синхронизируем с RemnaWave
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
logger.info(
f"Админ {db_user.id} изменил тариф пользователя {user_id}: "
f"{old_tariff_id} -> {tariff_id} ({tariff.name})"
)
await callback.message.edit_text(
f"✅ <b>Тариф успешно изменен</b>\n\n"
f"Новый тариф: <b>{tariff.name}</b>\n"
f"• Устройства: {tariff.device_limit}\n"
f"• Трафик: {'♾️' if tariff.traffic_limit_gb == 0 else f'{tariff.traffic_limit_gb} ГБ'}\n"
f"• Серверы: {len(tariff.allowed_squads) if tariff.allowed_squads else 0}",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")]
])
)
except Exception as e:
logger.error(f"Ошибка смены тарифа: {e}")
await db.rollback()
await callback.message.edit_text(
"❌ <b>Ошибка смены тарифа</b>\n\n"
f"Детали: {str(e)}",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")]
])
)
await callback.answer()
def register_handlers(dp: Dispatcher):
dp.callback_query.register(
@@ -5353,7 +5600,23 @@ def register_handlers(dp: Dispatcher):
toggle_user_modem,
F.data.startswith("admin_user_modem_")
)
# Смена тарифа пользователя
dp.callback_query.register(
show_admin_tariff_change,
F.data.startswith("admin_sub_change_tariff_")
)
dp.callback_query.register(
select_admin_tariff_change,
F.data.startswith("admin_sub_tariff_select_")
)
dp.callback_query.register(
confirm_admin_tariff_change,
F.data.startswith("admin_sub_tariff_confirm_")
)
dp.message.register(
process_devices_edit_text,
AdminStates.editing_user_devices

View File

@@ -183,13 +183,6 @@ async def handle_change_devices(
texts = get_texts(db_user.language)
subscription = db_user.subscription
if not settings.is_devices_selection_enabled():
await callback.answer(
texts.t("DEVICES_SELECTION_DISABLED", "⚠️ Изменение количества устройств недоступно"),
show_alert=True,
)
return
if not subscription or subscription.is_trial:
await callback.answer(
texts.t("PAID_FEATURE_ONLY", "⚠️ Эта функция доступна только для платных подписок"),
@@ -197,6 +190,30 @@ async def handle_change_devices(
)
return
# Проверяем тариф подписки
tariff = None
if subscription.tariff_id:
from app.database.crud.tariff import get_tariff_by_id
tariff = await get_tariff_by_id(db, subscription.tariff_id)
# Для тарифов - проверяем разрешено ли изменение устройств
tariff_device_price = getattr(tariff, 'device_price_kopeks', None) if tariff else None
if tariff:
if tariff_device_price is None or tariff_device_price <= 0:
await callback.answer(
texts.t("TARIFF_DEVICES_DISABLED", "⚠️ Изменение устройств недоступно для вашего тарифа"),
show_alert=True,
)
return
else:
# Для обычных подписок проверяем глобальную настройку
if not settings.is_devices_selection_enabled():
await callback.answer(
texts.t("DEVICES_SELECTION_DISABLED", "⚠️ Изменение количества устройств недоступно"),
show_alert=True,
)
return
current_devices = subscription.device_limit
period_hint_days = _get_period_hint_from_subscription(subscription)
@@ -206,17 +223,34 @@ async def handle_change_devices(
period_hint_days,
)
prompt_text = texts.t(
"CHANGE_DEVICES_PROMPT",
(
"📱 <b>Изменение количества устройств</b>\n\n"
"Текущий лимит: {current_devices} устройств\n"
"Выберите новое количество устройств:\n\n"
"💡 <b>Важно:</b>\n"
"• При увеличении - доплата пропорционально оставшемуся времени\n"
"• При уменьшении - возврат средств не производится"
),
).format(current_devices=current_devices)
# Для тарифов показываем цену из тарифа
if tariff:
price_per_device = tariff_device_price
price_text = texts.format_price(price_per_device)
prompt_text = texts.t(
"CHANGE_DEVICES_PROMPT_TARIFF",
(
"📱 <b>Изменение количества устройств</b>\n\n"
"Текущий лимит: {current_devices} устройств\n"
"Цена за доп. устройство: {price}/мес\n"
"Выберите новое количество устройств:\n\n"
"💡 <b>Важно:</b>\n"
"• При увеличении - доплата пропорционально оставшемуся времени\n"
"• При уменьшении - возврат средств не производится"
),
).format(current_devices=current_devices, price=price_text)
else:
prompt_text = texts.t(
"CHANGE_DEVICES_PROMPT",
(
"📱 <b>Изменение количества устройств</b>\n\n"
"Текущий лимит: {current_devices} устройств\n"
"Выберите новое количество устройств:\n\n"
"💡 <b>Важно:</b>\n"
"• При увеличении - доплата пропорционально оставшемуся времени\n"
"• При уменьшении - возврат средств не производится"
),
).format(current_devices=current_devices)
await callback.message.edit_text(
prompt_text,
@@ -225,6 +259,7 @@ async def handle_change_devices(
db_user.language,
subscription.end_date,
devices_discount_percent,
tariff=tariff,
),
parse_mode="HTML"
)
@@ -240,12 +275,30 @@ async def confirm_change_devices(
texts = get_texts(db_user.language)
subscription = db_user.subscription
if not settings.is_devices_selection_enabled():
await callback.answer(
texts.t("DEVICES_SELECTION_DISABLED", "⚠️ Изменение количества устройств недоступно"),
show_alert=True,
)
return
# Проверяем тариф подписки
tariff = None
if subscription.tariff_id:
from app.database.crud.tariff import get_tariff_by_id
tariff = await get_tariff_by_id(db, subscription.tariff_id)
# Для тарифов - проверяем разрешено ли изменение устройств
tariff_device_price = getattr(tariff, 'device_price_kopeks', None) if tariff else None
if tariff:
if tariff_device_price is None or tariff_device_price <= 0:
await callback.answer(
texts.t("TARIFF_DEVICES_DISABLED", "⚠️ Изменение устройств недоступно для вашего тарифа"),
show_alert=True,
)
return
price_per_device = tariff_device_price
else:
if not settings.is_devices_selection_enabled():
await callback.answer(
texts.t("DEVICES_SELECTION_DISABLED", "⚠️ Изменение количества устройств недоступно"),
show_alert=True,
)
return
price_per_device = settings.PRICE_PER_DEVICE
current_devices = subscription.device_limit
@@ -271,13 +324,16 @@ async def confirm_change_devices(
if devices_difference > 0:
additional_devices = devices_difference
if current_devices < settings.DEFAULT_DEVICE_LIMIT:
# Для тарифов - все устройства платные (нет бесплатного лимита)
if tariff:
chargeable_devices = additional_devices
elif current_devices < settings.DEFAULT_DEVICE_LIMIT:
free_devices = settings.DEFAULT_DEVICE_LIMIT - current_devices
chargeable_devices = max(0, additional_devices - free_devices)
else:
chargeable_devices = additional_devices
devices_price_per_month = chargeable_devices * settings.PRICE_PER_DEVICE
devices_price_per_month = chargeable_devices * price_per_device
months_hint = get_remaining_months(subscription.end_date)
period_hint_days = months_hint * 30 if months_hint > 0 else None
devices_discount_percent = _get_addon_discount_percent_for_user(
@@ -937,12 +993,30 @@ async def confirm_add_devices(
texts = get_texts(db_user.language)
subscription = db_user.subscription
if not settings.is_devices_selection_enabled():
await callback.answer(
texts.t("DEVICES_SELECTION_DISABLED", "⚠️ Изменение количества устройств недоступно"),
show_alert=True,
)
return
# Проверяем тариф подписки
tariff = None
if subscription.tariff_id:
from app.database.crud.tariff import get_tariff_by_id
tariff = await get_tariff_by_id(db, subscription.tariff_id)
# Для тарифов - проверяем разрешено ли добавление устройств
tariff_device_price = getattr(tariff, 'device_price_kopeks', None) if tariff else None
if tariff:
if tariff_device_price is None or tariff_device_price <= 0:
await callback.answer(
texts.t("TARIFF_DEVICES_DISABLED", "⚠️ Добавление устройств недоступно для вашего тарифа"),
show_alert=True,
)
return
price_per_device = tariff_device_price
else:
if not settings.is_devices_selection_enabled():
await callback.answer(
texts.t("DEVICES_SELECTION_DISABLED", "⚠️ Изменение количества устройств недоступно"),
show_alert=True,
)
return
price_per_device = settings.PRICE_PER_DEVICE
resume_callback = None
@@ -956,7 +1030,7 @@ async def confirm_add_devices(
)
return
devices_price_per_month = devices_count * settings.PRICE_PER_DEVICE
devices_price_per_month = devices_count * price_per_device
months_hint = get_remaining_months(subscription.end_date)
period_hint_days = months_hint * 30 if months_hint > 0 else None
devices_discount_percent = _get_addon_discount_percent_for_user(

View File

@@ -333,6 +333,17 @@ async def show_subscription_info(
else texts.t("SUBSCRIPTION_NO_SERVERS", "Нет серверов")
)
# Получаем название тарифа для режима тарифов
tariff_line = ""
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}")
message_template = texts.t(
"SUBSCRIPTION_OVERVIEW_TEMPLATE",
"""👤 {full_name}
@@ -340,7 +351,7 @@ async def show_subscription_info(
📱 Подписка: {status_emoji} {status_display}{warning}
📱 Информация о подписке
🎭 Тип: {subscription_type}
🎭 Тип: {subscription_type}{tariff_line}
📅 Действует до: {end_date}
⏰ Осталось: {time_left}
📈 Трафик: {traffic}
@@ -370,6 +381,7 @@ async def show_subscription_info(
status_display=status_display,
warning=warning_text,
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,
@@ -446,34 +458,74 @@ async def show_trial_offer(
await callback.answer()
return
# Получаем параметры триала (из тарифа или из глобальных настроек)
trial_days = settings.TRIAL_DURATION_DAYS
trial_traffic = settings.TRIAL_TRAFFIC_LIMIT_GB
trial_device_limit = settings.TRIAL_DEVICE_LIMIT
trial_tariff = None
trial_server_name = texts.t("TRIAL_SERVER_DEFAULT_NAME", "🎯 Тестовый сервер")
# Проверяем триальный тариф
if settings.is_tariffs_mode():
try:
from app.database.crud.tariff import get_trial_tariff, get_tariff_by_id as get_tariff
trial_tariff = await get_trial_tariff(db)
if not trial_tariff:
trial_tariff_id = settings.get_trial_tariff_id()
if trial_tariff_id > 0:
trial_tariff = await get_tariff(db, trial_tariff_id)
if trial_tariff and not trial_tariff.is_active:
trial_tariff = None
if trial_tariff:
trial_traffic = trial_tariff.traffic_limit_gb
trial_device_limit = trial_tariff.device_limit
tariff_trial_days = getattr(trial_tariff, 'trial_duration_days', None)
if tariff_trial_days:
trial_days = tariff_trial_days
logger.info(f"Показываем триал с тарифом {trial_tariff.name}")
except Exception as e:
logger.error(f"Ошибка получения триального тарифа: {e}")
try:
from app.database.crud.server_squad import get_trial_eligible_server_squads
trial_squads = await get_trial_eligible_server_squads(db, include_unavailable=True)
if trial_squads:
if len(trial_squads) == 1:
trial_server_name = trial_squads[0].display_name
else:
trial_server_name = texts.t(
"TRIAL_SERVER_RANDOM_POOL",
"🎲 Случайный из {count} серверов",
).format(count=len(trial_squads))
# Для тарифа используем его сервера
if trial_tariff and trial_tariff.allowed_squads:
from app.database.crud.server_squad import get_server_squads_by_uuids
tariff_squads = await get_server_squads_by_uuids(db, trial_tariff.allowed_squads)
if tariff_squads:
if len(tariff_squads) == 1:
trial_server_name = tariff_squads[0].display_name
else:
trial_server_name = texts.t(
"TRIAL_SERVER_RANDOM_POOL",
"🎲 Случайный из {count} серверов",
).format(count=len(tariff_squads))
else:
logger.warning("Не настроены сквады для выдачи триалов")
trial_squads = await get_trial_eligible_server_squads(db, include_unavailable=True)
if trial_squads:
if len(trial_squads) == 1:
trial_server_name = trial_squads[0].display_name
else:
trial_server_name = texts.t(
"TRIAL_SERVER_RANDOM_POOL",
"🎲 Случайный из {count} серверов",
).format(count=len(trial_squads))
else:
logger.warning("Не настроены сквады для выдачи триалов")
except Exception as e:
logger.error(f"Ошибка получения триального сервера: {e}")
trial_device_limit = settings.TRIAL_DEVICE_LIMIT
if not settings.is_devices_selection_enabled():
forced_limit = settings.get_disabled_mode_device_limit()
if forced_limit is not None:
trial_device_limit = forced_limit
devices_line = ""
if settings.is_devices_selection_enabled():
if settings.is_devices_selection_enabled() or trial_tariff:
devices_line_template = texts.t(
"TRIAL_AVAILABLE_DEVICES_LINE",
"\n📱 <b>Устройства:</b> {devices} шт.",
@@ -492,8 +544,8 @@ async def show_trial_offer(
).format(price=settings.format_price(trial_price))
trial_text = texts.TRIAL_AVAILABLE.format(
days=settings.TRIAL_DURATION_DAYS,
traffic=texts.format_traffic(settings.TRIAL_TRAFFIC_LIMIT_GB),
days=trial_days,
traffic=texts.format_traffic(trial_traffic),
devices=trial_device_limit if trial_device_limit is not None else "",
devices_line=devices_line,
server_name=trial_server_name,
@@ -668,10 +720,49 @@ async def activate_trial(
if not settings.is_devices_selection_enabled():
forced_devices = settings.get_disabled_mode_device_limit()
# Проверяем, настроен ли триальный тариф для режима тарифов
trial_tariff = None
trial_traffic_limit = None
trial_device_limit = forced_devices
trial_squads = None
tariff_id_for_trial = None
trial_duration = None # None = использовать TRIAL_DURATION_DAYS
if settings.is_tariffs_mode():
try:
from app.database.crud.tariff import get_tariff_by_id, get_trial_tariff
# Сначала проверяем тариф из БД с флагом is_trial_available
trial_tariff = await get_trial_tariff(db)
# Если не найден в БД, проверяем настройку TRIAL_TARIFF_ID
if not trial_tariff:
trial_tariff_id = settings.get_trial_tariff_id()
if trial_tariff_id > 0:
trial_tariff = await get_tariff_by_id(db, trial_tariff_id)
if trial_tariff and not trial_tariff.is_active:
trial_tariff = None
if trial_tariff:
trial_traffic_limit = trial_tariff.traffic_limit_gb
trial_device_limit = trial_tariff.device_limit
trial_squads = trial_tariff.allowed_squads or []
tariff_id_for_trial = trial_tariff.id
tariff_trial_days = getattr(trial_tariff, 'trial_duration_days', None)
if tariff_trial_days:
trial_duration = tariff_trial_days
logger.info(f"Используем триальный тариф {trial_tariff.name} (ID: {trial_tariff.id})")
except Exception as e:
logger.error(f"Ошибка получения триального тарифа: {e}")
subscription = await create_trial_subscription(
db,
db_user.id,
device_limit=forced_devices,
duration_days=trial_duration,
device_limit=trial_device_limit,
traffic_limit_gb=trial_traffic_limit,
connected_squads=trial_squads,
tariff_id=tariff_id_for_trial,
)
await db.refresh(db_user)
@@ -1048,6 +1139,12 @@ async def start_subscription_purchase(
):
texts = get_texts(db_user.language)
# Проверяем режим продаж - если tariffs, перенаправляем на выбор тарифов
if settings.is_tariffs_mode():
from .tariff_purchase import show_tariffs_list
await show_tariffs_list(callback, db_user, db, state)
return
keyboard = get_subscription_period_keyboard(db_user.language, db_user)
prompt_text = await _build_subscription_period_prompt(db_user, texts, db)
@@ -1323,6 +1420,35 @@ async def handle_extend_subscription(
await callback.answer("⚠ Продление доступно только для платных подписок", show_alert=True)
return
# В режиме тарифов проверяем наличие tariff_id
if settings.is_tariffs_mode():
if subscription.tariff_id:
# У подписки есть тариф - перенаправляем на продление по тарифу
from .tariff_purchase import show_tariff_extend
await show_tariff_extend(callback, db_user, db)
return
else:
# У подписки нет тарифа - предлагаем выбрать тариф
await callback.message.edit_text(
"📦 <b>Выберите тариф для продления</b>\n\n"
"Ваша текущая подписка была создана до введения тарифов.\n"
"Для продления необходимо выбрать один из доступных тарифов.\n\n"
"⚠️ Ваша текущая подписка продолжит действовать до окончания срока.",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(
text="📦 Выбрать тариф",
callback_data="tariff_switch"
)],
[types.InlineKeyboardButton(
text=texts.BACK,
callback_data="menu_subscription"
)]
]),
parse_mode="HTML"
)
await callback.answer()
return
subscription_service = SubscriptionService()
available_periods = settings.get_available_renewal_periods()
@@ -2829,6 +2955,12 @@ async def handle_subscription_settings(
texts = get_texts(db_user.language)
subscription = db_user.subscription
# Получаем тариф подписки если есть
tariff = None
if subscription and subscription.tariff_id:
from app.database.crud.tariff import get_tariff_by_id
tariff = await get_tariff_by_id(db, subscription.tariff_id)
if not subscription or subscription.is_trial:
await callback.answer(
texts.t(
@@ -2884,7 +3016,7 @@ async def handle_subscription_settings(
await callback.message.edit_text(
settings_text,
reply_markup=get_updated_subscription_settings_keyboard(db_user.language, show_countries),
reply_markup=get_updated_subscription_settings_keyboard(db_user.language, show_countries, tariff=tariff),
parse_mode="HTML"
)
await callback.answer()
@@ -3895,6 +4027,10 @@ def register_handlers(dp: Dispatcher):
from .modem import register_modem_handlers
register_modem_handlers(dp)
# Регистрируем обработчики покупки по тарифам
from .tariff_purchase import register_tariff_purchase_handlers
register_tariff_purchase_handlers(dp)
# Регистрируем обработчик для простой покупки
dp.callback_query.register(
handle_simple_subscription_purchase,

File diff suppressed because it is too large Load Diff

View File

@@ -107,6 +107,17 @@ async def handle_add_traffic(
)
return
# В режиме тарифов докупка трафика недоступна
if settings.is_tariffs_mode():
await callback.answer(
texts.t(
"TARIFF_TRAFFIC_TOPUP_DISABLED",
"⚠️ В режиме тарифов докупка трафика недоступна",
),
show_alert=True,
)
return
if settings.is_traffic_topup_blocked():
await callback.answer(
texts.t(

View File

@@ -24,10 +24,16 @@ def get_admin_main_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
),
],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_MAIN_TARIFFS", "📦 Тарифы"),
callback_data="admin_tariffs",
),
InlineKeyboardButton(
text=_t(texts, "ADMIN_MAIN_PRICING", "💰 Цены"),
callback_data="admin_pricing",
),
],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_MAIN_PROMO_STATS", "💰 Промокоды/Статистика"),
callback_data="admin_submenu_promo",

View File

@@ -993,6 +993,14 @@ def get_subscription_keyboard(
callback_data="subscription_settings",
)
])
# Кнопка смены тарифа для режима тарифов
if settings.is_tariffs_mode() and subscription:
keyboard.append([
InlineKeyboardButton(
text=texts.t("CHANGE_TARIFF_BUTTON", "📦 Сменить тариф"),
callback_data="tariff_switch"
)
])
# Кнопка докупки трафика для платных подписок
if (
settings.is_traffic_topup_enabled()
@@ -1783,28 +1791,37 @@ def get_change_devices_keyboard(
language: str = DEFAULT_LANGUAGE,
subscription_end_date: datetime = None,
discount_percent: int = 0,
tariff=None, # Тариф для цены за устройство
) -> InlineKeyboardMarkup:
from app.utils.pricing_utils import get_remaining_months
from app.config import settings
texts = get_texts(language)
months_multiplier = 1
period_text = ""
if subscription_end_date:
months_multiplier = get_remaining_months(subscription_end_date)
if months_multiplier > 1:
period_text = f" (за {months_multiplier} мес)"
device_price_per_month = settings.PRICE_PER_DEVICE
# Используем цену из тарифа если есть, иначе глобальную настройку
tariff_device_price = getattr(tariff, 'device_price_kopeks', None) if tariff else None
if tariff and tariff_device_price:
device_price_per_month = tariff_device_price
# Для тарифов все устройства платные (нет бесплатного лимита)
default_device_limit = 0
else:
device_price_per_month = settings.PRICE_PER_DEVICE
default_device_limit = settings.DEFAULT_DEVICE_LIMIT
buttons = []
min_devices = 1
min_devices = 1
max_devices = settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else 20
start_range = max(1, min(current_devices - 3, max_devices - 6))
end_range = min(max_devices + 1, max(current_devices + 4, 7))
for devices_count in range(start_range, end_range):
if devices_count == current_devices:
emoji = ""
@@ -1813,11 +1830,11 @@ def get_change_devices_keyboard(
elif devices_count > current_devices:
emoji = ""
additional_devices = devices_count - current_devices
current_chargeable = max(0, current_devices - settings.DEFAULT_DEVICE_LIMIT)
new_chargeable = max(0, devices_count - settings.DEFAULT_DEVICE_LIMIT)
current_chargeable = max(0, current_devices - default_device_limit)
new_chargeable = max(0, devices_count - default_device_limit)
chargeable_devices = new_chargeable - current_chargeable
if chargeable_devices > 0:
price_per_month = chargeable_devices * device_price_per_month
discounted_per_month, discount_per_month = apply_percentage_discount(
@@ -1839,19 +1856,19 @@ def get_change_devices_keyboard(
emoji = ""
action_text = ""
price_text = " (без возврата)"
button_text = f"{emoji} {devices_count} устр.{action_text}{price_text}"
buttons.append([
InlineKeyboardButton(text=button_text, callback_data=f"change_devices_{devices_count}")
])
if current_devices < start_range or current_devices >= end_range:
current_button = f"{current_devices} устр. (текущее)"
buttons.insert(0, [
InlineKeyboardButton(text=current_button, callback_data=f"change_devices_{current_devices}")
])
buttons.append([
InlineKeyboardButton(
text=texts.BACK,
@@ -2402,18 +2419,25 @@ def get_devices_management_keyboard(
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_updated_subscription_settings_keyboard(language: str = DEFAULT_LANGUAGE, show_countries_management: bool = True) -> InlineKeyboardMarkup:
def get_updated_subscription_settings_keyboard(
language: str = DEFAULT_LANGUAGE,
show_countries_management: bool = True,
tariff=None, # Тариф подписки (если есть - ограничиваем настройки)
) -> InlineKeyboardMarkup:
from app.config import settings
texts = get_texts(language)
keyboard = []
if show_countries_management:
# Если подписка на тарифе - отключаем страны, модем, трафик
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")
])
if settings.is_traffic_selectable():
if settings.is_traffic_selectable() and not has_tariff:
keyboard.append([
InlineKeyboardButton(text=texts.t("RESET_TRAFFIC_BUTTON", "🔄 Сбросить трафик"), callback_data="subscription_reset_traffic")
])
@@ -2421,7 +2445,17 @@ def get_updated_subscription_settings_keyboard(language: str = DEFAULT_LANGUAGE,
InlineKeyboardButton(text=texts.t("SWITCH_TRAFFIC_BUTTON", "🔄 Переключить трафик"), callback_data="subscription_switch_traffic")
])
if settings.is_devices_selection_enabled():
# Устройства: для тарифов - только если указана цена за устройство
if has_tariff:
tariff_device_price = getattr(tariff, 'device_price_kopeks', None)
if tariff_device_price is not None and tariff_device_price > 0:
keyboard.append([
InlineKeyboardButton(
text=texts.t("CHANGE_DEVICES_BUTTON", "📱 Изменить устройства"),
callback_data="subscription_change_devices"
)
])
elif settings.is_devices_selection_enabled():
keyboard.append([
InlineKeyboardButton(
text=texts.t("CHANGE_DEVICES_BUTTON", "📱 Изменить устройства"),
@@ -2429,7 +2463,7 @@ def get_updated_subscription_settings_keyboard(language: str = DEFAULT_LANGUAGE,
)
])
if settings.is_modem_enabled():
if settings.is_modem_enabled() and not has_tariff:
keyboard.append([
InlineKeyboardButton(
text=texts.t("MODEM_BUTTON", "📡 Модем"),

View File

@@ -666,6 +666,7 @@
"ADMIN_SETTINGS_PUBLIC_OFFER": "📄 Публичная оферта",
"ADMIN_SETTINGS_SUBMENU_DESCRIPTION": "Управление Remnawave, мониторингом и другими настройками:",
"ADMIN_SETTINGS_SUBMENU_TITLE": "⚙️ **Настройки системы**\n\n",
"ADMIN_SETTINGS_TARIFFS": "📦 Тарифы",
"ADMIN_SQUAD_ADD_ALL": "👥 Добавить всех пользователей",
"ADMIN_SQUAD_DELETE": "🗑️ Удалить сквад",
"ADMIN_SQUAD_EDIT": "✏️ Редактировать",
@@ -940,6 +941,7 @@
"CHANGE_DEVICES_SUCCESS_DECREASE": "\n ✅ Количество устройств уменьшено!\n\n 📱 Было: {old_count} → Стало: {new_count}\n Возврат средств не производится\n ",
"CHANGE_DEVICES_SUCCESS_INCREASE": "\n ✅ Количество устройств увеличено!\n\n 📱 Было: {old_count} → Стало: {new_count}\n 💰 Списано: {amount}\n ",
"CHANGE_DEVICES_TITLE": "📱 Изменение количества устройств",
"CHANGE_TARIFF_BUTTON": "📦 Сменить тариф",
"CHANNEL_CHECK_BUTTON": "✅ Я подписался",
"CHANNEL_REQUIRED_TEXT": "🔒 Для использования бота подпишитесь на новостной канал, а затем нажмите кнопку ниже.",
"CHANNEL_SUBSCRIBE_BUTTON": "🔗 Подписаться",

View File

@@ -719,6 +719,9 @@ class MenuLayoutService:
if conditions.get("traffic_topup_enabled") is True:
if not settings.is_traffic_topup_enabled():
return False
# В режиме тарифов докупка трафика недоступна
if settings.is_tariffs_mode():
return False
# is_admin
if conditions.get("is_admin") is True:

View File

@@ -134,6 +134,57 @@ def _safe_int(value: Optional[object], default: int = 0) -> int:
return default
def _apply_promo_discount_for_tariff(price: int, discount_percent: int) -> int:
"""Применяет скидку промогруппы к цене тарифа."""
if discount_percent <= 0:
return price
discount = int(price * discount_percent / 100)
return max(0, price - discount)
async def _get_tariff_price_for_period(
db: AsyncSession,
user: User,
tariff_id: int,
period_days: int,
) -> Optional[int]:
"""Получает актуальную цену тарифа для заданного периода с учётом скидки пользователя."""
from app.database.crud.tariff import get_tariff_by_id
from app.utils.promo_offer import get_user_active_promo_discount_percent
tariff = await get_tariff_by_id(db, tariff_id)
if not tariff or not tariff.is_active:
logger.warning(
"🔁 Автопокупка: тариф %s недоступен для пользователя %s",
tariff_id,
user.telegram_id,
)
return None
prices = tariff.period_prices or {}
base_price = prices.get(str(period_days))
if base_price is None:
logger.warning(
"🔁 Автопокупка: период %s дней недоступен для тарифа %s",
period_days,
tariff_id,
)
return None
# Получаем скидку пользователя
discount_percent = 0
promo_group = getattr(user, 'promo_group', None)
if promo_group:
discount_percent = getattr(promo_group, 'server_discount_percent', 0)
personal_discount = get_user_active_promo_discount_percent(user)
if personal_discount > discount_percent:
discount_percent = personal_discount
final_price = _apply_promo_discount_for_tariff(base_price, discount_percent)
return final_price
async def _prepare_auto_extend_context(
db: AsyncSession,
user: User,
@@ -162,11 +213,6 @@ async def _prepare_auto_extend_context(
return None
period_days = _safe_int(cart_data.get("period_days"))
price_kopeks = _safe_int(
cart_data.get("total_price")
or cart_data.get("price")
or cart_data.get("final_price"),
)
if period_days <= 0:
logger.warning(
@@ -176,6 +222,30 @@ async def _prepare_auto_extend_context(
)
return None
# Если в корзине есть tariff_id - пересчитываем цену по актуальному тарифу
tariff_id = cart_data.get("tariff_id")
if tariff_id:
tariff_id = _safe_int(tariff_id)
price_kopeks = await _get_tariff_price_for_period(db, user, tariff_id, period_days)
if price_kopeks is None:
# Тариф недоступен или период отсутствует - используем сохранённую цену как fallback
price_kopeks = _safe_int(
cart_data.get("total_price")
or cart_data.get("price")
or cart_data.get("final_price"),
)
logger.warning(
"🔁 Автопокупка: не удалось пересчитать цену тарифа %s, используем сохранённую: %s",
tariff_id,
price_kopeks,
)
else:
price_kopeks = _safe_int(
cart_data.get("total_price")
or cart_data.get("price")
or cart_data.get("final_price"),
)
if price_kopeks <= 0:
logger.warning(
"🔁 Автопокупка: некорректная цена продления (%s) у пользователя %s",
@@ -184,7 +254,14 @@ async def _prepare_auto_extend_context(
)
return None
description = cart_data.get("description") or f"Продление подписки на {period_days} дней"
# Формируем описание с учётом тарифа
if tariff_id:
from app.database.crud.tariff import get_tariff_by_id
tariff = await get_tariff_by_id(db, tariff_id)
tariff_name = tariff.name if tariff else "тариф"
description = cart_data.get("description") or f"Продление тарифа {tariff_name} на {period_days} дней"
else:
description = cart_data.get("description") or f"Продление подписки на {period_days} дней"
device_limit = cart_data.get("device_limit")
if device_limit is not None:

View File

@@ -158,6 +158,27 @@ class AdminStates(StatesGroup):
viewing_user_from_campaign_list = State()
viewing_user_from_ready_to_renew_list = State()
# Состояния для управления тарифами
creating_tariff_name = State()
creating_tariff_description = State()
creating_tariff_traffic = State()
creating_tariff_devices = State()
creating_tariff_tier = State()
creating_tariff_prices = State()
creating_tariff_squads = State()
editing_tariff_name = State()
editing_tariff_description = State()
editing_tariff_traffic = State()
editing_tariff_devices = State()
editing_tariff_tier = State()
editing_tariff_prices = State()
editing_tariff_device_price = State()
editing_tariff_trial_days = State()
editing_tariff_squads = State()
editing_tariff_promo_groups = State()
class SupportStates(StatesGroup):
waiting_for_message = State()

View File

@@ -33,6 +33,7 @@ from app.database.crud.server_squad import (
get_server_squad_by_uuid,
remove_user_from_servers,
)
from app.database.crud.tariff import get_all_tariffs, get_tariff_by_id, get_tariffs_for_user
from app.database.crud.subscription import (
add_subscription_servers,
create_trial_subscription,
@@ -183,6 +184,14 @@ from ..schemas.miniapp import (
MiniAppSubscriptionRenewalPeriod,
MiniAppSubscriptionRenewalRequest,
MiniAppSubscriptionRenewalResponse,
MiniAppTariff,
MiniAppTariffPeriod,
MiniAppTariffsRequest,
MiniAppTariffsResponse,
MiniAppTariffPurchaseRequest,
MiniAppTariffPurchaseResponse,
MiniAppCurrentTariff,
MiniAppConnectedServer,
)
@@ -3493,10 +3502,36 @@ async def get_subscription_details(
trial_payment_required=trial_payment_required,
trial_price_kopeks=trial_price_kopeks if trial_payment_required else None,
trial_price_label=trial_price_label,
sales_mode=settings.get_sales_mode(),
current_tariff=await _get_current_tariff_model(db, subscription) if subscription else None,
**autopay_extras,
)
async def _get_current_tariff_model(db: AsyncSession, subscription) -> Optional[MiniAppCurrentTariff]:
"""Возвращает модель текущего тарифа пользователя."""
if not subscription or not getattr(subscription, "tariff_id", None):
return None
tariff = await get_tariff_by_id(db, subscription.tariff_id)
if not tariff:
return None
servers_count = len(tariff.allowed_squads) if tariff.allowed_squads else 0
return MiniAppCurrentTariff(
id=tariff.id,
name=tariff.name,
description=tariff.description,
tier_level=tariff.tier_level,
traffic_limit_gb=tariff.traffic_limit_gb,
traffic_limit_label=_format_traffic_limit_label(tariff.traffic_limit_gb) if settings.is_tariffs_mode() else f"{tariff.traffic_limit_gb} ГБ",
is_unlimited_traffic=tariff.traffic_limit_gb == 0,
device_limit=tariff.device_limit,
servers_count=servers_count,
)
@router.post(
"/subscription/autopay",
response_model=MiniAppSubscriptionAutopayResponse,
@@ -3663,11 +3698,47 @@ async def activate_subscription_trial_endpoint(
if not settings.is_devices_selection_enabled():
forced_devices = settings.get_disabled_mode_device_limit()
# Получаем параметры триала для режима тарифов
trial_traffic_limit = None
trial_device_limit = forced_devices
trial_squads = None
tariff_id_for_trial = None
trial_duration = None # None = использовать TRIAL_DURATION_DAYS
if settings.is_tariffs_mode():
try:
from app.database.crud.tariff import get_tariff_by_id, get_trial_tariff
trial_tariff = await get_trial_tariff(db)
if not trial_tariff:
trial_tariff_id = settings.get_trial_tariff_id()
if trial_tariff_id > 0:
trial_tariff = await get_tariff_by_id(db, trial_tariff_id)
if trial_tariff and not trial_tariff.is_active:
trial_tariff = None
if trial_tariff:
trial_traffic_limit = trial_tariff.traffic_limit_gb
trial_device_limit = trial_tariff.device_limit
trial_squads = trial_tariff.allowed_squads or []
tariff_id_for_trial = trial_tariff.id
tariff_trial_days = getattr(trial_tariff, 'trial_duration_days', None)
if tariff_trial_days:
trial_duration = tariff_trial_days
logger.info(f"Miniapp: используем триальный тариф {trial_tariff.name}")
except Exception as e:
logger.error(f"Ошибка получения триального тарифа: {e}")
try:
subscription = await create_trial_subscription(
db,
user.id,
device_limit=forced_devices,
duration_days=trial_duration,
device_limit=trial_device_limit,
traffic_limit_gb=trial_traffic_limit,
connected_squads=trial_squads,
tariff_id=tariff_id_for_trial,
)
except Exception as error: # pragma: no cover - defensive logging
logger.error(
@@ -5905,3 +5976,278 @@ async def update_subscription_devices_endpoint(
)
return MiniAppSubscriptionUpdateResponse(success=True)
# =============================================================================
# Тарифы для режима продаж "Тарифы"
# =============================================================================
def _format_traffic_limit_label(traffic_gb: int) -> str:
"""Форматирует лимит трафика для отображения."""
if traffic_gb == 0:
return "♾️ Безлимит"
return f"{traffic_gb} ГБ"
async def _build_tariff_model(
db: AsyncSession,
tariff,
current_tariff_id: Optional[int] = None,
) -> MiniAppTariff:
"""Преобразует объект тарифа в модель для API."""
servers: List[MiniAppConnectedServer] = []
servers_count = 0
if tariff.allowed_squads:
servers_count = len(tariff.allowed_squads)
for squad_uuid in tariff.allowed_squads[:5]: # Ограничиваем для превью
server = await get_server_squad_by_uuid(db, squad_uuid)
if server:
servers.append(MiniAppConnectedServer(
uuid=squad_uuid,
name=server.display_name or squad_uuid[:8],
))
periods: List[MiniAppTariffPeriod] = []
if tariff.period_prices:
for period_str, price_kopeks in sorted(tariff.period_prices.items(), key=lambda x: int(x[0])):
period_days = int(period_str)
months = max(1, period_days // 30)
per_month = price_kopeks // months if months > 0 else price_kopeks
periods.append(MiniAppTariffPeriod(
days=period_days,
months=months,
label=format_period_description(period_days),
price_kopeks=price_kopeks,
price_label=settings.format_price(price_kopeks),
price_per_month_kopeks=per_month,
price_per_month_label=settings.format_price(per_month),
))
return MiniAppTariff(
id=tariff.id,
name=tariff.name,
description=tariff.description,
tier_level=tariff.tier_level,
traffic_limit_gb=tariff.traffic_limit_gb,
traffic_limit_label=_format_traffic_limit_label(tariff.traffic_limit_gb),
is_unlimited_traffic=tariff.traffic_limit_gb == 0,
device_limit=tariff.device_limit,
servers_count=servers_count,
servers=servers,
periods=periods,
is_current=current_tariff_id == tariff.id if current_tariff_id else False,
is_available=tariff.is_active,
)
async def _build_current_tariff_model(db: AsyncSession, tariff) -> MiniAppCurrentTariff:
"""Создаёт модель текущего тарифа."""
servers_count = len(tariff.allowed_squads) if tariff.allowed_squads else 0
return MiniAppCurrentTariff(
id=tariff.id,
name=tariff.name,
description=tariff.description,
tier_level=tariff.tier_level,
traffic_limit_gb=tariff.traffic_limit_gb,
traffic_limit_label=_format_traffic_limit_label(tariff.traffic_limit_gb),
is_unlimited_traffic=tariff.traffic_limit_gb == 0,
device_limit=tariff.device_limit,
servers_count=servers_count,
)
@router.post("/subscription/tariffs", response_model=MiniAppTariffsResponse)
async def get_tariffs_endpoint(
payload: MiniAppTariffsRequest,
db: AsyncSession = Depends(get_db_session),
) -> MiniAppTariffsResponse:
"""Возвращает список доступных тарифов для пользователя."""
user = await _authorize_miniapp_user(payload.init_data, db)
# Проверяем режим продаж
if not settings.is_tariffs_mode():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"code": "tariffs_mode_disabled",
"message": "Tariffs mode is not enabled",
},
)
# Получаем промогруппу пользователя
promo_group = getattr(user, "promo_group", None)
promo_group_id = promo_group.id if promo_group else None
# Получаем тарифы, доступные пользователю
tariffs = await get_tariffs_for_user(db, promo_group_id)
# Текущий тариф пользователя
subscription = getattr(user, "subscription", None)
current_tariff_id = subscription.tariff_id if subscription else None
current_tariff_model: Optional[MiniAppCurrentTariff] = None
if current_tariff_id:
current_tariff = await get_tariff_by_id(db, current_tariff_id)
if current_tariff:
current_tariff_model = await _build_current_tariff_model(db, current_tariff)
# Формируем список тарифов
tariff_models: List[MiniAppTariff] = []
for tariff in tariffs:
model = await _build_tariff_model(db, tariff, current_tariff_id)
tariff_models.append(model)
return MiniAppTariffsResponse(
success=True,
sales_mode="tariffs",
tariffs=tariff_models,
current_tariff=current_tariff_model,
balance_kopeks=user.balance_kopeks,
balance_label=settings.format_price(user.balance_kopeks),
)
@router.post("/subscription/tariff/purchase", response_model=MiniAppTariffPurchaseResponse)
async def purchase_tariff_endpoint(
payload: MiniAppTariffPurchaseRequest,
db: AsyncSession = Depends(get_db_session),
) -> MiniAppTariffPurchaseResponse:
"""Покупка или смена тарифа."""
user = await _authorize_miniapp_user(payload.init_data, db)
if not settings.is_tariffs_mode():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"code": "tariffs_mode_disabled",
"message": "Tariffs mode is not enabled",
},
)
tariff = await get_tariff_by_id(db, payload.tariff_id)
if not tariff or not tariff.is_active:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={
"code": "tariff_not_found",
"message": "Tariff not found or inactive",
},
)
# Проверяем доступность тарифа для пользователя
promo_group = getattr(user, "promo_group", None)
promo_group_id = promo_group.id if promo_group else None
if not tariff.is_available_for_promo_group(promo_group_id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
"code": "tariff_not_available",
"message": "This tariff is not available for your promo group",
},
)
# Получаем цену за выбранный период
price_kopeks = tariff.get_price_for_period(payload.period_days)
if price_kopeks is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"code": "invalid_period",
"message": "Invalid period for this tariff",
},
)
# Проверяем баланс
if user.balance_kopeks < price_kopeks:
missing = price_kopeks - user.balance_kopeks
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail={
"code": "insufficient_funds",
"message": f"Недостаточно средств. Не хватает {settings.format_price(missing)}",
"missing_amount": missing,
},
)
subscription = getattr(user, "subscription", None)
# Списываем баланс
description = f"Покупка тарифа '{tariff.name}' на {payload.period_days} дней"
success = await subtract_user_balance(db, user, price_kopeks, description)
if not success:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail={
"code": "balance_charge_failed",
"message": "Failed to charge balance",
},
)
# Создаём транзакцию
await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=price_kopeks,
description=description,
)
if subscription:
# Смена/продление тарифа
subscription = await extend_subscription(
db=db,
subscription=subscription,
days=payload.period_days,
tariff_id=tariff.id,
traffic_limit_gb=tariff.traffic_limit_gb,
device_limit=tariff.device_limit,
connected_squads=tariff.allowed_squads or [],
)
else:
# Создание новой подписки
from app.database.crud.subscription import create_paid_subscription
subscription = await create_paid_subscription(
db=db,
user_id=user.id,
days=payload.period_days,
traffic_limit_gb=tariff.traffic_limit_gb,
device_limit=tariff.device_limit,
connected_squads=tariff.allowed_squads or [],
tariff_id=tariff.id,
)
# Синхронизируем с RemnaWave
service = SubscriptionService()
await service.update_remnawave_user(db, subscription)
# Сохраняем корзину для автопродления
try:
from app.services.user_cart_service import user_cart_service
cart_data = {
"cart_mode": "extend",
"subscription_id": subscription.id,
"period_days": payload.period_days,
"total_price": price_kopeks,
"tariff_id": tariff.id,
"description": f"Продление тарифа {tariff.name} на {payload.period_days} дней",
}
await user_cart_service.save_user_cart(user.id, cart_data)
logger.info(f"Корзина тарифа сохранена для автопродления (miniapp) пользователя {user.telegram_id}")
except Exception as e:
logger.error(f"Ошибка сохранения корзины тарифа (miniapp): {e}")
await db.refresh(user)
return MiniAppTariffPurchaseResponse(
success=True,
message=f"Тариф '{tariff.name}' успешно активирован",
subscription_id=subscription.id,
tariff_id=tariff.id,
tariff_name=tariff.name,
new_end_date=subscription.end_date,
balance_kopeks=user.balance_kopeks,
balance_label=settings.format_price(user.balance_kopeks),
)

View File

@@ -487,6 +487,85 @@ class MiniAppPaymentStatusResponse(BaseModel):
results: List[MiniAppPaymentStatusResult] = Field(default_factory=list)
# =============================================================================
# Тарифы для режима продаж "Тарифы"
# =============================================================================
class MiniAppTariffPeriod(BaseModel):
"""Период тарифа с ценой."""
days: int
months: Optional[int] = None
label: str
price_kopeks: int
price_label: str
price_per_month_kopeks: Optional[int] = None
price_per_month_label: Optional[str] = None
class MiniAppTariff(BaseModel):
"""Тариф для отображения в miniapp."""
id: int
name: str
description: Optional[str] = None
tier_level: int = 1
traffic_limit_gb: int
traffic_limit_label: str
is_unlimited_traffic: bool = False
device_limit: int
servers_count: int
servers: List[MiniAppConnectedServer] = Field(default_factory=list)
periods: List[MiniAppTariffPeriod] = Field(default_factory=list)
is_current: bool = False
is_available: bool = True
class MiniAppCurrentTariff(BaseModel):
"""Текущий тариф пользователя."""
id: int
name: str
description: Optional[str] = None
tier_level: int = 1
traffic_limit_gb: int
traffic_limit_label: str
is_unlimited_traffic: bool = False
device_limit: int
servers_count: int
class MiniAppTariffsRequest(BaseModel):
"""Запрос списка тарифов."""
init_data: str = Field(..., alias="initData")
class MiniAppTariffsResponse(BaseModel):
"""Ответ со списком тарифов."""
success: bool = True
sales_mode: str = "tariffs"
tariffs: List[MiniAppTariff] = Field(default_factory=list)
current_tariff: Optional[MiniAppCurrentTariff] = None
balance_kopeks: int = 0
balance_label: Optional[str] = None
class MiniAppTariffPurchaseRequest(BaseModel):
"""Запрос на покупку/смену тарифа."""
init_data: str = Field(..., alias="initData")
tariff_id: int = Field(..., alias="tariffId")
period_days: int = Field(..., alias="periodDays")
class MiniAppTariffPurchaseResponse(BaseModel):
"""Ответ на покупку тарифа."""
success: bool = True
message: Optional[str] = None
subscription_id: Optional[int] = None
tariff_id: Optional[int] = None
tariff_name: Optional[str] = None
new_end_date: Optional[datetime] = None
balance_kopeks: Optional[int] = None
balance_label: Optional[str] = None
class MiniAppSubscriptionResponse(BaseModel):
success: bool = True
subscription_id: Optional[int] = None
@@ -535,6 +614,10 @@ class MiniAppSubscriptionResponse(BaseModel):
trial_price_kopeks: Optional[int] = Field(default=None, alias="trialPriceKopeks")
trial_price_label: Optional[str] = Field(default=None, alias="trialPriceLabel")
# Режим продаж и тариф
sales_mode: str = Field(default="classic", alias="salesMode")
current_tariff: Optional[MiniAppCurrentTariff] = Field(default=None, alias="currentTariff")
model_config = ConfigDict(extra="allow", populate_by_name=True)

View File

@@ -5135,6 +5135,43 @@
</div>
</div>
<!-- Tariffs Section (для режима тарифов) -->
<div class="card expandable hidden" id="tariffsCard">
<div class="card-header">
<div class="card-title">
<svg class="card-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/>
</svg>
<span data-i18n="tariffs.title">Тарифы</span>
</div>
<svg class="expand-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</div>
<div class="card-content">
<div id="tariffsContent">
<div class="subscription-settings-loading" id="tariffsLoading">
<div class="subscription-settings-loading-line"></div>
<div class="subscription-settings-loading-line" style="width: 70%;"></div>
</div>
<div class="subscription-settings-error hidden" id="tariffsError">
<div id="tariffsErrorText">Не удалось загрузить тарифы</div>
<button class="subscription-settings-retry" id="tariffsRetry" type="button">Повторить</button>
</div>
<div id="tariffsBody" class="hidden">
<div id="tariffsCurrentTariff" class="subscription-renewal-meta-block hidden" style="margin-bottom: 16px;">
<div class="subscription-renewal-meta-title">Текущий тариф</div>
<div class="subscription-renewal-meta-body" id="tariffsCurrentTariffName"></div>
</div>
<div id="tariffsList" style="display: flex; flex-direction: column; gap: 12px;"></div>
<div class="subscription-renewal-actions" style="margin-top: 16px;">
<button class="btn btn-primary" id="tariffsSelectBtn" type="button" disabled>Выбрать тариф</button>
</div>
</div>
</div>
</div>
</div>
<!-- Subscription Settings -->
<div class="card expandable subscription-settings-card hidden" id="subscriptionSettingsCard">
<div class="card-header">
@@ -8066,6 +8103,15 @@
userData.subscription_missing = subscriptionMissingValue;
userData.subscriptionMissing = subscriptionMissingValue;
// Режим продаж и тариф
const salesModeValue = userData.sales_mode ?? userData.salesMode ?? 'classic';
userData.sales_mode = salesModeValue;
userData.salesMode = salesModeValue;
const currentTariffValue = userData.current_tariff ?? userData.currentTariff ?? null;
userData.current_tariff = currentTariffValue;
userData.currentTariff = currentTariffValue;
const trialAvailableValue = Boolean(
userData.trial_available ?? userData.trialAvailable
);
@@ -15921,7 +15967,16 @@
}
}
function isTariffsMode() {
const mode = userData?.salesMode ?? userData?.sales_mode ?? 'classic';
return mode === 'tariffs';
}
function shouldShowPurchaseConfigurator() {
// В режиме тарифов не показываем классический конфигуратор покупки
if (isTariffsMode()) {
return false;
}
if (subscriptionPurchaseModalOpen) {
return true;
}
@@ -18892,6 +18947,207 @@
document.getElementById('purchaseBtn')?.addEventListener('click', handlePurchaseAction);
document.getElementById('subscriptionMissingTrialBtn')?.addEventListener('click', handleTrialAction);
// ============================================
// Tariffs Mode Support
// ============================================
let tariffsData = null;
let selectedTariffId = null;
let selectedTariffPeriod = null;
async function loadTariffs() {
if (!isTariffsMode()) {
return;
}
const card = document.getElementById('tariffsCard');
const loading = document.getElementById('tariffsLoading');
const error = document.getElementById('tariffsError');
const body = document.getElementById('tariffsBody');
if (!card) return;
card.classList.remove('hidden');
loading?.classList.remove('hidden');
error?.classList.add('hidden');
body?.classList.add('hidden');
try {
const initData = getInitData();
const response = await fetch('/miniapp/subscription/tariffs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ initData })
});
if (!response.ok) {
throw new Error('Failed to load tariffs');
}
tariffsData = await response.json();
renderTariffs();
} catch (err) {
console.error('Failed to load tariffs:', err);
loading?.classList.add('hidden');
error?.classList.remove('hidden');
document.getElementById('tariffsErrorText').textContent =
err.message || 'Не удалось загрузить тарифы';
}
}
function renderTariffs() {
const loading = document.getElementById('tariffsLoading');
const body = document.getElementById('tariffsBody');
const list = document.getElementById('tariffsList');
const currentBlock = document.getElementById('tariffsCurrentTariff');
const currentName = document.getElementById('tariffsCurrentTariffName');
loading?.classList.add('hidden');
body?.classList.remove('hidden');
// Текущий тариф
if (tariffsData?.current_tariff || tariffsData?.currentTariff) {
const current = tariffsData.current_tariff || tariffsData.currentTariff;
currentBlock?.classList.remove('hidden');
if (currentName) {
currentName.innerHTML = `
<strong>${escapeHtml(current.name)}</strong><br>
<span style="color: var(--text-secondary); font-size: 13px;">
${current.device_limit || current.deviceLimit} устр. •
${current.traffic_limit_label || current.trafficLimitLabel || (current.traffic_limit_gb || current.trafficLimitGb) + ' ГБ'}
${current.servers_count || current.serversCount || 0} серв.
</span>
`;
}
} else {
currentBlock?.classList.add('hidden');
}
// Список тарифов
if (!list) return;
list.innerHTML = '';
const tariffs = tariffsData?.tariffs || [];
if (tariffs.length === 0) {
list.innerHTML = '<div style="text-align: center; color: var(--text-secondary); padding: 20px;">Нет доступных тарифов</div>';
return;
}
tariffs.forEach(tariff => {
const isCurrent = tariff.is_current || tariff.isCurrent;
const periods = tariff.periods || [];
const firstPeriod = periods[0];
const div = document.createElement('div');
div.className = 'subscription-settings-toggle' + (isCurrent ? ' active' : '');
div.style.cssText = 'padding: 16px; border-radius: var(--radius); border: 2px solid var(--border-color); cursor: pointer; transition: all 0.2s;';
if (selectedTariffId === tariff.id) {
div.style.borderColor = 'var(--primary)';
div.style.background = 'rgba(var(--primary-rgb), 0.05)';
}
div.innerHTML = `
<div style="display: flex; justify-content: space-between; align-items: flex-start;">
<div>
<div style="font-weight: 600; margin-bottom: 4px;">
${isCurrent ? '✅ ' : ''}${escapeHtml(tariff.name)}
</div>
<div style="font-size: 13px; color: var(--text-secondary);">
${tariff.device_limit || tariff.deviceLimit} устр. •
${tariff.traffic_limit_label || tariff.trafficLimitLabel || (tariff.traffic_limit_gb || tariff.trafficLimitGb) + ' ГБ'}
${tariff.servers_count || tariff.serversCount || 0} серв.
</div>
${tariff.description ? `<div style="font-size: 12px; color: var(--text-secondary); margin-top: 4px;">${escapeHtml(tariff.description)}</div>` : ''}
</div>
<div style="text-align: right;">
${firstPeriod ? `
<div style="font-weight: 600; color: var(--primary);">
от ${firstPeriod.price_label || firstPeriod.priceLabel || '—'}
</div>
<div style="font-size: 11px; color: var(--text-secondary);">
${firstPeriod.label || firstPeriod.days + ' дн.'}
</div>
` : ''}
</div>
</div>
`;
div.addEventListener('click', () => selectTariff(tariff));
list.appendChild(div);
});
}
function selectTariff(tariff) {
selectedTariffId = tariff.id;
selectedTariffPeriod = (tariff.periods || [])[0];
const btn = document.getElementById('tariffsSelectBtn');
if (btn) {
btn.disabled = false;
const price = selectedTariffPeriod?.price_label || selectedTariffPeriod?.priceLabel || '';
btn.textContent = `Купить ${tariff.name} ${price ? '(' + price + ')' : ''}`;
}
renderTariffs();
}
async function purchaseTariff() {
if (!selectedTariffId || !selectedTariffPeriod) {
showPopup('Выберите тариф', 'Ошибка');
return;
}
const btn = document.getElementById('tariffsSelectBtn');
if (btn) {
btn.disabled = true;
btn.textContent = 'Обработка...';
}
try {
const initData = getInitData();
const periodDays = selectedTariffPeriod.days || selectedTariffPeriod.period_days || selectedTariffPeriod.periodDays;
const response = await fetch('/miniapp/subscription/tariff/purchase', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
initData,
tariffId: selectedTariffId,
periodDays: periodDays
})
});
const result = await response.json();
if (!response.ok) {
throw new Error(result?.detail?.message || result?.message || 'Ошибка покупки тарифа');
}
showPopup(result.message || 'Тариф успешно активирован!', 'Успех');
await refreshSubscriptionData();
} catch (err) {
console.error('Tariff purchase failed:', err);
showPopup(err.message || 'Не удалось купить тариф', 'Ошибка');
} finally {
if (btn) {
btn.disabled = !selectedTariffId;
btn.textContent = 'Выбрать тариф';
}
}
}
document.getElementById('tariffsRetry')?.addEventListener('click', loadTariffs);
document.getElementById('tariffsSelectBtn')?.addEventListener('click', purchaseTariff);
// Загружаем тарифы после загрузки данных подписки
const originalApplySubscriptionData = applySubscriptionData;
applySubscriptionData = function(payload) {
const result = originalApplySubscriptionData(payload);
if (isTariffsMode()) {
loadTariffs();
}
return result;
};
initializePromoCodeForm();
init();