diff --git a/.env.example b/.env.example index 0ffbda5c..0a8b3ea7 100644 --- a/.env.example +++ b/.env.example @@ -478,6 +478,10 @@ FREEKASSA_PAYMENT_TIMEOUT_SECONDS=3600 FREEKASSA_WEBHOOK_PATH=/freekassa-webhook FREEKASSA_WEBHOOK_HOST=0.0.0.0 FREEKASSA_WEBHOOK_PORT=8088 +# Способ оплаты: пусто = форма выбора, 42 = обычный СБП, 44 = NSPK СБП +FREEKASSA_PAYMENT_SYSTEM_ID= +# Использовать API для создания заказов (обязательно для NSPK СБП) +FREEKASSA_USE_API=false # ===== ИНТЕРФЕЙС И UX ===== diff --git a/.github/workflows/docker-hub.yml b/.github/workflows/docker-hub.yml index afc7f469..06ebd817 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.4-$(git rev-parse --short HEAD)" + VERSION="v2.9.5-$(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.4-dev-$(git rev-parse --short HEAD)" + VERSION="v2.9.5-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.4-pr-$(git rev-parse --short HEAD)" + VERSION="v2.9.5-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 aa0beb08..d23c7a8c 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.4-$(git rev-parse --short HEAD)" + VERSION="v2.9.5-$(git rev-parse --short HEAD)" echo "🚀 Building main version: $VERSION" elif [[ $GITHUB_REF == refs/heads/dev ]]; then - VERSION="v2.9.4-dev-$(git rev-parse --short HEAD)" + VERSION="v2.9.5-dev-$(git rev-parse --short HEAD)" echo "🧪 Building dev version: $VERSION" else - VERSION="v2.9.4-pr-$(git rev-parse --short HEAD)" + VERSION="v2.9.5-pr-$(git rev-parse --short HEAD)" echo "🔀 Building PR version: $VERSION" fi echo "version=$VERSION" >> $GITHUB_OUTPUT diff --git a/Dockerfile b/Dockerfile index d8996e29..b11675f7 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.4" +ARG VERSION="v2.9.5" ARG BUILD_DATE ARG VCS_REF diff --git a/README.md b/README.md index 6be821f6..9699a6e2 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ ### ⚡ **Полная автоматизация VPN бизнеса** - 🎯 **Готовое решение** - разверни за 5 минут, начни продавать сегодня -- 💰 **Многоканальные платежи** - Telegram Stars + Tribute + CryptoBot + Heleket + YooKassa (СБП + карты) + MulenPay + PayPalych (СБП + карты) + Platega (карты + СБП) + WATA +- 💰 **Многоканальные платежи** - Telegram Stars + Tribute + CryptoBot + Heleket + YooKassa (СБП + карты) + MulenPay + PayPalych (СБП + карты) + Platega (карты + СБП) + WATA + Freekassa (NSPK СБП + карты) + CloudPayments (карты + СБП) - 🔄 **Автоматизация 99%** - от регистрации до продления подписок - 📱 **MiniApp лк** - личный кабинет с возможностью покупки/продления подписки - 📊 **Детальная аналитика** - полная картина вашего бизнеса @@ -45,6 +45,7 @@ - 🌐 **Умный выбор серверов** - автоматический пропуск при одном сервере, мультивыбор - 📱 **Управление устройствами** - от 1 до неограниченного количества - 📊 **Режимы продажи трафика** - фиксированный лимит или выбор пакетов +- 🎯 **Режим тарифов** - готовые тарифные планы с фиксированными параметрами - 🎁 **Промо-система** - коды на деньги, дни подписки, триал-периоды - 🔧 **Гибкие тарифы** - от 5GB до безлимита, от 14 дней до года - 🛒 **Умная корзина** - сохранение параметров подписки при недостатке баланса @@ -58,6 +59,10 @@ - 🗄️ **Бекапы/Восстановление** - автобекапы и восстановление бд прямо в боте с уведомлениями в топики - ✔️ **Проверка на подписку** - проверяет подписку на канал - 🔄 **Автосинхронизация** - фоновая синхронизация подписок и серверов(сквадов) с Remnawave по расписанию +- 📝 **Ротация логов** - автоматическая очистка и архивация старых логов +- 🚫 **Система ограничений** - блокировка пользователей по различным критериям +- 🎮 **Конкурсы и игры** - ежедневные игры с призами и реферальные конкурсы +- 📡 **Модем-подписки** - дополнительный функционал для устройств-модемов ### 📚 Поддерживаемые методы авторизации @@ -539,7 +544,27 @@ hooks.domain.com { } } } - + + handle /freekassa-webhook { + reverse_proxy remnawave_bot:8080 { + header_up Host {host} + header_up X-Real-IP {remote_host} + transport http { + read_buffer 0 + } + } + } + + handle /cloudpayments-webhook { + reverse_proxy remnawave_bot:8080 { + header_up Host {host} + header_up X-Real-IP {remote_host} + transport http { + read_buffer 0 + } + } + } + # app-config.json с CORS handle /app-config.json { header Access-Control-Allow-Origin "*" @@ -723,7 +748,31 @@ http { proxy_buffering off; proxy_request_buffering off; } - + + location = /freekassa-webhook { + proxy_pass http://remnawave_bot_unified; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 120s; + proxy_send_timeout 120s; + proxy_buffering off; + proxy_request_buffering off; + } + + location = /cloudpayments-webhook { + proxy_pass http://remnawave_bot_unified; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 120s; + proxy_send_timeout 120s; + proxy_buffering off; + proxy_request_buffering off; + } + # app-config.json с CORS location = /app-config.json { add_header Access-Control-Allow-Origin "*"; @@ -953,6 +1002,43 @@ REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD REMNAWAVE_SECRET_KEY=secret_key_name ``` +### 💳 Freekassa + +Платёжный провайдер [Freekassa](https://freekassa.ru) поддерживает NSPK СБП и банковские карты. + +```env +FREEKASSA_ENABLED=true +FREEKASSA_SHOP_ID=12345 +FREEKASSA_API_KEY=your_api_key +FREEKASSA_SECRET_WORD_1=secret1 +FREEKASSA_SECRET_WORD_2=secret2 +FREEKASSA_USE_API=true +FREEKASSA_PAYMENT_SYSTEM_ID=44 # 44 = NSPK СБП, 42 = обычный СБП +``` + +**Важно:** +- `FREEKASSA_USE_API=true` - использовать API для создания заказов (рекомендуется для NSPK) +- `FREEKASSA_USE_API=false` - редирект на платежную форму +- `FREEKASSA_PAYMENT_SYSTEM_ID=44` - для NSPK СБП (быстрее и надежнее) +- Настройте webhook на `https://your-domain.com/freekassa-webhook` + +### 💳 CloudPayments + +Платёжный провайдер [CloudPayments](https://cloudpayments.ru) поддерживает оплату банковскими картами и СБП. + +```env +CLOUDPAYMENTS_ENABLED=true +CLOUDPAYMENTS_PUBLIC_ID=pk_xxxxxxxxxxxxx +CLOUDPAYMENTS_API_SECRET=your_api_secret +CLOUDPAYMENTS_CURRENCY=RUB +``` + +**Важно:** +- `CLOUDPAYMENTS_PUBLIC_ID` - Public ID из личного кабинета CloudPayments +- `CLOUDPAYMENTS_API_SECRET` - API Secret для подписи запросов +- Настройте webhook (Pay/Fail уведомления) на `https://your-domain.com/cloudpayments-webhook` +- Поддерживает 3D-Secure для безопасных платежей + ### 💳 Platega.io Платёжный провайдер [Platega.io](https://platega.io) добавляет ещё один способ приёма оплат картой и по СБП. Включите его, если у вас есть кабинет мерчанта и доступ к API. @@ -994,6 +1080,24 @@ FIXED_TRAFFIC_LIMIT_GB=100 # 0 = безлимит TRAFFIC_PACKAGES_CONFIG="100:15000:true" ``` +### 🎯 Режим тарифов + +Альтернативный режим продаж с готовыми тарифными планами: + +```env +# Включение режима тарифов +SALES_MODE=tariffs # classic | tariffs + +# Тарифы настраиваются в админ-панели бота +# Каждый тариф содержит: название, период, трафик, устройства, серверы, цену +``` + +**Преимущества режима тарифов:** +- Готовые пакеты без сложного выбора параметров +- Упрощенный UX для пользователей +- Возможность создавать акционные тарифы +- Поддержка скидок промогрупп на тарифы + ### 💰 Система ценообразования Цена подписки рассчитывается по формуле: @@ -1075,6 +1179,53 @@ MONITORING_INTERVAL=60 REDIS_URL=redis://redis:6379/0 ``` +### 🎮 Конкурсы и игры + +```env +# Видимость кнопки конкурсов в главном меню +CONTEST_BUTTON_VISIBLE=true + +# Конкурсы и ежедневные игры настраиваются в админ-панели +# Типы призов: баланс, дни подписки, промогруппа +``` + +**Возможности системы конкурсов:** +- Реферальные конкурсы с рейтингами +- Ежедневные игры с гарантированными призами +- Детальная статистика участников +- Гибкая настройка призов (баланс, подписка, промогруппа) + +### 🚫 Система ограничений пользователей + +```env +# Управление ограничениями через админ-панель +# Типы ограничений: покупка, триал, пополнение баланса +``` + +Позволяет блокировать отдельные функции для пользователей без полной блокировки аккаунта. + +### 📝 Ротация логов + +```env +# Настройка ротации логов +LOG_ROTATION_ENABLED=true +LOG_ROTATION_MAX_SIZE_MB=100 +LOG_ROTATION_MAX_FILES=10 +``` + +### 👁️ Управление видимостью + +```env +# Скрытие кнопок пополнения баланса +HIDE_TOPUP_BUTTONS=false + +# Скрытие реферальной программы +REFERRAL_PROGRAM_VISIBLE=true + +# Видимость кнопки конкурсов +CONTEST_BUTTON_VISIBLE=true +``` + --- #### ⭐ Функционал @@ -1099,6 +1250,8 @@ REDIS_URL=redis://redis:6379/0 - 🧾 Динамический калькулятор стоимости - 💾 **Сквозная корзина** - сохранение параметров при недостатке баланса - ↩️ Быстрый возврат к оформлению после пополнения +- 🎯 **Режим тарифов** - готовые пакеты без сложного выбора +- 🛒 **Простая покупка** - упрощенный режим для быстрого оформления 🧪 **Тестовая подписка** - 🎁 Гибко настраиваемый триал и welcome-цепочка @@ -1106,6 +1259,7 @@ REDIS_URL=redis://redis:6379/0 - 💎 Автовыдача бонусов за кампании и инвайты - 🛡️ Контроль обязательной подписки на канал (отключает подписку при отписке) - 💎 Автовыдача сквада из пула выбранных +- 💳 **Платный триал** с выбором способа оплаты 💰 **Платежи и баланс** - ⭐ Telegram Stars @@ -1117,19 +1271,23 @@ REDIS_URL=redis://redis:6379/0 - 🦐 PayPalych/Pal24 (СБП + карты) - 💳 Platega (СБП + банковские карты) - 💳 **WATA** +- 💳 **Freekassa** (NSPK СБП + карты) +- 💳 **CloudPayments** (карты + СБП) - 🔥 Автогенерация счетов и webhook-уведомления -- 💼 История операций +- 💼 История операций - 🔄 Автоплатёж с настройкой дня списания - 🎁 Реферальные и промо-бонусы -- ⚡ **Быстрое пополнение** с кнопками выбора суммы +- ⚡ **Быстрое пополнение** с кнопками быстрых сумм +- 🔄 **Умная автоактивация** подписки после пополнения баланса 📱 **Управление подписками** -- 📈 Реальный трафик, устройства и серверы -- 🌐 Переключение серверов и стран +- 📈 Реальный трафик, устройства и серверы +- 🌐 Переключение серверов и стран - 📱 Сброс HWID - 🧩 Смена языка, промогруппы и параметров - 🧾 Просмотр активных услуг и статуса - 🔗 Получение ссылок подключения в один клик +- 📡 **Модем-режим** - дополнительные устройства для модемов 🛟 **Поддержка и самообслуживание** - 🎫 **Система тикетов** с вложениями @@ -1145,6 +1303,12 @@ REDIS_URL=redis://redis:6379/0 - 📊 Аналитика доходов и конверсии рефералов - 🔗 Генерация реферальных ссылок и QR кодов +🎮 **Конкурсы и игры** +- 🎲 **Ежедневные игры** с призами (баланс, подписка, промогруппа) +- 🏆 **Реферальные конкурсы** с рейтингами и наградами +- 📊 **Детальная статистика** участия в конкурсах +- 🎁 **Разнообразные призы** - баланс, дни подписки, промогруппы + 💎 **Промо-группы и скидки** - 🏷️ **Система промогрупп** с индивидуальными скидками с приоритетами - 💰 Скидки на серверы, трафик и устройства @@ -1162,6 +1326,7 @@ REDIS_URL=redis://redis:6379/0 - 📋 FAQ и юридические документы - 📥 Библиотека загрузочных ссылок для клиентов - 🛰️ Web API для внешних интеграций +- 🎯 **Отображение тарифа** при режиме тарифов @@ -1174,38 +1339,50 @@ REDIS_URL=redis://redis:6379/0 - 🧮 Продажи по тарифам, устройствам и странам - 📣 Эффективность кампаний, промокодов и UTM - 🎯 **Статистика по промо-группам** и скидочным уровням -- 📊 **Расширенная фильтрация** пользователей (баланс, траты, активность) +- 📊 **Расширенная фильтрация** пользователей (баланс, траты, активность, трафик) +- 🏆 **Топ рефереров** по периодам с детальной аналитикой +- 📡 **Мониторинг трафика** и выявление злоупотреблений 💥 **Управление пользователями** - 🔍 Поиск по ID, имени, юзернейму, Telegram ID и фильтры -- 💰 Ручное изменение баланса +- 💰 Ручное изменение баланса - 📱 Изменение лимитов устройств, трафика, серверов - 🔄 Сброс HWID и перегенерация подписки - 🎯 Назначение промогрупп и тарифов - 💳 **Покупка подписки пользователю** прямо из админки - ⏰ **Продление/сокращение срока** подписки (±365 дней) - 🚫 Блокировки с таймером и аудит действий -- 🛡️ **Защита от запрещенных никнеймов** с настраиваемым список банвордов (автоблокировка подозрительных имен) +- 🛡️ **Защита от запрещенных никнеймов** с настраиваемым список банвордов - 💰 **Установка индивидуального реферального процента юзеру** +- 💬 **Отправка сообщения** конкретному пользователю +- 🚫 **Система ограничений** - блокировка функций (покупка, триал, пополнение) +- 📊 **Расширенные фильтры** - по балансу, тратам, трафику, активности +- 🔄 **Готовы к продлению** - фильтр пользователей для продления +- 🗑️ **Черный список** пользователей с управлением 🎯 **Продажи, маркетинг и удержание** -- 🎫 Промокоды +- 🎫 Промокоды - 💳 Промо-группы со скидками - 🎁 **Персональные промо-предложения** с поиском получателей - 💸 **Тестовые серверы** - временная выдача доступа - 💰 **Автоматические скидки** при оплате -- 📣 **Рекламные кампании** с deeplink и бонусами (Автовыдача подписки / баланса при переходе) -- 📨 Рассылки по сегментам с медиа и кнопка -- 🎨 **Кастомные кнопки** для рассылок (подключение, подписка, поддержка, партнертка и тд) +- 📣 **Рекламные кампании** с deeplink и бонусами +- 📨 Рассылки по сегментам с медиа и кнопками +- 🎨 **Кастомные кнопки** для рассылок - 📘 Настройка главного меню и приветственных экранов +- 🎮 **Управление конкурсами** - создание и настройка призов +- 🎲 **Ежедневные игры** с настраиваемыми призами +- 📊 **Детальная статистика** реферальных конкурсов +- 👁️ **Видимость кнопок** - управление отображением конкурсов и реферальной программы 🛟 **Поддержка и модерация** - 🎫 **Центр тикетов** с приоритетами и статусами - ⏱️ **SLA таймеры** и автоуведомления -- 🧑‍⚖️ Роли модераторов с ограниченным доступом (без выдачи админ прав) +- 🧑‍⚖️ Роли модераторов с ограниченным доступом - 📊 Детальный журнал всех операций - 🚫 Блокировки нарушителей - 🧾 История диалогов и быстрые ответы +- 🔔 **Уведомления об ответах** на тикеты для админов 🔔 **Уведомления и коммуникации** - 📢 **Топики для событий** (покупки, триалы, техработы) @@ -1219,6 +1396,8 @@ REDIS_URL=redis://redis:6379/0 - 🔄 **Автосинхронизация Remnawave** по расписанию и при старте бота - ♻️ Проверка обновлений репозитория - 📊 **Мониторинг серверов** (интеграция с XrayChecker) +- 📝 **Ротация логов** - автоматическая очистка и архивация +- 🔄 **Массовая синхронизация** пользователей с Remnawave 🗄️ **Бекапы и восстановление** - 🗓️ **Умные автобекапы** с гибким расписанием @@ -1233,6 +1412,10 @@ REDIS_URL=redis://redis:6379/0 - 🧪 Тестовые платежи для каждого провайдера - 🪙 Управление вебхуками всех платёжных систем - ⚙️ **Управление настройками из бота** (с приоритетом в .env) +- 🎯 **Режим тарифов** - готовые планы с фиксированными параметрами +- 🛒 **Простая покупка подписки** - упрощенный режим без выбора параметров +- 👁️ **Скрытие кнопок** пополнения баланса +- 👁️ **Скрытие реферальной** программы ⚙️ **Remnawave** - Синхронизация юзеров из панели в бота (Ручная/автоматическая по таймеру) @@ -1265,14 +1448,17 @@ REDIS_URL=redis://redis:6379/0 - 📦 **Интеграция с Remnawave API** - автоматическое создание пользователей и синхронизация - 🔄 **Миграция сквадов** - массовый перенос пользователей между сквадами - 🧾 **История операций** - хранение всех транзакций и действий для аудита -- 💸 **Сервис автопрворки транзакций** - автоматическая проверка транзакций в статусе "В ожидании оплаты" за последние 24ч +- 💸 **Сервис автопроверки транзакций** - автоматическая проверка транзакций в статусе "В ожидании оплаты" за последние 24ч +- 🔄 **Умная автоактивация** - автоматическая активация подписки после пополнения баланса +- 📝 **Ротация логов** - автоматическая очистка и архивация старых логов +- 🎮 **Система конкурсов** - ежедневные игры и реферальные конкурсы с призами ### 🌐 Веб-API и мини-приложение - ⚙️ **FastAPI Web API** с эндпоинтами для управления всеми аспектами бота - 🔒 **Управление API-ключами** - выпуск, отзыв, реактивация токенов - 🛰️ **Mini App** - полноценный личный кабинет внутри Telegram -- 💳 **Интегрированные платежи** в Mini App (Stars, Pal24, YooKassa, WATA) +- 💳 **Интегрированные платежи** в Mini App (Stars, Pal24, YooKassa, WATA, Freekassa, CloudPayments) - 🧭 **Единый стандартный app-config.json** - централизованная раздача ссылок на клиенты - 🪙 **Платёжные вебхуки** - встроенные серверы для всех платёжных систем - 📡 **Мониторинг серверов** - REST-эндпоинты для просмотра нод и статистики @@ -1326,6 +1512,8 @@ REDIS_URL=redis://redis:6379/0 - **PayPalych**: Укажи Result URL `https://your-domain.com/pal24-webhook` в кабинете Pal24 - **Platega**: Настрой webhook на `https://your-domain.com/platega-webhook` - **WATA**: Настрой webhook на `https://your-domain.com/wata-webhook` + - **Freekassa**: Настрой webhook на `https://your-domain.com/freekassa-webhook` + - **CloudPayments**: Настрой Pay/Fail уведомления на `https://your-domain.com/cloudpayments-webhook` 4. **🔄 Настройка автосинхронизации** (опционально) - В `.env` установи `REMNAWAVE_AUTO_SYNC_ENABLED=true` @@ -1495,77 +1683,6 @@ REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD --- -## 💝 Благодарности - -### 🌟 **Топ спонсоры проекта** - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
🏆 Место👤 Спонсор💰 Сумма💬 Благодарность
🥇@SmartSystemCompany₽8,500За щедрую поддержку и вклад в развитие
🥈@pilot_737800₽7,750За веру в проект с самого начала
🥈@psych0O$60За щедрую поддержку и вклад в развитие
🥉@Vldmrmtn₽5,000За значительную поддержку проекта
4@k0tbtc₽3,000За поддержку и доверие
5@Legacyyy777₽2,900За ценные предложения и UX улучшения
6@sklvg₽3,000За международную поддержку
- -### 🌟 **Особая благодарность контрибьюторам** - -- **@yazhog** - легенда проекта! За крутые PR'ы, рефакторинг, автобекапы и навигацию -- **@Gy9vin** - за модульную архитектуру, быстрое пополнение, админ-функции и тестирование -- **@Legacyyy777** - за улучшения рассылки, PayPalych, MulenPay и UX фиксы -- **@SantaSpeen** - за актуализацию app-config.json и кучу рекомендаций -- **@PEDZEO** - за SLA поддержки и управление модераторами - ### 🎉 **Сообщество** - **Remnawave Team** - за отличную панель и стабильный API @@ -1649,12 +1766,12 @@ REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD | Метрика | Значение | |---------|----------| -| 💳 **Платёжных систем** | 9 (Stars, YooKassa, Tribute, CryptoBot, Heleket, MulenPay, Pal24, Platega, WATA) | +| 💳 **Платёжных систем** | 11 (Stars, YooKassa, Tribute, CryptoBot, Heleket, MulenPay, Pal24, Platega, WATA, Freekassa, CloudPayments) | | 🌍 **Языков интерфейса** | 2 (RU, EN) с возможностью расширения | | 📊 **Периодов подписки** | 6 (от 14 дней до года) | | 🎁 **Типов промо-акций** | 5 (коды, группы, предложения, скидки, кампании) | | 🔌 **REST API эндпоинтов** | 50+ для полного управления | -| 📱 **Режимов работы** | 2 (классический бот + MiniApp focus) | +| 📱 **Режимов работы** | 3 (классический + тарифы + MiniApp focus) | | 🛡️ **Методов авторизации** | 4 (API Key, Bearer, Basic Auth, eGames) | | 🗄️ **Способов хранения** | 2 (PostgreSQL, SQLite) с автовыбором | diff --git a/app/config.py b/app/config.py index 214befb3..4589d4d6 100644 --- a/app/config.py +++ b/app/config.py @@ -438,6 +438,10 @@ class Settings(BaseSettings): FREEKASSA_WEBHOOK_PATH: str = "/freekassa-webhook" FREEKASSA_WEBHOOK_HOST: str = "0.0.0.0" FREEKASSA_WEBHOOK_PORT: int = 8088 + # Способ оплаты: None = форма выбора, 42 = обычный СБП, 44 = NSPK СБП + FREEKASSA_PAYMENT_SYSTEM_ID: Optional[int] = None + # Использовать API для создания заказов (нужно для NSPK СБП) + FREEKASSA_USE_API: bool = False 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 efe663bc..10dc78a5 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -402,8 +402,9 @@ async def extend_subscription( logger.info(f"🌍 Обновлены сквады: {old_squads} → {connected_squads}") # В режиме fixed_with_topup при продлении сбрасываем трафик до фиксированного лимита - # Только если не передан traffic_limit_gb (т.е. не режим тарифов) - if traffic_limit_gb is None and settings.is_traffic_fixed() and days > 0: + # Только если не передан traffic_limit_gb И у подписки нет тарифа (классический режим) + # Если у подписки есть tariff_id - трафик определяется тарифом, не сбрасываем + if traffic_limit_gb is None and settings.is_traffic_fixed() and days > 0 and subscription.tariff_id is None: fixed_limit = settings.get_fixed_traffic_limit() old_limit = subscription.traffic_limit_gb if subscription.traffic_limit_gb != fixed_limit or (subscription.purchased_traffic_gb or 0) > 0: diff --git a/app/external/remnawave_api.py b/app/external/remnawave_api.py index 73501edb..d975141a 100644 --- a/app/external/remnawave_api.py +++ b/app/external/remnawave_api.py @@ -538,10 +538,25 @@ class RemnaWaveAPI: user = self._parse_user(response['response']) return await self.enrich_user_with_happ_link(user) - async def revoke_user_subscription(self, uuid: str, new_short_uuid: Optional[str] = None) -> RemnaWaveUser: + async def revoke_user_subscription( + self, + uuid: str, + new_short_uuid: Optional[str] = None, + revoke_only_passwords: bool = False + ) -> RemnaWaveUser: + """ + Отзывает подписку пользователя (меняет ссылку/пароли). + + Args: + uuid: UUID пользователя + new_short_uuid: Новый короткий UUID (опционально, рекомендуется генерировать автоматически) + revoke_only_passwords: Если True, меняются только пароли без изменения URL подписки + """ data = {} if new_short_uuid: data['shortUuid'] = new_short_uuid + if revoke_only_passwords: + data['revokeOnlyPasswords'] = True response = await self._make_request('POST', f'/api/users/{uuid}/actions/revoke', data) user = self._parse_user(response['response']) @@ -809,6 +824,19 @@ class RemnaWaveAPI: async def get_system_stats(self) -> Dict[str, Any]: response = await self._make_request('GET', '/api/system/stats') return response['response'] + + async def get_system_metadata(self) -> Dict[str, Any]: + """ + Получает метаданные системы Remnawave. + + Returns: + Dict с полями: + - version: версия Remnawave + - build: {time, number} - информация о сборке + - git: {backend: {commitSha}, node: {commitSha}} - информация о коммитах + """ + response = await self._make_request('GET', '/api/system/metadata') + return response['response'] async def get_bandwidth_stats(self) -> Dict[str, Any]: response = await self._make_request('GET', '/api/system/stats/bandwidth') diff --git a/app/handlers/subscription/tariff_purchase.py b/app/handlers/subscription/tariff_purchase.py index 28844fa3..4b27debd 100644 --- a/app/handlers/subscription/tariff_purchase.py +++ b/app/handlers/subscription/tariff_purchase.py @@ -33,9 +33,12 @@ def _format_traffic(gb: int) -> str: return f"{gb} ГБ" -def _format_price_kopeks(kopeks: int) -> str: +def _format_price_kopeks(kopeks: int, compact: bool = False) -> str: """Форматирует цену из копеек в рубли.""" rubles = kopeks / 100 + if compact: + # Компактный формат - округляем до рублей + return f"{int(round(rubles))}₽" if rubles == int(rubles): return f"{int(rubles)} ₽" return f"{rubles:.2f} ₽" @@ -77,33 +80,63 @@ def _get_user_period_discount(db_user: User, period_days: int) -> int: return personal_discount +def format_tariffs_list_text( + tariffs: List[Tariff], + db_user: Optional[User] = None, + has_period_discounts: bool = False, +) -> str: + """Форматирует текст со списком тарифов для отображения.""" + lines = ["📦 Выберите тариф"] + + if has_period_discounts: + lines.append("🎁 Скидки по периодам") + + lines.append("") + + for tariff in tariffs: + # Трафик компактно + traffic_gb = tariff.traffic_limit_gb + traffic = "∞" if traffic_gb == 0 else f"{traffic_gb}ГБ" + + # Цена + prices = tariff.period_prices or {} + price_text = "" + discount_icon = "" + if prices: + min_period = min(prices.keys(), key=int) + min_price = prices[min_period] + discount_percent = 0 + if db_user: + discount_percent = _get_user_period_discount(db_user, int(min_period)) + if discount_percent > 0: + min_price = _apply_promo_discount(min_price, discount_percent) + discount_icon = "🔥" + price_text = f"от {_format_price_kopeks(min_price, compact=True)}{discount_icon}" + + # Компактный формат: Название — 250ГБ/10📱 от 179₽🔥 + lines.append(f"{tariff.name} — {traffic}/{tariff.device_limit}📱 {price_text}") + + # Описание тарифа если есть + if tariff.description: + lines.append(f"{tariff.description}") + + lines.append("") + + return "\n".join(lines) + + def get_tariffs_keyboard( tariffs: List[Tariff], language: str, - discount_percent: int = 0, ) -> InlineKeyboardMarkup: - """Создает клавиатуру выбора тарифов.""" + """Создает компактную клавиатуру выбора тарифов (только названия).""" texts = get_texts(language) buttons = [] for tariff in tariffs: - # Берем минимальную цену для отображения - prices = tariff.period_prices or {} - if prices: - min_period = min(prices.keys(), key=int) - min_price = prices[min_period] - if discount_percent > 0: - min_price = _apply_promo_discount(min_price, discount_percent) - price_text = f"от {_format_price_kopeks(min_price)}" - else: - price_text = "" - - traffic = _format_traffic(tariff.traffic_limit_gb) - - button_text = f"📦 {tariff.name} • {traffic} • {tariff.device_limit} уст. {price_text}" buttons.append([ InlineKeyboardButton( - text=button_text, + text=f"📦 {tariff.name}", callback_data=f"tariff_select:{tariff.id}" ) ]) @@ -137,7 +170,7 @@ def get_tariff_periods_keyboard( if discount_percent > 0: original_price = price price = _apply_promo_discount(price, discount_percent) - price_text = f"{_format_price_kopeks(price)} (было {_format_price_kopeks(original_price)}, -{discount_percent}%)" + price_text = f"{_format_price_kopeks(price)} 🔥−{discount_percent}%" else: price_text = _format_price_kopeks(price) @@ -265,14 +298,12 @@ async def show_tariffs_list( if period_discounts and isinstance(period_discounts, dict) and len(period_discounts) > 0: has_period_discounts = True - discount_hint = "" - if has_period_discounts: - discount_hint = "\n\n🎁 Скидки зависят от выбранного периода" + # Формируем текст со списком тарифов и их характеристиками + tariffs_text = format_tariffs_list_text(tariffs, db_user, has_period_discounts) await callback.message.edit_text( - f"📦 Выберите тариф{discount_hint}\n\n" - "Выберите подходящий тариф из списка:", - reply_markup=get_tariffs_keyboard(tariffs, db_user.language, discount_percent=0), + tariffs_text, + reply_markup=get_tariffs_keyboard(tariffs, db_user.language), parse_mode="HTML" ) @@ -540,7 +571,7 @@ def get_tariff_extend_keyboard( if discount_percent > 0: original_price = price price = _apply_promo_discount(price, discount_percent) - price_text = f"{_format_price_kopeks(price)} (было {_format_price_kopeks(original_price)}, -{discount_percent}%)" + price_text = f"{_format_price_kopeks(price)} 🔥−{discount_percent}%" else: price_text = _format_price_kopeks(price) @@ -823,34 +854,73 @@ async def confirm_tariff_extend( # ==================== Переключение тарифов ==================== +def format_tariff_switch_list_text( + tariffs: List[Tariff], + current_tariff_id: Optional[int], + current_tariff_name: str, + db_user: Optional[User] = None, + has_period_discounts: bool = False, +) -> str: + """Форматирует текст со списком тарифов для переключения.""" + lines = [ + "📦 Смена тарифа", + f"📌 Текущий: {current_tariff_name}", + ] + + if has_period_discounts: + lines.append("🎁 Скидки по периодам") + + lines.append("") + lines.append("⚠️ Оплачивается полная стоимость.") + lines.append("") + + 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}ГБ" + + prices = tariff.period_prices or {} + price_text = "" + discount_icon = "" + if prices: + min_period = min(prices.keys(), key=int) + min_price = prices[min_period] + discount_percent = 0 + if db_user: + discount_percent = _get_user_period_discount(db_user, int(min_period)) + if discount_percent > 0: + min_price = _apply_promo_discount(min_price, discount_percent) + discount_icon = "🔥" + price_text = f"от {_format_price_kopeks(min_price, compact=True)}{discount_icon}" + + lines.append(f"{tariff.name} — {traffic}/{tariff.device_limit}📱 {price_text}") + + if tariff.description: + lines.append(f"{tariff.description}") + + lines.append("") + + return "\n".join(lines) + + def get_tariff_switch_keyboard( tariffs: List[Tariff], current_tariff_id: Optional[int], language: str, ) -> InlineKeyboardMarkup: - """Создает клавиатуру выбора тарифа для переключения.""" + """Создает компактную клавиатуру выбора тарифа для переключения.""" texts = get_texts(language) buttons = [] for tariff in tariffs: - # Пропускаем текущий тариф if tariff.id == current_tariff_id: continue - prices = tariff.period_prices or {} - if prices: - min_period = min(prices.keys(), key=int) - min_price = prices[min_period] - price_text = f"от {_format_price_kopeks(min_price)}" - else: - price_text = "" - - traffic = _format_traffic(tariff.traffic_limit_gb) - - button_text = f"📦 {tariff.name} • {traffic} • {tariff.device_limit} уст. {price_text}" buttons.append([ InlineKeyboardButton( - text=button_text, + text=f"📦 {tariff.name}", callback_data=f"tariff_sw_select:{tariff.id}" ) ]) @@ -884,7 +954,7 @@ def get_tariff_switch_periods_keyboard( if discount_percent > 0: original_price = price price = _apply_promo_discount(price, discount_percent) - price_text = f"{_format_price_kopeks(price)} (было {_format_price_kopeks(original_price)}, -{discount_percent}%)" + price_text = f"{_format_price_kopeks(price)} 🔥−{discount_percent}%" else: price_text = _format_price_kopeks(price) @@ -1002,16 +1072,13 @@ async def show_tariff_switch_list( if period_discounts and isinstance(period_discounts, dict) and len(period_discounts) > 0: has_period_discounts = True - discount_hint = "" - if has_period_discounts: - discount_hint = "\n🎁 Скидки зависят от выбранного периода" + # Формируем текст со списком тарифов + switch_text = format_tariff_switch_list_text( + tariffs, current_tariff_id, current_tariff_name, db_user, has_period_discounts + ) await callback.message.edit_text( - f"📦 Смена тарифа{discount_hint}\n\n" - f"📌 Ваш текущий тариф: {current_tariff_name}\n\n" - "⚠️ При смене тарифа оплачивается полная стоимость нового тарифа.\n" - "Остаток времени текущей подписки будет сохранён.\n\n" - "Выберите новый тариф:", + switch_text, reply_markup=get_tariff_switch_keyboard(tariffs, current_tariff_id, db_user.language), parse_mode="HTML" ) diff --git a/app/services/campaign_service.py b/app/services/campaign_service.py index c645b0fd..15e84d40 100644 --- a/app/services/campaign_service.py +++ b/app/services/campaign_service.py @@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database.crud.campaign import record_campaign_registration from app.database.crud.subscription import ( - create_trial_subscription, + create_paid_subscription, get_subscription_by_user_id, ) from app.database.crud.user import add_user_balance @@ -141,7 +141,7 @@ class AdvertisingCampaignService: squad_uuid = squads[0] if squads else None - new_subscription = await create_trial_subscription( + new_subscription = await create_paid_subscription( db=db, user_id=user.id, duration_days=duration_days, @@ -150,7 +150,6 @@ class AdvertisingCampaignService: connected_squads=squads, update_server_counters=True, is_trial=True, - squad_uuid=squad_uuid, ) try: diff --git a/app/services/freekassa_service.py b/app/services/freekassa_service.py index 3afc917d..6a94d91b 100644 --- a/app/services/freekassa_service.py +++ b/app/services/freekassa_service.py @@ -1,6 +1,7 @@ """Сервис для работы с API Freekassa.""" import hashlib +import hmac import time import logging from typing import Optional, Dict, Any, Set @@ -55,15 +56,31 @@ class FreekassaService: self._secret2 = settings.FREEKASSA_SECRET_WORD_2 return self._secret2 or "" + def _generate_api_signature_hmac(self, params: Dict[str, Any]) -> str: + """ + Генерирует подпись для API запроса (HMAC-SHA256). + Используется для API методов (создание заказа и т.д.) + """ + # Исключаем signature из параметров и сортируем по ключу + sign_data = {k: v for k, v in params.items() if k != "signature"} + sorted_items = sorted(sign_data.items()) + + # Формируем строку: значения через | + msg = "|".join(str(v) for _, v in sorted_items) + + # HMAC-SHA256 + return hmac.new( + self.api_key.encode("utf-8"), + msg.encode("utf-8"), + hashlib.sha256 + ).hexdigest() + def _generate_api_signature(self, params: Dict[str, Any]) -> str: """ Генерирует подпись для API запроса. - Сортировка по ключам, конкатенация значений через | + Для новых API методов используется HMAC-SHA256. """ - sorted_keys = sorted(params.keys()) - values = [str(params[k]) for k in sorted_keys if params[k] is not None] - sign_string = "|".join(values) - return hashlib.md5(sign_string.encode()).hexdigest() + return self._generate_api_signature_hmac(params) def generate_form_signature( self, amount: float, currency: str, order_id: str @@ -72,7 +89,9 @@ class FreekassaService: Генерирует подпись для платежной формы. Формат: MD5(shop_id:amount:secret1:currency:order_id) """ - sign_string = f"{self.shop_id}:{amount}:{self.secret1}:{currency}:{order_id}" + # Приводим amount к int, если это целое число + final_amount = int(amount) if float(amount).is_integer() else amount + sign_string = f"{self.shop_id}:{final_amount}:{self.secret1}:{currency}:{order_id}" return hashlib.md5(sign_string.encode()).hexdigest() def verify_webhook_signature( @@ -82,8 +101,10 @@ class FreekassaService: Проверяет подпись webhook уведомления. Формат: MD5(shop_id:amount:secret2:order_id) """ + # Приводим amount к int, если это целое число + final_amount = int(amount) if float(amount).is_integer() else amount expected_sign = hashlib.md5( - f"{shop_id}:{amount}:{self.secret2}:{order_id}".encode() + f"{shop_id}:{final_amount}:{self.secret2}:{order_id}".encode() ).hexdigest() return sign.lower() == expected_sign.lower() @@ -102,13 +123,16 @@ class FreekassaService: lang: str = "ru", ) -> str: """ - Формирует URL для перенаправления на оплату. + Формирует URL для перенаправления на оплату (форма выбора). + Используется когда FREEKASSA_USE_API = False. """ - signature = self.generate_form_signature(amount, currency, order_id) + # Приводим amount к int, если это целое число + final_amount = int(amount) if float(amount).is_integer() else amount + signature = self.generate_form_signature(final_amount, currency, order_id) params = { "m": self.shop_id, - "oa": amount, + "oa": final_amount, "currency": currency, "o": order_id, "s": signature, @@ -119,8 +143,11 @@ class FreekassaService: params["em"] = email if phone: params["phone"] = phone - if payment_system_id: - params["i"] = payment_system_id + + # Используем payment_system_id из настроек, если не передан явно + ps_id = payment_system_id or settings.FREEKASSA_PAYMENT_SYSTEM_ID + if ps_id: + params["i"] = ps_id query = "&".join(f"{k}={v}" for k, v in params.items()) return f"https://pay.freekassa.ru/?{query}" @@ -140,27 +167,32 @@ class FreekassaService: """ Создает заказ через API Freekassa. POST /orders/create + + Используется для NSPK СБП (payment_system_id=44) и других методов. + Возвращает словарь с 'location' (ссылка на оплату). """ + # Приводим 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 or 1 + params = { "shopId": self.shop_id, - "nonce": int(time.time() * 1000), - "paymentId": order_id, - "i": payment_system_id or 1, + "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", - "amount": amount, + "amount": final_amount, "currency": currency, } - if success_url: - params["success_url"] = success_url - if failure_url: - params["failure_url"] = failure_url - if notification_url: - params["notification_url"] = notification_url - + # Генерируем подпись HMAC-SHA256 params["signature"] = self._generate_api_signature(params) + logger.info(f"Freekassa API create_order params: {params}") + try: async with aiohttp.ClientSession() as session: async with session.post( @@ -169,6 +201,9 @@ class FreekassaService: headers={"Content-Type": "application/json"}, timeout=aiohttp.ClientTimeout(total=30), ) as response: + text = await response.text() + logger.info(f"Freekassa API response: {text}") + data = await response.json() if response.status != 200 or data.get("type") == "error": @@ -182,6 +217,32 @@ class FreekassaService: logger.exception(f"Freekassa API connection error: {e}") raise + async def create_order_and_get_url( + self, + order_id: str, + amount: float, + currency: str = "RUB", + email: Optional[str] = None, + ip: Optional[str] = None, + payment_system_id: Optional[int] = None, + ) -> str: + """ + Создает заказ через API и возвращает URL для оплаты. + Удобный метод для получения только ссылки. + """ + result = await self.create_order( + order_id=order_id, + amount=amount, + currency=currency, + email=email, + ip=ip, + payment_system_id=payment_system_id, + ) + location = result.get("location") + if not location: + raise Exception("Freekassa API did not return payment URL (location)") + return location + async def get_order_status(self, order_id: str) -> Dict[str, Any]: """ Получает статус заказа. @@ -189,11 +250,13 @@ class FreekassaService: """ params = { "shopId": self.shop_id, - "nonce": int(time.time() * 1000), - "paymentId": order_id, + "nonce": int(time.time_ns()), + "paymentId": str(order_id), } params["signature"] = self._generate_api_signature(params) + logger.info(f"Freekassa get_order_status params: {params}") + try: async with aiohttp.ClientSession() as session: async with session.post( @@ -202,6 +265,8 @@ class FreekassaService: headers={"Content-Type": "application/json"}, timeout=aiohttp.ClientTimeout(total=30), ) as response: + text = await response.text() + logger.info(f"Freekassa get_order_status response: {text}") return await response.json() except aiohttp.ClientError as e: logger.exception(f"Freekassa API connection error: {e}") @@ -211,7 +276,7 @@ class FreekassaService: """Получает баланс магазина.""" params = { "shopId": self.shop_id, - "nonce": int(time.time() * 1000), + "nonce": int(time.time_ns()), } params["signature"] = self._generate_api_signature(params) @@ -232,7 +297,7 @@ class FreekassaService: """Получает список доступных платежных систем.""" params = { "shopId": self.shop_id, - "nonce": int(time.time() * 1000), + "nonce": int(time.time_ns()), } params["signature"] = self._generate_api_signature(params) diff --git a/app/services/payment/freekassa.py b/app/services/payment/freekassa.py index 1f91d88b..163b2734 100644 --- a/app/services/payment/freekassa.py +++ b/app/services/payment/freekassa.py @@ -90,14 +90,30 @@ class FreekassaPaymentMixin: } try: - # Генерируем URL для оплаты - payment_url = freekassa_service.build_payment_url( - order_id=order_id, - amount=amount_rubles, - currency=currency, - email=email, - lang=language, - ) + # Выбираем способ создания платежа: API или форма + if settings.FREEKASSA_USE_API: + # Используем API для создания заказа (нужно для NSPK СБП) + payment_url = await freekassa_service.create_order_and_get_url( + order_id=order_id, + amount=amount_rubles, + currency=currency, + email=email, + payment_system_id=settings.FREEKASSA_PAYMENT_SYSTEM_ID, + ) + logger.info( + "Freekassa API: создан заказ order_id=%s, url=%s", + order_id, + payment_url, + ) + else: + # Генерируем URL для формы оплаты (стандартный способ) + payment_url = freekassa_service.build_payment_url( + order_id=order_id, + amount=amount_rubles, + currency=currency, + email=email, + lang=language, + ) # Импортируем CRUD модуль freekassa_crud = import_module("app.database.crud.freekassa") @@ -116,11 +132,12 @@ class FreekassaPaymentMixin: ) logger.info( - "Freekassa: создан платеж order_id=%s, user_id=%s, amount=%s %s", + "Freekassa: создан платеж order_id=%s, user_id=%s, amount=%s %s, use_api=%s", order_id, user_id, amount_rubles, currency, + settings.FREEKASSA_USE_API, ) return { diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index f6beb775..2f6acade 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -5052,6 +5052,8 @@ async def get_subscription_renewal_options_endpoint( autopay_days_options=renewal_autopay_days_options, autopay=renewal_autopay_payload, autopay_settings=renewal_autopay_payload, + is_trial=bool(getattr(subscription, "is_trial", False)), + sales_mode=settings.get_sales_mode(), **renewal_autopay_extras, ) diff --git a/app/webapi/schemas/miniapp.py b/app/webapi/schemas/miniapp.py index 73360418..e33ad8b1 100644 --- a/app/webapi/schemas/miniapp.py +++ b/app/webapi/schemas/miniapp.py @@ -201,6 +201,9 @@ class MiniAppSubscriptionRenewalOptionsResponse(BaseModel): autopay_days_options: List[int] = Field(default_factory=list) autopay: Optional[MiniAppSubscriptionAutopay] = None autopay_settings: Optional[MiniAppSubscriptionAutopay] = None + # Флаги для определения типа действия (покупка vs продление) + is_trial: bool = Field(default=False, alias="isTrial") + sales_mode: str = Field(default="classic", alias="salesMode") model_config = ConfigDict(populate_by_name=True, extra="allow") diff --git a/docs/web-api-analysis-and-suggestions.md b/docs/web-api-analysis-and-suggestions.md deleted file mode 100644 index 79aca506..00000000 --- a/docs/web-api-analysis-and-suggestions.md +++ /dev/null @@ -1,360 +0,0 @@ -# Анализ существующих API и предложения по расширению - -## Обзор существующих API эндпоинтов - -### 1. Health & Monitoring (health.py) -- ✅ `GET /health` - статус API и версия бота -- ✅ `GET /health/database` - состояние БД -- ✅ `GET /metrics/pool` - метрики пула подключений - -### 2. Statistics (stats.py) -- ✅ `GET /stats/overview` - общая статистика -- ✅ `GET /stats/full` - полная статистика с детализацией - -### 3. Settings (config.py) -- ✅ `GET /settings/categories` - категории настроек -- ✅ `GET /settings` - список всех настроек -- ✅ `GET /settings/{key}` - получить настройку -- ✅ `PUT /settings/{key}` - обновить настройку -- ✅ `DELETE /settings/{key}` - сбросить настройку - -### 4. Users (users.py) -- ✅ `GET /users` - список пользователей (с фильтрами) -- ✅ `GET /users/{id}` - детали пользователя (поддерживает telegram_id) -- ✅ `GET /users/by-telegram-id/{telegram_id}` - получить по Telegram ID -- ✅ `POST /users` - создать пользователя -- ✅ `PATCH /users/{id}` - обновить пользователя -- ✅ `POST /users/{id}/balance` - корректировка баланса - -### 5. Subscriptions (subscriptions.py) -- ✅ `GET /subscriptions` - список подписок -- ✅ `GET /subscriptions/{id}` - детали подпискит -- ✅ `POST /subscriptions` - создать подписку -- ✅ `POST /subscriptions/{id}/extend` - продлить подписку -- ✅ `POST /subscriptions/{id}/traffic` - добавить трафик -- ✅ `POST /subscriptions/{id}/devices` - добавить устройства -- ✅ `POST /subscriptions/{id}/squads` - привязать сквад -- ✅ `DELETE /subscriptions/{id}/squads/{uuid}` - удалить сквад - -### 6. Support/Tickets (tickets.py) -- ✅ `GET /tickets` - список тикетов -- ✅ `GET /tickets/{id}` - детали тикета с сообщениями -- ✅ `POST /tickets/{id}/status` - изменить статус -- ✅ `POST /tickets/{id}/priority` - изменить приоритет -- ✅ `POST /tickets/{id}/reply-block` - заблокировать ответы -- ✅ `DELETE /tickets/{id}/reply-block` - снять блокировку -- ✅ `POST /tickets/{id}/reply` - ответить на тикет -- ✅ `GET /tickets/{id}/messages/{message_id}/media` - получить медиа - -### 7. Transactions (transactions.py) -- ✅ `GET /transactions` - история транзакций - -### 8. Promo Groups (promo_groups.py) -- ✅ Полный CRUD для промо-групп - -### 9. Promo Offers (promo_offers.py) -- ✅ Управление промо-предложениями, шаблонами и логами - -### 10. Promocodes (promocodes.py) -- ✅ Полный CRUD для промокодов - -### 11. Servers (servers.py) -- ✅ Управление серверами RemnaWave - -### 12. RemnaWave Integration (remnawave.py) -- ✅ Статус, ноды, сквады, синхронизация - -### 13. Contests (contests.py) -- ✅ Управление конкурсами (реферальные, ежедневные) - -### 14. Campaigns (campaigns.py) -- ✅ Управление кампаниями - -### 15. Broadcasts (broadcasts.py) -- ✅ `POST /broadcasts` - создать рассылку -- ✅ `GET /broadcasts` - список рассылок -- ✅ `POST /broadcasts/{id}/stop` - остановить рассылку - -### 16. Menu Layout (menu_layout.py) -- ✅ Полное управление меню, статистика кликов, история - -### 17. Main Menu (main_menu_buttons.py, user_messages.py) -- ✅ Управление кнопками и сообщениями главного меню - -### 18. Welcome Texts (welcome_texts.py) -- ✅ CRUD для приветственных текстов - -### 19. Pages (pages.py) -- ✅ Управление публичными страницами - -### 20. Partners (partners.py) -- ✅ Статистика реферальной программы - -### 21. Polls (polls.py) -- ✅ Управление опросами - -### 22. Logs (logs.py) -- ✅ Логи мониторинга, поддержки и системные - -### 23. Tokens (tokens.py) -- ✅ Управление токенами доступа - -### 24. Media (media.py) -- ✅ Загрузка медиа - -### 25. Miniapp (miniapp.py) -- ✅ Информация о подписке для Mini App - -### 26. Subscription Events (subscription_events.py) -- ✅ Уведомления о событиях подписок - -### 27. Support Settings (support_settings.py) -- ✅ Настройки поддержки - -### 28. Backups (backups.py) -- ✅ Управление бэкапами - ---- - -## Предложения по расширению API - -### 🔴 Высокий приоритет - -#### 1. Расширенная аналитика и отчеты -**Эндпоинты:** -- `GET /analytics/revenue` - доходы по периодам (день/неделя/месяц/год) -- `GET /analytics/users/growth` - динамика роста пользователей -- `GET /analytics/subscriptions/conversion` - конверсия триалов в платные -- `GET /analytics/churn` - анализ оттока пользователей -- `GET /analytics/retention` - анализ удержания пользователей -- `GET /analytics/export` - экспорт данных в CSV/Excel - -**Польза:** Глубокая аналитика для принятия бизнес-решений - -#### 2. Массовые операции с пользователями -**Эндпоинты:** -- `POST /users/batch/update` - массовое обновление пользователей -- `POST /users/batch/balance` - массовая корректировка балансов -- `POST /users/batch/status` - массовое изменение статусов -- `POST /users/batch/promo-group` - массовое назначение промо-групп -- `POST /users/export` - экспорт списка пользователей - -**Польза:** Эффективное управление большими группами пользователей - -#### 3. Расширенное управление подписками -**Эндпоинты:** -- `POST /subscriptions/{id}/pause` - приостановить подписку -- `POST /subscriptions/{id}/resume` - возобновить подписку -- `POST /subscriptions/{id}/cancel` - отменить подписку -- `POST /subscriptions/{id}/transfer` - перенести подписку другому пользователю -- `GET /subscriptions/{id}/history` - история изменений подписки -- `POST /subscriptions/batch/extend` - массовое продление подписок -- `GET /subscriptions/expiring` - подписки, истекающие в ближайшее время - -**Польза:** Гибкое управление жизненным циклом подписок - -#### 4. Улучшенная работа с тикетами -**Эндпоинты:** -- `GET /tickets/stats` - статистика по тикетам (среднее время ответа, распределение по приоритетам) -- `POST /tickets/{id}/assign` - назначить тикет модератору -- `GET /tickets/assigned/{admin_id}` - тикеты, назначенные модератору -- `POST /tickets/{id}/notes` - добавить внутреннюю заметку (не видна пользователю) -- `GET /tickets/{id}/notes` - получить заметки -- `POST /tickets/batch/close` - массовое закрытие тикетов -- `GET /tickets/search` - расширенный поиск по тикетам - -**Польза:** Улучшенная организация работы поддержки - -#### 5. Управление уведомлениями -**Эндпоинты:** -- `GET /notifications` - список всех уведомлений (расширенный) -- `GET /notifications/types` - типы уведомлений -- `POST /notifications/mark-read` - отметить как прочитанное -- `GET /notifications/unread-count` - количество непрочитанных -- `POST /notifications/settings` - настройки уведомлений для админа - -**Польза:** Централизованное управление уведомлениями в админке - -### 🟡 Средний приоритет - -#### 6. Управление платежными системами -**Эндпоинты:** -- `GET /payments/methods` - список доступных методов оплаты -- `GET /payments/methods/{id}/stats` - статистика по методу оплаты -- `POST /payments/methods/{id}/toggle` - включить/выключить метод -- `GET /payments/failed` - список неудачных платежей -- `POST /payments/{id}/retry` - повторить платеж -- `GET /payments/refunds` - список возвратов - -**Польза:** Мониторинг и управление платежами - -#### 7. Автоматизация и задачи -**Эндпоинты:** -- `GET /automation/tasks` - список автоматических задач -- `POST /automation/tasks` - создать задачу -- `PATCH /automation/tasks/{id}` - обновить задачу -- `DELETE /automation/tasks/{id}` - удалить задачу -- `POST /automation/tasks/{id}/run` - запустить задачу вручную -- `GET /automation/tasks/{id}/history` - история выполнения - -**Польза:** Автоматизация рутинных операций - -#### 8. Управление контентом -**Эндпоинты:** -- `GET /content/templates` - список шаблонов сообщений -- `POST /content/templates` - создать шаблон -- `PATCH /content/templates/{id}` - обновить шаблон -- `DELETE /content/templates/{id}` - удалить шаблон -- `POST /content/templates/{id}/preview` - предпросмотр шаблона -- `GET /content/media-library` - библиотека медиа-файлов -- `POST /content/media-library` - загрузить в библиотеку - -**Польза:** Централизованное управление контентом - -#### 9. Аудит и безопасность -**Эндпоинты:** -- `GET /audit/logs` - журнал действий администраторов -- `GET /audit/logs/{admin_id}` - действия конкретного админа -- `GET /audit/sessions` - активные сессии API -- `POST /audit/sessions/{id}/revoke` - отозвать сессию -- `GET /security/events` - события безопасности -- `POST /security/block-ip` - заблокировать IP -- `GET /security/blocked-ips` - список заблокированных IP - -**Польза:** Безопасность и отслеживание действий - -#### 10. Интеграции и вебхуки -**Эндпоинты:** -- `GET /integrations` - список интеграций -- `POST /integrations` - создать интеграцию -- `PATCH /integrations/{id}` - обновить интеграцию -- `DELETE /integrations/{id}` - удалить интеграцию -- `GET /integrations/{id}/webhooks` - вебхуки интеграции -- `POST /integrations/{id}/webhooks` - создать вебхук -- `GET /integrations/{id}/logs` - логи интеграции -- `POST /integrations/{id}/test` - протестировать интеграцию - -**Польза:** Расширение функциональности через интеграции - -### 🟢 Низкий приоритет - -#### 11. Экспорт и импорт данных -**Эндпоинты:** -- `POST /export/users` - экспорт пользователей -- `POST /export/subscriptions` - экспорт подписок -- `POST /export/transactions` - экспорт транзакций -- `POST /import/users` - импорт пользователей -- `GET /import/templates` - шаблоны для импорта -- `GET /import/{id}/status` - статус импорта - -**Польза:** Работа с большими объемами данных - -#### 12. Тестирование и разработка -**Эндпоинты:** -- `POST /dev/test-notification` - отправить тестовое уведомление -- `POST /dev/simulate-payment` - симулировать платеж -- `POST /dev/create-test-user` - создать тестового пользователя -- `GET /dev/test-endpoints` - список тестовых эндпоинтов - -**Польза:** Упрощение разработки и тестирования - -#### 13. Расширенная статистика по меню -**Эндпоинты:** -- `GET /menu-layout/stats/funnels` - воронки использования меню -- `GET /menu-layout/stats/heatmap` - тепловая карта кликов -- `GET /menu-layout/stats/ab-test` - A/B тестирование кнопок - -**Польза:** Оптимизация UX меню - -#### 14. Управление версиями и обновлениями -**Эндпоинты:** -- `GET /system/version` - версия системы -- `GET /system/updates` - доступные обновления -- `POST /system/updates/check` - проверить обновления -- `GET /system/changelog` - история изменений - -**Польза:** Управление версиями бота - -#### 15. Расширенная работа с промокодами -**Эндпоинты:** -- `GET /promo-codes/stats/usage` - статистика использования -- `POST /promo-codes/batch/create` - массовое создание -- `GET /promo-codes/{id}/users` - пользователи, использовавшие промокод -- `POST /promo-codes/{id}/deactivate` - деактивировать промокод - -**Польза:** Более гибкое управление промокодами - ---- - -## Рекомендации по приоритизации - -### Фаза 1 (Следующие 2-4 недели) -1. Расширенная аналитика и отчеты (п.1) -2. Массовые операции с пользователями (п.2) -3. Расширенное управление подписками (п.3) - -### Фаза 2 (Следующие 1-2 месяца) -4. Улучшенная работа с тикетами (п.4) -5. Управление уведомлениями (п.5) -6. Управление платежными системами (п.6) - -### Фаза 3 (Долгосрочная перспектива) -7. Автоматизация и задачи (п.7) -8. Управление контентом (п.8) -9. Аудит и безопасность (п.9) -10. Интеграции и вебхуки (п.10) - ---- - -## Технические улучшения - -### 1. WebSocket поддержка -- Real-time обновления для дашборда -- Уведомления о новых тикетах -- Мониторинг рассылок в реальном времени - -### 2. GraphQL endpoint -- Альтернатива REST для сложных запросов -- Более гибкая выборка данных - -### 3. Rate limiting по токенам -- Разные лимиты для разных токенов -- Защита от злоупотреблений - -### 4. Версионирование API -- `/v1/`, `/v2/` для обратной совместимости -- Плавная миграция на новые версии - -### 5. Webhooks для событий -- Подписка на события (новый пользователь, платеж, тикет) -- Автоматические уведомления внешних систем - -### 6. Расширенная документация -- Примеры использования для каждого эндпоинта -- Postman коллекция -- SDK для популярных языков (Python, JavaScript) - ---- - -## Метрики для отслеживания использования API - -### Предлагаемые эндпоинты для мониторинга: -- `GET /api/metrics/usage` - статистика использования API -- `GET /api/metrics/endpoints` - популярность эндпоинтов -- `GET /api/metrics/errors` - статистика ошибок -- `GET /api/metrics/performance` - производительность API - ---- - -## Заключение - -Текущий API уже достаточно функционален и покрывает основные потребности веб-админки. Предложенные расширения помогут: - -1. **Улучшить аналитику** - для принятия обоснованных бизнес-решений -2. **Повысить эффективность** - массовые операции и автоматизация -3. **Улучшить UX** - более гибкое управление подписками и тикетами -4. **Повысить безопасность** - аудит и мониторинг -5. **Расширить возможности** - интеграции и вебхуки - -Рекомендуется начать с Фазы 1, так как эти функции наиболее востребованы для ежедневной работы администраторов. - diff --git a/miniapp/index.html b/miniapp/index.html index e07f5459..acab2673 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -650,6 +650,108 @@ color: inherit; } + /* Tariff Name Styles */ + .tariff-name-badge { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + border-radius: 10px; + font-weight: 700; + font-size: 15px; + letter-spacing: 0.3px; + background: linear-gradient(135deg, rgba(var(--primary-rgb), 0.15), rgba(var(--primary-rgb), 0.05)); + color: var(--text-primary); + position: relative; + overflow: hidden; + } + + .tariff-name-badge::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(135deg, transparent 40%, rgba(255,255,255,0.1) 50%, transparent 60%); + opacity: 0; + transition: opacity 0.3s ease; + } + + .subscription-settings-toggle:hover .tariff-name-badge::before { + opacity: 1; + } + + .tariff-name-badge.tariff-premium { + background: linear-gradient(135deg, #FFD700 0%, #FFA500 50%, #FF8C00 100%); + color: #1a1a2e; + box-shadow: 0 2px 8px rgba(255, 165, 0, 0.3); + } + + .tariff-name-badge.tariff-current { + background: linear-gradient(135deg, rgba(34, 197, 94, 0.2), rgba(34, 197, 94, 0.08)); + border: 1px solid rgba(34, 197, 94, 0.3); + } + + .tariff-name-badge .tariff-icon { + font-size: 16px; + line-height: 1; + } + + .tariff-name-badge .tariff-text { + line-height: 1.2; + } + + .tariff-name-badge .tariff-check { + color: #22c55e; + font-weight: 700; + } + + /* Current tariff block name */ + .tariff-current-name { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 14px; + border-radius: 12px; + font-weight: 700; + font-size: 15px; + background: linear-gradient(135deg, rgba(34, 197, 94, 0.15), rgba(34, 197, 94, 0.05)); + border: 1px solid rgba(34, 197, 94, 0.25); + color: #22c55e; + } + + .tariff-current-name .check-icon { + font-size: 14px; + } + + :root[data-theme="dark"] .tariff-name-badge { + background: linear-gradient(135deg, rgba(var(--primary-rgb), 0.25), rgba(var(--primary-rgb), 0.1)); + } + + :root[data-theme="dark"] .tariff-name-badge.tariff-premium { + background: linear-gradient(135deg, #FFD700 0%, #FFA500 50%, #FF8C00 100%); + color: #1a1a2e; + } + + :root[data-theme="dark"] .tariff-name-badge.tariff-current { + background: linear-gradient(135deg, rgba(34, 197, 94, 0.25), rgba(34, 197, 94, 0.1)); + } + + :root[data-theme="dark"] .tariff-current-name { + background: linear-gradient(135deg, rgba(34, 197, 94, 0.2), rgba(34, 197, 94, 0.08)); + } + + .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); + } + + .subscription-settings-toggle.active .tariff-name-badge.tariff-premium { + background: linear-gradient(135deg, #FFD700 0%, #FFA500 50%, #FF8C00 100%); + box-shadow: 0 3px 12px rgba(255, 165, 0, 0.4); + } + .subscription-settings-actions { display: flex; flex-wrap: wrap; @@ -4541,6 +4643,7 @@ @media (max-width: 480px) { .container { padding: 12px; + padding-bottom: 100px; } .promo-offer-card { @@ -4945,6 +5048,10 @@ Type - +
Device Limit - @@ -5159,13 +5266,29 @@
@@ -5729,6 +5852,7 @@ 'info.traffic_used': 'Traffic used', 'info.traffic_limit': 'Traffic limit', 'info.subscription_type': 'Subscription type', + 'info.current_tariff': 'Current tariff', 'info.promo_group': 'Promo group', 'info.device_limit': 'Device limit', 'info.autopay': 'Auto-pay', @@ -5903,7 +6027,11 @@ 'subscription_settings.confirm.months.one': '{count} month', 'subscription_settings.confirm.months.other': '{count} months', 'subscription_renewal.title': 'Renew subscription', + 'subscription_renewal.title.trial_classic': 'Buy subscription', + 'subscription_renewal.title.trial_tariffs': 'Buy tariff', 'subscription_renewal.subtitle': 'Choose how long you want to extend access.', + 'subscription_renewal.subtitle.trial_classic': 'Choose a period to get started.', + 'subscription_renewal.subtitle.trial_tariffs': 'Choose a period to get started.', 'subscription_renewal.status.loading': 'Loading renewal options…', 'subscription_renewal.status.empty': 'No renewal options are currently available.', 'subscription_renewal.error.generic': 'Unable to load renewal options. Please try again later.', @@ -5915,9 +6043,12 @@ 'subscription_renewal.summary.discount': 'You save {percent}%', 'subscription_renewal.summary.insufficient': 'Not enough balance — missing {missing}.', 'subscription_renewal.submit': 'Renew', + 'subscription_renewal.submit.trial': 'Buy', 'subscription_renewal.submit.loading': 'Processing…', 'subscription_renewal.confirm.title': 'Confirm renewal', + 'subscription_renewal.confirm.title.trial': 'Confirm purchase', 'subscription_renewal.confirm.message': 'Renew the subscription for {period} and pay {amount}?', + 'subscription_renewal.confirm.message.trial': 'Buy subscription for {period} and pay {amount}?', 'subscription_renewal.confirm.balance': 'Current balance: {balance}', 'subscription_renewal.confirm.accept': 'Pay', 'subscription_renewal.confirm.cancel': 'Cancel', @@ -5953,6 +6084,10 @@ 'promo_code.error.generic': 'Unable to activate the promo code.', 'promo_code.error.unauthorized': 'Authorization failed. Please reopen the mini app from Telegram.', 'promo_code.error.network': 'Network error. Please try again later.', + 'tariffs.title': 'Tariffs', + 'tariffs.select': 'Select tariff', + 'tariffs.current': 'Current tariff', + 'tariffs.no_tariffs': 'No tariffs available', 'card.referral.title': 'Referral Program', 'card.history.title': 'Transaction History', 'card.servers.title': 'Connected Servers', @@ -6153,6 +6288,7 @@ 'info.traffic_used': 'Использовано трафика', 'info.traffic_limit': 'Лимит трафика', 'info.subscription_type': 'Тип подписки', + 'info.current_tariff': 'Текущий тариф', 'info.promo_group': 'Уровень', 'info.device_limit': 'Лимит устройств', 'info.autopay': 'Автоплатеж', @@ -6327,7 +6463,11 @@ 'subscription_settings.confirm.months.one': '{count} месяц', 'subscription_settings.confirm.months.other': '{count} месяцев', 'subscription_renewal.title': 'Продление подписки', + 'subscription_renewal.title.trial_classic': 'Покупка подписки', + 'subscription_renewal.title.trial_tariffs': 'Покупка тарифа', 'subscription_renewal.subtitle': 'Выберите период продления.', + 'subscription_renewal.subtitle.trial_classic': 'Выберите период для начала.', + 'subscription_renewal.subtitle.trial_tariffs': 'Выберите период для начала.', 'subscription_renewal.status.loading': 'Загружаем доступные периоды…', 'subscription_renewal.status.empty': 'Доступных периодов продления нет.', 'subscription_renewal.error.generic': 'Не удалось загрузить параметры продления. Попробуйте позже.', @@ -6339,9 +6479,12 @@ 'subscription_renewal.summary.discount': 'Выгода {percent}%', 'subscription_renewal.summary.insufficient': 'Недостаточно средств — не хватает {missing}.', 'subscription_renewal.submit': 'Продлить', + 'subscription_renewal.submit.trial': 'Купить', 'subscription_renewal.submit.loading': 'Продление…', 'subscription_renewal.confirm.title': 'Подтвердите продление', + 'subscription_renewal.confirm.title.trial': 'Подтвердите покупку', 'subscription_renewal.confirm.message': 'Продлить подписку на {period} и списать {amount}?', + 'subscription_renewal.confirm.message.trial': 'Купить подписку на {period} и списать {amount}?', 'subscription_renewal.confirm.balance': 'Текущий баланс: {balance}', 'subscription_renewal.confirm.accept': 'Оплатить', 'subscription_renewal.confirm.cancel': 'Отмена', @@ -6377,6 +6520,10 @@ 'promo_code.error.generic': 'Не удалось активировать промокод.', 'promo_code.error.unauthorized': 'Ошибка авторизации. Откройте мини-приложение из Telegram.', 'promo_code.error.network': 'Ошибка сети. Попробуйте ещё раз.', + 'tariffs.title': 'Тарифы', + 'tariffs.select': 'Выбрать тариф', + 'tariffs.current': 'Текущий тариф', + 'tariffs.no_tariffs': 'Нет доступных тарифов', 'card.referral.title': 'Реферальная программа', 'card.history.title': 'История операций', 'card.servers.title': 'Подключённые серверы', @@ -8456,6 +8603,20 @@ : subscriptionTypeLabel; } + // Текущий тариф (только в режиме tariffs) + const currentTariffRow = document.getElementById('currentTariffRow'); + const currentTariffNameEl = document.getElementById('currentTariffName'); + if (currentTariffRow && currentTariffNameEl) { + const salesMode = userData?.salesMode ?? userData?.sales_mode ?? 'classic'; + const currentTariff = userData?.current_tariff ?? userData?.currentTariff; + if (salesMode === 'tariffs' && currentTariff && currentTariff.name) { + currentTariffRow.classList.remove('hidden'); + currentTariffNameEl.textContent = currentTariff.name; + } else { + currentTariffRow.classList.add('hidden'); + } + } + const autopayElement = document.getElementById('autopayStatus'); if (autopayElement) { const autopayKey = userData?.autopay_enabled ? 'autopay.enabled' : 'autopay.disabled'; @@ -13694,6 +13855,30 @@ return typeRaw === 'paid'; } + function isTrialSubscription() { + if (!userData?.user) { + return false; + } + + const statusRaw = String( + userData.user.subscription_actual_status + || userData.user.subscription_status + || '' + ).toLowerCase(); + + if (['trial', 'trialing'].includes(statusRaw)) { + return true; + } + + const typeRaw = String( + userData.subscription_type + ?? userData.user.subscription_type + ?? '' + ).toLowerCase(); + + return typeRaw === 'trial'; + } + function normalizeServerEntry(entry) { if (!entry) { return null; @@ -14237,6 +14422,27 @@ payload.autopaySettings, ); + // Определяем, триал ли это (из API или из userData) + const isTrial = Boolean( + root.is_trial + ?? root.isTrial + ?? payload.is_trial + ?? payload.isTrial + ?? (userData?.subscription_type === 'trial') + ?? (userData?.user?.subscription_type === 'trial') + ); + + // Режим продаж + const salesMode = ( + root.sales_mode + ?? root.salesMode + ?? payload.sales_mode + ?? payload.salesMode + ?? userData?.sales_mode + ?? userData?.salesMode + ?? 'classic' + ); + return { subscriptionId, currency, @@ -14249,6 +14455,8 @@ missingAmountKopeks, statusMessage, autopay: autopayData, + isTrial, + salesMode, }; } @@ -14859,7 +15067,8 @@ return; } - const shouldShow = hasActiveSubscription(); + // Для триала НЕ показываем карточку продления — юзер должен купить подписку + const shouldShow = hasActiveSubscription() && !isTrialSubscription(); card.classList.toggle('hidden', !shouldShow); if (!shouldShow) { return; @@ -14919,6 +15128,44 @@ statusText.classList.add('hidden'); } + // Обновляем заголовок, подзаголовок и кнопку в зависимости от типа подписки + const isTrial = subscriptionRenewalData.isTrial || subscriptionRenewalData.is_trial || false; + const salesMode = subscriptionRenewalData.salesMode || subscriptionRenewalData.sales_mode || 'classic'; + + // Обновляем заголовок карточки + const titleEl = card.querySelector('[data-i18n="subscription_renewal.title"]'); + if (titleEl) { + let titleKey = 'subscription_renewal.title'; + if (isTrial) { + titleKey = salesMode === 'tariffs' + ? 'subscription_renewal.title.trial_tariffs' + : 'subscription_renewal.title.trial_classic'; + } + const titleText = t(titleKey); + titleEl.textContent = titleText !== titleKey ? titleText : (isTrial ? (salesMode === 'tariffs' ? 'Buy tariff' : 'Buy subscription') : 'Renew subscription'); + } + + // Обновляем подзаголовок + const subtitleEl = card.querySelector('[data-i18n="subscription_renewal.subtitle"]'); + if (subtitleEl) { + let subtitleKey = 'subscription_renewal.subtitle'; + if (isTrial) { + subtitleKey = salesMode === 'tariffs' + ? 'subscription_renewal.subtitle.trial_tariffs' + : 'subscription_renewal.subtitle.trial_classic'; + } + const subtitleText = t(subtitleKey); + subtitleEl.textContent = subtitleText !== subtitleKey ? subtitleText : (isTrial ? 'Choose a period to get started.' : 'Choose how long you want to extend access.'); + } + + // Обновляем текст кнопки + const submitBtn = document.getElementById('subscriptionRenewalSubmit'); + if (submitBtn && !subscriptionRenewalSubmitting) { + const btnKey = isTrial ? 'subscription_renewal.submit.trial' : 'subscription_renewal.submit'; + const btnText = t(btnKey); + submitBtn.textContent = btnText !== btnKey ? btnText : (isTrial ? 'Buy' : 'Renew'); + } + renderSubscriptionRenewalMeta(subscriptionRenewalData); renderSubscriptionRenewalOptions(subscriptionRenewalData); renderSubscriptionRenewalSummary(subscriptionRenewalData); @@ -14930,10 +15177,17 @@ || (option.priceKopeks !== null ? formatPriceFromKopeks(option.priceKopeks, data.currency) : ''); const periodLabel = buildRenewalPeriodLabel(option); - const messageTemplate = t('subscription_renewal.confirm.message'); - const message = messageTemplate && messageTemplate !== 'subscription_renewal.confirm.message' - ? messageTemplate.replace('{period}', periodLabel).replace('{amount}', amountLabel) + // Определяем, триал ли это + const isTrial = data?.isTrial || data?.is_trial || false; + + const messageKey = isTrial ? 'subscription_renewal.confirm.message.trial' : 'subscription_renewal.confirm.message'; + const messageTemplate = t(messageKey); + const defaultMessage = isTrial + ? `Buy subscription for ${periodLabel} and pay ${amountLabel}?` : `Renew the subscription for ${periodLabel} and pay ${amountLabel}?`; + const message = messageTemplate && messageTemplate !== messageKey + ? messageTemplate.replace('{period}', periodLabel).replace('{amount}', amountLabel) + : defaultMessage; const balanceLabel = data?.balanceLabel || (data.balanceKopeks !== null ? formatPriceFromKopeks(data.balanceKopeks, data.currency) : null); @@ -14947,7 +15201,7 @@ finalMessage = `${message}\n${balanceText}`; } - const titleKey = 'subscription_renewal.confirm.title'; + const titleKey = isTrial ? 'subscription_renewal.confirm.title.trial' : 'subscription_renewal.confirm.title'; const confirmKey = 'subscription_renewal.confirm.accept'; const cancelKey = 'subscription_renewal.confirm.cancel'; @@ -14956,7 +15210,7 @@ const cancelText = t(cancelKey); return showConfirmationPopup({ - title: title === titleKey ? 'Confirm renewal' : title, + title: title === titleKey ? (isTrial ? 'Confirm purchase' : 'Confirm renewal') : title, message: finalMessage, confirmText: confirmText === confirmKey ? 'Pay' : confirmText, cancelText: cancelText === cancelKey ? 'Cancel' : cancelText, @@ -15287,7 +15541,8 @@ return; } - const shouldShow = hasActiveSubscription(); + // Для триала НЕ показываем настройки подписки — юзер должен сначала купить + const shouldShow = hasActiveSubscription() && !isTrialSubscription(); card.classList.toggle('hidden', !shouldShow); if (!shouldShow) { return; @@ -15980,7 +16235,8 @@ if (subscriptionPurchaseModalOpen) { return true; } - return Boolean(userData?.user) && !hasPaidSubscription(); + // Показываем для пользователей без оплаченной подписки ИЛИ с триалом + return Boolean(userData?.user) && (!hasPaidSubscription() || isTrialSubscription()); } function resolvePurchasePeriodId(period) { @@ -18972,7 +19228,7 @@ body?.classList.add('hidden'); try { - const initData = getInitData(); + const initData = tg.initData || ''; const response = await fetch('/miniapp/subscription/tariffs', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -18994,6 +19250,8 @@ } } + let selectedTariffData = null; + function renderTariffs() { const loading = document.getElementById('tariffsLoading'); const body = document.getElementById('tariffsBody'); @@ -19009,13 +19267,47 @@ const current = tariffsData.current_tariff || tariffsData.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; + + let serverTags = ''; + if (servers.length > 0) { + const serverNames = servers.slice(0, 3).map(s => s.name || s.display_name).filter(Boolean); + serverTags = serverNames.map(name => + `${escapeHtml(name)}` + ).join(''); + if (serversCount > serverNames.length) { + serverTags += `+${serversCount - serverNames.length}`; + } + } else { + serverTags = `🌍 Все серверы`; + } + currentName.innerHTML = ` - ${escapeHtml(current.name)}
- - ${current.device_limit || current.deviceLimit} устр. • - ${current.traffic_limit_label || current.trafficLimitLabel || (current.traffic_limit_gb || current.trafficLimitGb) + ' ГБ'} • - ${current.servers_count || current.serversCount || 0} серв. - +
+ + ${escapeHtml(current.name)} +
+
+ + + + + ${deviceLimit} + + + + + + ${trafficLabel} + +
+
+ ${serverTags} +
`; } } else { @@ -19028,66 +19320,278 @@ const tariffs = tariffsData?.tariffs || []; if (tariffs.length === 0) { - list.innerHTML = '
Нет доступных тарифов
'; + list.innerHTML = '
' + t('tariffs.no_tariffs') + '
'; return; } tariffs.forEach(tariff => { const isCurrent = tariff.is_current || tariff.isCurrent; const periods = tariff.periods || []; - const firstPeriod = periods[0]; + 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; const div = document.createElement('div'); - div.className = 'subscription-settings-toggle' + (isCurrent ? ' active' : ''); - div.style.cssText = 'padding: 16px; border-radius: var(--radius); border: 2px solid var(--border-color); cursor: pointer; transition: all 0.2s;'; - if (selectedTariffId === tariff.id) { - div.style.borderColor = 'var(--primary)'; - div.style.background = 'rgba(var(--primary-rgb), 0.05)'; + div.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 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(', '); + if (serversCount > serverNames.length) { + serversText += ` +${serversCount - serverNames.length}`; + } + } else { + serversText = serversCount + ' серв.'; + } + } else if (serversCount > 0) { + serversText = serversCount + ' серв.'; + } else { + serversText = 'Все серверы'; } + const description = tariff.description || ''; + + // Серверные теги + 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 = `🌍 Все серверы`; + } + + // 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 = ` -
-
-
- ${isCurrent ? '✅ ' : ''}${escapeHtml(tariff.name)} -
-
- ${tariff.device_limit || tariff.deviceLimit} устр. • - ${tariff.traffic_limit_label || tariff.trafficLimitLabel || (tariff.traffic_limit_gb || tariff.trafficLimitGb) + ' ГБ'} • - ${tariff.servers_count || tariff.serversCount || 0} серв. -
- ${tariff.description ? `
${escapeHtml(tariff.description)}
` : ''} -
-
- ${firstPeriod ? ` -
- от ${firstPeriod.price_label || firstPeriod.priceLabel || '—'} +
+
+
+
+ ${tariffIcon} + ${escapeHtml(tariff.name)}
-
- ${firstPeriod.label || firstPeriod.days + ' дн.'} + ${description ? `
${escapeHtml(description)}
` : ''} +
+ ${minPriceLabel ? ` +
+
от
+
${minPriceLabel}
` : ''}
+
+ + + + + ${deviceLimit} + + + + + + ${trafficLabel} + +
+ ${serverTags ? ` +
+ ${serverTags} +
+ ` : ''}
`; div.addEventListener('click', () => selectTariff(tariff)); list.appendChild(div); }); + + // Обновляем периоды если тариф выбран + renderTariffPeriods(); } function selectTariff(tariff) { selectedTariffId = tariff.id; - selectedTariffPeriod = (tariff.periods || [])[0]; + selectedTariffData = tariff; + selectedTariffPeriod = null; - const btn = document.getElementById('tariffsSelectBtn'); - if (btn) { - btn.disabled = false; - const price = selectedTariffPeriod?.price_label || selectedTariffPeriod?.priceLabel || ''; - btn.textContent = `Купить ${tariff.name} ${price ? '(' + price + ')' : ''}`; + // Обновляем UI + document.querySelectorAll('#tariffsList .subscription-settings-toggle').forEach(el => { + el.classList.remove('active'); + }); + event?.target?.closest('.subscription-settings-toggle')?.classList.add('active'); + + renderTariffPeriods(); + updateTariffButton(); + } + + function renderTariffPeriods() { + const section = document.getElementById('tariffPeriodsSection'); + const list = document.getElementById('tariffPeriodsList'); + const summary = document.getElementById('tariffsSummary'); + + if (!selectedTariffData || !section || !list) { + section?.classList.add('hidden'); + summary?.classList.add('hidden'); + return; } - renderTariffs(); + const periods = selectedTariffData.periods || []; + if (periods.length === 0) { + section.classList.add('hidden'); + summary?.classList.add('hidden'); + return; + } + + section.classList.remove('hidden'); + list.innerHTML = ''; + + 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 hasDiscount = originalKopeks > priceKopeks; + const 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; + + let periodLabel = period.label || period.name; + if (!periodLabel) { + if (days === 30) periodLabel = '1 месяц'; + else if (days === 90) periodLabel = '3 месяца'; + else if (days === 180) periodLabel = '6 месяцев'; + else if (days === 365) periodLabel = '12 месяцев'; + else periodLabel = days + ' дней'; + } + + const isSelected = selectedTariffPeriod && + (selectedTariffPeriod.id === period.id || + (selectedTariffPeriod.days || selectedTariffPeriod.period_days) === days); + + const div = document.createElement('div'); + div.className = 'subscription-settings-toggle' + (isSelected ? ' active' : ''); + + div.innerHTML = ` +
+
${periodLabel}
+ ${hasDiscount ? `
-${discountPercent}%
` : ''} +
+
+ ${originalLabel ? `${originalLabel}` : ''} + ${priceLabel} +
+ `; + + div.addEventListener('click', () => selectTariffPeriod(period)); + list.appendChild(div); + + // Автовыбор первого периода + if (index === 0 && !selectedTariffPeriod) { + selectedTariffPeriod = period; + div.classList.add('active'); + } + }); + + updateTariffSummary(); + } + + function selectTariffPeriod(period) { + selectedTariffPeriod = period; + + document.querySelectorAll('#tariffPeriodsList .subscription-settings-toggle').forEach(el => { + el.classList.remove('active'); + }); + event?.target?.closest('.subscription-settings-toggle')?.classList.add('active'); + + updateTariffSummary(); + updateTariffButton(); + } + + function updateTariffSummary() { + const summary = document.getElementById('tariffsSummary'); + const priceEl = document.getElementById('tariffPriceCurrent'); + const originalEl = document.getElementById('tariffPriceOriginal'); + const discountEl = document.getElementById('tariffPriceDiscount'); + + if (!selectedTariffPeriod || !summary) { + summary?.classList.add('hidden'); + return; + } + + summary.classList.remove('hidden'); + + const priceKopeks = selectedTariffPeriod.price_kopeks || selectedTariffPeriod.priceKopeks || + selectedTariffPeriod.final_price || selectedTariffPeriod.finalPrice || 0; + const originalKopeks = selectedTariffPeriod.original_price || selectedTariffPeriod.originalPrice || + selectedTariffPeriod.base_price || selectedTariffPeriod.basePrice || priceKopeks; + const hasDiscount = originalKopeks > priceKopeks; + + if (priceEl) { + priceEl.textContent = formatPriceFromKopeks(priceKopeks, tariffsData?.currency || 'RUB'); + } + + if (originalEl) { + if (hasDiscount) { + originalEl.textContent = formatPriceFromKopeks(originalKopeks, tariffsData?.currency || 'RUB'); + originalEl.classList.remove('hidden'); + } else { + originalEl.classList.add('hidden'); + } + } + + if (discountEl) { + if (hasDiscount) { + const percent = Math.round((1 - priceKopeks / originalKopeks) * 100); + discountEl.textContent = `Выгода ${percent}%`; + discountEl.classList.remove('hidden'); + } else { + discountEl.classList.add('hidden'); + } + } + } + + function updateTariffButton() { + const btn = document.getElementById('tariffsSelectBtn'); + if (!btn) return; + + if (selectedTariffData && selectedTariffPeriod) { + btn.disabled = false; + const priceKopeks = selectedTariffPeriod.price_kopeks || selectedTariffPeriod.priceKopeks || + selectedTariffPeriod.final_price || selectedTariffPeriod.finalPrice || 0; + const priceLabel = formatPriceFromKopeks(priceKopeks, tariffsData?.currency || 'RUB'); + btn.textContent = `Купить за ${priceLabel}`; + } else { + btn.disabled = true; + btn.textContent = t('tariffs.select'); + } } async function purchaseTariff() { @@ -19103,7 +19607,7 @@ } try { - const initData = getInitData(); + const initData = tg.initData || ''; const periodDays = selectedTariffPeriod.days || selectedTariffPeriod.period_days || selectedTariffPeriod.periodDays; const response = await fetch('/miniapp/subscription/tariff/purchase', { @@ -19128,10 +19632,7 @@ console.error('Tariff purchase failed:', err); showPopup(err.message || 'Не удалось купить тариф', 'Ошибка'); } finally { - if (btn) { - btn.disabled = !selectedTariffId; - btn.textContent = 'Выбрать тариф'; - } + updateTariffButton(); } }