Merge branch 'main' of https://github.com/Gy9vin/remnawave-bedolaga-telegram-bot
This commit is contained in:
@@ -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 =====
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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 для внешних интеграций
|
||||
- 🎯 **Отображение тарифа** при режиме тарифов
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
@@ -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
|
||||
|
||||
---
|
||||
|
||||
## 💝 Благодарности
|
||||
|
||||
### 🌟 **Топ спонсоры проекта**
|
||||
|
||||
<table align="center">
|
||||
<tr>
|
||||
<th>🏆 Место</th>
|
||||
<th>👤 Спонсор</th>
|
||||
<th>💰 Сумма</th>
|
||||
<th>💬 Благодарность</th>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>🥇</td>
|
||||
<td><strong>@SmartSystemCompany</strong></td>
|
||||
<td>₽8,500</td>
|
||||
<td>За щедрую поддержку и вклад в развитие</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>🥈</td>
|
||||
<td><strong>@pilot_737800</strong></td>
|
||||
<td>₽7,750</td>
|
||||
<td>За веру в проект с самого начала</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>🥈</td>
|
||||
<td><strong>@psych0O</strong></td>
|
||||
<td>$60</td>
|
||||
<td>За щедрую поддержку и вклад в развитие</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>🥉</td>
|
||||
<td><strong>@Vldmrmtn</strong></td>
|
||||
<td>₽5,000</td>
|
||||
<td>За значительную поддержку проекта</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>4</td>
|
||||
<td><strong>@k0tbtc</strong></td>
|
||||
<td>₽3,000</td>
|
||||
<td>За поддержку и доверие</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>5</td>
|
||||
<td><strong>@Legacyyy777</strong></td>
|
||||
<td>₽2,900</td>
|
||||
<td>За ценные предложения и UX улучшения</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>6</td>
|
||||
<td><strong>@sklvg</strong></td>
|
||||
<td>₽3,000</td>
|
||||
<td>За международную поддержку</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
### 🌟 **Особая благодарность контрибьюторам**
|
||||
|
||||
- **@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) с автовыбором |
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
Vendored
+29
-1
@@ -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')
|
||||
|
||||
@@ -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 = ["📦 <b>Выберите тариф</b>"]
|
||||
|
||||
if has_period_discounts:
|
||||
lines.append("🎁 <i>Скидки по периодам</i>")
|
||||
|
||||
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"<b>{tariff.name}</b> — {traffic}/{tariff.device_limit}📱 {price_text}")
|
||||
|
||||
# Описание тарифа если есть
|
||||
if tariff.description:
|
||||
lines.append(f"<i>{tariff.description}</i>")
|
||||
|
||||
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🎁 <i>Скидки зависят от выбранного периода</i>"
|
||||
# Формируем текст со списком тарифов и их характеристиками
|
||||
tariffs_text = format_tariffs_list_text(tariffs, db_user, has_period_discounts)
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"📦 <b>Выберите тариф</b>{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 = [
|
||||
"📦 <b>Смена тарифа</b>",
|
||||
f"📌 Текущий: <b>{current_tariff_name}</b>",
|
||||
]
|
||||
|
||||
if has_period_discounts:
|
||||
lines.append("🎁 <i>Скидки по периодам</i>")
|
||||
|
||||
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"<b>{tariff.name}</b> — {traffic}/{tariff.device_limit}📱 {price_text}")
|
||||
|
||||
if tariff.description:
|
||||
lines.append(f"<i>{tariff.description}</i>")
|
||||
|
||||
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🎁 <i>Скидки зависят от выбранного периода</i>"
|
||||
# Формируем текст со списком тарифов
|
||||
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"📦 <b>Смена тарифа</b>{discount_hint}\n\n"
|
||||
f"📌 Ваш текущий тариф: <b>{current_tariff_name}</b>\n\n"
|
||||
"⚠️ При смене тарифа оплачивается полная стоимость нового тарифа.\n"
|
||||
"Остаток времени текущей подписки будет сохранён.\n\n"
|
||||
"Выберите новый тариф:",
|
||||
switch_text,
|
||||
reply_markup=get_tariff_switch_keyboard(tariffs, current_tariff_id, db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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, так как эти функции наиболее востребованы для ежедневной работы администраторов.
|
||||
|
||||
+557
-56
@@ -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 @@
|
||||
<span class="info-label" data-i18n="info.subscription_type">Type</span>
|
||||
<span class="info-value" id="subscriptionType">-</span>
|
||||
</div>
|
||||
<div class="info-item hidden" id="currentTariffRow">
|
||||
<span class="info-label" data-i18n="info.current_tariff">Tariff</span>
|
||||
<span class="info-value" id="currentTariffName">-</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label" data-i18n="info.device_limit">Device Limit</span>
|
||||
<span class="info-value" id="deviceLimit">-</span>
|
||||
@@ -5159,13 +5266,29 @@
|
||||
<button class="subscription-settings-retry" id="tariffsRetry" type="button">Повторить</button>
|
||||
</div>
|
||||
<div id="tariffsBody" class="hidden">
|
||||
<div id="tariffsCurrentTariff" class="subscription-renewal-meta-block hidden" style="margin-bottom: 16px;">
|
||||
<div class="subscription-renewal-meta-title">Текущий тариф</div>
|
||||
<div id="tariffsCurrentTariff" class="subscription-renewal-meta-block hidden" style="margin-top: 12px; margin-bottom: 16px;">
|
||||
<div class="subscription-renewal-meta-title" data-i18n="tariffs.current">Текущий тариф</div>
|
||||
<div class="subscription-renewal-meta-body" id="tariffsCurrentTariffName"></div>
|
||||
</div>
|
||||
<div id="tariffsList" style="display: flex; flex-direction: column; gap: 12px;"></div>
|
||||
<div class="subscription-renewal-section">
|
||||
<div class="subscription-renewal-section-title">Выберите тариф</div>
|
||||
<div id="tariffsList" class="subscription-renewal-options"></div>
|
||||
</div>
|
||||
<div id="tariffPeriodsSection" class="subscription-renewal-section hidden" style="margin-top: 16px;">
|
||||
<div class="subscription-renewal-section-title">Выберите период</div>
|
||||
<div id="tariffPeriodsList" class="subscription-renewal-options"></div>
|
||||
</div>
|
||||
<div class="subscription-renewal-summary hidden" id="tariffsSummary" style="margin-top: 16px;">
|
||||
<div class="subscription-renewal-summary-header">
|
||||
<div class="subscription-renewal-summary-prices">
|
||||
<div class="subscription-renewal-price-original hidden" id="tariffPriceOriginal"></div>
|
||||
<div class="subscription-renewal-price-current" id="tariffPriceCurrent">—</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="subscription-renewal-price-discount hidden" id="tariffPriceDiscount"></div>
|
||||
</div>
|
||||
<div class="subscription-renewal-actions" style="margin-top: 16px;">
|
||||
<button class="btn btn-primary" id="tariffsSelectBtn" type="button" disabled>Выбрать тариф</button>
|
||||
<button class="btn btn-primary" id="tariffsSelectBtn" type="button" disabled data-i18n="tariffs.select">Выбрать тариф</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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 =>
|
||||
`<span style="display: inline-block; padding: 2px 8px; background: var(--bg-tertiary, var(--bg-secondary)); border-radius: 12px; font-size: 11px;">${escapeHtml(name)}</span>`
|
||||
).join('');
|
||||
if (serversCount > serverNames.length) {
|
||||
serverTags += `<span style="display: inline-block; padding: 2px 8px; background: var(--bg-tertiary, var(--bg-secondary)); border-radius: 12px; font-size: 11px;">+${serversCount - serverNames.length}</span>`;
|
||||
}
|
||||
} else {
|
||||
serverTags = `<span style="display: inline-block; padding: 2px 8px; background: var(--bg-tertiary, var(--bg-secondary)); border-radius: 12px; font-size: 11px;">🌍 Все серверы</span>`;
|
||||
}
|
||||
|
||||
currentName.innerHTML = `
|
||||
<strong>${escapeHtml(current.name)}</strong><br>
|
||||
<span style="color: var(--text-secondary); font-size: 13px;">
|
||||
${current.device_limit || current.deviceLimit} устр. •
|
||||
${current.traffic_limit_label || current.trafficLimitLabel || (current.traffic_limit_gb || current.trafficLimitGb) + ' ГБ'} •
|
||||
${current.servers_count || current.serversCount || 0} серв.
|
||||
</span>
|
||||
<div class="tariff-current-name">
|
||||
<span class="check-icon">✓</span>
|
||||
<span>${escapeHtml(current.name)}</span>
|
||||
</div>
|
||||
<div style="display: flex; gap: 12px; margin-top: 10px; font-size: 13px; color: var(--text-secondary);">
|
||||
<span style="display: inline-flex; align-items: center; gap: 4px;">
|
||||
<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24" style="opacity: 0.6;">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
${deviceLimit}
|
||||
</span>
|
||||
<span style="display: inline-flex; align-items: center; gap: 4px;">
|
||||
<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24" style="opacity: 0.6;">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/>
|
||||
</svg>
|
||||
${trafficLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px;">
|
||||
${serverTags}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
} else {
|
||||
@@ -19028,66 +19320,278 @@
|
||||
|
||||
const tariffs = tariffsData?.tariffs || [];
|
||||
if (tariffs.length === 0) {
|
||||
list.innerHTML = '<div style="text-align: center; color: var(--text-secondary); padding: 20px;">Нет доступных тарифов</div>';
|
||||
list.innerHTML = '<div style="text-align: center; color: var(--text-secondary); padding: 20px;">' + t('tariffs.no_tariffs') + '</div>';
|
||||
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 =>
|
||||
`<span style="display: inline-block; padding: 2px 8px; background: var(--bg-secondary); border-radius: 12px; font-size: 11px; color: var(--text-secondary);">${escapeHtml(name)}</span>`
|
||||
).join('');
|
||||
if (serversCount > serverNames.length) {
|
||||
serverTags += `<span style="display: inline-block; padding: 2px 8px; background: var(--bg-secondary); border-radius: 12px; font-size: 11px; color: var(--text-secondary);">+${serversCount - serverNames.length}</span>`;
|
||||
}
|
||||
} else if (serversCount === 0) {
|
||||
serverTags = `<span style="display: inline-block; padding: 2px 8px; background: var(--bg-secondary); border-radius: 12px; font-size: 11px; color: var(--text-secondary);">🌍 Все серверы</span>`;
|
||||
}
|
||||
|
||||
// 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 = `
|
||||
<div style="display: flex; justify-content: space-between; align-items: flex-start;">
|
||||
<div>
|
||||
<div style="font-weight: 600; margin-bottom: 4px;">
|
||||
${isCurrent ? '✅ ' : ''}${escapeHtml(tariff.name)}
|
||||
</div>
|
||||
<div style="font-size: 13px; color: var(--text-secondary);">
|
||||
${tariff.device_limit || tariff.deviceLimit} устр. •
|
||||
${tariff.traffic_limit_label || tariff.trafficLimitLabel || (tariff.traffic_limit_gb || tariff.trafficLimitGb) + ' ГБ'} •
|
||||
${tariff.servers_count || tariff.serversCount || 0} серв.
|
||||
</div>
|
||||
${tariff.description ? `<div style="font-size: 12px; color: var(--text-secondary); margin-top: 4px;">${escapeHtml(tariff.description)}</div>` : ''}
|
||||
</div>
|
||||
<div style="text-align: right;">
|
||||
${firstPeriod ? `
|
||||
<div style="font-weight: 600; color: var(--primary);">
|
||||
от ${firstPeriod.price_label || firstPeriod.priceLabel || '—'}
|
||||
<div style="flex: 1; min-width: 0;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: flex-start; gap: 12px;">
|
||||
<div style="flex: 1; min-width: 0;">
|
||||
<div class="${badgeClass}">
|
||||
<span class="tariff-icon">${tariffIcon}</span>
|
||||
<span class="tariff-text">${escapeHtml(tariff.name)}</span>
|
||||
</div>
|
||||
<div style="font-size: 11px; color: var(--text-secondary);">
|
||||
${firstPeriod.label || firstPeriod.days + ' дн.'}
|
||||
${description ? `<div style="font-size: 12px; color: var(--text-secondary); margin-top: 8px; line-height: 1.4;">${escapeHtml(description)}</div>` : ''}
|
||||
</div>
|
||||
${minPriceLabel ? `
|
||||
<div style="text-align: right; flex-shrink: 0;">
|
||||
<div style="font-size: 10px; color: var(--text-secondary); text-transform: uppercase;">от</div>
|
||||
<div style="font-weight: 600; color: var(--primary); font-size: 15px;">${minPriceLabel}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
<div style="display: flex; gap: 12px; margin-top: 10px; font-size: 13px; color: var(--text-secondary);">
|
||||
<span style="display: inline-flex; align-items: center; gap: 4px;">
|
||||
<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24" style="opacity: 0.6;">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
${deviceLimit}
|
||||
</span>
|
||||
<span style="display: inline-flex; align-items: center; gap: 4px;">
|
||||
<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24" style="opacity: 0.6;">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/>
|
||||
</svg>
|
||||
${trafficLabel}
|
||||
</span>
|
||||
</div>
|
||||
${serverTags ? `
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px;">
|
||||
${serverTags}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 = `
|
||||
<div class="subscription-settings-toggle-label">
|
||||
<div class="subscription-settings-toggle-title">${periodLabel}</div>
|
||||
${hasDiscount ? `<div class="subscription-renewal-option-discount">-${discountPercent}%</div>` : ''}
|
||||
</div>
|
||||
<div class="subscription-renewal-option-price">
|
||||
${originalLabel ? `<span class="subscription-renewal-option-price-original">${originalLabel}</span>` : ''}
|
||||
<span class="subscription-renewal-option-price-current">${priceLabel}</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user