mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-02-19 19:01:12 +00:00
11
.env.example
11
.env.example
@@ -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 опционален (для обратной совместимости)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
401
app/database/crud/tariff.py
Normal 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)
|
||||
@@ -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}>"
|
||||
@@ -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:
|
||||
|
||||
1871
app/handlers/admin/tariffs.py
Normal file
1871
app/handlers/admin/tariffs.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
1329
app/handlers/subscription/tariff_purchase.py
Normal file
1329
app/handlers/subscription/tariff_purchase.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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", "📡 Модем"),
|
||||
|
||||
@@ -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": "🔗 Подписаться",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user