diff --git a/.github/workflows/docker-hub.yml b/.github/workflows/docker-hub.yml index 06ebd817..ecc4234e 100644 --- a/.github/workflows/docker-hub.yml +++ b/.github/workflows/docker-hub.yml @@ -36,15 +36,15 @@ jobs: TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}" echo "🏷️ Собираем релизную версию: $VERSION" elif [[ $GITHUB_REF == refs/heads/main ]]; then - VERSION="v2.9.5-$(git rev-parse --short HEAD)" + VERSION="v2.9.6-$(git rev-parse --short HEAD)" TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}" echo "🚀 Собираем версию из main: $VERSION" elif [[ $GITHUB_REF == refs/heads/dev ]]; then - VERSION="v2.9.5-dev-$(git rev-parse --short HEAD)" + VERSION="v2.9.6-dev-$(git rev-parse --short HEAD)" TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}" echo "🧪 Собираем dev версию: $VERSION" else - VERSION="v2.9.5-pr-$(git rev-parse --short HEAD)" + VERSION="v2.9.6-pr-$(git rev-parse --short HEAD)" TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)" echo "🔀 Собираем PR версию: $VERSION" fi diff --git a/.github/workflows/docker-registry.yml b/.github/workflows/docker-registry.yml index d23c7a8c..87ea6baa 100644 --- a/.github/workflows/docker-registry.yml +++ b/.github/workflows/docker-registry.yml @@ -49,13 +49,13 @@ jobs: VERSION=${GITHUB_REF#refs/tags/} echo "🏷️ Building release version: $VERSION" elif [[ $GITHUB_REF == refs/heads/main ]]; then - VERSION="v2.9.5-$(git rev-parse --short HEAD)" + VERSION="v2.9.6-$(git rev-parse --short HEAD)" echo "🚀 Building main version: $VERSION" elif [[ $GITHUB_REF == refs/heads/dev ]]; then - VERSION="v2.9.5-dev-$(git rev-parse --short HEAD)" + VERSION="v2.9.6-dev-$(git rev-parse --short HEAD)" echo "🧪 Building dev version: $VERSION" else - VERSION="v2.9.5-pr-$(git rev-parse --short HEAD)" + VERSION="v2.9.6-pr-$(git rev-parse --short HEAD)" echo "🔀 Building PR version: $VERSION" fi echo "version=$VERSION" >> $GITHUB_OUTPUT diff --git a/Dockerfile b/Dockerfile index b11675f7..4f7bbed1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \ FROM python:3.13-slim -ARG VERSION="v2.9.5" +ARG VERSION="v2.9.6" ARG BUILD_DATE ARG VCS_REF diff --git a/app/config.py b/app/config.py index 58ff2239..b97779ff 100644 --- a/app/config.py +++ b/app/config.py @@ -447,6 +447,8 @@ class Settings(BaseSettings): FREEKASSA_PAYMENT_SYSTEM_ID: Optional[int] = None # Использовать API для создания заказов (нужно для NSPK СБП) FREEKASSA_USE_API: bool = False + # Публичный IP сервера для Freekassa API (если не задан - определяется автоматически) + SERVER_PUBLIC_IP: Optional[str] = None MAIN_MENU_MODE: str = "default" CONNECT_BUTTON_MODE: str = "guide" diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index 10dc78a5..37df85f2 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -316,11 +316,22 @@ async def extend_subscription( current_time = datetime.utcnow() logger.info(f"🔄 Продление подписки {subscription.id} на {days} дней") - logger.info(f"📊 Текущие параметры: статус={subscription.status}, окончание={subscription.end_date}") + logger.info(f"📊 Текущие параметры: статус={subscription.status}, окончание={subscription.end_date}, тариф={subscription.tariff_id}") + + # Определяем, происходит ли СМЕНА тарифа (а не продление того же) + is_tariff_change = ( + tariff_id is not None + and subscription.tariff_id is not None + and tariff_id != subscription.tariff_id + ) + + if is_tariff_change: + logger.info(f"🔄 Обнаружена СМЕНА тарифа: {subscription.tariff_id} → {tariff_id}") # НОВОЕ: Вычисляем бонусные дни от триала ДО изменения end_date + # Бонусные дни НЕ начисляются при смене тарифа bonus_days = 0 - if subscription.is_trial and settings.TRIAL_ADD_REMAINING_DAYS_TO_PAID: + if not is_tariff_change and subscription.is_trial and settings.TRIAL_ADD_REMAINING_DAYS_TO_PAID: # Вычисляем остаток триала if subscription.end_date and subscription.end_date > current_time: remaining = subscription.end_date - current_time @@ -342,6 +353,11 @@ async def extend_subscription( abs(days), subscription.end_date, ) + elif is_tariff_change: + # При СМЕНЕ тарифа срок начинается с текущей даты + subscription.end_date = current_time + timedelta(days=total_days) + subscription.start_date = current_time + logger.info(f"📅 СМЕНА тарифа: срок начинается с текущей даты + {total_days} дней") elif subscription.end_date > current_time: subscription.end_date = subscription.end_date + timedelta(days=total_days) logger.info(f"📅 Подписка активна, добавляем {total_days} дней ({days} + {bonus_days} бонус) к текущей дате окончания") @@ -380,6 +396,11 @@ async def extend_subscription( subscription.tariff_id = tariff_id logger.info(f"📦 Обновлен тариф подписки: {old_tariff_id} → {tariff_id}") + # При покупке тарифа сбрасываем триальный статус + if subscription.is_trial: + subscription.is_trial = False + logger.info(f"🎓 Подписка {subscription.id} конвертирована из триала в платную") + if traffic_limit_gb is not None: old_traffic = subscription.traffic_limit_gb subscription.traffic_limit_gb = traffic_limit_gb @@ -388,8 +409,12 @@ async def extend_subscription( 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 subscription.tariff_id is None: + subscription.purchased_traffic_gb = 0 + logger.info("🔄 Сбрасываем использованный и докупленный трафик согласно настройке RESET_TRAFFIC_ON_PAYMENT") + else: + logger.info("🔄 Сбрасываем использованный трафик, докупленный сохранен (режим тарифов)") if device_limit is not None: old_devices = subscription.device_limit diff --git a/app/database/crud/tariff.py b/app/database/crud/tariff.py index 2ade37cc..04c4f193 100644 --- a/app/database/crud/tariff.py +++ b/app/database/crud/tariff.py @@ -167,6 +167,9 @@ async def create_tariff( tier_level: int = 1, is_trial_available: bool = False, promo_group_ids: Optional[List[int]] = None, + traffic_topup_enabled: bool = False, + traffic_topup_packages: Optional[Dict[str, int]] = None, + max_topup_traffic_gb: int = 0, ) -> Tariff: """Создает новый тариф.""" normalized_prices = _normalize_period_prices(period_prices) @@ -182,6 +185,9 @@ async def create_tariff( period_prices=normalized_prices, tier_level=max(1, tier_level), is_trial_available=is_trial_available, + traffic_topup_enabled=traffic_topup_enabled, + traffic_topup_packages=traffic_topup_packages or {}, + max_topup_traffic_gb=max(0, max_topup_traffic_gb), ) db.add(tariff) @@ -227,6 +233,9 @@ async def update_tariff( tier_level: Optional[int] = None, is_trial_available: Optional[bool] = None, promo_group_ids: Optional[List[int]] = None, + traffic_topup_enabled: Optional[bool] = None, + traffic_topup_packages: Optional[Dict[str, int]] = None, + max_topup_traffic_gb: Optional[int] = None, ) -> Tariff: """Обновляет существующий тариф.""" if name is not None: @@ -252,6 +261,12 @@ async def update_tariff( tariff.tier_level = max(1, tier_level) if is_trial_available is not None: tariff.is_trial_available = is_trial_available + if traffic_topup_enabled is not None: + tariff.traffic_topup_enabled = traffic_topup_enabled + if traffic_topup_packages is not None: + tariff.traffic_topup_packages = traffic_topup_packages + if max_topup_traffic_gb is not None: + tariff.max_topup_traffic_gb = max(0, max_topup_traffic_gb) # Обновляем промогруппы если указаны if promo_group_ids is not None: diff --git a/app/database/models.py b/app/database/models.py index b7850486..9f47209b 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -762,6 +762,13 @@ class Tariff(Base): # Дополнительные настройки is_trial_available = Column(Boolean, default=False, nullable=False) # Можно ли взять триал на этом тарифе + # Докупка трафика + traffic_topup_enabled = Column(Boolean, default=False, nullable=False) # Разрешена ли докупка трафика + # Пакеты трафика: JSON {"5": 5000, "10": 9000, "20": 15000} (ГБ: цена в копейках) + traffic_topup_packages = Column(JSON, default=dict) + # Максимальный лимит трафика после докупки (0 = без ограничений) + max_topup_traffic_gb = Column(Integer, default=0, nullable=False) + created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) @@ -805,6 +812,29 @@ class Tariff(Base): return True # Если у пользователя нет группы - доступен return any(pg.id == promo_group_id for pg in self.allowed_promo_groups) + def get_traffic_topup_packages(self) -> Dict[int, int]: + """Возвращает пакеты трафика для докупки: {ГБ: цена в копейках}.""" + packages = self.traffic_topup_packages or {} + return {int(gb): int(price) for gb, price in packages.items()} + + def get_traffic_topup_price(self, gb: int) -> Optional[int]: + """Возвращает цену в копейках для указанного пакета трафика.""" + packages = self.get_traffic_topup_packages() + return packages.get(gb) + + def get_available_traffic_packages(self) -> List[int]: + """Возвращает список доступных пакетов трафика в ГБ.""" + packages = self.get_traffic_topup_packages() + return sorted(packages.keys()) + + def can_topup_traffic(self) -> bool: + """Проверяет, можно ли докупить трафик на этом тарифе.""" + return ( + self.traffic_topup_enabled + and bool(self.traffic_topup_packages) + and not self.is_unlimited_traffic + ) + def __repr__(self): return f"" @@ -2249,4 +2279,4 @@ class CabinetRefreshToken(Base): def __repr__(self) -> str: status = "valid" if self.is_valid else ("revoked" if self.is_revoked else "expired") - return f"" \ No newline at end of file + return f"" diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index 92c9904e..2c5afdfd 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -5332,6 +5332,87 @@ async def add_tariff_device_price_column() -> bool: return False +async def add_tariff_traffic_topup_columns() -> bool: + """Добавляет колонки для докупки трафика в тарифах.""" + try: + columns_added = 0 + + # Колонка traffic_topup_enabled + if not await check_column_exists('tariffs', 'traffic_topup_enabled'): + 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 traffic_topup_enabled INTEGER DEFAULT 0 NOT NULL" + )) + elif db_type == 'postgresql': + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN traffic_topup_enabled BOOLEAN DEFAULT FALSE NOT NULL" + )) + else: # MySQL + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN traffic_topup_enabled TINYINT(1) DEFAULT 0 NOT NULL" + )) + + logger.info("✅ Колонка traffic_topup_enabled добавлена в tariffs") + columns_added += 1 + else: + logger.info("ℹ️ Колонка traffic_topup_enabled уже существует в tariffs") + + # Колонка traffic_topup_packages (JSON) + if not await check_column_exists('tariffs', 'traffic_topup_packages'): + 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 traffic_topup_packages TEXT DEFAULT '{}'" + )) + elif db_type == 'postgresql': + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN traffic_topup_packages JSONB DEFAULT '{}'" + )) + else: # MySQL + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN traffic_topup_packages JSON DEFAULT NULL" + )) + + logger.info("✅ Колонка traffic_topup_packages добавлена в tariffs") + columns_added += 1 + else: + logger.info("ℹ️ Колонка traffic_topup_packages уже существует в tariffs") + + # Колонка max_topup_traffic_gb (максимальный лимит трафика после докупок) + if not await check_column_exists('tariffs', 'max_topup_traffic_gb'): + 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 max_topup_traffic_gb INTEGER DEFAULT 0 NOT NULL" + )) + elif db_type == 'postgresql': + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN max_topup_traffic_gb INTEGER DEFAULT 0 NOT NULL" + )) + else: # MySQL + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN max_topup_traffic_gb INT DEFAULT 0 NOT NULL" + )) + + logger.info("✅ Колонка max_topup_traffic_gb добавлена в tariffs") + columns_added += 1 + else: + logger.info("ℹ️ Колонка max_topup_traffic_gb уже существует в tariffs") + + return True + + except Exception as error: + logger.error(f"❌ Ошибка добавления колонок докупки трафика: {error}") + return False + + async def run_universal_migration(): logger.info("=== НАЧАЛО УНИВЕРСАЛЬНОЙ МИГРАЦИИ ===") @@ -5834,6 +5915,12 @@ async def run_universal_migration(): else: logger.warning("⚠️ Проблемы с колонкой device_price_kopeks в tariffs") + traffic_topup_columns_ready = await add_tariff_traffic_topup_columns() + if traffic_topup_columns_ready: + logger.info("✅ Колонки докупки трафика в tariffs готовы") + else: + logger.warning("⚠️ Проблемы с колонками докупки трафика в tariffs") + logger.info("=== ОБНОВЛЕНИЕ ВНЕШНИХ КЛЮЧЕЙ ===") fk_updated = await fix_foreign_keys_for_user_deletion() if fk_updated: diff --git a/app/external/webhook_server.py b/app/external/webhook_server.py index 0a57abb7..c8ad3210 100644 --- a/app/external/webhook_server.py +++ b/app/external/webhook_server.py @@ -39,6 +39,23 @@ class WebhookServer: if settings.is_freekassa_enabled(): self.app.router.add_post(settings.FREEKASSA_WEBHOOK_PATH, self._freekassa_webhook_handler) + else: + # Диагностика почему Freekassa не включена + if settings.FREEKASSA_ENABLED: + missing = [] + if settings.FREEKASSA_SHOP_ID is None: + missing.append("FREEKASSA_SHOP_ID") + if settings.FREEKASSA_API_KEY is None: + missing.append("FREEKASSA_API_KEY") + if settings.FREEKASSA_SECRET_WORD_1 is None: + missing.append("FREEKASSA_SECRET_WORD_1") + if settings.FREEKASSA_SECRET_WORD_2 is None: + missing.append("FREEKASSA_SECRET_WORD_2") + if missing: + logger.warning( + f"Freekassa ENABLED=true, но webhook не зарегистрирован. " + f"Отсутствуют параметры: {', '.join(missing)}" + ) self.app.router.add_get('/health', self._health_check) diff --git a/app/external/yookassa_webhook.py b/app/external/yookassa_webhook.py index 6ea1811d..246c948a 100644 --- a/app/external/yookassa_webhook.py +++ b/app/external/yookassa_webhook.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import asyncio import logging import json @@ -9,13 +11,15 @@ from ipaddress import ( ip_address, ip_network, ) -from typing import Iterable, Optional, Dict, Any, List, Union, Tuple +from typing import Iterable, Optional, Dict, Any, List, Union, Tuple, TYPE_CHECKING from aiohttp import web from app.config import settings -from app.services.payment_service import PaymentService from app.database.database import get_db +if TYPE_CHECKING: + from app.services.payment_service import PaymentService + logger = logging.getLogger(__name__) diff --git a/app/handlers/admin/payments.py b/app/handlers/admin/payments.py index 1d4ef7c3..3205e01c 100644 --- a/app/handlers/admin/payments.py +++ b/app/handlers/admin/payments.py @@ -135,6 +135,16 @@ def _status_info( return "✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid") return "⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending") + if record.method == PaymentMethod.FREEKASSA: + mapping = { + "pending": ("⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")), + "success": ("✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")), + "paid": ("✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")), + "canceled": ("❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")), + "error": ("❌", texts.t("ADMIN_PAYMENT_STATUS_FAILED", "❌ Failed")), + } + return mapping.get(status, ("❓", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown"))) + return "❓", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown") @@ -158,6 +168,8 @@ def _is_checkable(record: PendingPayment) -> bool: return status in {"pending", "waiting_for_capture"} if record.method == PaymentMethod.CRYPTOBOT: return status in {"active"} + if record.method == PaymentMethod.FREEKASSA: + return status in {"pending", ""} return False diff --git a/app/handlers/admin/tariffs.py b/app/handlers/admin/tariffs.py index 86bd7ba4..71434a7b 100644 --- a/app/handlers/admin/tariffs.py +++ b/app/handlers/admin/tariffs.py @@ -191,6 +191,9 @@ def get_tariff_view_keyboard( InlineKeyboardButton(text="📱💰 Цена за устройство", callback_data=f"admin_tariff_edit_device_price:{tariff.id}"), InlineKeyboardButton(text="⏰ Дни триала", callback_data=f"admin_tariff_edit_trial_days:{tariff.id}"), ]) + buttons.append([ + InlineKeyboardButton(text="📈 Докупка трафика", callback_data=f"admin_tariff_edit_traffic_topup:{tariff.id}"), + ]) buttons.append([ InlineKeyboardButton(text="🌐 Серверы", callback_data=f"admin_tariff_edit_squads:{tariff.id}"), InlineKeyboardButton(text="👥 Промогруппы", callback_data=f"admin_tariff_edit_promo:{tariff.id}"), @@ -229,6 +232,23 @@ def get_tariff_view_keyboard( return InlineKeyboardMarkup(inline_keyboard=buttons) +def _format_traffic_topup_packages(tariff: Tariff) -> str: + """Форматирует пакеты докупки трафика для отображения.""" + if not getattr(tariff, 'traffic_topup_enabled', False): + return "❌ Отключено" + + packages = tariff.get_traffic_topup_packages() if hasattr(tariff, 'get_traffic_topup_packages') else {} + if not packages: + return "✅ Включено, но пакеты не настроены" + + lines = ["✅ Включено"] + for gb in sorted(packages.keys()): + price = packages[gb] + lines.append(f" • {gb} ГБ: {_format_price_kopeks(price)}") + + return "\n".join(lines) + + def format_tariff_info(tariff: Tariff, language: str, subs_count: int = 0) -> str: """Форматирует информацию о тарифе.""" texts = get_texts(language) @@ -264,6 +284,9 @@ def format_tariff_info(tariff: Tariff, language: str, subs_count: int = 0) -> st else: device_price_display = "Недоступно" + # Форматируем докупку трафика + traffic_topup_display = _format_traffic_topup_packages(tariff) + return f"""📦 Тариф: {tariff.name} {status} @@ -277,6 +300,9 @@ def format_tariff_info(tariff: Tariff, language: str, subs_count: int = 0) -> st • Триал: {trial_status} • Дней триала: {trial_days_display} +Докупка трафика: +{traffic_topup_display} + Цены: {prices_display} @@ -1310,6 +1336,419 @@ async def process_edit_tariff_trial_days( ) +# ============ РЕДАКТИРОВАНИЕ ДОКУПКИ ТРАФИКА ============ + +def _parse_traffic_topup_packages(text: str) -> Dict[int, int]: + """ + Парсит строку с пакетами докупки трафика. + Формат: "5:5000, 10:9000, 20:15000" (ГБ:цена_в_копейках) + """ + packages = {} + text = text.replace(";", ",").replace("=", ":") + + for part in text.split(","): + part = part.strip() + if not part: + continue + + if ":" not in part: + continue + + gb_str, price_str = part.split(":", 1) + try: + gb = int(gb_str.strip()) + price = int(price_str.strip()) + if gb > 0 and price > 0: + packages[gb] = price + except ValueError: + continue + + return packages + + +def _format_traffic_topup_packages_for_edit(packages: Dict[int, int]) -> str: + """Форматирует пакеты докупки для редактирования.""" + if not packages: + return "5:5000, 10:9000, 20:15000" + + parts = [] + for gb in sorted(packages.keys()): + parts.append(f"{gb}:{packages[gb]}") + + return ", ".join(parts) + + +@admin_required +@error_handler +async def start_edit_tariff_traffic_topup( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Показывает меню настройки докупки трафика.""" + texts = get_texts(db_user.language) + tariff_id = int(callback.data.split(":")[1]) + tariff = await get_tariff_by_id(db, tariff_id) + + if not tariff: + await callback.answer("Тариф не найден", show_alert=True) + return + + # Проверяем, безлимитный ли тариф + if tariff.is_unlimited_traffic: + await callback.answer("Докупка недоступна для безлимитного тарифа", show_alert=True) + return + + is_enabled = getattr(tariff, 'traffic_topup_enabled', False) + packages = tariff.get_traffic_topup_packages() if hasattr(tariff, 'get_traffic_topup_packages') else {} + max_topup_traffic = getattr(tariff, 'max_topup_traffic_gb', 0) or 0 + + # Форматируем текущие настройки + if is_enabled: + status = "✅ Включено" + if packages: + packages_display = "\n".join(f" • {gb} ГБ: {_format_price_kopeks(price)}" for gb, price in sorted(packages.items())) + else: + packages_display = " Пакеты не настроены" + else: + status = "❌ Отключено" + packages_display = " -" + + # Форматируем лимит + if max_topup_traffic > 0: + max_limit_display = f"{max_topup_traffic} ГБ" + else: + max_limit_display = "Без ограничений" + + buttons = [] + + # Переключение вкл/выкл + if is_enabled: + buttons.append([ + InlineKeyboardButton(text="❌ Отключить", callback_data=f"admin_tariff_toggle_traffic_topup:{tariff_id}") + ]) + else: + buttons.append([ + InlineKeyboardButton(text="✅ Включить", callback_data=f"admin_tariff_toggle_traffic_topup:{tariff_id}") + ]) + + # Редактирование пакетов и лимита (только если включено) + if is_enabled: + buttons.append([ + InlineKeyboardButton(text="📦 Настроить пакеты", callback_data=f"admin_tariff_edit_topup_packages:{tariff_id}") + ]) + buttons.append([ + InlineKeyboardButton(text="📊 Макс. лимит трафика", callback_data=f"admin_tariff_edit_max_topup:{tariff_id}") + ]) + + buttons.append([ + InlineKeyboardButton(text=texts.BACK, callback_data=f"admin_tariff_view:{tariff_id}") + ]) + + await callback.message.edit_text( + f"📈 Докупка трафика для «{tariff.name}»\n\n" + f"Статус: {status}\n\n" + f"Пакеты:\n{packages_display}\n\n" + f"Макс. лимит: {max_limit_display}\n\n" + "Пользователи смогут докупать трафик по заданным ценам.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons), + parse_mode="HTML" + ) + await callback.answer() + + +@admin_required +@error_handler +async def toggle_tariff_traffic_topup( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + """Переключает включение/выключение докупки трафика.""" + tariff_id = int(callback.data.split(":")[1]) + tariff = await get_tariff_by_id(db, tariff_id) + + if not tariff: + await callback.answer("Тариф не найден", show_alert=True) + return + + is_enabled = getattr(tariff, 'traffic_topup_enabled', False) + new_value = not is_enabled + + tariff = await update_tariff(db, tariff, traffic_topup_enabled=new_value) + + status_text = "включена" if new_value else "отключена" + await callback.answer(f"Докупка трафика {status_text}") + + # Перерисовываем меню + texts = get_texts(db_user.language) + packages = tariff.get_traffic_topup_packages() if hasattr(tariff, 'get_traffic_topup_packages') else {} + max_topup_traffic = getattr(tariff, 'max_topup_traffic_gb', 0) or 0 + + if new_value: + status = "✅ Включено" + if packages: + packages_display = "\n".join(f" • {gb} ГБ: {_format_price_kopeks(price)}" for gb, price in sorted(packages.items())) + else: + packages_display = " Пакеты не настроены" + else: + status = "❌ Отключено" + packages_display = " -" + + # Форматируем лимит + if max_topup_traffic > 0: + max_limit_display = f"{max_topup_traffic} ГБ" + else: + max_limit_display = "Без ограничений" + + buttons = [] + + if new_value: + buttons.append([ + InlineKeyboardButton(text="❌ Отключить", callback_data=f"admin_tariff_toggle_traffic_topup:{tariff_id}") + ]) + buttons.append([ + InlineKeyboardButton(text="📦 Настроить пакеты", callback_data=f"admin_tariff_edit_topup_packages:{tariff_id}") + ]) + buttons.append([ + InlineKeyboardButton(text="📊 Макс. лимит трафика", callback_data=f"admin_tariff_edit_max_topup:{tariff_id}") + ]) + else: + buttons.append([ + InlineKeyboardButton(text="✅ Включить", callback_data=f"admin_tariff_toggle_traffic_topup:{tariff_id}") + ]) + + buttons.append([ + InlineKeyboardButton(text=texts.BACK, callback_data=f"admin_tariff_view:{tariff_id}") + ]) + + try: + await callback.message.edit_text( + f"📈 Докупка трафика для «{tariff.name}»\n\n" + f"Статус: {status}\n\n" + f"Пакеты:\n{packages_display}\n\n" + f"Макс. лимит: {max_limit_display}\n\n" + "Пользователи смогут докупать трафик по заданным ценам.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons), + parse_mode="HTML" + ) + except TelegramBadRequest: + pass + + +@admin_required +@error_handler +async def start_edit_traffic_topup_packages( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Начинает редактирование пакетов докупки трафика.""" + texts = get_texts(db_user.language) + tariff_id = int(callback.data.split(":")[1]) + tariff = await get_tariff_by_id(db, tariff_id) + + if not tariff: + await callback.answer("Тариф не найден", show_alert=True) + return + + await state.set_state(AdminStates.editing_tariff_traffic_topup_packages) + await state.update_data(tariff_id=tariff_id, language=db_user.language) + + packages = tariff.get_traffic_topup_packages() if hasattr(tariff, 'get_traffic_topup_packages') else {} + current_packages = _format_traffic_topup_packages_for_edit(packages) + + if packages: + packages_display = "\n".join(f" • {gb} ГБ: {_format_price_kopeks(price)}" for gb, price in sorted(packages.items())) + else: + packages_display = " Не настроены" + + await callback.message.edit_text( + f"📦 Настройка пакетов докупки трафика\n\n" + f"Тариф: {tariff.name}\n\n" + f"Текущие пакеты:\n{packages_display}\n\n" + "Введите пакеты в формате:\n" + f"{current_packages}\n\n" + "(ГБ:цена_в_копейках, через запятую)\n" + "Например: 5:5000, 10:9000 = 5ГБ за 50₽, 10ГБ за 90₽", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text=texts.CANCEL, callback_data=f"admin_tariff_edit_traffic_topup:{tariff_id}")] + ]), + parse_mode="HTML" + ) + await callback.answer() + + +@admin_required +@error_handler +async def process_edit_traffic_topup_packages( + message: types.Message, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Обрабатывает новые пакеты докупки трафика.""" + data = await state.get_data() + tariff_id = data.get("tariff_id") + + tariff = await get_tariff_by_id(db, tariff_id) + if not tariff: + await message.answer("Тариф не найден") + await state.clear() + return + + packages = _parse_traffic_topup_packages(message.text.strip()) + + if not packages: + await message.answer( + "Не удалось распознать пакеты.\n\n" + "Формат: ГБ:цена_в_копейках\n" + "Пример: 5:5000, 10:9000, 20:15000", + parse_mode="HTML" + ) + return + + # Преобразуем в формат для JSON (строковые ключи) + packages_json = {str(gb): price for gb, price in packages.items()} + + tariff = await update_tariff(db, tariff, traffic_topup_packages=packages_json) + await state.clear() + + # Показываем обновленное меню + texts = get_texts(db_user.language) + packages_display = "\n".join(f" • {gb} ГБ: {_format_price_kopeks(price)}" for gb, price in sorted(packages.items())) + max_topup_traffic = getattr(tariff, 'max_topup_traffic_gb', 0) or 0 + max_limit_display = f"{max_topup_traffic} ГБ" if max_topup_traffic > 0 else "Без ограничений" + + buttons = [ + [InlineKeyboardButton(text="❌ Отключить", callback_data=f"admin_tariff_toggle_traffic_topup:{tariff_id}")], + [InlineKeyboardButton(text="📦 Настроить пакеты", callback_data=f"admin_tariff_edit_topup_packages:{tariff_id}")], + [InlineKeyboardButton(text="📊 Макс. лимит трафика", callback_data=f"admin_tariff_edit_max_topup:{tariff_id}")], + [InlineKeyboardButton(text=texts.BACK, callback_data=f"admin_tariff_view:{tariff_id}")] + ] + + await message.answer( + f"✅ Пакеты обновлены!\n\n" + f"📈 Докупка трафика для «{tariff.name}»\n\n" + f"Статус: ✅ Включено\n\n" + f"Пакеты:\n{packages_display}\n\n" + f"Макс. лимит: {max_limit_display}\n\n" + "Пользователи смогут докупать трафик по заданным ценам.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons), + parse_mode="HTML" + ) + + +# ============ МАКСИМАЛЬНЫЙ ЛИМИТ ДОКУПКИ ТРАФИКА ============ + +@admin_required +@error_handler +async def start_edit_max_topup_traffic( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Начинает редактирование максимального лимита докупки трафика.""" + texts = get_texts(db_user.language) + tariff_id = int(callback.data.split(":")[1]) + tariff = await get_tariff_by_id(db, tariff_id) + + if not tariff: + await callback.answer("Тариф не найден", show_alert=True) + return + + await state.set_state(AdminStates.editing_tariff_max_topup_traffic) + await state.update_data(tariff_id=tariff_id) + + current_limit = getattr(tariff, 'max_topup_traffic_gb', 0) or 0 + if current_limit > 0: + current_display = f"{current_limit} ГБ" + else: + current_display = "Без ограничений" + + await callback.message.edit_text( + f"📊 Максимальный лимит трафика\n\n" + f"Тариф: {tariff.name}\n" + f"Текущий лимит: {current_display}\n\n" + f"Введите максимальный общий объем трафика (в ГБ), который может быть на подписке после всех докупок.\n\n" + f"• Например, если тариф дает 100 ГБ и лимит 200 ГБ — пользователь сможет докупить еще 100 ГБ\n" + f"• Введите 0 для снятия ограничения", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text=texts.CANCEL, callback_data=f"admin_tariff_edit_traffic_topup:{tariff_id}")] + ]), + parse_mode="HTML" + ) + await callback.answer() + + +@admin_required +@error_handler +async def process_edit_max_topup_traffic( + message: types.Message, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Обрабатывает новое значение максимального лимита докупки трафика.""" + texts = get_texts(db_user.language) + state_data = await state.get_data() + tariff_id = state_data.get("tariff_id") + + tariff = await get_tariff_by_id(db, tariff_id) + if not tariff: + await message.answer("Тариф не найден") + await state.clear() + return + + # Парсим значение + text = message.text.strip() + try: + new_limit = int(text) + if new_limit < 0: + raise ValueError("Negative value") + except ValueError: + await message.answer( + "Введите целое число (0 или больше).\n\n" + "• 0 — без ограничений\n" + "• 200 — максимум 200 ГБ на подписке", + parse_mode="HTML" + ) + return + + tariff = await update_tariff(db, tariff, max_topup_traffic_gb=new_limit) + await state.clear() + + # Показываем обновленное меню + packages = tariff.get_traffic_topup_packages() if hasattr(tariff, 'get_traffic_topup_packages') else {} + if packages: + packages_display = "\n".join(f" • {gb} ГБ: {_format_price_kopeks(price)}" for gb, price in sorted(packages.items())) + else: + packages_display = " Пакеты не настроены" + + max_limit_display = f"{new_limit} ГБ" if new_limit > 0 else "Без ограничений" + + buttons = [ + [InlineKeyboardButton(text="❌ Отключить", callback_data=f"admin_tariff_toggle_traffic_topup:{tariff_id}")], + [InlineKeyboardButton(text="📦 Настроить пакеты", callback_data=f"admin_tariff_edit_topup_packages:{tariff_id}")], + [InlineKeyboardButton(text="📊 Макс. лимит трафика", callback_data=f"admin_tariff_edit_max_topup:{tariff_id}")], + [InlineKeyboardButton(text=texts.BACK, callback_data=f"admin_tariff_view:{tariff_id}")] + ] + + await message.answer( + f"✅ Лимит обновлен!\n\n" + f"📈 Докупка трафика для «{tariff.name}»\n\n" + f"Статус: ✅ Включено\n\n" + f"Пакеты:\n{packages_display}\n\n" + f"Макс. лимит: {max_limit_display}\n\n" + "Пользователи смогут докупать трафик по заданным ценам.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons), + parse_mode="HTML" + ) + + # ============ УДАЛЕНИЕ ТАРИФА ============ @admin_required @@ -1855,6 +2294,16 @@ def register_handlers(dp: Dispatcher): dp.callback_query.register(start_edit_tariff_trial_days, F.data.startswith("admin_tariff_edit_trial_days:")) dp.message.register(process_edit_tariff_trial_days, AdminStates.editing_tariff_trial_days) + # Редактирование докупки трафика + dp.callback_query.register(start_edit_tariff_traffic_topup, F.data.startswith("admin_tariff_edit_traffic_topup:")) + dp.callback_query.register(toggle_tariff_traffic_topup, F.data.startswith("admin_tariff_toggle_traffic_topup:")) + dp.callback_query.register(start_edit_traffic_topup_packages, F.data.startswith("admin_tariff_edit_topup_packages:")) + dp.message.register(process_edit_traffic_topup_packages, AdminStates.editing_tariff_traffic_topup_packages) + + # Редактирование макс. лимита докупки трафика + dp.callback_query.register(start_edit_max_topup_traffic, F.data.startswith("admin_tariff_edit_max_topup:")) + dp.message.register(process_edit_max_topup_traffic, AdminStates.editing_tariff_max_topup_traffic) + # Удаление dp.callback_query.register(confirm_delete_tariff, F.data.startswith("admin_tariff_delete:")) dp.callback_query.register(delete_tariff_confirmed, F.data.startswith("admin_tariff_delete_confirm:")) diff --git a/app/handlers/admin/users.py b/app/handlers/admin/users.py index 8eb315ad..6d347b28 100644 --- a/app/handlers/admin/users.py +++ b/app/handlers/admin/users.py @@ -1063,12 +1063,16 @@ 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}" + ), + types.InlineKeyboardButton( + text="💳 Купить тариф", + callback_data=f"admin_tariff_buy_{user_id}" ) ]) @@ -4983,6 +4987,366 @@ async def admin_buy_subscription_execute( await db.rollback() +# ==================== Покупка тарифа администратором ==================== + +@admin_required +@error_handler +async def admin_buy_tariff( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + """Показывает список тарифов для покупки админом.""" + user_id = int(callback.data.split('_')[-1]) + + user_service = UserService() + profile = await user_service.get_user_profile(db, user_id) + + if not profile: + await callback.answer("❌ Пользователь не найден", show_alert=True) + return + + target_user = profile["user"] + + # Получаем доступные тарифы + from app.database.crud.tariff import get_tariffs_for_user + tariffs = await get_tariffs_for_user(db, target_user) + + if not tariffs: + await callback.message.edit_text( + "❌ Нет доступных тарифов\n\n" + "Создайте тарифы в разделе управления тарифами.", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_user_subscription_{user_id}")] + ]) + ) + await callback.answer() + return + + target_user_link = f'{target_user.full_name}' + text = f"💳 Покупка тарифа для пользователя\n\n" + text += f"👤 {target_user_link} (ID: {target_user.telegram_id})\n" + text += f"💰 Баланс: {settings.format_price(target_user.balance_kopeks)}\n\n" + text += "📦 Выберите тариф:\n\n" + + for tariff in tariffs: + traffic = "♾️" if tariff.traffic_limit_gb == 0 else f"{tariff.traffic_limit_gb} ГБ" + prices = tariff.period_prices or {} + min_price = min(prices.values()) if prices else 0 + text += f"{tariff.name} — {traffic}/{tariff.device_limit}📱 от {settings.format_price(min_price)}\n" + + keyboard = [] + for tariff in tariffs: + keyboard.append([ + types.InlineKeyboardButton( + text=f"📦 {tariff.name}", + callback_data=f"admin_tariff_buy_select_{user_id}_{tariff.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), + parse_mode="HTML" + ) + await callback.answer() + + +@admin_required +@error_handler +async def admin_buy_tariff_period( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + """Показывает выбор периода для тарифа.""" + parts = callback.data.split('_') + user_id = int(parts[4]) + tariff_id = int(parts[5]) + + user_service = UserService() + profile = await user_service.get_user_profile(db, user_id) + + if not profile: + await callback.answer("❌ Пользователь не найден", show_alert=True) + return + + target_user = profile["user"] + + from app.database.crud.tariff import get_tariff_by_id + tariff = await get_tariff_by_id(db, tariff_id) + + if not tariff or not tariff.is_active: + await callback.answer("❌ Тариф недоступен", show_alert=True) + return + + target_user_link = f'{target_user.full_name}' + traffic = "♾️ Безлимит" if tariff.traffic_limit_gb == 0 else f"{tariff.traffic_limit_gb} ГБ" + + text = f"💳 Покупка тарифа для пользователя\n\n" + text += f"👤 {target_user_link} (ID: {target_user.telegram_id})\n" + text += f"💰 Баланс: {settings.format_price(target_user.balance_kopeks)}\n\n" + text += f"📦 Тариф: {tariff.name}\n" + text += f"📊 Трафик: {traffic}\n" + text += f"📱 Устройств: {tariff.device_limit}\n" + text += f"🌐 Серверов: {len(tariff.allowed_squads) if tariff.allowed_squads else 0}\n\n" + text += "Выберите период:" + + prices = tariff.period_prices or {} + keyboard = [] + + for period_str, price in sorted(prices.items(), key=lambda x: int(x[0])): + period = int(period_str) + keyboard.append([ + types.InlineKeyboardButton( + text=f"{period} дней — {settings.format_price(price)}", + callback_data=f"admin_tariff_buy_confirm_{user_id}_{tariff_id}_{period}_{price}" + ) + ]) + + keyboard.append([ + types.InlineKeyboardButton(text="⬅️ К тарифам", callback_data=f"admin_tariff_buy_{user_id}") + ]) + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), + parse_mode="HTML" + ) + await callback.answer() + + +@admin_required +@error_handler +async def admin_buy_tariff_confirm( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + """Подтверждение покупки тарифа.""" + parts = callback.data.split('_') + user_id = int(parts[4]) + tariff_id = int(parts[5]) + period = int(parts[6]) + price_kopeks = int(parts[7]) + + user_service = UserService() + profile = await user_service.get_user_profile(db, user_id) + + if not profile: + await callback.answer("❌ Пользователь не найден", show_alert=True) + return + + target_user = profile["user"] + + from app.database.crud.tariff import get_tariff_by_id + tariff = await get_tariff_by_id(db, tariff_id) + + if not tariff or not tariff.is_active: + await callback.answer("❌ Тариф недоступен", show_alert=True) + return + + # Проверяем баланс + if target_user.balance_kopeks < price_kopeks: + missing = price_kopeks - target_user.balance_kopeks + await callback.message.edit_text( + f"❌ Недостаточно средств\n\n" + f"💰 Баланс: {settings.format_price(target_user.balance_kopeks)}\n" + f"💳 Стоимость: {settings.format_price(price_kopeks)}\n" + f"📉 Не хватает: {settings.format_price(missing)}\n\n" + f"Пополните баланс пользователя перед покупкой.", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_tariff_buy_select_{user_id}_{tariff_id}")] + ]), + parse_mode="HTML" + ) + await callback.answer() + return + + target_user_link = f'{target_user.full_name}' + traffic = "♾️ Безлимит" if tariff.traffic_limit_gb == 0 else f"{tariff.traffic_limit_gb} ГБ" + + text = f"💳 Подтверждение покупки тарифа\n\n" + text += f"👤 {target_user_link} (ID: {target_user.telegram_id})\n" + text += f"💰 Баланс: {settings.format_price(target_user.balance_kopeks)}\n\n" + text += f"📦 Тариф: {tariff.name}\n" + text += f"📊 Трафик: {traffic}\n" + text += f"📱 Устройств: {tariff.device_limit}\n" + text += f"📅 Период: {period} дней\n" + text += f"💰 Стоимость: {settings.format_price(price_kopeks)}\n\n" + text += "Подтвердить покупку?" + + keyboard = [ + [ + types.InlineKeyboardButton( + text="✅ Подтвердить", + callback_data=f"admin_tariff_buy_exec_{user_id}_{tariff_id}_{period}_{price_kopeks}" + ) + ], + [ + types.InlineKeyboardButton( + text="❌ Отмена", + callback_data=f"admin_tariff_buy_select_{user_id}_{tariff_id}" + ) + ] + ] + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), + parse_mode="HTML" + ) + await callback.answer() + + +@admin_required +@error_handler +async def admin_buy_tariff_execute( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + """Выполняет покупку тарифа для пользователя.""" + parts = callback.data.split('_') + user_id = int(parts[4]) + tariff_id = int(parts[5]) + period = int(parts[6]) + price_kopeks = int(parts[7]) + + user_service = UserService() + profile = await user_service.get_user_profile(db, user_id) + + if not profile: + await callback.answer("❌ Пользователь не найден", show_alert=True) + return + + target_user = profile["user"] + + from app.database.crud.tariff import get_tariff_by_id + tariff = await get_tariff_by_id(db, tariff_id) + + if not tariff or not tariff.is_active: + await callback.answer("❌ Тариф недоступен", show_alert=True) + return + + # Проверяем баланс ещё раз + if target_user.balance_kopeks < price_kopeks: + await callback.answer("❌ Недостаточно средств на балансе", show_alert=True) + return + + try: + from app.database.crud.user import subtract_user_balance + from app.database.crud.subscription import extend_subscription, create_paid_subscription, get_subscription_by_user_id + from app.database.crud.transaction import create_transaction + from app.services.subscription_service import SubscriptionService + + # Списываем баланс + success = await subtract_user_balance( + db, target_user, price_kopeks, + f"Покупка тарифа {tariff.name} на {period} дней (администратор)" + ) + + if not success: + await callback.answer("❌ Ошибка списания средств", show_alert=True) + return + + # Получаем серверы из тарифа + squads = tariff.allowed_squads or [] + + # Проверяем есть ли подписка + existing_subscription = await get_subscription_by_user_id(db, target_user.id) + + if existing_subscription: + # Продлеваем существующую подписку + subscription = await extend_subscription( + db, + existing_subscription, + days=period, + tariff_id=tariff.id, + traffic_limit_gb=tariff.traffic_limit_gb, + device_limit=tariff.device_limit, + connected_squads=squads, + ) + else: + # Создаем новую подписку + subscription = await create_paid_subscription( + db=db, + user_id=target_user.id, + duration_days=period, + traffic_limit_gb=tariff.traffic_limit_gb, + device_limit=tariff.device_limit, + connected_squads=squads, + tariff_id=tariff.id, + ) + + # Обновляем в Remnawave + try: + subscription_service = SubscriptionService() + await subscription_service.create_remnawave_user( + db, + subscription, + reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT, + reset_reason="покупка тарифа (администратор)", + ) + except Exception as e: + logger.error(f"Ошибка обновления Remnawave: {e}") + + # Создаем транзакцию + await create_transaction( + db, + user_id=target_user.id, + type=TransactionType.SUBSCRIPTION_PAYMENT, + amount_kopeks=-price_kopeks, + description=f"Покупка тарифа {tariff.name} на {period} дней (администратор)", + ) + + target_user_link = f'{target_user.full_name}' + traffic = "♾️ Безлимит" if tariff.traffic_limit_gb == 0 else f"{tariff.traffic_limit_gb} ГБ" + + await callback.message.edit_text( + f"✅ Тариф успешно куплен!\n\n" + f"👤 {target_user_link} (ID: {target_user.telegram_id})\n" + f"📦 Тариф: {tariff.name}\n" + f"📊 Трафик: {traffic}\n" + f"📱 Устройств: {tariff.device_limit}\n" + f"📅 Период: {period} дней\n" + f"💰 Списано: {settings.format_price(price_kopeks)}\n" + f"📅 Действует до: {format_datetime(subscription.end_date)}", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")] + ]), + parse_mode="HTML" + ) + + # Уведомляем пользователя + try: + if callback.bot: + await callback.bot.send_message( + chat_id=target_user.telegram_id, + text=f"💳 Администратор оформил вам тариф\n\n" + f"📦 Тариф: {tariff.name}\n" + f"📊 Трафик: {traffic}\n" + f"📱 Устройств: {tariff.device_limit}\n" + f"📅 Период: {period} дней\n" + f"💰 Списано с баланса: {settings.format_price(price_kopeks)}\n" + f"📅 Действует до: {format_datetime(subscription.end_date)}", + parse_mode="HTML" + ) + except Exception as e: + logger.error(f"Ошибка отправки уведомления пользователю: {e}") + + await callback.answer("✅ Тариф куплен!", show_alert=True) + + except Exception as e: + logger.error(f"Ошибка покупки тарифа администратором: {e}", exc_info=True) + await callback.answer("❌ Ошибка при покупке тарифа", show_alert=True) + await db.rollback() + + @admin_required @error_handler async def change_subscription_type_confirm( @@ -5673,7 +6037,28 @@ def register_handlers(dp: Dispatcher): admin_buy_subscription_execute, F.data.startswith("admin_buy_sub_execute_") ) - + + # Регистрация обработчиков для покупки тарифа администратором + dp.callback_query.register( + admin_buy_tariff, + F.data.startswith("admin_tariff_buy_") & ~F.data.startswith("admin_tariff_buy_select_") & ~F.data.startswith("admin_tariff_buy_confirm_") & ~F.data.startswith("admin_tariff_buy_exec_") + ) + + dp.callback_query.register( + admin_buy_tariff_period, + F.data.startswith("admin_tariff_buy_select_") + ) + + dp.callback_query.register( + admin_buy_tariff_confirm, + F.data.startswith("admin_tariff_buy_confirm_") + ) + + dp.callback_query.register( + admin_buy_tariff_execute, + F.data.startswith("admin_tariff_buy_exec_") + ) + # Регистрация обработчиков для фильтрации пользователей dp.callback_query.register( show_users_filters, diff --git a/app/handlers/subscription/tariff_purchase.py b/app/handlers/subscription/tariff_purchase.py index 4b27debd..322228c4 100644 --- a/app/handlers/subscription/tariff_purchase.py +++ b/app/handlers/subscription/tariff_purchase.py @@ -69,13 +69,12 @@ def _apply_promo_discount(price: int, discount_percent: int) -> int: def _get_user_period_discount(db_user: User, period_days: int) -> int: """Получает скидку пользователя на период из промогруппы.""" promo_group = getattr(db_user, 'promo_group', None) + if promo_group: - # Используем метод get_discount_percent с категорией "period" discount = promo_group.get_discount_percent("period", period_days) if discount > 0: return discount - # Проверяем персональную скидку personal_discount = get_user_active_promo_discount_percent(db_user) return personal_discount @@ -1176,12 +1175,8 @@ async def select_tariff_switch_period( if subscription and subscription.end_date: remaining_days = max(0, (subscription.end_date - datetime.utcnow()).days) - # Определяем что произойдёт с временем - if remaining_days >= period: - time_info = f"⏰ Осталось дней: {remaining_days} (будет сохранено)" - else: - days_to_add = period - remaining_days - time_info = f"⏰ Осталось дней: {remaining_days} → будет {period} (+{days_to_add})" + # При смене тарифа устанавливается ровно оплаченный период + time_info = f"⏰ Будет установлено: {period} дней" if user_balance >= final_price: discount_text = "" @@ -1275,21 +1270,15 @@ async def confirm_tariff_switch( # Получаем список серверов из тарифа squads = tariff.allowed_squads or [] - # Рассчитываем сколько дней осталось у текущей подписки - from datetime import datetime - remaining_days = (subscription.end_date - datetime.utcnow()).days - if remaining_days < 0: - remaining_days = 0 - - # Если выбранный период больше оставшегося - добавляем разницу - # Пользователь должен получить минимум то, за что заплатил - days_to_add = max(0, period - remaining_days) + # При смене тарифа пользователь получает ровно тот период, за который заплатил + # Старые дни не сохраняются - это смена тарифа, а не продление + days_for_new_tariff = period # Обновляем подписку с новыми параметрами тарифа subscription = await extend_subscription( db, subscription, - days=days_to_add, # Добавляем только разницу, если период > остатка + days=days_for_new_tariff, # Даем ровно оплаченный период tariff_id=tariff.id, traffic_limit_gb=tariff.traffic_limit_gb, device_limit=tariff.device_limit, @@ -1325,7 +1314,7 @@ async def confirm_tariff_switch( db_user, subscription, None, # Транзакция отсутствует, оплата с баланса - days_to_add, # Добавленные дни (0 если остаток >= периода) + days_for_new_tariff, # Итоговый срок подписки was_trial_conversion=False, amount_kopeks=final_price, ) @@ -1343,11 +1332,8 @@ async def confirm_tariff_switch( traffic = _format_traffic(tariff.traffic_limit_gb) - # Формируем текст о времени подписки - if days_to_add > 0: - time_info = f"📅 Добавлено дней: {days_to_add}" - else: - time_info = "📅 Остаток времени подписки сохранён" + # При смене тарифа устанавливается оплаченный период + time_info = f"📅 Период: {days_for_new_tariff} дней" await callback.message.edit_text( f"🎉 Тариф успешно изменён!\n\n" @@ -1370,6 +1356,509 @@ async def confirm_tariff_switch( await callback.answer("Произошла ошибка при переключении тарифа", show_alert=True) +# ==================== Мгновенное переключение тарифов (без выбора периода) ==================== + +def _get_tariff_monthly_price(tariff: Tariff) -> int: + """Получает месячную цену тарифа (30 дней) с fallback на пропорциональный расчёт.""" + price = tariff.get_price_for_period(30) + if price is not None: + return price + + # Fallback: пропорционально пересчитываем из первого доступного периода + periods = tariff.get_available_periods() + if periods: + first_period = periods[0] + first_price = tariff.get_price_for_period(first_period) + if first_price: + return int(first_price * 30 / first_period) + + return 0 + + +def _calculate_instant_switch_cost( + current_tariff: Tariff, + new_tariff: Tariff, + remaining_days: int, + db_user: Optional[User] = None, +) -> tuple[int, bool]: + """ + Рассчитывает стоимость мгновенного переключения тарифа. + + Если новый тариф дороже - доплата пропорционально оставшимся дням. + Если дешевле или равен - бесплатно. + + Формула: (new_monthly - current_monthly) * remaining_days / 30 + Скидка применяется к обоим тарифам одинаково. + + Returns: + (upgrade_cost_kopeks, is_upgrade) + """ + current_monthly = _get_tariff_monthly_price(current_tariff) + new_monthly = _get_tariff_monthly_price(new_tariff) + + discount_percent = 0 + if db_user: + discount_percent = _get_user_period_discount(db_user, 30) + + if discount_percent > 0: + current_monthly = _apply_promo_discount(current_monthly, discount_percent) + new_monthly = _apply_promo_discount(new_monthly, discount_percent) + + price_diff = new_monthly - current_monthly + + if price_diff <= 0: + return 0, False + + upgrade_cost = int(price_diff * remaining_days / 30) + return upgrade_cost, True + + +def format_instant_switch_list_text( + tariffs: List[Tariff], + current_tariff: Tariff, + remaining_days: int, + db_user: Optional[User] = None, +) -> str: + """Форматирует текст со списком тарифов для мгновенного переключения.""" + lines = [ + "📦 Мгновенная смена тарифа", + f"📌 Текущий: {current_tariff.name}", + f"⏰ Осталось: {remaining_days} дн.", + "", + "💡 При переключении остаток дней сохраняется.", + "⬆️ Повышение тарифа = доплата за разницу", + "⬇️ Понижение = бесплатно", + "", + ] + + for tariff in tariffs: + if tariff.id == current_tariff.id: + continue + + traffic_gb = tariff.traffic_limit_gb + traffic = "∞" if traffic_gb == 0 else f"{traffic_gb}ГБ" + + # Рассчитываем стоимость переключения + cost, is_upgrade = _calculate_instant_switch_cost( + current_tariff, tariff, remaining_days, db_user + ) + + if is_upgrade: + cost_text = f"⬆️ +{_format_price_kopeks(cost, compact=True)}" + else: + cost_text = "⬇️ Бесплатно" + + lines.append(f"{tariff.name} — {traffic}/{tariff.device_limit}📱 {cost_text}") + + if tariff.description: + lines.append(f"{tariff.description}") + + lines.append("") + + return "\n".join(lines) + + +def get_instant_switch_keyboard( + tariffs: List[Tariff], + current_tariff: Tariff, + remaining_days: int, + language: str, + db_user: Optional[User] = None, +) -> InlineKeyboardMarkup: + """Создает клавиатуру для мгновенного переключения тарифа.""" + texts = get_texts(language) + buttons = [] + + for tariff in tariffs: + if tariff.id == current_tariff.id: + continue + + # Рассчитываем стоимость + cost, is_upgrade = _calculate_instant_switch_cost( + current_tariff, tariff, remaining_days, db_user + ) + + if is_upgrade: + btn_text = f"📦 {tariff.name} (+{_format_price_kopeks(cost, compact=True)})" + else: + btn_text = f"📦 {tariff.name} (бесплатно)" + + buttons.append([ + InlineKeyboardButton( + text=btn_text, + callback_data=f"instant_sw_preview:{tariff.id}" + ) + ]) + + buttons.append([ + InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription") + ]) + + return InlineKeyboardMarkup(inline_keyboard=buttons) + + +def get_instant_switch_confirm_keyboard( + tariff_id: int, + language: str, +) -> InlineKeyboardMarkup: + """Создает клавиатуру подтверждения мгновенного переключения.""" + texts = get_texts(language) + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton( + text="✅ Подтвердить переключение", + callback_data=f"instant_sw_confirm:{tariff_id}" + ) + ], + [ + InlineKeyboardButton( + text=texts.BACK, + callback_data="instant_switch" + ) + ] + ]) + + +def get_instant_switch_insufficient_balance_keyboard( + tariff_id: int, + language: str, +) -> InlineKeyboardMarkup: + """Создает клавиатуру при недостаточном балансе для мгновенного переключения.""" + texts = get_texts(language) + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton( + text="💳 Пополнить баланс", + callback_data="balance_topup" + ) + ], + [ + InlineKeyboardButton( + text=texts.BACK, + callback_data="instant_switch" + ) + ] + ]) + + +@error_handler +async def show_instant_switch_list( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Показывает список тарифов для мгновенного переключения.""" + from datetime import datetime + + texts = get_texts(db_user.language) + await state.clear() + + # Проверяем наличие активной подписки + subscription = await get_subscription_by_user_id(db, db_user.id) + if not subscription: + await callback.answer("У вас нет активной подписки", show_alert=True) + return + + if not subscription.tariff_id: + await callback.answer("У вашей подписки нет тарифа", show_alert=True) + return + + # Получаем текущий тариф + current_tariff = await get_tariff_by_id(db, subscription.tariff_id) + if not current_tariff: + await callback.answer("Текущий тариф не найден", show_alert=True) + return + + # Рассчитываем оставшиеся дни + remaining_days = 0 + if subscription.end_date: + remaining_days = max(0, (subscription.end_date - datetime.utcnow()).days) + + if remaining_days == 0: + await callback.message.edit_text( + "❌ Переключение недоступно\n\n" + "У вашей подписки не осталось активных дней.\n" + "Используйте продление или покупку нового тарифа.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")] + ]), + parse_mode="HTML" + ) + await callback.answer() + return + + # Получаем доступные тарифы + promo_group_id = getattr(db_user, 'promo_group_id', None) + tariffs = await get_tariffs_for_user(db, promo_group_id) + + # Фильтруем текущий тариф + available_tariffs = [t for t in tariffs if t.id != current_tariff.id] + + if not available_tariffs: + await callback.message.edit_text( + "😔 Нет доступных тарифов для переключения\n\n" + "Вы уже используете единственный доступный тариф.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")] + ]), + parse_mode="HTML" + ) + await callback.answer() + return + + # Формируем текст со списком тарифов + switch_text = format_instant_switch_list_text( + tariffs, current_tariff, remaining_days, db_user + ) + + await callback.message.edit_text( + switch_text, + reply_markup=get_instant_switch_keyboard( + tariffs, current_tariff, remaining_days, db_user.language, db_user + ), + parse_mode="HTML" + ) + + await state.update_data( + current_tariff_id=current_tariff.id, + remaining_days=remaining_days, + ) + await callback.answer() + + +@error_handler +async def preview_instant_switch( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Показывает превью мгновенного переключения тарифа.""" + from datetime import datetime + + tariff_id = int(callback.data.split(":")[1]) + new_tariff = await get_tariff_by_id(db, tariff_id) + + if not new_tariff or not new_tariff.is_active: + await callback.answer("Тариф недоступен", show_alert=True) + return + + # Получаем данные из состояния + data = await state.get_data() + current_tariff_id = data.get('current_tariff_id') + remaining_days = data.get('remaining_days', 0) + + # Если данных нет в state, получаем заново + subscription = await get_subscription_by_user_id(db, db_user.id) + if not subscription or not subscription.tariff_id: + await callback.answer("Подписка не найдена", show_alert=True) + return + + current_tariff_id = current_tariff_id or subscription.tariff_id + current_tariff = await get_tariff_by_id(db, current_tariff_id) + if not current_tariff: + await callback.answer("Текущий тариф не найден", show_alert=True) + return + + if not remaining_days and subscription.end_date: + remaining_days = max(0, (subscription.end_date - datetime.utcnow()).days) + + # Рассчитываем стоимость переключения + upgrade_cost, is_upgrade = _calculate_instant_switch_cost( + current_tariff, new_tariff, remaining_days, db_user + ) + + # Проверяем баланс + user_balance = db_user.balance_kopeks or 0 + + traffic = _format_traffic(new_tariff.traffic_limit_gb) + current_traffic = _format_traffic(current_tariff.traffic_limit_gb) + + texts = get_texts(db_user.language) + + if is_upgrade: + # Upgrade - нужна доплата + if user_balance >= upgrade_cost: + await callback.message.edit_text( + f"⬆️ Повышение тарифа\n\n" + f"📌 Текущий: {current_tariff.name}\n" + f" • Трафик: {current_traffic}\n" + f" • Устройств: {current_tariff.device_limit}\n\n" + f"📦 Новый: {new_tariff.name}\n" + f" • Трафик: {traffic}\n" + f" • Устройств: {new_tariff.device_limit}\n\n" + f"⏰ Осталось дней: {remaining_days}\n" + f"💰 Доплата: {_format_price_kopeks(upgrade_cost)}\n\n" + f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n" + f"После оплаты: {_format_price_kopeks(user_balance - upgrade_cost)}", + reply_markup=get_instant_switch_confirm_keyboard(tariff_id, db_user.language), + parse_mode="HTML" + ) + else: + missing = upgrade_cost - user_balance + await callback.message.edit_text( + f"❌ Недостаточно средств\n\n" + f"📦 Новый тариф: {new_tariff.name}\n" + f"💰 Требуется доплата: {_format_price_kopeks(upgrade_cost)}\n\n" + f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n" + f"⚠️ Не хватает: {_format_price_kopeks(missing)}", + reply_markup=get_instant_switch_insufficient_balance_keyboard(tariff_id, db_user.language), + parse_mode="HTML" + ) + else: + # Downgrade или тот же уровень - бесплатно + await callback.message.edit_text( + f"⬇️ Переключение тарифа\n\n" + f"📌 Текущий: {current_tariff.name}\n" + f" • Трафик: {current_traffic}\n" + f" • Устройств: {current_tariff.device_limit}\n\n" + f"📦 Новый: {new_tariff.name}\n" + f" • Трафик: {traffic}\n" + f" • Устройств: {new_tariff.device_limit}\n\n" + f"⏰ Осталось дней: {remaining_days}\n" + f"💰 Бесплатно (понижение/равный тариф)", + reply_markup=get_instant_switch_confirm_keyboard(tariff_id, db_user.language), + parse_mode="HTML" + ) + + await state.update_data( + switch_tariff_id=tariff_id, + upgrade_cost=upgrade_cost, + is_upgrade=is_upgrade, + current_tariff_id=current_tariff_id, + remaining_days=remaining_days, + ) + await callback.answer() + + +@error_handler +async def confirm_instant_switch( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Подтверждает мгновенное переключение тарифа.""" + from datetime import datetime, timedelta + + tariff_id = int(callback.data.split(":")[1]) + new_tariff = await get_tariff_by_id(db, tariff_id) + + if not new_tariff or not new_tariff.is_active: + await callback.answer("Тариф недоступен", show_alert=True) + return + + # Получаем данные из состояния + data = await state.get_data() + upgrade_cost = data.get('upgrade_cost', 0) + is_upgrade = data.get('is_upgrade', False) + remaining_days = data.get('remaining_days', 0) + + # Проверяем подписку + subscription = await get_subscription_by_user_id(db, db_user.id) + if not subscription: + await callback.answer("Подписка не найдена", show_alert=True) + return + + # Проверяем баланс если это upgrade + user_balance = db_user.balance_kopeks or 0 + if is_upgrade and user_balance < upgrade_cost: + await callback.answer("Недостаточно средств на балансе", show_alert=True) + return + + texts = get_texts(db_user.language) + + try: + # Списываем баланс если это upgrade + if is_upgrade and upgrade_cost > 0: + success = await subtract_user_balance( + db, db_user, upgrade_cost, + f"Переключение на тариф {new_tariff.name}" + ) + if not success: + await callback.answer("Ошибка списания баланса", show_alert=True) + return + + # Получаем список серверов из нового тарифа + squads = new_tariff.allowed_squads or [] + + # Обновляем подписку с новыми параметрами тарифа + # НЕ меняем end_date - только параметры тарифа + subscription.tariff_id = new_tariff.id + subscription.traffic_limit_gb = new_tariff.traffic_limit_gb + subscription.device_limit = new_tariff.device_limit + subscription.connected_squads = squads + + await db.commit() + await db.refresh(subscription) + + # Обновляем пользователя в Remnawave + try: + subscription_service = SubscriptionService() + await subscription_service.create_remnawave_user( + db, + subscription, + reset_traffic=False, # Не сбрасываем трафик при переключении + reset_reason="мгновенное переключение тарифа", + ) + except Exception as e: + logger.error(f"Ошибка обновления Remnawave при мгновенном переключении: {e}") + + # Создаем транзакцию если была оплата + if is_upgrade and upgrade_cost > 0: + await create_transaction( + db, + user_id=db_user.id, + type=TransactionType.SUBSCRIPTION_PAYMENT, + amount_kopeks=-upgrade_cost, + description=f"Переключение на тариф {new_tariff.name}", + ) + + # Отправляем уведомление админу + try: + admin_notification_service = AdminNotificationService(callback.bot) + await admin_notification_service.send_subscription_purchase_notification( + db, + db_user, + subscription, + None, + remaining_days, + was_trial_conversion=False, + amount_kopeks=upgrade_cost, + ) + except Exception as e: + logger.error(f"Ошибка отправки уведомления админу: {e}") + + await state.clear() + + traffic = _format_traffic(new_tariff.traffic_limit_gb) + + if is_upgrade: + cost_text = f"💰 Списано: {_format_price_kopeks(upgrade_cost)}" + else: + cost_text = "💰 Бесплатно" + + await callback.message.edit_text( + f"🎉 Тариф успешно изменён!\n\n" + f"📦 Новый тариф: {new_tariff.name}\n" + f"📊 Трафик: {traffic}\n" + f"📱 Устройств: {new_tariff.device_limit}\n" + f"⏰ Осталось дней: {remaining_days}\n" + f"{cost_text}", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")], + [InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")] + ]), + parse_mode="HTML" + ) + await callback.answer("Тариф изменён!", show_alert=True) + + except Exception as e: + logger.error(f"Ошибка при мгновенном переключении тарифа: {e}", exc_info=True) + await callback.answer("Произошла ошибка при переключении тарифа", show_alert=True) + + def register_tariff_purchase_handlers(dp: Dispatcher): """Регистрирует обработчики покупки по тарифам.""" # Список тарифов (для режима tariffs) @@ -1389,8 +1878,13 @@ def register_tariff_purchase_handlers(dp: Dispatcher): dp.callback_query.register(select_tariff_extend_period, F.data.startswith("tariff_extend:")) dp.callback_query.register(confirm_tariff_extend, F.data.startswith("tariff_ext_confirm:")) - # Переключение тарифов + # Переключение тарифов (с выбором периода) dp.callback_query.register(show_tariff_switch_list, F.data == "tariff_switch") dp.callback_query.register(select_tariff_switch, F.data.startswith("tariff_sw_select:")) dp.callback_query.register(select_tariff_switch_period, F.data.startswith("tariff_sw_period:")) dp.callback_query.register(confirm_tariff_switch, F.data.startswith("tariff_sw_confirm:")) + + # Мгновенное переключение тарифов (без выбора периода) + dp.callback_query.register(show_instant_switch_list, F.data == "instant_switch") + dp.callback_query.register(preview_instant_switch, F.data.startswith("instant_sw_preview:")) + dp.callback_query.register(confirm_instant_switch, F.data.startswith("instant_sw_confirm:")) diff --git a/app/handlers/subscription/traffic.py b/app/handlers/subscription/traffic.py index 1c1a8de5..ed2b8e28 100644 --- a/app/handlers/subscription/traffic.py +++ b/app/handlers/subscription/traffic.py @@ -31,7 +31,7 @@ from app.keyboards.inline import ( get_countries_keyboard, get_devices_keyboard, get_subscription_confirm_keyboard, get_autopay_keyboard, get_autopay_days_keyboard, get_back_keyboard, - get_add_traffic_keyboard, + get_add_traffic_keyboard, get_add_traffic_keyboard_from_tariff, get_change_devices_keyboard, get_reset_traffic_confirm_keyboard, get_manage_countries_keyboard, get_device_selection_keyboard, get_connection_guide_keyboard, @@ -93,41 +93,9 @@ async def handle_add_traffic( db: AsyncSession ): from app.config import settings + from app.database.crud.tariff import get_tariff_by_id texts = get_texts(db_user.language) - - # Проверяем, включена ли функция докупки трафика - if not settings.is_traffic_topup_enabled(): - await callback.answer( - texts.t( - "TRAFFIC_TOPUP_DISABLED", - "⚠️ Функция докупки трафика отключена", - ), - show_alert=True, - ) - 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( - "TRAFFIC_FIXED_MODE", - "⚠️ В текущем режиме трафик фиксированный и не может быть изменен", - ), - show_alert=True, - ) - return - subscription = db_user.subscription if not subscription or subscription.is_trial: @@ -144,6 +112,74 @@ async def handle_add_traffic( ) return + # Режим тарифов - проверяем настройки тарифа + if settings.is_tariffs_mode() and subscription.tariff_id: + tariff = await get_tariff_by_id(db, subscription.tariff_id) + if not tariff or not tariff.can_topup_traffic(): + await callback.answer( + texts.t( + "TARIFF_TRAFFIC_TOPUP_DISABLED", + "⚠️ На вашем тарифе докупка трафика недоступна", + ), + show_alert=True, + ) + return + + # Показываем пакеты из тарифа + current_traffic = subscription.traffic_limit_gb + packages = tariff.get_traffic_topup_packages() + + period_hint_days = _get_period_hint_from_subscription(subscription) + traffic_discount_percent = _get_addon_discount_percent_for_user( + db_user, + "traffic", + period_hint_days, + ) + + prompt_text = texts.t( + "ADD_TRAFFIC_PROMPT", + ( + "📈 Добавить трафик к подписке\n\n" + "Текущий лимит: {current_traffic}\n" + "Выберите дополнительный трафик:" + ), + ).format(current_traffic=texts.format_traffic(current_traffic)) + + await callback.message.edit_text( + prompt_text, + reply_markup=get_add_traffic_keyboard_from_tariff( + db_user.language, + packages, + subscription.end_date, + traffic_discount_percent, + ), + parse_mode="HTML" + ) + + await callback.answer() + return + + # Стандартный режим - проверяем глобальные настройки + if not settings.is_traffic_topup_enabled(): + await callback.answer( + texts.t( + "TRAFFIC_TOPUP_DISABLED", + "⚠️ Функция докупки трафика отключена", + ), + show_alert=True, + ) + return + + if settings.is_traffic_topup_blocked(): + await callback.answer( + texts.t( + "TRAFFIC_FIXED_MODE", + "⚠️ В текущем режиме трафик фиксированный и не может быть изменен", + ), + show_alert=True, + ) + return + current_traffic = subscription.traffic_limit_gb period_hint_days = _get_period_hint_from_subscription(subscription) traffic_discount_percent = _get_addon_discount_percent_for_user( @@ -472,15 +508,30 @@ async def add_traffic( db_user: User, db: AsyncSession ): - if settings.is_traffic_topup_blocked(): - await callback.answer("⚠️ В текущем режиме трафик фиксированный", show_alert=True) - return + from app.database.crud.tariff import get_tariff_by_id traffic_gb = int(callback.data.split('_')[2]) texts = get_texts(db_user.language) subscription = db_user.subscription - base_price = settings.get_traffic_topup_price(traffic_gb) + # Получаем цену: из тарифа или из глобальных настроек + base_price = 0 + tariff = None + + if settings.is_tariffs_mode() and subscription and subscription.tariff_id: + # Режим тарифов - берем цену из тарифа + tariff = await get_tariff_by_id(db, subscription.tariff_id) + if tariff and tariff.can_topup_traffic(): + base_price = tariff.get_traffic_topup_price(traffic_gb) or 0 + else: + await callback.answer("⚠️ На вашем тарифе докупка трафика недоступна", show_alert=True) + return + else: + # Стандартный режим + if settings.is_traffic_topup_blocked(): + await callback.answer("⚠️ В текущем режиме трафик фиксированный", show_alert=True) + return + base_price = settings.get_traffic_topup_price(traffic_gb) if base_price == 0 and traffic_gb != 0: await callback.answer("⚠️ Цена для этого пакета не настроена", show_alert=True) diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index ef0ed9db..2b4e4ca5 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -508,14 +508,18 @@ def get_main_menu_keyboard( ) # Добавляем кнопку докупки трафика для лимитированных подписок - if ( - settings.BUY_TRAFFIC_BUTTON_VISIBLE - and settings.is_traffic_topup_enabled() - and not settings.is_traffic_topup_blocked() - and subscription - and not subscription.is_trial - and (subscription.traffic_limit_gb or 0) > 0 - ): + # В режиме тарифов проверяем tariff_id (детальная проверка в хендлере) + # В классическом режиме проверяем глобальные настройки + show_traffic_topup = False + if subscription and not subscription.is_trial and (subscription.traffic_limit_gb or 0) > 0: + if settings.is_tariffs_mode() and getattr(subscription, 'tariff_id', None): + # Режим тарифов - показываем кнопку, проверка настроек тарифа в хендлере + show_traffic_topup = settings.BUY_TRAFFIC_BUTTON_VISIBLE + elif settings.is_traffic_topup_enabled() and not settings.is_traffic_topup_blocked(): + # Классический режим - проверяем глобальные настройки + show_traffic_topup = settings.BUY_TRAFFIC_BUTTON_VISIBLE + + if show_traffic_topup: paired_buttons.append( InlineKeyboardButton(text=texts.t("BUY_TRAFFIC_BUTTON", "📈 Докупить трафик"), callback_data="buy_traffic") ) @@ -971,43 +975,46 @@ def get_subscription_keyboard( if happ_row: keyboard.append(happ_row) - if not is_trial: + if is_trial: keyboard.append([ - InlineKeyboardButton(text=texts.MENU_EXTEND_SUBSCRIPTION, callback_data="subscription_extend") + InlineKeyboardButton(text=texts.MENU_BUY_SUBSCRIPTION, callback_data="subscription_upgrade") ]) + else: + # Ряд: [Продлить] [Автоплатеж] keyboard.append([ + InlineKeyboardButton(text=texts.MENU_EXTEND_SUBSCRIPTION, callback_data="subscription_extend"), InlineKeyboardButton( text=texts.t("AUTOPAY_BUTTON", "💳 Автоплатеж"), callback_data="subscription_autopay", ) ]) - if is_trial: - keyboard.append([ - InlineKeyboardButton(text=texts.MENU_BUY_SUBSCRIPTION, callback_data="subscription_upgrade") - ]) - else: - keyboard.append([ + # Ряд: [Настройки] [Тариф] (если режим тарифов) + settings_row = [ InlineKeyboardButton( - text=texts.t("SUBSCRIPTION_SETTINGS_BUTTON", "⚙️ Настройки подписки"), + text=texts.t("SUBSCRIPTION_SETTINGS_BUTTON", "⚙️ Настройки"), callback_data="subscription_settings", ) - ]) - # Кнопка смены тарифа для режима тарифов + ] if settings.is_tariffs_mode() and subscription: - keyboard.append([ + settings_row.append( InlineKeyboardButton( - text=texts.t("CHANGE_TARIFF_BUTTON", "📦 Сменить тариф"), - callback_data="tariff_switch" + text=texts.t("CHANGE_TARIFF_BUTTON", "📦 Тариф"), + callback_data="instant_switch" ) - ]) + ) + keyboard.append(settings_row) + # Кнопка докупки трафика для платных подписок - if ( - settings.is_traffic_topup_enabled() - and not settings.is_traffic_topup_blocked() - and subscription - and (subscription.traffic_limit_gb or 0) > 0 - ): + # В режиме тарифов проверяем tariff_id, в классическом - глобальные настройки + show_traffic_topup = False + if subscription and (subscription.traffic_limit_gb or 0) > 0: + if settings.is_tariffs_mode() and getattr(subscription, 'tariff_id', None): + show_traffic_topup = True + elif settings.is_traffic_topup_enabled() and not settings.is_traffic_topup_blocked(): + show_traffic_topup = True + + if show_traffic_topup: keyboard.append([ InlineKeyboardButton( text=texts.t("BUY_TRAFFIC_BUTTON", "📈 Докупить трафик"), @@ -1795,7 +1802,81 @@ def get_add_traffic_keyboard( ]) return InlineKeyboardMarkup(inline_keyboard=buttons) - + + +def get_add_traffic_keyboard_from_tariff( + language: str, + packages: dict, # {gb: price_kopeks} + subscription_end_date: datetime = None, + discount_percent: int = 0, +) -> InlineKeyboardMarkup: + """ + Клавиатура для докупки трафика из настроек тарифа. + + Args: + language: Язык интерфейса + packages: Словарь {ГБ: цена_в_копейках} из тарифа + subscription_end_date: Дата окончания подписки для расчета цены + discount_percent: Процент скидки + """ + from app.utils.pricing_utils import get_remaining_months + + 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} мес)" + + if not packages: + return InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton( + text=texts.t("NO_TRAFFIC_PACKAGES", "❌ Нет доступных пакетов"), + callback_data="no_traffic_packages" + )], + [InlineKeyboardButton( + text=texts.BACK, + callback_data="menu_subscription" + )] + ]) + + buttons = [] + + # Сортируем пакеты по размеру + sorted_packages = sorted(packages.items(), key=lambda x: x[0]) + + for gb, price_per_month in sorted_packages: + discounted_per_month, discount_per_month = apply_percentage_discount( + price_per_month, + discount_percent, + ) + total_price = discounted_per_month * months_multiplier + total_discount = discount_per_month * months_multiplier + + if language == "ru": + text = f"📊 +{gb} ГБ трафика - {total_price // 100} ₽{period_text}" + else: + text = f"📊 +{gb} GB traffic - {total_price // 100} ₽{period_text}" + + if discount_percent > 0 and total_discount > 0: + text += f" (скидка {discount_percent}%: -{total_discount // 100}₽)" + + buttons.append([ + InlineKeyboardButton(text=text, callback_data=f"add_traffic_{gb}") + ]) + + buttons.append([ + InlineKeyboardButton( + text=texts.BACK, + callback_data="menu_subscription" + ) + ]) + + return InlineKeyboardMarkup(inline_keyboard=buttons) + + def get_change_devices_keyboard( current_devices: int, language: str = DEFAULT_LANGUAGE, diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index 494b82e8..bd0721ec 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -201,6 +201,7 @@ "ADMIN_MAIN_SETTINGS": "⚙️ Settings", "ADMIN_MAIN_SUPPORT": "🛟 Support", "ADMIN_MAIN_SYSTEM": "🛠️ System", + "ADMIN_MAIN_TARIFFS": "📦 Tariffs", "ADMIN_MAIN_TRIALS": "🧪 Trials", "ADMIN_MAIN_PAYMENTS": "💳 Top-ups", "ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Users / Subscriptions", @@ -889,6 +890,7 @@ "AUTOPAY_SUCCESS": "\n✅ Autopay completed\n\nYour subscription was automatically renewed for {days} days.\nCharged from balance: {amount}\n", "AUTOPAY_TOGGLE_SUCCESS": "✅ Autopay {status}!", "BACK": "⬅️ Back", + "BACK_BUTTON": "◀️ Back", "BACK_TO_MAIN_MENU_BUTTON": "⬅️ Back to main menu", "BACK_TO_MENU": "🏠 Back to menu", "BACK_TO_OPEN_TICKETS": "🔴 Open tickets", @@ -1031,6 +1033,7 @@ "FAQ_PAGE_NOT_AVAILABLE": "This FAQ page is not available.", "FAQ_PAGE_TITLE": "{title}", "FAQ_PAGE_UNTITLED": "Untitled", + "FREEKASSA_ENTER_AMOUNT": "💳 Top up via {name}\n\nEnter the amount in rubles.\n\nMinimum: {min_amount}\nMaximum: {max_amount}", "GO_TO_BALANCE_TOP_UP": "💳 Go to balance top up", "HAPP_DOWNLOAD_BUTTON": "⬇️ Download Happ", "HAPP_DOWNLOAD_LINK_MESSAGE": "⬇️ Download Happ for {platform}:", @@ -1140,6 +1143,7 @@ "PAYMENT_CARD_YOOKASSA": "💳 Bank card (YooKassa)", "PAYMENT_PLATEGA": "💳 Platega", "PAYMENT_CHARGE_ERROR": "⚠️ Failed to charge the payment", + "PAYMENT_CREATE_ERROR": "❌ Failed to create payment. Please try again later.", "PAYMENT_CRYPTOBOT": "🪙 Cryptocurrency (CryptoBot)", "PAYMENT_HELEKET": "🪙 Cryptocurrency (Heleket)", "HELEKET_PAYMENT_ALREADY_PAID": "✅ Payment has already been credited", @@ -1397,7 +1401,7 @@ "SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 Personal {percent}% discount\n\nIt's been {trigger_days} days without a subscription. Come back and activate the extra discount — it stacks with your promo group and is valid until {expires_at}.", "SUBSCRIPTION_EXPIRING": "\n⚠️ Subscription expiring!\n\nYour subscription expires in {days} days.\n\nRenew it now so you don't lose access.\n", "SUBSCRIPTION_EXPIRING_PAID": "\n⚠️ Subscription expires in {days_text}!\n\nYour paid subscription ends on {end_date}.\n\n💳 Autopay: {autopay_status}\n\n{action_text}\n", - "SUBSCRIPTION_EXTEND": "💎 Extend subscription", + "SUBSCRIPTION_EXTEND": "💎 Extend", "SUBSCRIPTION_HAPP_CRYPTOLINK_BLOCK": "
{crypto_link}
", "SUBSCRIPTION_HAPP_LINK_PROMPT": "🔒 Subscription link is ready. Tap the \"Connect\" button below to open it in Happ.", "SUBSCRIPTION_HAPP_OPEN_BUTTON_HINT": "▶️ Tap the \"Connect\" button below to open Happ and add the subscription automatically.", @@ -1425,7 +1429,7 @@ "SUBSCRIPTION_PROMO_DISCOUNT_NOTE": "⚡ Extra discount {percent}%: -{amount}", "SUBSCRIPTION_PROMO_DISCOUNT_TIMER": "⏳ Discount active for {time_left}\n{bar}", "SUBSCRIPTION_PURCHASED": "🎉 Subscription purchased successfully!", - "SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ Subscription settings", + "SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ Settings", "SUBSCRIPTION_SETTINGS_OVERVIEW": "⚙️ Subscription settings\n\n📊 Current parameters:\n🌐 Countries: {countries_count}\n📈 Traffic: {traffic_used} / {traffic_limit}\n📱 Devices: {devices_used} / {devices_limit}\n\nChoose what you want to change:", "SUBSCRIPTION_SETTINGS_PAID_ONLY": "⚠️ Settings are available only for paid subscriptions", "SUBSCRIPTION_SPECIFIC_APP_TITLE": "📱 {app_name} - {device_name}", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index 14866b55..ea9167fa 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -204,6 +204,7 @@ "ADMIN_MAIN_SETTINGS": "⚙️ Настройки", "ADMIN_MAIN_SUPPORT": "🛟 Поддержка", "ADMIN_MAIN_SYSTEM": "🛠️ Система", + "ADMIN_MAIN_TARIFFS": "📦 Тарифы", "ADMIN_MAIN_TRIALS": "🧪 Триалы", "ADMIN_MAIN_PAYMENTS": "💳 Пополнения", "ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Юзеры/Подписки", @@ -905,6 +906,7 @@ "AUTOPAY_SUCCESS": "\n✅ Автоплатеж выполнен\n\nВаша подписка автоматически продлена на {days} дней.\nСписано с баланса: {amount}\n", "AUTOPAY_TOGGLE_SUCCESS": "✅ Автоплатеж {status}!", "BACK": "⬅️ Назад", + "BACK_BUTTON": "◀️ Назад", "BACK_TO_MAIN_MENU_BUTTON": "⬅️ В главное меню", "BACK_TO_MENU": "🏠 В главное меню", "BACK_TO_OPEN_TICKETS": "🔴 Открытые тикеты", @@ -941,7 +943,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": "📦 Сменить тариф", + "CHANGE_TARIFF_BUTTON": "📦 Тариф", "CHANNEL_CHECK_BUTTON": "✅ Я подписался", "CHANNEL_REQUIRED_TEXT": "🔒 Для использования бота подпишитесь на новостной канал, а затем нажмите кнопку ниже.", "CHANNEL_SUBSCRIBE_BUTTON": "🔗 Подписаться", @@ -1048,6 +1050,7 @@ "FAQ_PAGE_NOT_AVAILABLE": "Эта страница FAQ недоступна.", "FAQ_PAGE_TITLE": "{title}", "FAQ_PAGE_UNTITLED": "Без названия", + "FREEKASSA_ENTER_AMOUNT": "💳 Пополнение через {name}\n\nВведите сумму пополнения в рублях.\n\nМинимум: {min_amount}\nМаксимум: {max_amount}", "GO_TO_BALANCE_TOP_UP": "💳 Перейти к пополнению баланса", "HAPP_DOWNLOAD_BUTTON": "⬇️ Скачать Happ", "HAPP_DOWNLOAD_LINK_MESSAGE": "⬇️ Скачайте Happ для {platform}:", @@ -1157,6 +1160,7 @@ "PAYMENT_CARD_YOOKASSA": "💳 Банковская карта (YooKassa)", "PAYMENT_PLATEGA": "💳 Platega", "PAYMENT_CHARGE_ERROR": "⚠️ Ошибка списания средств", + "PAYMENT_CREATE_ERROR": "❌ Не удалось создать платёж. Попробуйте позже.", "PAYMENT_CRYPTOBOT": "🪙 Криптовалюта (CryptoBot)", "PAYMENT_HELEKET": "🪙 Криптовалюта (Heleket)", "HELEKET_PAYMENT_ALREADY_PAID": "✅ Платёж уже зачислен", @@ -1414,7 +1418,7 @@ "SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 Индивидуальная скидка {percent}%\n\nПрошло {trigger_days} дней без подписки. Вернитесь и активируйте дополнительную скидку — она суммируется с промогруппой и действует до {expires_at}.", "SUBSCRIPTION_EXPIRING": "\n⚠️ Подписка истекает!\n\nВаша подписка истекает через {days} дней.\n\nНе забудьте продлить подписку, чтобы не потерять доступ к серверам.\n", "SUBSCRIPTION_EXPIRING_PAID": "\n⚠️ Подписка истекает через {days_text}!\n\nВаша платная подписка истекает {end_date}.\n\n💳 Автоплатеж: {autopay_status}\n\n{action_text}\n", - "SUBSCRIPTION_EXTEND": "💎 Продлить подписку", + "SUBSCRIPTION_EXTEND": "💎 Продлить", "SUBSCRIPTION_HAPP_CRYPTOLINK_BLOCK": "
{crypto_link}
", "SUBSCRIPTION_HAPP_LINK_PROMPT": "🔒 Ссылка на подписку создана. Нажмите кнопку \"Подключиться\" ниже, чтобы открыть её в Happ.", "SUBSCRIPTION_HAPP_OPEN_BUTTON_HINT": "▶️ Нажмите кнопку \"Подключиться\" ниже, чтобы открыть Happ и добавить подписку автоматически.", @@ -1442,7 +1446,7 @@ "SUBSCRIPTION_PROMO_DISCOUNT_NOTE": "⚡ Доп. скидка {percent}%: -{amount}", "SUBSCRIPTION_PROMO_DISCOUNT_TIMER": "⏳ Скидка действует ещё: {time_left}\n{bar}", "SUBSCRIPTION_PURCHASED": "🎉 Подписка успешно приобретена!", - "SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ Настройки подписки", + "SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ Настройки", "SUBSCRIPTION_SETTINGS_OVERVIEW": "⚙️ Настройки подписки\n\n📊 Текущие параметры:\n🌐 Стран: {countries_count}\n📈 Трафик: {traffic_used} / {traffic_limit}\n📱 Устройства: {devices_used} / {devices_limit}\n\nВыберите что хотите изменить:", "SUBSCRIPTION_SETTINGS_PAID_ONLY": "⚠️ Настройки доступны только для платных подписок", "SUBSCRIPTION_SPECIFIC_APP_TITLE": "📱 {app_name} - {device_name}", diff --git a/app/localization/locales/ua.json b/app/localization/locales/ua.json index 5639c0b7..baa7ef9a 100644 --- a/app/localization/locales/ua.json +++ b/app/localization/locales/ua.json @@ -131,6 +131,7 @@ "ADMIN_MAIN_SETTINGS": "⚙️ Налаштування", "ADMIN_MAIN_SUPPORT": "🛟 Підтримка", "ADMIN_MAIN_SYSTEM": "🛠️ Система", + "ADMIN_MAIN_TARIFFS": "📦 Тарифи", "ADMIN_MAIN_TRIALS": "🧪 Тріали", "ADMIN_MAIN_PAYMENTS": "💳 Поповнення", "ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Юзери/Підписки", diff --git a/app/localization/locales/zh.json b/app/localization/locales/zh.json index 279fcbca..8b7f7c27 100644 --- a/app/localization/locales/zh.json +++ b/app/localization/locales/zh.json @@ -130,6 +130,7 @@ "ADMIN_MAIN_SETTINGS":"⚙️设置", "ADMIN_MAIN_SUPPORT":"🛟支持", "ADMIN_MAIN_SYSTEM":"🛠️系统", +"ADMIN_MAIN_TARIFFS":"📦套餐", "ADMIN_MAIN_TRIALS":"🧪试用", "ADMIN_MAIN_PAYMENTS":"💳充值", "ADMIN_MAIN_USERS_SUBSCRIPTIONS":"👥用户/订阅", diff --git a/app/services/freekassa_service.py b/app/services/freekassa_service.py index 6a94d91b..c609132c 100644 --- a/app/services/freekassa_service.py +++ b/app/services/freekassa_service.py @@ -4,6 +4,9 @@ import hashlib import hmac import time import logging +import asyncio +import json +import urllib.request from typing import Optional, Dict, Any, Set import aiohttp @@ -12,6 +15,10 @@ from app.config import settings logger = logging.getLogger(__name__) +# Кэш для публичного IP +_cached_public_ip: Optional[str] = None +_ip_fetch_lock = asyncio.Lock() + # IP-адреса Freekassa для проверки webhook FREEKASSA_IPS: Set[str] = { "168.119.157.136", @@ -22,6 +29,62 @@ FREEKASSA_IPS: Set[str] = { API_BASE_URL = "https://api.fk.life/v1" +# Сервисы для определения публичного IP (в порядке приоритета) +IP_SERVICES = [ + "https://api.ipify.org", + "https://ifconfig.me/ip", + "https://icanhazip.com", + "https://ipinfo.io/ip", +] + + +async def get_public_ip() -> str: + """ + Получает публичный IP сервера. + 1. Сначала проверяет переменную окружения SERVER_PUBLIC_IP + 2. Если нет - запрашивает через внешние сервисы и кэширует + """ + global _cached_public_ip + + # Проверяем переменную окружения + env_ip = getattr(settings, 'SERVER_PUBLIC_IP', None) + if env_ip: + return env_ip + + # Возвращаем кэшированный IP если есть + if _cached_public_ip: + return _cached_public_ip + + async with _ip_fetch_lock: + # Повторная проверка после получения блокировки + if _cached_public_ip: + return _cached_public_ip + + # Пробуем получить IP от внешних сервисов + async with aiohttp.ClientSession() as session: + for service_url in IP_SERVICES: + try: + async with session.get( + service_url, + timeout=aiohttp.ClientTimeout(total=5) + ) as response: + if response.status == 200: + ip = (await response.text()).strip() + # Простая валидация IPv4 + if ip and len(ip.split('.')) == 4: + _cached_public_ip = ip + logger.info(f"Определён публичный IP сервера: {ip}") + return ip + except Exception as e: + logger.debug(f"Не удалось получить IP от {service_url}: {e}") + continue + + # Fallback на известный рабочий IP если ничего не получилось + fallback_ip = "185.92.183.173" + logger.warning(f"Не удалось определить публичный IP, используем fallback: {fallback_ip}") + _cached_public_ip = fallback_ip + return fallback_ip + class FreekassaService: """Сервис для работы с API Freekassa.""" @@ -121,6 +184,7 @@ class FreekassaService: phone: Optional[str] = None, payment_system_id: Optional[int] = None, lang: str = "ru", + ip: Optional[str] = None, ) -> str: """ Формирует URL для перенаправления на оплату (форма выбора). @@ -128,6 +192,60 @@ class FreekassaService: """ # Приводим amount к int, если это целое число final_amount = int(amount) if float(amount).is_integer() else amount + + # Используем payment_system_id из настроек, если не передан явно + ps_id = payment_system_id or settings.FREEKASSA_PAYMENT_SYSTEM_ID + + # Специальная обработка для метода оплаты 44 (NSPK), чтобы работало как в старой версии + if ps_id == 44: + try: + # Определяем IP (важно для API запроса) - здесь синхронно, поэтому лучше иметь передачу IP + # Если IP не передан, используем fallback + target_ip = ip or "185.92.183.173" + target_email = email or "test@example.com" + + params = { + "shopId": self.shop_id, + "nonce": int(time.time_ns()), + "paymentId": str(order_id), + "i": 44, + "email": target_email, + "ip": target_ip, + "amount": final_amount, + "currency": "RUB" + } + + # Генерация подписи + params["signature"] = self._generate_api_signature(params) + + logger.info(f"Freekassa synchronous build_payment_url for 44: {params}") + + data_json = json.dumps(params).encode('utf-8') + req = urllib.request.Request( + f"{API_BASE_URL}/orders/create", + data=data_json, + headers={"Content-Type": "application/json"} + ) + + with urllib.request.urlopen(req, timeout=30) as response: + resp_body = response.read().decode('utf-8') + data = json.loads(resp_body) + + if data.get("type") == "error": + logger.error(f"Freekassa build_payment_url error: {data}") + # Fallback to standard flow if error? Or raise? + # User wants it to work. Raise to see error is safer. + # raise Exception(f"Freekassa API Error: {data.get('message')}") + # Но чтобы не ломать полностью, можно попробовать вернуть обычную ссылку, + # если API не сработал? Нет, вернем ошибку или ссылку из data. + + if data.get("location"): + return data.get("location") + except Exception as e: + logger.error(f"Failed to create order 44 via sync API: {e}") + # Если не получилось, попробуем сгенерировать обычную ссылку как fallback + pass + signature = self.generate_form_signature(final_amount, currency, order_id) params = { @@ -144,8 +262,6 @@ class FreekassaService: if phone: params["phone"] = phone - # Используем payment_system_id из настроек, если не передан явно - ps_id = payment_system_id or settings.FREEKASSA_PAYMENT_SYSTEM_ID if ps_id: params["i"] = ps_id @@ -177,13 +293,18 @@ class FreekassaService: # Используем payment_system_id из настроек, если не передан явно ps_id = payment_system_id or settings.FREEKASSA_PAYMENT_SYSTEM_ID or 1 + target_email = email or "test@example.com" + + # Определяем публичный IP сервера + server_ip = ip or await get_public_ip() + params = { "shopId": self.shop_id, "nonce": int(time.time_ns()), # Наносекунды для уникальности "paymentId": str(order_id), "i": ps_id, - "email": email or "user@example.com", - "ip": ip or "127.0.0.1", + "email": target_email, + "ip": server_ip, "amount": final_amount, "currency": currency, } @@ -206,10 +327,12 @@ class FreekassaService: data = await response.json() - if response.status != 200 or data.get("type") == "error": + # Проверяем на ошибку - API может вернуть error или type=error + error_msg = data.get("error") or data.get("message") + if response.status != 200 or data.get("type") == "error" or error_msg: logger.error(f"Freekassa create_order error: {data}") raise Exception( - f"Freekassa API error: {data.get('message', 'Unknown error')}" + f"Freekassa API error: {error_msg or 'Unknown error'}" ) return data @@ -255,7 +378,7 @@ class FreekassaService: } params["signature"] = self._generate_api_signature(params) - logger.info(f"Freekassa get_order_status params: {params}") + logger.debug(f"Freekassa get_order_status params: {params}") try: async with aiohttp.ClientSession() as session: @@ -266,7 +389,7 @@ class FreekassaService: timeout=aiohttp.ClientTimeout(total=30), ) as response: text = await response.text() - logger.info(f"Freekassa get_order_status response: {text}") + logger.debug(f"Freekassa get_order_status response: {text}") return await response.json() except aiohttp.ClientError as e: logger.exception(f"Freekassa API connection error: {e}") diff --git a/app/services/payment/freekassa.py b/app/services/payment/freekassa.py index 163b2734..b6e13fb2 100644 --- a/app/services/payment/freekassa.py +++ b/app/services/payment/freekassa.py @@ -505,17 +505,7 @@ class FreekassaPaymentMixin: local_payment_id: int, ) -> Optional[Dict[str, Any]]: """ - Проверяет статус платежа Freekassa по локальному ID. - - Freekassa не предоставляет API для проверки статуса платежа, - поэтому возвращаем текущее состояние из БД. - - Args: - db: Сессия БД - local_payment_id: Внутренний ID платежа - - Returns: - Dict с информацией о платеже или None если не найден + Проверяет статус платежа Freekassa по локальному ID через API. """ freekassa_crud = import_module("app.database.crud.freekassa") @@ -524,8 +514,73 @@ class FreekassaPaymentMixin: logger.warning("Freekassa payment not found: id=%s", local_payment_id) return None - # Freekassa не имеет API для проверки статуса, - # информация приходит только через webhook + if payment.is_paid: + return { + "payment": payment, + "status": "success", + "is_paid": True, + } + + if not settings.FREEKASSA_API_KEY: + return { + "payment": payment, + "status": payment.status or "pending", + "is_paid": payment.is_paid, + } + + try: + # Запрашиваем статус заказа в Freekassa + response = await freekassa_service.get_order_status(payment.order_id) + + # Freekassa возвращает список заказов + orders = response.get("orders", []) + target_order = None + + # Ищем наш заказ в списке + for order in orders: + # В ответе API поле называется merchant_order_id, а не paymentId + # Поддерживаем оба варианта на всякий случай + order_key = str(order.get("merchant_order_id") or order.get("paymentId")) + if order_key == str(payment.order_id): + target_order = order + break + + if target_order: + # Статус 1 = Оплачен + fk_status = int(target_order.get("status", 0)) + + if fk_status == 1: + logger.info("Freekassa payment %s confirmed via API", payment.order_id) + + callback_payload = { + "check_source": "api", + "fk_order_data": target_order, + } + + # ID заказа на стороне FK (fk_order_id или id) + fk_intid = str(target_order.get("fk_order_id") or target_order.get("id")) + + # Обновляем статус + payment = await freekassa_crud.update_freekassa_payment_status( + db=db, + payment=payment, + status="success", + is_paid=True, + freekassa_order_id=fk_intid, + payment_system_id=int(target_order.get("curID")) if target_order.get("curID") else None, + callback_payload=callback_payload, + ) + + # Финализируем + await self._finalize_freekassa_payment( + db, + payment, + intid=fk_intid, + trigger="api_check", + ) + except Exception as e: + logger.error("Error checking Freekassa payment status: %s", e) + return { "payment": payment, "status": payment.status or "pending", diff --git a/app/services/user_service.py b/app/services/user_service.py index 404c650d..52474dc3 100644 --- a/app/services/user_service.py +++ b/app/services/user_service.py @@ -23,7 +23,7 @@ from app.database.models import ( ReferralEarning, SubscriptionServer, YooKassaPayment, BroadcastHistory, CryptoBotPayment, PlategaPayment, SubscriptionConversion, UserMessage, WelcomeText, SentNotification, PromoGroup, MulenPayPayment, Pal24Payment, HeleketPayment, - AdvertisingCampaign, AdvertisingCampaignRegistration, PaymentMethod, + FreekassaPayment, AdvertisingCampaign, AdvertisingCampaignRegistration, PaymentMethod, TransactionType ) from app.config import settings @@ -960,6 +960,28 @@ class UserService: except Exception as e: logger.error(f"❌ Ошибка удаления Heleket платежей: {e}") + # Удаляем Freekassa платежи + try: + freekassa_payments_result = await db.execute( + select(FreekassaPayment).where(FreekassaPayment.user_id == user_id) + ) + freekassa_payments = freekassa_payments_result.scalars().all() + + if freekassa_payments: + logger.info(f"🔄 Удаляем {len(freekassa_payments)} Freekassa платежей") + await db.execute( + update(FreekassaPayment) + .where(FreekassaPayment.user_id == user_id) + .values(transaction_id=None) + ) + await db.flush() + await db.execute( + delete(FreekassaPayment).where(FreekassaPayment.user_id == user_id) + ) + await db.flush() + except Exception as e: + logger.error(f"❌ Ошибка удаления Freekassa платежей: {e}") + try: transactions_result = await db.execute( select(Transaction).where(Transaction.user_id == user_id) diff --git a/app/states.py b/app/states.py index 33d74fd4..88ebce08 100644 --- a/app/states.py +++ b/app/states.py @@ -180,6 +180,8 @@ class AdminStates(StatesGroup): editing_tariff_trial_days = State() editing_tariff_squads = State() editing_tariff_promo_groups = State() + editing_tariff_traffic_topup_packages = State() + editing_tariff_max_topup_traffic = State() class SupportStates(StatesGroup): diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index 2f6acade..c469335f 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -190,8 +190,13 @@ from ..schemas.miniapp import ( MiniAppTariffsResponse, MiniAppTariffPurchaseRequest, MiniAppTariffPurchaseResponse, + MiniAppTariffSwitchRequest, + MiniAppTariffSwitchPreviewResponse, + MiniAppTariffSwitchResponse, MiniAppCurrentTariff, MiniAppConnectedServer, + MiniAppTrafficTopupRequest, + MiniAppTrafficTopupResponse, ) @@ -208,6 +213,23 @@ _CRYPTOBOT_MAX_USD = 1000.0 _CRYPTOBOT_FALLBACK_RATE = 95.0 +def _get_tariff_monthly_price(tariff) -> int: + """Получает месячную цену тарифа (30 дней) с fallback на пропорциональный расчёт.""" + price = tariff.get_price_for_period(30) + if price is not None: + return price + + # Fallback: пропорционально пересчитываем из первого доступного периода + periods = tariff.get_available_periods() + if periods: + first_period = periods[0] + first_price = tariff.get_price_for_period(first_period) + if first_price: + return int(first_price * 30 / first_period) + + return 0 + + @router.get("/app-config.json") async def get_app_config() -> Dict[str, Any]: data = _load_app_config_data() @@ -3503,13 +3525,15 @@ async def get_subscription_details( 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, + current_tariff=await _get_current_tariff_model(db, subscription, user) if subscription else None, **autopay_extras, ) -async def _get_current_tariff_model(db: AsyncSession, subscription) -> Optional[MiniAppCurrentTariff]: +async def _get_current_tariff_model(db: AsyncSession, subscription, user=None) -> Optional[MiniAppCurrentTariff]: """Возвращает модель текущего тарифа пользователя.""" + from app.webapi.schemas.miniapp import MiniAppTrafficTopupPackage + if not subscription or not getattr(subscription, "tariff_id", None): return None @@ -3519,6 +3543,71 @@ async def _get_current_tariff_model(db: AsyncSession, subscription) -> Optional[ servers_count = len(tariff.allowed_squads) if tariff.allowed_squads else 0 + # Получаем скидку на трафик из промогруппы + traffic_discount_percent = 0 + promo_group = (user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else getattr(user, "promo_group", None)) if user else None + if promo_group: + apply_to_addons = getattr(promo_group, 'apply_discounts_to_addons', True) + if apply_to_addons: + traffic_discount_percent = max(0, min(100, int(getattr(promo_group, 'traffic_discount_percent', 0) or 0))) + + # Лимит докупки трафика + max_topup_traffic_gb = getattr(tariff, 'max_topup_traffic_gb', 0) or 0 + current_subscription_traffic = subscription.traffic_limit_gb or 0 + + # Рассчитываем доступный лимит докупки + available_topup_gb = None + if max_topup_traffic_gb > 0: + available_topup_gb = max(0, max_topup_traffic_gb - current_subscription_traffic) + + # Пакеты докупки трафика + traffic_topup_enabled = getattr(tariff, 'traffic_topup_enabled', False) and tariff.traffic_limit_gb > 0 + traffic_topup_packages = [] + + if traffic_topup_enabled and hasattr(tariff, 'get_traffic_topup_packages'): + packages = tariff.get_traffic_topup_packages() + for gb in sorted(packages.keys()): + # Фильтруем пакеты, которые превышают доступный лимит + if available_topup_gb is not None and gb > available_topup_gb: + continue + + base_price = packages[gb] + # Применяем скидку + if traffic_discount_percent > 0: + discounted_price = int(base_price * (100 - traffic_discount_percent) / 100) + traffic_topup_packages.append(MiniAppTrafficTopupPackage( + gb=gb, + price_kopeks=discounted_price, + price_label=settings.format_price(discounted_price), + original_price_kopeks=base_price, + original_price_label=settings.format_price(base_price), + discount_percent=traffic_discount_percent, + )) + else: + traffic_topup_packages.append(MiniAppTrafficTopupPackage( + gb=gb, + price_kopeks=base_price, + price_label=settings.format_price(base_price), + )) + + # Если нет доступных пакетов из-за лимита - отключаем докупку + if traffic_topup_enabled and not traffic_topup_packages and available_topup_gb == 0: + traffic_topup_enabled = False + + monthly_price = _get_tariff_monthly_price(tariff) + + # Применяем скидку промогруппы для 30-дневного периода + if promo_group: + raw_discounts = getattr(promo_group, 'period_discounts', None) or {} + for k, v in raw_discounts.items(): + try: + if int(k) == 30: + discount = max(0, min(100, int(v))) + monthly_price = int(monthly_price * (100 - discount) / 100) + break + except (TypeError, ValueError): + pass + return MiniAppCurrentTariff( id=tariff.id, name=tariff.name, @@ -3529,6 +3618,11 @@ async def _get_current_tariff_model(db: AsyncSession, subscription) -> Optional[ is_unlimited_traffic=tariff.traffic_limit_gb == 0, device_limit=tariff.device_limit, servers_count=servers_count, + monthly_price_kopeks=monthly_price, + traffic_topup_enabled=traffic_topup_enabled, + traffic_topup_packages=traffic_topup_packages, + max_topup_traffic_gb=max_topup_traffic_gb, + available_topup_gb=available_topup_gb, ) @@ -4289,15 +4383,15 @@ def _safe_int(value: Any) -> int: def _normalize_period_discounts( raw: Optional[Dict[Any, Any]] -) -> Dict[int, int]: +) -> Dict[str, int]: if not isinstance(raw, dict): return {} - normalized: Dict[int, int] = {} + normalized: Dict[str, int] = {} for key, value in raw.items(): try: period = int(key) - normalized[period] = int(value) + normalized[str(period)] = int(value) except (TypeError, ValueError): continue @@ -4477,57 +4571,128 @@ async def _prepare_subscription_renewal_options( user: User, subscription: Subscription, ) -> Tuple[List[MiniAppSubscriptionRenewalPeriod], Dict[Union[str, int], Dict[str, Any]], Optional[str]]: - available_periods = [ - period for period in settings.get_available_renewal_periods() if period > 0 - ] - option_payloads: List[Tuple[MiniAppSubscriptionRenewalPeriod, Dict[str, Any]]] = [] - for period_days in available_periods: - try: - pricing_model = await _calculate_subscription_renewal_pricing( - db, - user, - subscription, + # Проверяем, есть ли у подписки тариф (режим тарифов) + tariff_id = getattr(subscription, 'tariff_id', None) + tariff = None + if tariff_id: + from app.database.crud.tariff import get_tariff_by_id + tariff = await get_tariff_by_id(db, tariff_id) + + if tariff and tariff.period_prices: + # Режим тарифов: используем периоды и цены из тарифа + promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else getattr(user, "promo_group", None) + + # Получаем скидки промогруппы по периодам + period_discounts = {} + if promo_group: + raw_discounts = getattr(promo_group, 'period_discounts', None) or {} + for k, v in raw_discounts.items(): + try: + period_discounts[int(k)] = max(0, min(100, int(v))) + except (TypeError, ValueError): + pass + + for period_str, original_price_kopeks in sorted(tariff.period_prices.items(), key=lambda x: int(x[0])): + period_days = int(period_str) + + # Применяем скидку промогруппы + discount_percent = period_discounts.get(period_days, 0) + if discount_percent > 0: + price_kopeks = int(original_price_kopeks * (100 - discount_percent) / 100) + else: + price_kopeks = original_price_kopeks + + months = max(1, period_days // 30) + per_month = price_kopeks // months if months > 0 else price_kopeks + + label = format_period_description( period_days, + getattr(user, "language", settings.DEFAULT_LANGUAGE), ) - pricing = pricing_model.to_payload() - except Exception as error: # pragma: no cover - defensive logging - logger.warning( - "Failed to calculate renewal pricing for subscription %s (period %s): %s", - subscription.id, + + price_label = settings.format_price(price_kopeks) + original_label = settings.format_price(original_price_kopeks) if discount_percent > 0 else None + per_month_label = settings.format_price(per_month) + + option_model = MiniAppSubscriptionRenewalPeriod( + id=f"tariff_{tariff.id}_{period_days}", + days=period_days, + months=months, + price_kopeks=price_kopeks, + price_label=price_label, + original_price_kopeks=original_price_kopeks if discount_percent > 0 else None, + original_price_label=original_label, + discount_percent=discount_percent, + price_per_month_kopeks=per_month, + price_per_month_label=per_month_label, + title=label, + ) + + pricing = { + "period_id": option_model.id, + "period_days": period_days, + "months": months, + "final_total": price_kopeks, + "base_original_total": original_price_kopeks if discount_percent > 0 else price_kopeks, + "overall_discount_percent": discount_percent, + "per_month": per_month, + "tariff_id": tariff.id, + } + + option_payloads.append((option_model, pricing)) + else: + # Классический режим: используем периоды из настроек + available_periods = [ + period for period in settings.get_available_renewal_periods() if period > 0 + ] + + for period_days in available_periods: + try: + pricing_model = await _calculate_subscription_renewal_pricing( + db, + user, + subscription, + period_days, + ) + pricing = pricing_model.to_payload() + except Exception as error: # pragma: no cover - defensive logging + logger.warning( + "Failed to calculate renewal pricing for subscription %s (period %s): %s", + subscription.id, + period_days, + error, + ) + continue + + label = format_period_description( period_days, - error, + getattr(user, "language", settings.DEFAULT_LANGUAGE), ) - continue - label = format_period_description( - period_days, - getattr(user, "language", settings.DEFAULT_LANGUAGE), - ) + price_label = settings.format_price(pricing["final_total"]) + original_label = None + if pricing["base_original_total"] and pricing["base_original_total"] != pricing["final_total"]: + original_label = settings.format_price(pricing["base_original_total"]) - price_label = settings.format_price(pricing["final_total"]) - original_label = None - if pricing["base_original_total"] and pricing["base_original_total"] != pricing["final_total"]: - original_label = settings.format_price(pricing["base_original_total"]) + per_month_label = settings.format_price(pricing["per_month"]) - per_month_label = settings.format_price(pricing["per_month"]) + option_model = MiniAppSubscriptionRenewalPeriod( + id=pricing["period_id"], + days=period_days, + months=pricing["months"], + price_kopeks=pricing["final_total"], + price_label=price_label, + original_price_kopeks=pricing["base_original_total"], + original_price_label=original_label, + discount_percent=pricing["overall_discount_percent"], + price_per_month_kopeks=pricing["per_month"], + price_per_month_label=per_month_label, + title=label, + ) - option_model = MiniAppSubscriptionRenewalPeriod( - id=pricing["period_id"], - days=period_days, - months=pricing["months"], - price_kopeks=pricing["final_total"], - price_label=price_label, - original_price_kopeks=pricing["base_original_total"], - original_price_label=original_label, - discount_percent=pricing["overall_discount_percent"], - price_per_month_kopeks=pricing["per_month"], - price_per_month_label=per_month_label, - title=label, - ) - - option_payloads.append((option_model, pricing)) + option_payloads.append((option_model, pricing)) if not option_payloads: return [], {}, None @@ -5092,79 +5257,187 @@ async def submit_subscription_renewal_endpoint( detail={"code": "invalid_period", "message": "Invalid renewal period"}, ) - available_periods = [ - period for period in settings.get_available_renewal_periods() if period > 0 - ] - if period_days not in available_periods: - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail={"code": "period_unavailable", "message": "Selected renewal period is not available"}, - ) + # Проверяем, есть ли у подписки тариф (режим тарифов) + tariff_id = getattr(subscription, 'tariff_id', None) + tariff = None + tariff_pricing = None + + if tariff_id: + from app.database.crud.tariff import get_tariff_by_id + tariff = await get_tariff_by_id(db, tariff_id) + + if tariff and tariff.period_prices: + # Режим тарифов: проверяем периоды из тарифа + available_periods = [int(p) for p in tariff.period_prices.keys()] + if period_days not in available_periods: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail={"code": "period_unavailable", "message": "Selected renewal period is not available for this tariff"}, + ) + + # Рассчитываем цену из тарифа + original_price_kopeks = tariff.period_prices.get(str(period_days), tariff.period_prices.get(period_days, 0)) + + # Применяем скидку промогруппы + promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else getattr(user, "promo_group", None) + discount_percent = 0 + if promo_group: + raw_discounts = getattr(promo_group, 'period_discounts', None) or {} + for k, v in raw_discounts.items(): + try: + if int(k) == period_days: + discount_percent = max(0, min(100, int(v))) + break + except (TypeError, ValueError): + pass + + if discount_percent > 0: + final_total = int(original_price_kopeks * (100 - discount_percent) / 100) + else: + final_total = original_price_kopeks + + tariff_pricing = { + "period_days": period_days, + "original_price_kopeks": original_price_kopeks, + "discount_percent": discount_percent, + "final_total": final_total, + "tariff_id": tariff.id, + } + else: + # Классический режим + available_periods = [ + period for period in settings.get_available_renewal_periods() if period > 0 + ] + if period_days not in available_periods: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail={"code": "period_unavailable", "message": "Selected renewal period is not available"}, + ) method = (payload.method or "").strip().lower() - try: - pricing_model = await _calculate_subscription_renewal_pricing( - db, - user, - subscription, - period_days, - ) - except HTTPException: - raise - except Exception as error: - logger.error( - "Failed to calculate renewal pricing for subscription %s (period %s): %s", - subscription.id, - period_days, - error, - ) - raise HTTPException( - status.HTTP_502_BAD_GATEWAY, - detail={"code": "pricing_failed", "message": "Failed to calculate renewal pricing"}, - ) from error + # Для тарифного режима используем упрощённый расчёт + if tariff_pricing: + final_total = tariff_pricing["final_total"] + pricing = tariff_pricing + else: + try: + pricing_model = await _calculate_subscription_renewal_pricing( + db, + user, + subscription, + period_days, + ) + except HTTPException: + raise + except Exception as error: + logger.error( + "Failed to calculate renewal pricing for subscription %s (period %s): %s", + subscription.id, + period_days, + error, + ) + raise HTTPException( + status.HTTP_502_BAD_GATEWAY, + detail={"code": "pricing_failed", "message": "Failed to calculate renewal pricing"}, + ) from error - pricing = pricing_model.to_payload() - final_total = int(pricing_model.final_total) + pricing = pricing_model.to_payload() + final_total = int(pricing_model.final_total) balance_kopeks = getattr(user, "balance_kopeks", 0) missing_amount = calculate_missing_amount(balance_kopeks, final_total) description = f"Продление подписки на {period_days} дней" if missing_amount <= 0: - try: - result = await renewal_service.finalize( - db, + if tariff_pricing: + # Тарифный режим: простое продление + from app.database.crud.user import subtract_user_balance + from app.database.crud.subscription import extend_subscription + from app.database.crud.transaction import create_transaction + + try: + # Списываем баланс (subtract_user_balance делает commit и обновляет user.balance_kopeks) + success = await subtract_user_balance(db, user, final_total, description) + if not success: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={"code": "balance_error", "message": "Failed to subtract balance"}, + ) + + # Продлеваем подписку + subscription = await extend_subscription(db, subscription, period_days) + new_end_date = subscription.end_date + + # Записываем транзакцию + from app.database.models import TransactionType + await create_transaction( + db, + user_id=user.id, + type=TransactionType.SUBSCRIPTION_PAYMENT, + amount_kopeks=-final_total, + description=description, + ) + + lang = getattr(user, "language", settings.DEFAULT_LANGUAGE) + if lang == "ru": + message = f"Подписка продлена до {new_end_date.strftime('%d.%m.%Y')}" + else: + message = f"Subscription extended until {new_end_date.strftime('%Y-%m-%d')}" + + return MiniAppSubscriptionRenewalResponse( + message=message, + balance_kopeks=user.balance_kopeks, + balance_label=settings.format_price(user.balance_kopeks), + subscription_id=subscription.id, + renewed_until=new_end_date, + ) + except Exception as error: + await db.rollback() + logger.error( + "Failed to renew tariff subscription %s: %s", + subscription.id, + error, + ) + raise HTTPException( + status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={"code": "renewal_failed", "message": "Failed to renew subscription"}, + ) from error + else: + # Классический режим + try: + result = await renewal_service.finalize( + db, + user, + subscription, + pricing_model, + description=description, + ) + except SubscriptionRenewalChargeError as error: + logger.error( + "Failed to charge balance for subscription renewal %s: %s", + subscription.id, + error, + ) + raise HTTPException( + status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={"code": "charge_failed", "message": "Failed to charge balance"}, + ) from error + + updated_subscription = result.subscription + message = _build_renewal_success_message( user, - subscription, - pricing_model, - description=description, + updated_subscription, + result.total_amount_kopeks, + pricing_model.promo_discount_value, ) - except SubscriptionRenewalChargeError as error: - logger.error( - "Failed to charge balance for subscription renewal %s: %s", - subscription.id, - error, + + return MiniAppSubscriptionRenewalResponse( + message=message, + balance_kopeks=user.balance_kopeks, + balance_label=settings.format_price(user.balance_kopeks), + subscription_id=updated_subscription.id, + renewed_until=updated_subscription.end_date, ) - raise HTTPException( - status.HTTP_500_INTERNAL_SERVER_ERROR, - detail={"code": "charge_failed", "message": "Failed to charge balance"}, - ) from error - - updated_subscription = result.subscription - message = _build_renewal_success_message( - user, - updated_subscription, - result.total_amount_kopeks, - pricing_model.promo_discount_value, - ) - - return MiniAppSubscriptionRenewalResponse( - message=message, - balance_kopeks=user.balance_kopeks, - balance_label=settings.format_price(user.balance_kopeks), - subscription_id=updated_subscription.id, - renewed_until=updated_subscription.end_date, - ) if not method: if final_total > 0 and balance_kopeks < final_total: @@ -5995,6 +6268,10 @@ async def _build_tariff_model( db: AsyncSession, tariff, current_tariff_id: Optional[int] = None, + promo_group=None, + current_tariff=None, + remaining_days: int = 0, + user=None, ) -> MiniAppTariff: """Преобразует объект тарифа в модель для API.""" servers: List[MiniAppConnectedServer] = [] @@ -6010,10 +6287,28 @@ async def _build_tariff_model( name=server.display_name or squad_uuid[:8], )) + # Получаем скидки промогруппы по периодам + period_discounts = {} + if promo_group: + raw_discounts = getattr(promo_group, 'period_discounts', None) or {} + for k, v in raw_discounts.items(): + try: + period_discounts[int(k)] = max(0, min(100, int(v))) + except (TypeError, ValueError): + pass + periods: List[MiniAppTariffPeriod] = [] if tariff.period_prices: - for period_str, price_kopeks in sorted(tariff.period_prices.items(), key=lambda x: int(x[0])): + for period_str, original_price_kopeks in sorted(tariff.period_prices.items(), key=lambda x: int(x[0])): period_days = int(period_str) + + # Применяем скидку промогруппы + discount_percent = period_discounts.get(period_days, 0) + if discount_percent > 0: + price_kopeks = int(original_price_kopeks * (100 - discount_percent) / 100) + else: + price_kopeks = original_price_kopeks + months = max(1, period_days // 30) per_month = price_kopeks // months if months > 0 else price_kopeks @@ -6025,8 +6320,26 @@ async def _build_tariff_model( price_label=settings.format_price(price_kopeks), price_per_month_kopeks=per_month, price_per_month_label=settings.format_price(per_month), + original_price_kopeks=original_price_kopeks if discount_percent > 0 else None, + original_price_label=settings.format_price(original_price_kopeks) if discount_percent > 0 else None, + discount_percent=discount_percent, )) + # Расчёт стоимости переключения тарифа (если есть текущий тариф и это не он же) + switch_cost_kopeks = None + switch_cost_label = None + is_upgrade = None + is_switch_free = None + + if current_tariff and current_tariff.id != tariff.id and remaining_days > 0: + cost, upgrade = _calculate_tariff_switch_cost( + current_tariff, tariff, remaining_days, promo_group, user + ) + switch_cost_kopeks = cost + switch_cost_label = settings.format_price(cost) if cost > 0 else None + is_upgrade = upgrade + is_switch_free = cost == 0 + return MiniAppTariff( id=tariff.id, name=tariff.name, @@ -6041,12 +6354,29 @@ async def _build_tariff_model( periods=periods, is_current=current_tariff_id == tariff.id if current_tariff_id else False, is_available=tariff.is_active, + switch_cost_kopeks=switch_cost_kopeks, + switch_cost_label=switch_cost_label, + is_upgrade=is_upgrade, + is_switch_free=is_switch_free, ) -async def _build_current_tariff_model(db: AsyncSession, tariff) -> MiniAppCurrentTariff: +async def _build_current_tariff_model(db: AsyncSession, tariff, promo_group=None) -> MiniAppCurrentTariff: """Создаёт модель текущего тарифа.""" servers_count = len(tariff.allowed_squads) if tariff.allowed_squads else 0 + monthly_price = _get_tariff_monthly_price(tariff) + + # Применяем скидку промогруппы для 30-дневного периода + if promo_group: + raw_discounts = getattr(promo_group, 'period_discounts', None) or {} + for k, v in raw_discounts.items(): + try: + if int(k) == 30: + discount = max(0, min(100, int(v))) + monthly_price = int(monthly_price * (100 - discount) / 100) + break + except (TypeError, ValueError): + pass return MiniAppCurrentTariff( id=tariff.id, @@ -6058,6 +6388,7 @@ async def _build_current_tariff_model(db: AsyncSession, tariff) -> MiniAppCurren is_unlimited_traffic=tariff.traffic_limit_gb == 0, device_limit=tariff.device_limit, servers_count=servers_count, + monthly_price_kopeks=monthly_price, ) @@ -6079,8 +6410,8 @@ async def get_tariffs_endpoint( }, ) - # Получаем промогруппу пользователя - promo_group = getattr(user, "promo_group", None) + # Получаем промогруппу пользователя (с приоритетом) + promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else getattr(user, "promo_group", None) promo_group_id = promo_group.id if promo_group else None # Получаем тарифы, доступные пользователю @@ -6090,18 +6421,39 @@ async def get_tariffs_endpoint( subscription = getattr(user, "subscription", None) current_tariff_id = subscription.tariff_id if subscription else None current_tariff_model: Optional[MiniAppCurrentTariff] = None + current_tariff = None + + # Вычисляем оставшиеся дни подписки + remaining_days = 0 + if subscription and subscription.end_date: + delta = subscription.end_date - datetime.utcnow() + remaining_days = max(0, delta.days) 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) + current_tariff_model = await _build_current_tariff_model(db, current_tariff, promo_group) # Формируем список тарифов tariff_models: List[MiniAppTariff] = [] for tariff in tariffs: - model = await _build_tariff_model(db, tariff, current_tariff_id) + model = await _build_tariff_model( + db, tariff, current_tariff_id, promo_group, + current_tariff=current_tariff, + remaining_days=remaining_days, + user=user, + ) tariff_models.append(model) + # Формируем модель промогруппы для ответа + promo_group_model = None + if promo_group: + promo_group_model = MiniAppPromoGroup( + id=promo_group.id, + name=promo_group.name, + **_extract_promo_discounts(promo_group), + ) + return MiniAppTariffsResponse( success=True, sales_mode="tariffs", @@ -6109,6 +6461,7 @@ async def get_tariffs_endpoint( current_tariff=current_tariff_model, balance_kopeks=user.balance_kopeks, balance_label=settings.format_price(user.balance_kopeks), + promo_group=promo_group_model, ) @@ -6140,7 +6493,7 @@ async def purchase_tariff_endpoint( ) # Проверяем доступность тарифа для пользователя - promo_group = getattr(user, "promo_group", None) + promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else 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( @@ -6152,8 +6505,8 @@ async def purchase_tariff_endpoint( ) # Получаем цену за выбранный период - price_kopeks = tariff.get_price_for_period(payload.period_days) - if price_kopeks is None: + base_price_kopeks = tariff.get_price_for_period(payload.period_days) + if base_price_kopeks is None: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail={ @@ -6162,6 +6515,21 @@ async def purchase_tariff_endpoint( }, ) + # Применяем скидку промогруппы + price_kopeks = base_price_kopeks + discount_percent = 0 + if promo_group: + raw_discounts = getattr(promo_group, 'period_discounts', None) or {} + for k, v in raw_discounts.items(): + try: + if int(k) == payload.period_days: + discount_percent = max(0, min(100, int(v))) + break + except (TypeError, ValueError): + pass + if discount_percent > 0: + price_kopeks = int(base_price_kopeks * (100 - discount_percent) / 100) + # Проверяем баланс if user.balance_kopeks < price_kopeks: missing = price_kopeks - user.balance_kopeks @@ -6177,7 +6545,10 @@ async def purchase_tariff_endpoint( subscription = getattr(user, "subscription", None) # Списываем баланс - description = f"Покупка тарифа '{tariff.name}' на {payload.period_days} дней" + if discount_percent > 0: + description = f"Покупка тарифа '{tariff.name}' на {payload.period_days} дней (скидка {discount_percent}%)" + else: + description = f"Покупка тарифа '{tariff.name}' на {payload.period_days} дней" success = await subtract_user_balance(db, user, price_kopeks, description) if not success: raise HTTPException( @@ -6214,7 +6585,7 @@ async def purchase_tariff_endpoint( subscription = await create_paid_subscription( db=db, user_id=user.id, - days=payload.period_days, + duration_days=payload.period_days, traffic_limit_gb=tariff.traffic_limit_gb, device_limit=tariff.device_limit, connected_squads=tariff.allowed_squads or [], @@ -6253,3 +6624,472 @@ async def purchase_tariff_endpoint( balance_kopeks=user.balance_kopeks, balance_label=settings.format_price(user.balance_kopeks), ) + + +def _get_user_period_discount(user, period_days: int) -> int: + """Получает скидку пользователя на период (унифицировано с ботом).""" + promo_group = getattr(user, 'promo_group', None) if user else None + + if promo_group: + discount = promo_group.get_discount_percent("period", period_days) + if discount > 0: + return discount + + personal_discount = get_user_active_promo_discount_percent(user) if user else 0 + return personal_discount + + +def _apply_promo_discount(price: int, discount_percent: int) -> int: + """Применяет скидку к цене.""" + if discount_percent <= 0: + return price + discount = int(price * discount_percent / 100) + return max(0, price - discount) + + +def _calculate_tariff_switch_cost( + current_tariff, + new_tariff, + remaining_days: int, + promo_group=None, + user=None, +) -> tuple[int, bool]: + """ + Рассчитывает стоимость переключения тарифа. + Логика унифицирована с ботом (tariff_purchase.py). + + Формула: (new_monthly - current_monthly) * remaining_days / 30 + Скидка применяется к обоим тарифам одинаково. + + Returns: + (cost_kopeks, is_upgrade) - стоимость доплаты и флаг апгрейда + """ + current_monthly = _get_tariff_monthly_price(current_tariff) + new_monthly = _get_tariff_monthly_price(new_tariff) + + discount_percent = _get_user_period_discount(user, 30) if user else 0 + + # Fallback на promo_group.period_discounts если user не передан + if discount_percent == 0 and promo_group: + raw_discounts = getattr(promo_group, 'period_discounts', None) or {} + for k, v in raw_discounts.items(): + try: + if int(k) == 30: + discount_percent = max(0, min(100, int(v))) + break + except (TypeError, ValueError): + pass + + if discount_percent > 0: + current_monthly = _apply_promo_discount(current_monthly, discount_percent) + new_monthly = _apply_promo_discount(new_monthly, discount_percent) + + price_diff = new_monthly - current_monthly + + if price_diff <= 0: + return 0, False + + upgrade_cost = int(price_diff * remaining_days / 30) + return upgrade_cost, True + + +@router.post("/subscription/tariff/switch/preview") +async def preview_tariff_switch_endpoint( + payload: MiniAppTariffSwitchRequest, + db: AsyncSession = Depends(get_db_session), +): + """Предпросмотр переключения тарифа - показывает стоимость.""" + + 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"}, + ) + + subscription = getattr(user, "subscription", None) + if not subscription or not subscription.tariff_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"code": "no_subscription", "message": "No active subscription with tariff"}, + ) + + if subscription.status not in ("active", "trial"): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"code": "subscription_inactive", "message": "Subscription is not active"}, + ) + + current_tariff = await get_tariff_by_id(db, subscription.tariff_id) + new_tariff = await get_tariff_by_id(db, payload.tariff_id) + + if not new_tariff or not new_tariff.is_active: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={"code": "tariff_not_found", "message": "Tariff not found or inactive"}, + ) + + if subscription.tariff_id == payload.tariff_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"code": "same_tariff", "message": "Already on this tariff"}, + ) + + # Проверяем доступность тарифа для пользователя + promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else getattr(user, "promo_group", None) + promo_group_id = promo_group.id if promo_group else None + if not new_tariff.is_available_for_promo_group(promo_group_id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={"code": "tariff_not_available", "message": "Tariff not available for your promo group"}, + ) + + # Рассчитываем оставшиеся дни + remaining_days = 0 + if subscription.end_date and subscription.end_date > datetime.utcnow(): + delta = subscription.end_date - datetime.utcnow() + remaining_days = max(0, delta.days) + + # Рассчитываем стоимость переключения + upgrade_cost, is_upgrade = _calculate_tariff_switch_cost( + current_tariff, new_tariff, remaining_days, promo_group, user + ) + + balance = user.balance_kopeks or 0 + has_enough = balance >= upgrade_cost + missing = max(0, upgrade_cost - balance) if not has_enough else 0 + + return MiniAppTariffSwitchPreviewResponse( + can_switch=has_enough, + current_tariff_id=current_tariff.id if current_tariff else None, + current_tariff_name=current_tariff.name if current_tariff else None, + new_tariff_id=new_tariff.id, + new_tariff_name=new_tariff.name, + remaining_days=remaining_days, + upgrade_cost_kopeks=upgrade_cost, + upgrade_cost_label=settings.format_price(upgrade_cost) if upgrade_cost > 0 else "Бесплатно", + balance_kopeks=balance, + balance_label=settings.format_price(balance), + has_enough_balance=has_enough, + missing_amount_kopeks=missing, + missing_amount_label=settings.format_price(missing) if missing > 0 else "", + is_upgrade=is_upgrade, + message=None, + ) + + +@router.post("/subscription/tariff/switch") +async def switch_tariff_endpoint( + payload: MiniAppTariffSwitchRequest, + db: AsyncSession = Depends(get_db_session), +): + """Переключение тарифа без изменения даты окончания.""" + 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"}, + ) + + subscription = getattr(user, "subscription", None) + if not subscription or not subscription.tariff_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"code": "no_subscription", "message": "No active subscription with tariff"}, + ) + + if subscription.status not in ("active", "trial"): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"code": "subscription_inactive", "message": "Subscription is not active"}, + ) + + current_tariff = await get_tariff_by_id(db, subscription.tariff_id) + new_tariff = await get_tariff_by_id(db, payload.tariff_id) + + if not new_tariff or not new_tariff.is_active: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={"code": "tariff_not_found", "message": "Tariff not found or inactive"}, + ) + + if subscription.tariff_id == payload.tariff_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"code": "same_tariff", "message": "Already on this tariff"}, + ) + + # Проверяем доступность тарифа + promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else getattr(user, "promo_group", None) + promo_group_id = promo_group.id if promo_group else None + if not new_tariff.is_available_for_promo_group(promo_group_id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={"code": "tariff_not_available", "message": "Tariff not available"}, + ) + + # Рассчитываем оставшиеся дни + remaining_days = 0 + if subscription.end_date and subscription.end_date > datetime.utcnow(): + delta = subscription.end_date - datetime.utcnow() + remaining_days = max(0, delta.days) + + # Рассчитываем стоимость + upgrade_cost, is_upgrade = _calculate_tariff_switch_cost( + current_tariff, new_tariff, remaining_days, promo_group, user + ) + + # Списываем доплату если апгрейд + if upgrade_cost > 0: + if user.balance_kopeks < upgrade_cost: + missing = upgrade_cost - 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, + }, + ) + + description = f"Переход на тариф '{new_tariff.name}' (доплата за {remaining_days} дней)" + success = await subtract_user_balance(db, user, upgrade_cost, description) + if not success: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={"code": "balance_error", "message": "Failed to charge balance"}, + ) + + # Записываем транзакцию + await create_transaction( + db=db, + user_id=user.id, + type=TransactionType.SUBSCRIPTION_PAYMENT, + amount_kopeks=upgrade_cost, + description=description, + ) + + # Обновляем подписку - меняем тариф без изменения даты + subscription.tariff_id = new_tariff.id + subscription.traffic_limit_gb = new_tariff.traffic_limit_gb + subscription.device_limit = new_tariff.device_limit + subscription.connected_squads = new_tariff.allowed_squads or [] + # Сбрасываем докупленный трафик при смене тарифа + subscription.purchased_traffic_gb = 0 + + await db.commit() + await db.refresh(subscription) + await db.refresh(user) + + # Синхронизируем с RemnaWave + try: + service = SubscriptionService() + await service.update_remnawave_user(db, subscription) + except Exception as e: + logger.error(f"Ошибка синхронизации с RemnaWave при смене тарифа: {e}") + + lang = getattr(user, "language", settings.DEFAULT_LANGUAGE) + if upgrade_cost > 0: + if lang == "ru": + message = f"Тариф изменён на '{new_tariff.name}'. Списано {settings.format_price(upgrade_cost)}" + else: + message = f"Switched to '{new_tariff.name}'. Charged {settings.format_price(upgrade_cost)}" + else: + if lang == "ru": + message = f"Тариф изменён на '{new_tariff.name}'" + else: + message = f"Switched to '{new_tariff.name}'" + + return MiniAppTariffSwitchResponse( + success=True, + message=message, + tariff_id=new_tariff.id, + tariff_name=new_tariff.name, + charged_kopeks=upgrade_cost, + balance_kopeks=user.balance_kopeks, + balance_label=settings.format_price(user.balance_kopeks), + ) + + +@router.post("/subscription/traffic-topup") +async def purchase_traffic_topup_endpoint( + payload: MiniAppTrafficTopupRequest, + db: AsyncSession = Depends(get_db_session), +): + """Докупка трафика для подписки.""" + from app.webapi.schemas.miniapp import MiniAppTrafficTopupRequest, MiniAppTrafficTopupResponse + from app.database.crud.subscription import add_subscription_traffic + from app.database.crud.user import subtract_user_balance + from app.database.crud.transaction import create_transaction + from app.database.models import TransactionType + from app.utils.pricing_utils import calculate_prorated_price + + user = await _authorize_miniapp_user(payload.init_data, db) + subscription = _ensure_paid_subscription(user) + _validate_subscription_id(payload.subscription_id, subscription) + + # Проверяем режим тарифов + if not settings.is_tariffs_mode(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "code": "tariffs_mode_disabled", + "message": "Traffic top-up is only available in tariffs mode", + }, + ) + + # Проверяем наличие тарифа + tariff_id = getattr(subscription, 'tariff_id', None) + if not tariff_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "code": "no_tariff", + "message": "Subscription has no tariff", + }, + ) + + tariff = await get_tariff_by_id(db, tariff_id) + if not tariff: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={ + "code": "tariff_not_found", + "message": "Tariff not found", + }, + ) + + # Проверяем, разрешена ли докупка трафика + if not getattr(tariff, 'traffic_topup_enabled', False): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={ + "code": "traffic_topup_disabled", + "message": "Traffic top-up is disabled for this tariff", + }, + ) + + # Проверяем безлимит + if tariff.traffic_limit_gb == 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "code": "unlimited_traffic", + "message": "Cannot add traffic to unlimited subscription", + }, + ) + + # Проверяем лимит докупки трафика + max_topup_limit = getattr(tariff, 'max_topup_traffic_gb', 0) or 0 + if max_topup_limit > 0: + current_traffic = subscription.traffic_limit_gb or 0 + new_traffic = current_traffic + payload.gb + if new_traffic > max_topup_limit: + available_gb = max(0, max_topup_limit - current_traffic) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "code": "topup_limit_exceeded", + "message": f"Traffic top-up limit exceeded. Maximum allowed: {max_topup_limit} GB, current: {current_traffic} GB, available: {available_gb} GB", + "max_limit_gb": max_topup_limit, + "current_gb": current_traffic, + "available_gb": available_gb, + }, + ) + + # Получаем цену пакета + packages = tariff.get_traffic_topup_packages() if hasattr(tariff, 'get_traffic_topup_packages') else {} + if payload.gb not in packages: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "code": "invalid_package", + "message": f"Traffic package {payload.gb}GB is not available", + }, + ) + + base_price_kopeks = packages[payload.gb] + + # Применяем скидку промогруппы на трафик + traffic_discount_percent = 0 + promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else getattr(user, "promo_group", None) + if promo_group: + apply_to_addons = getattr(promo_group, 'apply_discounts_to_addons', True) + if apply_to_addons: + traffic_discount_percent = max(0, min(100, int(getattr(promo_group, 'traffic_discount_percent', 0) or 0))) + + if traffic_discount_percent > 0: + base_price_kopeks = int(base_price_kopeks * (100 - traffic_discount_percent) / 100) + + # Пропорциональный расчет цены с учетом оставшегося времени подписки + final_price, months_charged = calculate_prorated_price( + base_price_kopeks, + subscription.end_date, + ) + + # Проверяем баланс + if user.balance_kopeks < final_price: + raise HTTPException( + status_code=status.HTTP_402_PAYMENT_REQUIRED, + detail={ + "code": "insufficient_balance", + "message": "Insufficient balance", + "required": final_price, + "balance": user.balance_kopeks, + }, + ) + + # Списываем баланс + if traffic_discount_percent > 0: + traffic_description = f"Докупка {payload.gb} ГБ трафика (скидка {traffic_discount_percent}%)" + else: + traffic_description = f"Докупка {payload.gb} ГБ трафика" + success = await subtract_user_balance( + db, user, final_price, + traffic_description + ) + if not success: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={ + "code": "balance_error", + "message": "Failed to subtract balance", + }, + ) + + # Добавляем трафик + await add_subscription_traffic(db, subscription, payload.gb) + + # Обновляем purchased_traffic_gb + current_purchased = getattr(subscription, 'purchased_traffic_gb', 0) or 0 + subscription.purchased_traffic_gb = current_purchased + payload.gb + await db.commit() + + # Синхронизируем с RemnaWave + try: + service = SubscriptionService() + await service.update_remnawave_user(db, subscription) + except Exception as e: + logger.error(f"Ошибка синхронизации с RemnaWave при докупке трафика: {e}") + + # Создаем транзакцию + await create_transaction( + db, + user_id=user.id, + type=TransactionType.SUBSCRIPTION_PAYMENT, + amount_kopeks=-final_price, + description=traffic_description, + ) + + await db.refresh(user) + await db.refresh(subscription) + + return MiniAppTrafficTopupResponse( + success=True, + message=f"Добавлено {payload.gb} ГБ трафика", + new_traffic_limit_gb=subscription.traffic_limit_gb, + new_balance_kopeks=user.balance_kopeks, + charged_kopeks=final_price, + ) diff --git a/app/webapi/schemas/miniapp.py b/app/webapi/schemas/miniapp.py index e33ad8b1..70e7fdf8 100644 --- a/app/webapi/schemas/miniapp.py +++ b/app/webapi/schemas/miniapp.py @@ -55,7 +55,7 @@ class MiniAppPromoGroup(BaseModel): server_discount_percent: int = 0 traffic_discount_percent: int = 0 device_discount_percent: int = 0 - period_discounts: Dict[int, int] = Field(default_factory=dict) + period_discounts: Dict[str, int] = Field(default_factory=dict) apply_discounts_to_addons: bool = True @@ -70,7 +70,7 @@ class MiniAppAutoPromoGroupLevel(BaseModel): server_discount_percent: int = 0 traffic_discount_percent: int = 0 device_discount_percent: int = 0 - period_discounts: Dict[int, int] = Field(default_factory=dict) + period_discounts: Dict[str, int] = Field(default_factory=dict) apply_discounts_to_addons: bool = True @@ -503,6 +503,10 @@ class MiniAppTariffPeriod(BaseModel): price_label: str price_per_month_kopeks: Optional[int] = None price_per_month_label: Optional[str] = None + # Скидка промогруппы + original_price_kopeks: Optional[int] = None # Цена без скидки + original_price_label: Optional[str] = None + discount_percent: int = 0 # Процент скидки class MiniAppTariff(BaseModel): @@ -520,6 +524,22 @@ class MiniAppTariff(BaseModel): periods: List[MiniAppTariffPeriod] = Field(default_factory=list) is_current: bool = False is_available: bool = True + # Для режима мгновенного переключения тарифа + switch_cost_kopeks: Optional[int] = None # Стоимость переключения (None если не в режиме switch) + switch_cost_label: Optional[str] = None # Форматированная стоимость + is_upgrade: Optional[bool] = None # True = повышение, False = понижение + is_switch_free: Optional[bool] = None # True = бесплатное переключение + + +class MiniAppTrafficTopupPackage(BaseModel): + """Пакет докупки трафика.""" + gb: int + price_kopeks: int + price_label: str + # Скидка промогруппы на трафик + original_price_kopeks: Optional[int] = None + original_price_label: Optional[str] = None + discount_percent: int = 0 class MiniAppCurrentTariff(BaseModel): @@ -533,6 +553,30 @@ class MiniAppCurrentTariff(BaseModel): is_unlimited_traffic: bool = False device_limit: int servers_count: int + # Месячная цена для расчёта стоимости переключения тарифа + monthly_price_kopeks: int = 0 + # Докупка трафика + traffic_topup_enabled: bool = False + traffic_topup_packages: List[MiniAppTrafficTopupPackage] = Field(default_factory=list) + # Лимит докупки трафика (0 = без лимита) + max_topup_traffic_gb: int = 0 + available_topup_gb: Optional[int] = None # Сколько еще можно докупить (None = без лимита) + + +class MiniAppTrafficTopupRequest(BaseModel): + """Запрос на докупку трафика.""" + init_data: str = Field(..., alias="initData") + subscription_id: Optional[int] = Field(None, alias="subscriptionId") + gb: int + + +class MiniAppTrafficTopupResponse(BaseModel): + """Ответ на докупку трафика.""" + success: bool = True + message: str = "" + new_traffic_limit_gb: int = 0 + new_balance_kopeks: int = 0 + charged_kopeks: int = 0 class MiniAppTariffsRequest(BaseModel): @@ -548,6 +592,7 @@ class MiniAppTariffsResponse(BaseModel): current_tariff: Optional[MiniAppCurrentTariff] = None balance_kopeks: int = 0 balance_label: Optional[str] = None + promo_group: Optional[MiniAppPromoGroup] = None # Промогруппа пользователя для отображения скидок class MiniAppTariffPurchaseRequest(BaseModel): @@ -569,6 +614,42 @@ class MiniAppTariffPurchaseResponse(BaseModel): balance_label: Optional[str] = None +class MiniAppTariffSwitchRequest(BaseModel): + """Запрос на переключение тарифа (без выбора периода).""" + init_data: str = Field(...) + tariff_id: int = Field(...) + + +class MiniAppTariffSwitchPreviewResponse(BaseModel): + """Предпросмотр переключения тарифа.""" + can_switch: bool = True + current_tariff_id: Optional[int] = None + current_tariff_name: Optional[str] = None + new_tariff_id: int + new_tariff_name: str + remaining_days: int = 0 + upgrade_cost_kopeks: int = 0 # 0 если даунгрейд или равная цена + upgrade_cost_label: str = "" + balance_kopeks: int = 0 + balance_label: str = "" + has_enough_balance: bool = True + missing_amount_kopeks: int = 0 + missing_amount_label: str = "" + is_upgrade: bool = False # True если новый тариф дороже + message: Optional[str] = None + + +class MiniAppTariffSwitchResponse(BaseModel): + """Ответ на переключение тарифа.""" + success: bool = True + message: Optional[str] = None + tariff_id: int + tariff_name: str + charged_kopeks: int = 0 + balance_kopeks: int = 0 + balance_label: str = "" + + class MiniAppSubscriptionResponse(BaseModel): success: bool = True subscription_id: Optional[int] = None diff --git a/app/webserver/payments.py b/app/webserver/payments.py index 113b1ac5..fd10c53f 100644 --- a/app/webserver/payments.py +++ b/app/webserver/payments.py @@ -798,6 +798,100 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute routes_registered = True + if settings.is_freekassa_enabled(): + @router.options(settings.FREEKASSA_WEBHOOK_PATH) + async def freekassa_options() -> Response: + return Response( + status_code=status.HTTP_200_OK, + headers={ + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "POST, GET, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + }, + ) + + @router.get(settings.FREEKASSA_WEBHOOK_PATH) + async def freekassa_health() -> JSONResponse: + return JSONResponse( + { + "status": "ok", + "service": "freekassa_webhook", + "enabled": settings.is_freekassa_enabled(), + } + ) + + @router.post(settings.FREEKASSA_WEBHOOK_PATH) + async def freekassa_webhook(request: Request) -> Response: + # Получаем IP клиента с учетом прокси + x_forwarded_for = request.headers.get("X-Forwarded-For") + if x_forwarded_for: + client_ip = x_forwarded_for.split(",")[0].strip() + else: + real_ip = request.headers.get("X-Real-IP") + if real_ip: + client_ip = real_ip.strip() + else: + client_ip = request.client.host if request.client else "127.0.0.1" + + # Получаем данные формы + try: + form_data = await request.form() + except Exception: + logger.error("Freekassa webhook: не удалось прочитать данные формы") + return Response("Error reading form data", status_code=status.HTTP_400_BAD_REQUEST) + + # Извлекаем параметры + merchant_id = form_data.get("MERCHANT_ID") + amount = form_data.get("AMOUNT") + order_id = form_data.get("MERCHANT_ORDER_ID") + sign = form_data.get("SIGN") + intid = form_data.get("intid") + cur_id = form_data.get("CUR_ID") + + if not all([merchant_id, amount, order_id, sign, intid]): + logger.warning("Freekassa webhook: отсутствуют обязательные параметры") + return Response("Missing parameters", status_code=status.HTTP_400_BAD_REQUEST) + + # Преобразуем типы + try: + merchant_id_int = int(merchant_id) + amount_float = float(amount) + cur_id_int = int(cur_id) if cur_id else None + except ValueError: + logger.warning("Freekassa webhook: неверный формат параметров") + return Response("Invalid parameters format", status_code=status.HTTP_400_BAD_REQUEST) + + # Обрабатываем callback + db_generator = get_db() + try: + db = await db_generator.__anext__() + except StopAsyncIteration: + return Response("DB Error", status_code=status.HTTP_500_INTERNAL_SERVER_ERROR) + + try: + success = await payment_service.process_freekassa_webhook( + db, + merchant_id=merchant_id_int, + amount=amount_float, + order_id=order_id, + sign=sign, + intid=intid, + cur_id=cur_id_int, + client_ip=client_ip, + ) + finally: + try: + await db_generator.__anext__() + except StopAsyncIteration: + pass + + if success: + return Response("YES", status_code=status.HTTP_200_OK) + + return Response("Error", status_code=status.HTTP_400_BAD_REQUEST) + + routes_registered = True + if routes_registered: @router.get("/health/payment-webhooks") async def payment_webhooks_health() -> JSONResponse: @@ -813,6 +907,7 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute "pal24_enabled": settings.is_pal24_enabled(), "platega_enabled": settings.is_platega_enabled(), "cloudpayments_enabled": settings.is_cloudpayments_enabled(), + "freekassa_enabled": settings.is_freekassa_enabled(), } ) diff --git a/app/webserver/unified_app.py b/app/webserver/unified_app.py index 50375e50..d93a52b7 100644 --- a/app/webserver/unified_app.py +++ b/app/webserver/unified_app.py @@ -120,6 +120,7 @@ def create_unified_app( "pal24": settings.is_pal24_enabled(), "wata": settings.is_wata_enabled(), "heleket": settings.is_heleket_enabled(), + "freekassa": settings.is_freekassa_enabled(), } if enable_telegram_webhook: diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 26893b7c..61bb6c5f 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -7,14 +7,18 @@ services: POSTGRES_DB: ${POSTGRES_DB:-remnawave_bot} POSTGRES_USER: ${POSTGRES_USER:-remnawave_user} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-secure_password_123} - POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C" + POSTGRES_INITDB_ARGS: '--encoding=UTF8 --locale=C' volumes: - postgres_data:/var/lib/postgresql/data networks: - bot_network - - remnawave-network # Подключаем к сети панели + - remnawave-network # Подключаем к сети панели healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-remnawave_user} -d ${POSTGRES_DB:-remnawave_bot}"] + test: + [ + 'CMD-SHELL', + 'pg_isready -U ${POSTGRES_USER:-remnawave_user} -d ${POSTGRES_DB:-remnawave_bot}', + ] interval: 30s timeout: 5s retries: 5 @@ -29,9 +33,9 @@ services: - redis_data:/data networks: - bot_network - - remnawave-network # Подключаем к сети панели + - remnawave-network # Подключаем к сети панели healthcheck: - test: ["CMD", "redis-cli", "ping"] + test: ['CMD', 'redis-cli', 'ping'] interval: 30s timeout: 10s retries: 3 @@ -48,16 +52,16 @@ services: env_file: - .env environment: - DOCKER_ENV: "true" - DATABASE_MODE: "auto" - POSTGRES_HOST: "postgres" - POSTGRES_PORT: "5432" - POSTGRES_DB: "${POSTGRES_DB:-remnawave_bot}" - POSTGRES_USER: "${POSTGRES_USER:-remnawave_user}" - POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:-secure_password_123}" - REDIS_URL: "redis://redis:6379/0" - TZ: "Europe/Moscow" - LOCALES_PATH: "${LOCALES_PATH:-/app/locales}" + DOCKER_ENV: 'true' + DATABASE_MODE: 'auto' + POSTGRES_HOST: 'postgres' + POSTGRES_PORT: '5432' + POSTGRES_DB: '${POSTGRES_DB:-remnawave_bot}' + POSTGRES_USER: '${POSTGRES_USER:-remnawave_user}' + POSTGRES_PASSWORD: '${POSTGRES_PASSWORD:-secure_password_123}' + REDIS_URL: 'redis://redis:6379/0' + TZ: 'Europe/Moscow' + LOCALES_PATH: '${LOCALES_PATH:-/app/locales}' volumes: - ./logs:/app/logs:rw - ./data:/app/data:rw @@ -66,12 +70,16 @@ services: - /etc/localtime:/etc/localtime:ro - ./vpn_logo.png:/app/vpn_logo.png:ro ports: - - "${WEB_API_PORT:-8080}:8080" + - '${WEB_API_PORT:-8080}:8080' networks: - bot_network - - remnawave-network # Подключаем к сети панели + - remnawave-network # Подключаем к сети панели healthcheck: - test: ["CMD-SHELL", "python -c \"import requests, os; requests.get('http://localhost:8080/health', headers={'X-API-Key': os.environ.get('WEB_API_DEFAULT_TOKEN')}, timeout=5) or exit(1)\""] + test: + [ + 'CMD-SHELL', + 'python -c "import requests, os; requests.get(''http://localhost:8080/health'', headers={''X-API-Key'': os.environ.get(''WEB_API_DEFAULT_TOKEN'')}, timeout=5) or exit(1)"', + ] interval: 60s timeout: 10s retries: 3 @@ -90,7 +98,9 @@ networks: config: - subnet: 172.20.0.0/16 gateway: 172.20.0.1 - + driver_opts: + com.docker.network.driver.mtu: 1350 + remnawave-network: name: remnawave-network - external: true # Используем существующую сеть панели + external: true # Используем существующую сеть панели diff --git a/docker-compose.yml b/docker-compose.yml index c62611f2..1d015da6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,13 +7,17 @@ services: POSTGRES_DB: ${POSTGRES_DB:-remnawave_bot} POSTGRES_USER: ${POSTGRES_USER:-remnawave_user} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-secure_password_123} - POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C" + POSTGRES_INITDB_ARGS: '--encoding=UTF8 --locale=C' volumes: - postgres_data:/var/lib/postgresql/data networks: - bot_network healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-remnawave_user} -d ${POSTGRES_DB:-remnawave_bot}"] + test: + [ + 'CMD-SHELL', + 'pg_isready -U ${POSTGRES_USER:-remnawave_user} -d ${POSTGRES_DB:-remnawave_bot}', + ] interval: 30s timeout: 5s retries: 5 @@ -29,7 +33,7 @@ services: networks: - bot_network healthcheck: - test: ["CMD", "redis-cli", "ping"] + test: ['CMD', 'redis-cli', 'ping'] interval: 30s timeout: 10s retries: 3 @@ -46,18 +50,18 @@ services: env_file: - .env environment: - DOCKER_ENV: "true" - DATABASE_MODE: "auto" - POSTGRES_HOST: "postgres" - POSTGRES_PORT: "5432" - POSTGRES_DB: "${POSTGRES_DB:-remnawave_bot}" - POSTGRES_USER: "${POSTGRES_USER:-remnawave_user}" - POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:-secure_password_123}" - - REDIS_URL: "redis://redis:6379/0" - - TZ: "Europe/Moscow" - LOCALES_PATH: "${LOCALES_PATH:-/app/locales}" + DOCKER_ENV: 'true' + DATABASE_MODE: 'auto' + POSTGRES_HOST: 'postgres' + POSTGRES_PORT: '5432' + POSTGRES_DB: '${POSTGRES_DB:-remnawave_bot}' + POSTGRES_USER: '${POSTGRES_USER:-remnawave_user}' + POSTGRES_PASSWORD: '${POSTGRES_PASSWORD:-secure_password_123}' + + REDIS_URL: 'redis://redis:6379/0' + + TZ: 'Europe/Moscow' + LOCALES_PATH: '${LOCALES_PATH:-/app/locales}' volumes: # Логи - ./logs:/app/logs:rw @@ -72,11 +76,15 @@ services: # Логотип для сообщений - ./vpn_logo.png:/app/vpn_logo.png:ro ports: - - "${WEB_API_PORT:-8080}:8080" + - '${WEB_API_PORT:-8080}:8080' networks: - bot_network healthcheck: - test: ["CMD-SHELL", "python -c \"import requests, os; requests.get('http://localhost:8080/health', headers={'X-API-Key': os.environ.get('WEB_API_DEFAULT_TOKEN')}, timeout=5) or exit(1)\""] + test: + [ + 'CMD-SHELL', + 'python -c "import requests, os; requests.get(''http://localhost:8080/health'', headers={''X-API-Key'': os.environ.get(''WEB_API_DEFAULT_TOKEN'')}, timeout=5) or exit(1)"', + ] interval: 60s timeout: 10s retries: 3 @@ -95,3 +103,5 @@ networks: config: - subnet: 172.20.0.0/16 gateway: 172.20.0.1 + driver_opts: + com.docker.network.driver.mtu: 1350 diff --git a/main.py b/main.py index 0d622271..07e992bd 100644 --- a/main.py +++ b/main.py @@ -648,6 +648,8 @@ async def main(): webhook_lines.append(f"WATA: {_fmt(settings.WATA_WEBHOOK_PATH)}") if settings.is_heleket_enabled(): webhook_lines.append(f"Heleket: {_fmt(settings.HELEKET_WEBHOOK_PATH)}") + if settings.is_freekassa_enabled(): + webhook_lines.append(f"Freekassa: {_fmt(settings.FREEKASSA_WEBHOOK_PATH)}") timeline.log_section( "Активные webhook endpoints", diff --git a/miniapp/index.html b/miniapp/index.html index acab2673..60b3525f 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -725,6 +725,80 @@ font-size: 14px; } + /* Бейдж скидки промогруппы на карточке тарифа */ + .tariff-discount-badge { + display: inline-block; + padding: 3px 8px; + background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%); + color: #fff; + font-size: 11px; + font-weight: 700; + border-radius: 8px; + margin-top: 4px; + box-shadow: 0 2px 4px rgba(34, 197, 94, 0.3); + animation: discount-pulse 2s ease-in-out infinite; + } + + @keyframes discount-pulse { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.05); } + } + + :root[data-theme="dark"] .tariff-discount-badge { + background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%); + box-shadow: 0 2px 8px rgba(34, 197, 94, 0.4); + } + + /* Баннер скидки промогруппы */ + .promo-group-discount-banner { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 16px; + margin-bottom: 16px; + background: linear-gradient(135deg, rgba(34, 197, 94, 0.12) 0%, rgba(16, 163, 74, 0.08) 100%); + border: 1px solid rgba(34, 197, 94, 0.25); + border-radius: 14px; + animation: banner-glow 3s ease-in-out infinite; + } + + @keyframes banner-glow { + 0%, 100% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0); } + 50% { box-shadow: 0 0 15px 2px rgba(34, 197, 94, 0.15); } + } + + .promo-group-discount-icon { + font-size: 28px; + filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.1)); + } + + .promo-group-discount-content { + flex: 1; + min-width: 0; + } + + .promo-group-discount-title { + font-weight: 700; + font-size: 14px; + color: #22c55e; + line-height: 1.3; + } + + .promo-group-discount-subtitle { + font-size: 12px; + color: var(--text-secondary); + margin-top: 2px; + } + + :root[data-theme="dark"] .promo-group-discount-banner { + background: linear-gradient(135deg, rgba(34, 197, 94, 0.18) 0%, rgba(16, 163, 74, 0.1) 100%); + border-color: rgba(34, 197, 94, 0.35); + } + + :root[data-theme="dark"] .promo-group-discount-title { + color: #4ade80; + } + :root[data-theme="dark"] .tariff-name-badge { background: linear-gradient(135deg, rgba(var(--primary-rgb), 0.25), rgba(var(--primary-rgb), 0.1)); } @@ -742,6 +816,313 @@ background: linear-gradient(135deg, rgba(34, 197, 94, 0.2), rgba(34, 197, 94, 0.08)); } + /* ============================================ + Instant Tariff Switch Styles + ============================================ */ + .instant-switch-current-info { + display: flex; + gap: 16px; + padding: 16px; + background: var(--bg-secondary); + border-radius: 12px; + margin-bottom: 12px; + } + + .instant-switch-current-tariff, + .instant-switch-remaining { + flex: 1; + } + + .instant-switch-label { + font-size: 11px; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 4px; + } + + .instant-switch-value { + font-size: 15px; + font-weight: 600; + color: var(--text-primary); + } + + .instant-switch-hint { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 12px 14px; + background: linear-gradient(135deg, rgba(59, 130, 246, 0.08) 0%, rgba(59, 130, 246, 0.04) 100%); + border: 1px solid rgba(59, 130, 246, 0.15); + border-radius: 10px; + margin-bottom: 4px; + } + + .instant-switch-hint-icon { + font-size: 18px; + flex-shrink: 0; + } + + .instant-switch-hint-text { + font-size: 12px; + color: var(--text-secondary); + line-height: 1.5; + } + + /* Tariff item in instant switch */ + .instant-switch-tariff-item { + display: flex; + align-items: flex-start; + justify-content: space-between; + padding: 16px; + margin-bottom: 12px; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 14px; + cursor: pointer; + transition: all 0.2s ease; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); + } + + .instant-switch-tariff-item:last-child { + margin-bottom: 0; + } + + .instant-switch-tariff-item:hover { + border-color: var(--primary); + background: rgba(var(--primary-rgb), 0.03); + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(var(--primary-rgb), 0.12); + } + + .instant-switch-tariff-item:active { + transform: translateY(0); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); + } + + .instant-switch-tariff-item.current { + opacity: 0.5; + cursor: default; + pointer-events: none; + } + + .instant-switch-tariff-info { + flex: 1; + min-width: 0; + } + + .instant-switch-tariff-name { + font-weight: 600; + font-size: 15px; + color: var(--text-primary); + margin-bottom: 6px; + display: flex; + align-items: center; + gap: 6px; + } + + .instant-switch-tariff-details { + display: flex; + gap: 14px; + font-size: 13px; + color: var(--text-secondary); + } + + .instant-switch-tariff-details span { + display: inline-flex; + align-items: center; + gap: 4px; + } + + .instant-switch-tariff-cost { + text-align: right; + flex-shrink: 0; + margin-left: 12px; + } + + .instant-switch-cost-badge { + display: inline-flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 8px 14px; + border-radius: 12px; + font-size: 14px; + font-weight: 600; + min-width: 80px; + text-align: center; + } + + .instant-switch-cost-badge.upgrade { + background: linear-gradient(135deg, rgba(249, 115, 22, 0.12) 0%, rgba(251, 146, 60, 0.08) 100%); + color: #ea580c; + border: 1px solid rgba(249, 115, 22, 0.2); + } + + .instant-switch-cost-badge.free { + background: linear-gradient(135deg, rgba(34, 197, 94, 0.12) 0%, rgba(74, 222, 128, 0.08) 100%); + color: #16a34a; + border: 1px solid rgba(34, 197, 94, 0.2); + } + + /* Confirmation panel */ + .instant-switch-confirm { + margin-top: 20px; + padding: 20px; + background: var(--bg-secondary); + border-radius: 16px; + border: 2px solid var(--primary); + animation: slideUp 0.3s ease; + } + + @keyframes slideUp { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + + .instant-switch-confirm-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; + } + + .instant-switch-confirm-title { + font-size: 16px; + font-weight: 700; + color: var(--text-primary); + } + + .instant-switch-confirm-close { + width: 28px; + height: 28px; + border-radius: 50%; + border: none; + background: var(--bg-tertiary, var(--bg-primary)); + color: var(--text-secondary); + font-size: 18px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + } + + .instant-switch-confirm-close:hover { + background: var(--bg-primary); + color: var(--text-primary); + } + + .instant-switch-compare { + display: flex; + align-items: center; + justify-content: center; + gap: 16px; + margin-bottom: 16px; + } + + .instant-switch-compare-item { + text-align: center; + flex: 1; + } + + .instant-switch-compare-label { + font-size: 11px; + color: var(--text-secondary); + text-transform: uppercase; + margin-bottom: 4px; + } + + .instant-switch-compare-value { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + } + + .instant-switch-compare-arrow { + font-size: 20px; + color: var(--primary); + flex-shrink: 0; + } + + .instant-switch-cost { + text-align: center; + padding: 12px; + background: var(--bg-primary); + border-radius: 10px; + margin-bottom: 12px; + } + + .instant-switch-cost-label { + font-size: 13px; + color: var(--text-secondary); + } + + .instant-switch-cost-value { + font-size: 18px; + font-weight: 700; + color: var(--primary); + margin-left: 8px; + } + + .instant-switch-cost-value.free { + color: #22c55e; + } + + .instant-switch-balance { + text-align: center; + font-size: 13px; + color: var(--text-secondary); + margin-bottom: 8px; + } + + .instant-switch-insufficient { + text-align: center; + font-size: 13px; + color: #ef4444; + padding: 10px; + background: rgba(239, 68, 68, 0.1); + border-radius: 8px; + margin-bottom: 12px; + } + + .instant-switch-confirm-actions { + display: flex; + gap: 10px; + margin-top: 16px; + } + + .instant-switch-confirm-actions .btn { + flex: 1; + } + + .instant-switch-confirm-actions .btn-secondary { + background: var(--bg-tertiary, var(--bg-primary)); + color: var(--text-primary); + border: 1px solid var(--border-color); + } + + :root[data-theme="dark"] .instant-switch-hint { + background: linear-gradient(135deg, rgba(59, 130, 246, 0.12) 0%, rgba(59, 130, 246, 0.06) 100%); + border-color: rgba(59, 130, 246, 0.2); + } + + :root[data-theme="dark"] .instant-switch-cost-badge.upgrade { + background: linear-gradient(135deg, rgba(249, 115, 22, 0.2) 0%, rgba(249, 115, 22, 0.1) 100%); + color: #fb923c; + } + + :root[data-theme="dark"] .instant-switch-cost-badge.free { + background: linear-gradient(135deg, rgba(34, 197, 94, 0.2) 0%, rgba(34, 197, 94, 0.1) 100%); + color: #4ade80; + } + .subscription-settings-toggle.active .tariff-name-badge { background: linear-gradient(135deg, rgba(var(--primary-rgb), 0.25), rgba(var(--primary-rgb), 0.12)); box-shadow: 0 2px 6px rgba(var(--primary-rgb), 0.15); @@ -2418,6 +2799,167 @@ text-align: right; } + .info-action-btn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 50%; + border: none; + background: var(--primary); + color: white; + cursor: pointer; + margin-left: 8px; + transition: all 0.2s ease; + flex-shrink: 0; + } + + .info-action-btn:hover { + transform: scale(1.1); + box-shadow: 0 2px 8px rgba(var(--primary-rgb), 0.4); + } + + .info-action-btn:active { + transform: scale(0.95); + } + + .info-action-btn svg { + width: 16px; + height: 16px; + } + + /* Traffic Topup Modal */ + .traffic-topup-packages { + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 16px; + } + + .traffic-topup-package { + display: flex; + justify-content: space-between; + align-items: center; + padding: 14px 16px; + border-radius: var(--radius); + border: 1px solid var(--border-color); + background: var(--card-bg); + cursor: pointer; + transition: all 0.2s ease; + } + + .traffic-topup-package:hover { + border-color: var(--primary); + background: rgba(var(--primary-rgb), 0.05); + } + + .traffic-topup-package.disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .traffic-topup-package.disabled:hover { + border-color: var(--border-color); + background: var(--card-bg); + } + + .traffic-topup-package-info { + display: flex; + flex-direction: column; + gap: 2px; + } + + .traffic-topup-package-gb { + font-size: 16px; + font-weight: 700; + color: var(--text-primary); + } + + .traffic-topup-package-price { + font-size: 14px; + font-weight: 600; + color: var(--primary); + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + } + + .traffic-topup-price-original { + font-size: 12px; + color: var(--text-secondary); + text-decoration: line-through; + font-weight: 400; + } + + .traffic-topup-price-current { + color: var(--primary); + } + + .traffic-topup-discount-badge { + display: inline-block; + padding: 2px 6px; + background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%); + color: #fff; + font-size: 10px; + font-weight: 700; + border-radius: 6px; + box-shadow: 0 1px 3px rgba(34, 197, 94, 0.3); + } + + .traffic-topup-package-btn { + padding: 8px 16px; + border-radius: var(--radius); + border: none; + background: var(--primary); + color: white; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + } + + .traffic-topup-package-btn:hover { + box-shadow: 0 2px 8px rgba(var(--primary-rgb), 0.4); + } + + .traffic-topup-package-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .traffic-topup-balance { + text-align: center; + font-size: 13px; + color: var(--text-secondary); + padding: 8px; + border-radius: var(--radius); + background: var(--surface); + } + + .traffic-topup-success { + text-align: center; + padding: 20px; + } + + .traffic-topup-success-icon { + font-size: 48px; + margin-bottom: 12px; + } + + .traffic-topup-success-title { + font-size: 18px; + font-weight: 700; + color: var(--text-primary); + margin-bottom: 8px; + } + + .traffic-topup-success-message { + font-size: 14px; + color: var(--text-secondary); + } + /* Promo card */ .promo-card .card-header { gap: 12px; @@ -5043,6 +5585,11 @@
Traffic Limit - +
Type @@ -5270,6 +5817,14 @@
Текущий тариф
+ +
Выберите тариф
@@ -5471,6 +6026,27 @@
+ +
Activate promo code
@@ -5922,6 +6498,18 @@ 'topup.status.failed.description': 'We could not confirm the payment automatically. Please check later or contact support.', 'topup.status.retry': 'Try again', 'topup.done': 'Done', + 'traffic_topup.title': 'Buy extra traffic', + 'traffic_topup.subtitle': 'Choose a traffic package', + 'traffic_topup.cancel': 'Close', + 'traffic_topup.gb_suffix': 'GB', + 'traffic_topup.buy_button': 'Buy', + 'traffic_topup.your_balance': 'Your balance', + 'traffic_topup.empty': 'No packages available', + 'traffic_topup.error.generic': 'Failed to purchase traffic. Please try again.', + 'traffic_topup.success.title': 'Traffic added!', + 'traffic_topup.success.message': '+{gb} GB has been added to your subscription.', + 'traffic_topup.available_limit': 'Available to purchase: {gb} GB', + 'traffic_topup.limit_reached': 'Maximum traffic limit reached', 'button.buy_subscription': 'Buy Subscription', 'button.open_bot': 'Open Telegram bot', 'subscription_purchase.title': 'Purchase subscription', @@ -6088,6 +6676,20 @@ 'tariffs.select': 'Select tariff', 'tariffs.current': 'Current tariff', 'tariffs.no_tariffs': 'No tariffs available', + 'instant_switch.title': 'Switch tariff', + 'instant_switch.current': 'Current tariff', + 'instant_switch.remaining': 'Remaining', + 'instant_switch.hint': 'When switching, your remaining days are preserved. Upgrade = pay the difference, downgrade = free.', + 'instant_switch.select': 'Select new tariff', + 'instant_switch.confirm': 'Confirmation', + 'instant_switch.from': 'Current', + 'instant_switch.to': 'New', + 'instant_switch.cost': 'Cost:', + 'instant_switch.free': 'Free', + 'instant_switch.balance': 'Your balance:', + 'instant_switch.insufficient': 'Insufficient funds. Missing:', + 'instant_switch.cancel': 'Cancel', + 'instant_switch.confirm_btn': 'Confirm', 'card.referral.title': 'Referral Program', 'card.history.title': 'Transaction History', 'card.servers.title': 'Connected Servers', @@ -6358,6 +6960,18 @@ 'topup.status.failed.description': 'Не удалось подтвердить платеж автоматически. Проверьте позже или обратитесь в поддержку.', 'topup.status.retry': 'Повторить попытку', 'topup.done': 'Готово', + 'traffic_topup.title': 'Докупить трафик', + 'traffic_topup.subtitle': 'Выберите пакет трафика', + 'traffic_topup.cancel': 'Закрыть', + 'traffic_topup.gb_suffix': 'ГБ', + 'traffic_topup.buy_button': 'Купить', + 'traffic_topup.your_balance': 'Ваш баланс', + 'traffic_topup.empty': 'Нет доступных пакетов', + 'traffic_topup.error.generic': 'Не удалось докупить трафик. Попробуйте снова.', + 'traffic_topup.success.title': 'Трафик добавлен!', + 'traffic_topup.success.message': '+{gb} ГБ добавлено к вашей подписке.', + 'traffic_topup.available_limit': 'Доступно для покупки: {gb} ГБ', + 'traffic_topup.limit_reached': 'Достигнут максимальный лимит трафика', 'button.buy_subscription': 'Купить подписку', 'button.open_bot': 'Открыть бота', 'subscription_purchase.title': 'Оформление подписки', @@ -6524,6 +7138,20 @@ 'tariffs.select': 'Выбрать тариф', 'tariffs.current': 'Текущий тариф', 'tariffs.no_tariffs': 'Нет доступных тарифов', + 'instant_switch.title': 'Сменить тариф', + 'instant_switch.current': 'Текущий тариф', + 'instant_switch.remaining': 'Осталось', + 'instant_switch.hint': 'При смене тарифа остаток дней сохраняется. Повышение = доплата, понижение = бесплатно.', + 'instant_switch.select': 'Выберите новый тариф', + 'instant_switch.confirm': 'Подтверждение', + 'instant_switch.from': 'Текущий', + 'instant_switch.to': 'Новый', + 'instant_switch.cost': 'Стоимость:', + 'instant_switch.free': 'Бесплатно', + 'instant_switch.balance': 'Ваш баланс:', + 'instant_switch.insufficient': 'Недостаточно средств. Не хватает:', + 'instant_switch.cancel': 'Отмена', + 'instant_switch.confirm_btn': 'Подтвердить', 'card.referral.title': 'Реферальная программа', 'card.history.title': 'История операций', 'card.servers.title': 'Подключённые серверы', @@ -8615,6 +9243,27 @@ } else { currentTariffRow.classList.add('hidden'); } + + // Кнопка докупки трафика + const trafficTopupBtn = document.getElementById('trafficTopupBtn'); + if (trafficTopupBtn) { + const trafficTopupEnabled = currentTariff?.traffic_topup_enabled || currentTariff?.trafficTopupEnabled; + const trafficTopupPackages = currentTariff?.traffic_topup_packages || currentTariff?.trafficTopupPackages || []; + const isTrial = (user?.subscription_status || '').toLowerCase() === 'trial'; + + if (trafficTopupEnabled && trafficTopupPackages.length > 0 && !isTrial) { + trafficTopupBtn.classList.remove('hidden'); + // Сохраняем данные для модального окна + window._trafficTopupPackages = trafficTopupPackages; + window._subscriptionId = userData?.subscription_id ?? userData?.subscriptionId; + window._userBalance = userData?.balance_kopeks || userData?.balanceKopeks || 0; + // Лимит докупки трафика (null = без лимита) + window._availableTopupGb = currentTariff?.available_topup_gb ?? currentTariff?.availableTopupGb ?? null; + window._maxTopupTrafficGb = currentTariff?.max_topup_traffic_gb ?? currentTariff?.maxTopupTrafficGb ?? 0; + } else { + trafficTopupBtn.classList.add('hidden'); + } + } } const autopayElement = document.getElementById('autopayStatus'); @@ -11839,6 +12488,205 @@ clearTopupError(); } + // ============ Traffic Topup Modal ============ + function openTrafficTopupModal() { + const modal = document.getElementById('trafficTopupModal'); + if (!modal) return; + + modal.classList.remove('hidden'); + document.body.classList.add('modal-open'); + renderTrafficTopupPackages(); + } + + function closeTrafficTopupModal() { + const modal = document.getElementById('trafficTopupModal'); + if (modal) { + modal.classList.add('hidden'); + } + document.body.classList.remove('modal-open'); + clearTrafficTopupError(); + } + + function clearTrafficTopupError() { + const errorEl = document.getElementById('trafficTopupModalError'); + if (errorEl) { + errorEl.classList.add('hidden'); + errorEl.textContent = ''; + } + } + + function showTrafficTopupError(message) { + const errorEl = document.getElementById('trafficTopupModalError'); + if (errorEl) { + errorEl.textContent = message; + errorEl.classList.remove('hidden'); + } + } + + function renderTrafficTopupPackages() { + const packagesContainer = document.getElementById('trafficTopupPackages'); + const balanceContainer = document.getElementById('trafficTopupBalance'); + + if (!packagesContainer) return; + + const packages = window._trafficTopupPackages || []; + const balance = window._userBalance || 0; + const availableTopupGb = window._availableTopupGb; + + packagesContainer.innerHTML = ''; + + // Показываем доступный лимит, если он есть + if (availableTopupGb !== null && availableTopupGb !== undefined) { + const limitEl = document.createElement('div'); + limitEl.className = 'traffic-topup-limit-info'; + limitEl.style.cssText = 'text-align: center; padding: 8px 12px; margin-bottom: 12px; background: var(--bg-tertiary); border-radius: 8px; font-size: 13px; color: var(--text-secondary);'; + if (availableTopupGb <= 0) { + limitEl.textContent = t('traffic_topup.limit_reached'); + limitEl.style.color = 'var(--danger-color)'; + } else { + limitEl.textContent = t('traffic_topup.available_limit').replace('{gb}', availableTopupGb); + } + packagesContainer.appendChild(limitEl); + } + + if (packages.length === 0) { + const emptyEl = document.createElement('div'); + emptyEl.className = 'traffic-topup-empty'; + emptyEl.textContent = t('traffic_topup.empty'); + packagesContainer.appendChild(emptyEl); + return; + } + + packages.forEach(pkg => { + const gb = pkg.gb; + const priceKopeks = pkg.price_kopeks || pkg.priceKopeks; + const priceLabel = pkg.price_label || pkg.priceLabel || formatPrice(priceKopeks); + const originalPriceKopeks = pkg.original_price_kopeks || pkg.originalPriceKopeks; + const originalPriceLabel = pkg.original_price_label || pkg.originalPriceLabel; + const discountPercent = pkg.discount_percent || pkg.discountPercent || 0; + const hasDiscount = discountPercent > 0 && originalPriceKopeks; + const canAfford = balance >= priceKopeks; + + const packageEl = document.createElement('div'); + packageEl.className = 'traffic-topup-package' + (canAfford ? '' : ' disabled'); + + packageEl.innerHTML = ` +
+
+${gb} ${t('traffic_topup.gb_suffix')}
+
+ ${hasDiscount ? `${originalPriceLabel}` : ''} + ${priceLabel} + ${hasDiscount ? `-${discountPercent}%` : ''} +
+
+ + `; + + if (canAfford) { + const btn = packageEl.querySelector('.traffic-topup-package-btn'); + btn.addEventListener('click', () => purchaseTrafficPackage(gb, priceKopeks)); + } + + packagesContainer.appendChild(packageEl); + }); + + if (balanceContainer) { + balanceContainer.innerHTML = t('traffic_topup.your_balance') + ': ' + formatPrice(balance) + ''; + } + } + + async function purchaseTrafficPackage(gb, priceKopeks) { + const packagesContainer = document.getElementById('trafficTopupPackages'); + if (!packagesContainer) return; + + // Показываем загрузку + const buttons = packagesContainer.querySelectorAll('.traffic-topup-package-btn'); + buttons.forEach(btn => { + btn.disabled = true; + btn.textContent = '...'; + }); + + clearTrafficTopupError(); + + try { + const initData = tg.initData || ''; + const requestBody = { + initData: initData, + gb: gb + }; + if (window._subscriptionId) { + requestBody.subscriptionId = window._subscriptionId; + } + + const response = await fetch('/miniapp/subscription/traffic-topup', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(requestBody) + }); + + const data = await response.json(); + + if (!response.ok) { + const errorMessage = data?.detail?.message || data?.message || t('traffic_topup.error.generic'); + showTrafficTopupError(errorMessage); + renderTrafficTopupPackages(); + return; + } + + // Успех - показываем сообщение + packagesContainer.innerHTML = ` +
+
+
${t('traffic_topup.success.title')}
+
${t('traffic_topup.success.message').replace('{gb}', gb)}
+
+ `; + + // Обновляем баланс + window._userBalance = data.new_balance_kopeks || data.newBalanceKopeks || 0; + const balanceContainer = document.getElementById('trafficTopupBalance'); + if (balanceContainer) { + balanceContainer.innerHTML = t('traffic_topup.your_balance') + ': ' + formatPrice(window._userBalance) + ''; + } + + // Обновляем отображение баланса на странице + const balanceAmountEl = document.getElementById('balanceAmount'); + if (balanceAmountEl) { + balanceAmountEl.textContent = formatPrice(window._userBalance); + } + + // Обновляем лимит трафика + const trafficLimitEl = document.getElementById('trafficLimit'); + if (trafficLimitEl && data.new_traffic_limit_gb) { + trafficLimitEl.textContent = formatTrafficLimit(data.new_traffic_limit_gb); + } + + // Обновляем доступный лимит докупки + if (window._availableTopupGb !== null && window._availableTopupGb !== undefined) { + window._availableTopupGb = Math.max(0, window._availableTopupGb - gb); + } + + // Обновляем данные через 2 секунды + setTimeout(() => { + refreshSubscriptionData({ silent: true }); + }, 2000); + + } catch (error) { + console.error('Traffic topup error:', error); + showTrafficTopupError(t('traffic_topup.error.generic')); + renderTrafficTopupPackages(); + } + } + + function formatPrice(kopeks) { + const rubles = Math.floor(kopeks / 100); + return rubles + ' ₽'; + } + + // ============ End Traffic Topup Modal ============ + function renderTopupLoading(messageKey = 'topup.loading') { const { body } = getTopupElements(); if (!body) { @@ -15541,8 +16389,9 @@ return; } - // Для триала НЕ показываем настройки подписки — юзер должен сначала купить - const shouldShow = hasActiveSubscription() && !isTrialSubscription(); + // В режиме тарифов НЕ показываем настройки подписки — там своя логика + // Для триала тоже НЕ показываем — юзер должен сначала купить + const shouldShow = hasActiveSubscription() && !isTrialSubscription() && !isTariffsMode(); card.classList.toggle('hidden', !shouldShow); if (!shouldShow) { return; @@ -19097,6 +19946,31 @@ }); } + // Кнопка докупки трафика + const trafficTopupBtn = document.getElementById('trafficTopupBtn'); + if (trafficTopupBtn) { + trafficTopupBtn.addEventListener('click', () => { + openTrafficTopupModal(); + }); + } + + // Модальное окно докупки трафика + const trafficTopupModal = document.getElementById('trafficTopupModal'); + if (trafficTopupModal) { + trafficTopupModal.addEventListener('click', event => { + if (event.target === trafficTopupModal) { + closeTrafficTopupModal(); + } + }); + } + + const trafficTopupCancelBtn = document.getElementById('trafficTopupModalCancelBtn'); + if (trafficTopupCancelBtn) { + trafficTopupCancelBtn.addEventListener('click', () => { + closeTrafficTopupModal(); + }); + } + const topupModal = document.getElementById('topupModal'); if (topupModal) { topupModal.addEventListener('click', event => { @@ -19251,6 +20125,25 @@ } let selectedTariffData = null; + let isInstantSwitchMode = false; + let instantSwitchPreviewData = null; + + function getMonthlyPrice(tariff) { + if (!tariff) return 0; + const periods = tariff.periods || []; + const period30 = periods.find(p => (p.days || p.period_days || p.periodDays) === 30); + if (period30) { + return period30.price_kopeks || period30.priceKopeks || period30.final_price || period30.finalPrice || 0; + } + // Fallback - пропорционально пересчитываем + if (periods.length > 0) { + const firstPeriod = periods[0]; + const days = firstPeriod.days || firstPeriod.period_days || firstPeriod.periodDays || 30; + const price = firstPeriod.price_kopeks || firstPeriod.priceKopeks || 0; + return Math.round(price * 30 / days); + } + return 0; + } function renderTariffs() { const loading = document.getElementById('tariffsLoading'); @@ -19258,20 +20151,46 @@ const list = document.getElementById('tariffsList'); const currentBlock = document.getElementById('tariffsCurrentTariff'); const currentName = document.getElementById('tariffsCurrentTariffName'); + const periodsSection = document.getElementById('tariffPeriodsSection'); + const summary = document.getElementById('tariffsSummary'); + const selectBtn = document.getElementById('tariffsSelectBtn'); loading?.classList.add('hidden'); body?.classList.remove('hidden'); - // Текущий тариф - if (tariffsData?.current_tariff || tariffsData?.currentTariff) { - const current = tariffsData.current_tariff || tariffsData.currentTariff; + // Определяем режим: мгновенная смена или покупка + // Данные пользователя находятся в userData.user (структура MiniAppSubscriptionResponse) + const userInfo = userData?.user || userData; + const actualStatus = ( + userInfo?.subscription_actual_status || + userInfo?.subscriptionActualStatus || + userInfo?.subscription_status || + userInfo?.subscriptionStatus || + '' + ).toLowerCase(); + const hasActiveSubscription = actualStatus === 'active' || actualStatus === 'trial' || + userInfo?.has_active_subscription || userInfo?.hasActiveSubscription; + const currentTariff = tariffsData?.current_tariff || tariffsData?.currentTariff; + isInstantSwitchMode = hasActiveSubscription && currentTariff; + + // Вычисляем оставшиеся дни из expires_at + let daysLeft = 0; + const expiresAt = userInfo?.expires_at || userInfo?.expiresAt; + if (expiresAt) { + const endDate = new Date(expiresAt); + const now = new Date(); + daysLeft = Math.max(0, Math.ceil((endDate - now) / (1000 * 60 * 60 * 24))); + } + + // Текущий тариф с информацией о режиме + if (currentTariff) { currentBlock?.classList.remove('hidden'); if (currentName) { - const trafficLabel = current.traffic_limit_label || current.trafficLimitLabel - || ((current.traffic_limit_gb || current.trafficLimitGb) === 0 ? '∞' : (current.traffic_limit_gb || current.trafficLimitGb) + ' ГБ'); - const deviceLimit = current.device_limit || current.deviceLimit || 1; - const servers = current.servers || []; - const serversCount = current.servers_count || current.serversCount || servers.length || 0; + const trafficLabel = currentTariff.traffic_limit_label || currentTariff.trafficLimitLabel + || ((currentTariff.traffic_limit_gb || currentTariff.trafficLimitGb) === 0 ? '∞' : (currentTariff.traffic_limit_gb || currentTariff.trafficLimitGb) + ' ГБ'); + const deviceLimit = currentTariff.device_limit || currentTariff.deviceLimit || 1; + const servers = currentTariff.servers || []; + const serversCount = currentTariff.servers_count || currentTariff.serversCount || servers.length || 0; let serverTags = ''; if (servers.length > 0) { @@ -19286,10 +20205,21 @@ serverTags = `🌍 Все серверы`; } + // В режиме смены тарифа показываем остаток дней + const remainingDaysText = preferredLanguage === 'en' + ? `${daysLeft} days remaining — will be preserved on switch` + : `Осталось ${daysLeft} дн. — сохранятся при смене`; + const remainingInfo = isInstantSwitchMode + ? `
+ + ${remainingDaysText} +
` + : ''; + currentName.innerHTML = `
- ${escapeHtml(current.name)} + ${escapeHtml(currentTariff.name)}
@@ -19308,12 +20238,46 @@
${serverTags}
+ ${remainingInfo} `; } } else { currentBlock?.classList.add('hidden'); } + // Баннер скидки промогруппы + const promoGroupBanner = document.getElementById('tariffsPromoGroupBanner'); + const promoGroupNameEl = document.getElementById('tariffsPromoGroupName'); + const promoGroupDiscountEl = document.getElementById('tariffsPromoGroupDiscount'); + const promoGroup = tariffsData?.promo_group || tariffsData?.promoGroup; + + if (promoGroup && promoGroupBanner) { + const periodDiscounts = promoGroup.period_discounts || promoGroup.periodDiscounts || {}; + let maxDiscount = 0; + Object.values(periodDiscounts).forEach(discount => { + const d = parseInt(discount) || 0; + if (d > maxDiscount) maxDiscount = d; + }); + + if (maxDiscount > 0) { + promoGroupBanner.classList.remove('hidden'); + if (promoGroupNameEl) { + promoGroupNameEl.textContent = preferredLanguage === 'en' + ? `Promo group "${promoGroup.name}"` + : `Промогруппа «${promoGroup.name}»`; + } + if (promoGroupDiscountEl) { + promoGroupDiscountEl.textContent = preferredLanguage === 'en' + ? `Your discount: up to ${maxDiscount}%` + : `Ваша скидка: до ${maxDiscount}%`; + } + } else { + promoGroupBanner.classList.add('hidden'); + } + } else { + promoGroupBanner?.classList.add('hidden'); + } + // Список тарифов if (!list) return; list.innerHTML = ''; @@ -19324,117 +20288,352 @@ return; } + // Добавляем информационный блок в режиме мгновенной смены + if (isInstantSwitchMode) { + const hintDiv = document.createElement('div'); + hintDiv.className = 'instant-switch-mode-hint'; + hintDiv.innerHTML = ` +
+
+ 🔄 +
+
+
+ ${preferredLanguage === 'en' ? 'Instant tariff switch' : 'Мгновенная смена тарифа'} +
+
+ ${preferredLanguage === 'en' + ? '⬆️ Upgrade = pay the difference • ⬇️ Downgrade = free
Your remaining ' + daysLeft + ' days will be preserved' + : '⬆️ Повышение = доплата • ⬇️ Понижение = бесплатно
Ваши ' + daysLeft + ' дней сохраняются'} +
+
+
+ `; + list.appendChild(hintDiv); + } + + // В режиме мгновенной смены скрываем периоды и summary, в обычном - показываем + if (isInstantSwitchMode) { + periodsSection?.classList.add('hidden'); + summary?.classList.add('hidden'); + if (selectBtn) selectBtn.classList.add('hidden'); + } else { + // В обычном режиме покупки - убедимся что элементы видны + if (selectBtn) selectBtn.classList.remove('hidden'); + } + + const currentTariffId = currentTariff?.id; + tariffs.forEach(tariff => { - const isCurrent = tariff.is_current || tariff.isCurrent; + const isCurrent = tariff.id === currentTariffId; const periods = tariff.periods || []; - const minPrice = periods.length > 0 - ? periods.reduce((min, p) => { - const price = p.price_kopeks || p.priceKopeks || 0; - return price < min ? price : min; - }, Infinity) - : null; - const minPriceLabel = minPrice !== null && minPrice !== Infinity - ? formatPriceFromKopeks(minPrice, tariffsData?.currency || 'RUB') - : null; + + // В режиме смены пропускаем текущий тариф + if (isInstantSwitchMode && isCurrent) { + return; + } const div = document.createElement('div'); - div.className = 'subscription-settings-toggle' + (selectedTariffId === tariff.id ? ' active' : ''); - const trafficLabel = tariff.traffic_limit_label || tariff.trafficLimitLabel - || ((tariff.traffic_limit_gb || tariff.trafficLimitGb) === 0 ? '∞' : (tariff.traffic_limit_gb || tariff.trafficLimitGb) + ' ГБ'); + // Разные стили для режима смены и покупки + if (isInstantSwitchMode) { + // Режим мгновенной смены тарифа - используем точные данные с сервера + const isUpgrade = tariff.is_upgrade ?? tariff.isUpgrade ?? false; + const isSwitchFree = tariff.is_switch_free ?? tariff.isSwitchFree ?? !isUpgrade; + const upgradeCost = tariff.switch_cost_kopeks ?? tariff.switchCostKopeks ?? 0; + const upgradeCostLabel = tariff.switch_cost_label ?? tariff.switchCostLabel ?? formatPriceFromKopeks(upgradeCost, tariffsData?.currency || 'RUB'); - const deviceLimit = tariff.device_limit || tariff.deviceLimit || 1; + const trafficLabel = tariff.traffic_limit_label || tariff.trafficLimitLabel + || ((tariff.traffic_limit_gb || tariff.trafficLimitGb) === 0 ? '∞' : (tariff.traffic_limit_gb || tariff.trafficLimitGb) + ' ГБ'); + const deviceLimit = tariff.device_limit || tariff.deviceLimit || 1; + const description = tariff.description || ''; - // Серверы - показываем названия или количество - const servers = tariff.servers || []; - const serversCount = tariff.servers_count || tariff.serversCount || servers.length || 0; - let serversText = ''; - if (servers.length > 0) { - const serverNames = servers.slice(0, 3).map(s => s.name || s.display_name || s.displayName).filter(Boolean); - if (serverNames.length > 0) { - serversText = serverNames.join(', '); + // Серверы тарифа + const servers = tariff.servers || []; + const serversCount = tariff.servers_count || tariff.serversCount || servers.length || 0; + let serverTags = ''; + if (servers.length > 0) { + const serverNames = servers.slice(0, 3).map(s => s.name || s.display_name || s.displayName).filter(Boolean); + serverTags = serverNames.map(name => + `${escapeHtml(name)}` + ).join(''); if (serversCount > serverNames.length) { - serversText += ` +${serversCount - serverNames.length}`; + serverTags += `+${serversCount - serverNames.length}`; } - } else { - serversText = serversCount + ' серв.'; + } else if (serversCount === 0) { + serverTags = `🌍 Все`; } - } else if (serversCount > 0) { - serversText = serversCount + ' серв.'; - } else { - serversText = 'Все серверы'; - } - const description = tariff.description || ''; + // Определяем тип тарифа + const tariffNameLower = (tariff.name || '').toLowerCase(); + const isPremium = tariffNameLower.includes('премиум') || tariffNameLower.includes('premium') || + tariffNameLower.includes('про') || tariffNameLower.includes('pro') || + tariffNameLower.includes('vip') || tariffNameLower.includes('ultimate') || + tariffNameLower.includes('макс') || tariffNameLower.includes('max'); - // Серверные теги - let serverTags = ''; - if (servers.length > 0) { - const serverNames = servers.slice(0, 4).map(s => s.name || s.display_name || s.displayName).filter(Boolean); - serverTags = serverNames.map(name => - `${escapeHtml(name)}` - ).join(''); - if (serversCount > serverNames.length) { - serverTags += `+${serversCount - serverNames.length}`; - } - } else if (serversCount === 0) { - serverTags = `🌍 Все серверы`; - } + const upgradeLabel = preferredLanguage === 'en' ? 'upgrade' : 'доплата'; + const freeLabel = preferredLanguage === 'en' ? '✓ Free' : '✓ Бесплатно'; - // Determine tariff type for styling - const tariffNameLower = (tariff.name || '').toLowerCase(); - const isPremium = tariffNameLower.includes('премиум') || tariffNameLower.includes('premium') || - tariffNameLower.includes('про') || tariffNameLower.includes('pro') || - tariffNameLower.includes('vip') || tariffNameLower.includes('ultimate') || - tariffNameLower.includes('макс') || tariffNameLower.includes('max'); - const tariffIcon = isPremium ? '👑' : (isCurrent ? '✓' : '⚡'); - const badgeClass = isPremium ? 'tariff-name-badge tariff-premium' : (isCurrent ? 'tariff-name-badge tariff-current' : 'tariff-name-badge'); - - div.innerHTML = ` -
-
-
-
- ${tariffIcon} - ${escapeHtml(tariff.name)} -
- ${description ? `
${escapeHtml(description)}
` : ''} + div.className = 'instant-switch-tariff-item'; + div.innerHTML = ` +
+
+ ${isPremium ? '👑 ' : '⚡ '}${escapeHtml(tariff.name)}
- ${minPriceLabel ? ` -
-
от
-
${minPriceLabel}
+ ${description ? `
${escapeHtml(description)}
` : ''} +
+ 📱 ${deviceLimit} + 📊 ${trafficLabel} +
+ ${serverTags ? `
${serverTags}
` : ''} +
+
+ + ${isUpgrade + ? `${upgradeLabel}+${upgradeCostLabel}` + : freeLabel} + +
+ `; + + div.addEventListener('click', () => showInstantSwitchConfirm(tariff, isUpgrade, upgradeCost)); + } else { + // Обычный режим покупки + let minPrice = null; + let minPriceOriginal = null; + let maxDiscountPercent = 0; + + if (periods.length > 0) { + periods.forEach(p => { + const price = p.price_kopeks || p.priceKopeks || 0; + const originalPrice = p.original_price_kopeks || p.originalPriceKopeks || price; + const discountPct = p.discount_percent || p.discountPercent || 0; + + if (minPrice === null || price < minPrice) { + minPrice = price; + minPriceOriginal = originalPrice > price ? originalPrice : null; + } + if (discountPct > maxDiscountPercent) { + maxDiscountPercent = discountPct; + } + }); + } + + const minPriceLabel = minPrice !== null + ? formatPriceFromKopeks(minPrice, tariffsData?.currency || 'RUB') + : null; + const minPriceOriginalLabel = minPriceOriginal !== null + ? formatPriceFromKopeks(minPriceOriginal, tariffsData?.currency || 'RUB') + : null; + + div.className = 'subscription-settings-toggle' + (selectedTariffId === tariff.id ? ' active' : ''); + + const trafficLabel = tariff.traffic_limit_label || tariff.trafficLimitLabel + || ((tariff.traffic_limit_gb || tariff.trafficLimitGb) === 0 ? '∞' : (tariff.traffic_limit_gb || tariff.trafficLimitGb) + ' ГБ'); + const deviceLimit = tariff.device_limit || tariff.deviceLimit || 1; + const description = tariff.description || ''; + + const servers = tariff.servers || []; + const serversCount = tariff.servers_count || tariff.serversCount || servers.length || 0; + let serverTags = ''; + if (servers.length > 0) { + const serverNames = servers.slice(0, 4).map(s => s.name || s.display_name || s.displayName).filter(Boolean); + serverTags = serverNames.map(name => + `${escapeHtml(name)}` + ).join(''); + if (serversCount > serverNames.length) { + serverTags += `+${serversCount - serverNames.length}`; + } + } else if (serversCount === 0) { + serverTags = `🌍 Все серверы`; + } + + const tariffNameLower = (tariff.name || '').toLowerCase(); + const isPremium = tariffNameLower.includes('премиум') || tariffNameLower.includes('premium') || + tariffNameLower.includes('про') || tariffNameLower.includes('pro') || + tariffNameLower.includes('vip') || tariffNameLower.includes('ultimate') || + tariffNameLower.includes('макс') || tariffNameLower.includes('max'); + const tariffIcon = isPremium ? '👑' : (isCurrent ? '✓' : '⚡'); + const badgeClass = isPremium ? 'tariff-name-badge tariff-premium' : (isCurrent ? 'tariff-name-badge tariff-current' : 'tariff-name-badge'); + + div.innerHTML = ` +
+
+
+
+ ${tariffIcon} + ${escapeHtml(tariff.name)} +
+ ${description ? `
${escapeHtml(description)}
` : ''} +
+ ${minPriceLabel ? ` +
+
от
+ ${minPriceOriginalLabel ? `
${minPriceOriginalLabel}
` : ''} +
${minPriceLabel}
+ ${maxDiscountPercent > 0 ? `
-${maxDiscountPercent}%
` : ''} +
+ ` : ''} +
+
+ + + + + ${deviceLimit} + + + + + + ${trafficLabel} + +
+ ${serverTags ? ` +
+ ${serverTags}
` : ''}
-
- - - - - ${deviceLimit} - - - - - - ${trafficLabel} - -
- ${serverTags ? ` -
- ${serverTags} -
- ` : ''} -
- `; + `; + + div.addEventListener('click', () => selectTariff(tariff)); + } - div.addEventListener('click', () => selectTariff(tariff)); list.appendChild(div); }); - // Обновляем периоды если тариф выбран - renderTariffPeriods(); + // Обновляем периоды если обычный режим и тариф выбран + if (!isInstantSwitchMode) { + renderTariffPeriods(); + } + } + + // Показать подтверждение мгновенной смены тарифа + async function showInstantSwitchConfirm(tariff, isUpgrade, estimatedCost) { + const currentTariff = tariffsData?.current_tariff || tariffsData?.currentTariff; + + // Запрашиваем точную стоимость с сервера + try { + const initData = tg.initData || ''; + const tariffId = parseInt(tariff.id, 10); + + if (!initData) { + throw new Error(preferredLanguage === 'en' ? 'Authorization error' : 'Ошибка авторизации'); + } + if (isNaN(tariffId)) { + throw new Error(preferredLanguage === 'en' ? 'Invalid tariff' : 'Неверный тариф'); + } + + const requestBody = { init_data: initData, tariff_id: tariffId }; + + const response = await fetch('/miniapp/subscription/tariff/switch/preview', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(requestBody) + }); + + if (!response.ok) { + const error = await response.json(); + console.error('Switch preview error:', error); + // Показываем детали ошибки валидации + let errorMsg = error?.detail?.message || error?.message || 'Ошибка'; + if (Array.isArray(error?.detail)) { + errorMsg = error.detail.map(e => e.msg || e.message).join('; '); + } + throw new Error(errorMsg); + } + + instantSwitchPreviewData = await response.json(); + + const actualCost = instantSwitchPreviewData.upgrade_cost_kopeks || instantSwitchPreviewData.upgradeCostKopeks || 0; + const costLabel = instantSwitchPreviewData.upgrade_cost_label || instantSwitchPreviewData.upgradeCostLabel || formatPriceFromKopeks(actualCost, tariffsData?.currency || 'RUB'); + const balanceLabel = instantSwitchPreviewData.balance_label || instantSwitchPreviewData.balanceLabel || '—'; + const hasEnough = instantSwitchPreviewData.has_enough_balance ?? instantSwitchPreviewData.hasEnoughBalance ?? true; + const missingLabel = instantSwitchPreviewData.missing_amount_label || instantSwitchPreviewData.missingAmountLabel || ''; + const actualIsUpgrade = instantSwitchPreviewData.is_upgrade || instantSwitchPreviewData.isUpgrade || false; + + // Тексты для подтверждения + const txt = { + current: preferredLanguage === 'en' ? 'Current' : 'Текущий', + new: preferredLanguage === 'en' ? 'New' : 'Новый', + cost: preferredLanguage === 'en' ? 'Cost' : 'Стоимость', + free: preferredLanguage === 'en' ? '✓ Free' : '✓ Бесплатно', + balance: preferredLanguage === 'en' ? 'Your balance:' : 'Ваш баланс:', + insufficient: preferredLanguage === 'en' ? '⚠️ Insufficient funds. Missing:' : '⚠️ Недостаточно средств. Не хватает:', + confirmUpgrade: preferredLanguage === 'en' + ? `Switch to "${tariff.name}"?\n\nUpgrade cost: ${costLabel}\nYour balance: ${balanceLabel}` + : `Сменить тариф на «${tariff.name}»?\n\nДоплата: ${costLabel}\nВаш баланс: ${balanceLabel}`, + confirmDowngrade: preferredLanguage === 'en' + ? `Switch to "${tariff.name}"?\n\nFree (downgrade)` + : `Сменить тариф на «${tariff.name}»?\n\nБесплатно (понижение тарифа)`, + error: preferredLanguage === 'en' ? 'Error' : 'Ошибка', + insufficientError: preferredLanguage === 'en' + ? `Insufficient funds to switch tariff. Missing: ${missingLabel}` + : `Недостаточно средств для смены тарифа. Не хватает: ${missingLabel}`, + }; + + // Используем кастомный попап или showConfirm + if (typeof tg.showConfirm === 'function' && hasEnough) { + const message = actualIsUpgrade ? txt.confirmUpgrade : txt.confirmDowngrade; + + tg.showConfirm(message, async (confirmed) => { + if (confirmed) { + await executeInstantSwitch(tariff); + } + }); + } else if (hasEnough) { + // Fallback на простой confirm + const message = actualIsUpgrade ? txt.confirmUpgrade : txt.confirmDowngrade; + + if (confirm(message)) { + await executeInstantSwitch(tariff); + } + } else { + showPopup(txt.insufficientError, txt.error); + } + + } catch (err) { + console.error('Preview failed:', err); + const errTitle = preferredLanguage === 'en' ? 'Error' : 'Ошибка'; + const errMsg = preferredLanguage === 'en' ? 'Failed to load info' : 'Не удалось получить информацию'; + showPopup(err.message || errMsg, errTitle); + } + } + + // Выполнить мгновенную смену тарифа + async function executeInstantSwitch(tariff) { + try { + const initData = tg.initData || ''; + const tariffId = parseInt(tariff.id, 10); + + if (!initData || isNaN(tariffId)) { + throw new Error(preferredLanguage === 'en' ? 'Invalid data' : 'Неверные данные'); + } + + const response = await fetch('/miniapp/subscription/tariff/switch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ init_data: initData, tariff_id: tariffId }) + }); + + const result = await response.json(); + + if (!response.ok) { + const defaultErr = preferredLanguage === 'en' ? 'Switch failed' : 'Ошибка переключения'; + throw new Error(result?.detail?.message || result?.message || defaultErr); + } + + const successTitle = preferredLanguage === 'en' ? 'Success' : 'Успех'; + const successMsg = preferredLanguage === 'en' ? 'Tariff changed successfully!' : 'Тариф успешно изменён!'; + showPopup(result.message || successMsg, successTitle); + await refreshSubscriptionData(); + } catch (err) { + console.error('Switch failed:', err); + const errTitle = preferredLanguage === 'en' ? 'Error' : 'Ошибка'; + const errMsg = preferredLanguage === 'en' ? 'Failed to switch tariff' : 'Не удалось сменить тариф'; + showPopup(err.message || errMsg, errTitle); + } } function selectTariff(tariff) { @@ -19476,9 +20675,9 @@ periods.forEach((period, index) => { const days = period.days || period.period_days || period.periodDays; const priceKopeks = period.price_kopeks || period.priceKopeks || period.final_price || period.finalPrice || 0; - const originalKopeks = period.original_price || period.originalPrice || period.base_price || period.basePrice || priceKopeks; + const originalKopeks = period.original_price_kopeks || period.originalPriceKopeks || period.original_price || period.originalPrice || period.base_price || period.basePrice || priceKopeks; const hasDiscount = originalKopeks > priceKopeks; - const discountPercent = hasDiscount ? Math.round((1 - priceKopeks / originalKopeks) * 100) : 0; + const discountPercent = period.discount_percent || period.discountPercent || (hasDiscount ? Math.round((1 - priceKopeks / originalKopeks) * 100) : 0); const priceLabel = formatPriceFromKopeks(priceKopeks, tariffsData?.currency || 'RUB'); const originalLabel = hasDiscount ? formatPriceFromKopeks(originalKopeks, tariffsData?.currency || 'RUB') : null; @@ -19639,6 +20838,10 @@ document.getElementById('tariffsRetry')?.addEventListener('click', loadTariffs); document.getElementById('tariffsSelectBtn')?.addEventListener('click', purchaseTariff); + // ============================================ + // Instant Tariff Switch + // ============================================ + // Загружаем тарифы после загрузки данных подписки const originalApplySubscriptionData = applySubscriptionData; applySubscriptionData = function(payload) {