Compare commits
140 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c3c105619b | |||
| 01e575676f | |||
| b53a43fcc6 | |||
| 6a8ff1c1b4 | |||
| 451ab514cc | |||
| c8b6830704 | |||
| 74ee7149c5 | |||
| 506b96dec4 | |||
| 927d4230a2 | |||
| 69ff737b72 | |||
| d0114a1f29 | |||
| f92a0fd88b | |||
| e7478cc1e1 | |||
| 5814f9757d | |||
| cb7b9a9e22 | |||
| 6dc525dd72 | |||
| 409c8c161b | |||
| 78db3a1e77 | |||
| d0c5ff19e1 | |||
| 6a29765ddf | |||
| 95147fbe03 | |||
| 0137321d24 | |||
| 0521306699 | |||
| 2a10e7d2c7 | |||
| e78e9e1a4d | |||
| 29d0b54b04 | |||
| 64fe3fdbb7 | |||
| 04edca175e | |||
| 840091cb05 | |||
| 473c3704cf | |||
| af630b22c9 | |||
| 29fe2f2016 | |||
| cd5691640e | |||
| 77c217f9ad | |||
| 9e963569bc | |||
| c7114ec359 | |||
| b9586ad72a | |||
| b6e333127c | |||
| 6ea2c2cceb | |||
| af42377c3b | |||
| 82881a4c45 | |||
| 70d50e9c47 | |||
| ddb5c01026 | |||
| e287adcb84 | |||
| 83d041c13b | |||
| 545c5fd749 | |||
| 7f0e2de7c4 | |||
| 1fdf1e49a3 | |||
| 08f82ee511 | |||
| fba217b87f | |||
| 6668a18ad3 | |||
| 11c239f46c | |||
| ac62dee279 | |||
| 29a17a7876 | |||
| 694c934612 | |||
| c01d2bfcd7 | |||
| b3708f99c0 | |||
| f651f921aa | |||
| ada6845036 | |||
| d33b259537 | |||
| 3c2ed147ca | |||
| d46e88c2b5 | |||
| 1477491e6e | |||
| 647dc0bd7c | |||
| f6a45993e9 | |||
| 0766320411 | |||
| 5ebf113fd6 | |||
| 2005d103f8 | |||
| 3935813336 | |||
| ec71fa78c0 | |||
| cd2b0a2d67 | |||
| 68d502804f | |||
| afbd343442 | |||
| 991e5a3112 | |||
| 7d9de6ca5d | |||
| 562bb69082 | |||
| a02416c78b | |||
| 81f56008d4 | |||
| 43a7039479 | |||
| a9f3904524 | |||
| 9136c7cfe3 | |||
| 5595846929 | |||
| 9ebe2971bf | |||
| fadea75314 | |||
| a2009cae39 | |||
| 0187adb6f9 | |||
| 302a028873 | |||
| 77c9c19806 | |||
| 724da2545e | |||
| 079af66d9f | |||
| e701d234f0 | |||
| 1b111975bb | |||
| c874425e0e | |||
| c6c112fd17 | |||
| 57d924e3ba | |||
| 9d5b9ebf82 | |||
| 354358e860 | |||
| 327c9d52d2 | |||
| c03051614d | |||
| ac9e5cd908 | |||
| 585bb56a99 | |||
| 8f33eb0cc6 | |||
| 71515a87de | |||
| 0a4ede8f79 | |||
| 040b33b541 | |||
| 5c3484ee28 | |||
| ef309c3101 | |||
| 51dad7936b | |||
| 097734b31d | |||
| 7185fde9bc | |||
| 75f2cdb04b | |||
| d3c445ed2d | |||
| 804f088435 | |||
| 6c1b70e796 | |||
| a3532e5878 | |||
| a8c1b32912 | |||
| 20b0c8a0e2 | |||
| bc18692d75 | |||
| c05ecf1ecd | |||
| 8381fc52f1 | |||
| a697ffa8f1 | |||
| 4eb36fab6d | |||
| 4b0780b549 | |||
| 1befc2982a | |||
| df1f225405 | |||
| df6feca53c | |||
| bd41efb974 | |||
| a1216bface | |||
| e0969b315a | |||
| 395bccf4e5 | |||
| f6e5cfce54 | |||
| a26e89147b | |||
| 4396020177 | |||
| e2c89ec19e | |||
| 68c125ba14 | |||
| 8c794fc91c | |||
| efbe8475df | |||
| 7835e02d5c | |||
| 21a5499af8 | |||
| dd85db071a |
+1
-1
@@ -428,7 +428,7 @@ MAINTENANCE_MESSAGE=Ведутся технические работы. Серв
|
|||||||
# ===== ЛОКАЛИЗАЦИЯ =====
|
# ===== ЛОКАЛИЗАЦИЯ =====
|
||||||
# Укажите язык из AVAILABLE_LANGUAGES. При некорректном значении используется ru.
|
# Укажите язык из AVAILABLE_LANGUAGES. При некорректном значении используется ru.
|
||||||
DEFAULT_LANGUAGE=ru
|
DEFAULT_LANGUAGE=ru
|
||||||
AVAILABLE_LANGUAGES=ru,en
|
AVAILABLE_LANGUAGES=ru,en,ua,zh
|
||||||
# Включить выбор языка при старте и отображение кнопки в меню
|
# Включить выбор языка при старте и отображение кнопки в меню
|
||||||
LANGUAGE_SELECTION_ENABLED=true
|
LANGUAGE_SELECTION_ENABLED=true
|
||||||
# Часовой пояс
|
# Часовой пояс
|
||||||
|
|||||||
@@ -36,15 +36,15 @@ jobs:
|
|||||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||||
echo "🏷️ Собираем релизную версию: $VERSION"
|
echo "🏷️ Собираем релизную версию: $VERSION"
|
||||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||||
VERSION="v2.6.2-$(git rev-parse --short HEAD)"
|
VERSION="v2.7.0-$(git rev-parse --short HEAD)"
|
||||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||||
echo "🚀 Собираем версию из main: $VERSION"
|
echo "🚀 Собираем версию из main: $VERSION"
|
||||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||||
VERSION="v2.6.2-dev-$(git rev-parse --short HEAD)"
|
VERSION="v2.7.0-dev-$(git rev-parse --short HEAD)"
|
||||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||||
echo "🧪 Собираем dev версию: $VERSION"
|
echo "🧪 Собираем dev версию: $VERSION"
|
||||||
else
|
else
|
||||||
VERSION="v2.6.2-pr-$(git rev-parse --short HEAD)"
|
VERSION="v2.7.0-pr-$(git rev-parse --short HEAD)"
|
||||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
|
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
|
||||||
echo "🔀 Собираем PR версию: $VERSION"
|
echo "🔀 Собираем PR версию: $VERSION"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -49,13 +49,13 @@ jobs:
|
|||||||
VERSION=${GITHUB_REF#refs/tags/}
|
VERSION=${GITHUB_REF#refs/tags/}
|
||||||
echo "🏷️ Building release version: $VERSION"
|
echo "🏷️ Building release version: $VERSION"
|
||||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||||
VERSION="v2.6.2-$(git rev-parse --short HEAD)"
|
VERSION="v2.7.0-$(git rev-parse --short HEAD)"
|
||||||
echo "🚀 Building main version: $VERSION"
|
echo "🚀 Building main version: $VERSION"
|
||||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||||
VERSION="v2.6.2-dev-$(git rev-parse --short HEAD)"
|
VERSION="v2.7.0-dev-$(git rev-parse --short HEAD)"
|
||||||
echo "🧪 Building dev version: $VERSION"
|
echo "🧪 Building dev version: $VERSION"
|
||||||
else
|
else
|
||||||
VERSION="v2.6.2-pr-$(git rev-parse --short HEAD)"
|
VERSION="v2.7.0-pr-$(git rev-parse --short HEAD)"
|
||||||
echo "🔀 Building PR version: $VERSION"
|
echo "🔀 Building PR version: $VERSION"
|
||||||
fi
|
fi
|
||||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
|
|||||||
|
|
||||||
FROM python:3.13-slim
|
FROM python:3.13-slim
|
||||||
|
|
||||||
ARG VERSION="v2.6.2"
|
ARG VERSION="v2.7.0"
|
||||||
ARG BUILD_DATE
|
ARG BUILD_DATE
|
||||||
ARG VCS_REF
|
ARG VCS_REF
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
<img width="1024" height="1024" alt="ChatGPT Image 23 окт 2025 г , 13_18_33" src="https://github.com/user-attachments/assets/17ad0128-231d-4553-9f4b-ce0644da796c" />
|
<img width="1024" height="1024" alt="ChatGPT Image 23 окт 2025 г , 13_18_33" src="https://github.com/user-attachments/assets/17ad0128-231d-4553-9f4b-ce0644da796c" />
|
||||||
|
|
||||||
|
|
||||||
**🤖 Современный Telegram-бот для управления VPN подписками через Remnawave API**
|
**🤖 Современный Telegram-бот для управления VPN подписками через Remnawave API**
|
||||||
|
|
||||||
@@ -14,7 +13,7 @@
|
|||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://github.com/Fr1ngg/remnawave-bedolaga-telegram-bot/stargazers)
|
[](https://github.com/Fr1ngg/remnawave-bedolaga-telegram-bot/stargazers)
|
||||||
|
|
||||||
[🚀 Быстрый старт](#-быстрый-старт) • [📖 Функционал](#-функционал) • [🐳 Docker](#-docker-развертывание) • [💻 Локальная разработка](#-локальная-разработка) • [💬 Поддержка](#-поддержка-и-сообщество)
|
[🚀 Быстрый старт](#-быстрый-старт) • [📖 Функционал](#-функционал) • [Docker](#-docker-развертывание) • [💻 Локальная разработка](#-локальная-разработка) • [💬 Поддержка](#-поддержка-и-сообщество)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -32,18 +31,18 @@
|
|||||||
Он просто делает вашу работу вместо вас, принимает оплату, выдаёт подписки, интегрируется с Remnawave и тихо ненавидит всех, кто ещё не подключил его.
|
Он просто делает вашу работу вместо вас, принимает оплату, выдаёт подписки, интегрируется с Remnawave и тихо ненавидит всех, кто ещё не подключил его.
|
||||||
|
|
||||||
Вы хотите продавать VPN — Бедолага позволит это делать.
|
Вы хотите продавать VPN — Бедолага позволит это делать.
|
||||||
Вы хотите спать — он позволит и это.
|
Вы хотите спать — он позволит и это...
|
||||||
|
|
||||||
### ⚡ **Полная автоматизация VPN бизнеса**
|
### ⚡ **Полная автоматизация VPN бизнеса**
|
||||||
- 🎯 **Готовое решение** - разверни за 5 минут, начни продавать сегодня
|
- 🎯 **Готовое решение** - разверни за 5 минут, начни продавать сегодня
|
||||||
- 💰 **Многоканальные платежи** - Telegram Stars + Tribute + CryptoBot + Heleket + YooKassa (СБП + карты) + MulenPay + PayPalych (СБП + карты) + Platega (карты + СБП) + WATA
|
- 💰 **Многоканальные платежи** - Telegram Stars + Tribute + CryptoBot + Heleket + YooKassa (СБП + карты) + MulenPay + PayPalych (СБП + карты) + Platega (карты + СБП) + WATA
|
||||||
- 🔄 **Автоматизация 99%** - от регистрации до продления подписок
|
- 🔄 **Автоматизация 99%** - от регистрации до продления подписок
|
||||||
- - 📱 **MiniApp лк** - личный кабинет с возможностью покупки/продления подписки
|
- 📱 **MiniApp лк** - личный кабинет с возможностью покупки/продления подписки
|
||||||
- 📊 **Детальная аналитика** - полная картина вашего бизнеса
|
- 📊 **Детальная аналитика** - полная картина вашего бизнеса
|
||||||
- 💬 **Уведомления в топики** об: Активация триала 💎 Покупка подписки 🔄 Конверсия из триала в платную ⏰ Продление подписки 💰 Пополнение баланса 🚧 Включении тех работ ♻️ Появлении новой версии бота
|
- 💬 **Уведомления в топики** об: Активация триала 💎 Покупка подписки 🔄 Конверсия из триала в платную ⏰ Продление подписки 💰 Пополнение баланса 🚧 Включении тех работ ♻️ Появлении новой версии бота
|
||||||
|
|
||||||
### 🎛️ **Гибкость конфигурации**
|
### 🎛️ **Гибкость конфигурации**
|
||||||
- 🌍 **Умный выбор серверов** - автоматический пропуск при одном сервере, мультивыбор
|
- 🌐 **Умный выбор серверов** - автоматический пропуск при одном сервере, мультивыбор
|
||||||
- 📱 **Управление устройствами** - от 1 до неограниченного количества
|
- 📱 **Управление устройствами** - от 1 до неограниченного количества
|
||||||
- 📊 **Режимы продажи трафика** - фиксированный лимит или выбор пакетов
|
- 📊 **Режимы продажи трафика** - фиксированный лимит или выбор пакетов
|
||||||
- 🎁 **Промо-система** - коды на деньги, дни подписки, триал-периоды
|
- 🎁 **Промо-система** - коды на деньги, дни подписки, триал-периоды
|
||||||
@@ -51,13 +50,13 @@
|
|||||||
- 🛒 **Умная корзина** - сохранение параметров подписки при недостатке баланса
|
- 🛒 **Умная корзина** - сохранение параметров подписки при недостатке баланса
|
||||||
|
|
||||||
### 💪 **Enterprise готовность**
|
### 💪 **Enterprise готовность**
|
||||||
- 🏗️ **Современная архитектура** - AsyncIO, PostgreSQL, Redis, модульная структура
|
- 🗃️ **Современная архитектура** - AsyncIO, PostgreSQL, Redis, модульная структура
|
||||||
- 🔒 **Безопасность** - интеграция с системой защиты панели через куки-аутентификацию
|
- 🔒 **Безопасность** - интеграция с системой защиты панели через куки-аутентификацию
|
||||||
- 📈 **Масштабируемость** - от стартапа до крупного бизнеса
|
- 📈 **Масштабируемость** - от стартапа до крупного бизнеса
|
||||||
- 🔧 **Мониторинг** - автоматическое управление режимом тех. работ
|
- 🔧 **Мониторинг** - автоматическое управление режимом тех. работ
|
||||||
- 🛡️ **Защита панели** - поддержка [remnawave-reverse-proxy](https://github.com/eGamesAPI/remnawave-reverse-proxy)
|
- 🛡️ **Защита панели** - поддержка [remnawave-reverse-proxy](https://github.com/eGamesAPI/remnawave-reverse-proxy)
|
||||||
- 🗄️ **Бекапы/Восстановление** - автобекапы и восстановление бд прямо в боте с уведомлениями в топики
|
- 🗄️ **Бекапы/Восстановление** - автобекапы и восстановление бд прямо в боте с уведомлениями в топики
|
||||||
- ✍️ **Проверка на подписку** - проверяет подписку на канал
|
- ✔️ **Проверка на подписку** - проверяет подписку на канал
|
||||||
- 🔄 **Автосинхронизация** - фоновая синхронизация подписок и серверов(сквадов) с Remnawave по расписанию
|
- 🔄 **Автосинхронизация** - фоновая синхронизация подписок и серверов(сквадов) с Remnawave по расписанию
|
||||||
|
|
||||||
### 📚 Поддерживаемые методы авторизации
|
### 📚 Поддерживаемые методы авторизации
|
||||||
@@ -83,19 +82,22 @@ mkdir -p ./logs ./data ./data/backups ./data/referral_qr
|
|||||||
chmod -R 755 ./logs ./data
|
chmod -R 755 ./logs ./data
|
||||||
sudo chown -R 1000:1000 ./logs ./data
|
sudo chown -R 1000:1000 ./logs ./data
|
||||||
|
|
||||||
|
# 3.Установи «Docker» командой:
|
||||||
|
sudo curl -fsSL https://get.docker.com | sh
|
||||||
|
|
||||||
# 3. make up # Поднять контейнеры
|
# 4.Установи «make» следующей командой:
|
||||||
|
apt install make -y
|
||||||
|
|
||||||
|
make up # Поднять контейнеры
|
||||||
|
|
||||||
make help # Показать все команды
|
make help # Показать все команды
|
||||||
make down # Остановить контейнеры
|
make down # Остановить контейнеры
|
||||||
make reload # Перезапустить
|
make reload # Перезапустить
|
||||||
make reload-follow # Перезапустить с логами
|
make reload-follow # Перезапустить с логами
|
||||||
make test # Запустить тесты
|
make test # Запустить тесты
|
||||||
|
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 🐳 Ручной Docker запуск
|
### Ручной Docker запуск
|
||||||
|
|
||||||
Если не хочется пользоваться мастером, можно настроить всё вручную:
|
Если не хочется пользоваться мастером, можно настроить всё вручную:
|
||||||
|
|
||||||
@@ -122,21 +124,9 @@ docker compose logs
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🌐 Настройка webhook-режима и обратного прокси
|
## 🌐 Настройка веб-сервера и обратного проксирования
|
||||||
|
|
||||||
> Встроенный FastAPI-сервер теперь обслуживает Telegram webhook, платежные webhooks, административное API и статические файлы миниапки на **одном порту 8080**. Снаружи вы публикуете только HTTPS-прокси, которое проксирует весь трафик на этот порт.
|
> Встроенный FastAPI-сервер обслуживает Telegram webhook, платежные webhooks, административное API и статические файлы миниапки на **одном порту 8080**. Снаружи вы публикуете только HTTPS-прокси, которое проксирует весь трафик на этот порт.
|
||||||
|
|
||||||
### ♻️ Миграция со старой схемы (несколько портов)
|
|
||||||
|
|
||||||
Если бот уже развернут в режиме polling или с отдельными контейнерами для `payments-webhook`, выполните переход на единую схему:
|
|
||||||
|
|
||||||
1. **Обновите `.env`:** установите `BOT_RUN_MODE=webhook` (или `both` для гибридного режима), задайте `WEBHOOK_URL`, `WEBHOOK_PATH` и обязательно сгенерируйте собственный `WEBHOOK_SECRET_TOKEN` командой `openssl rand -hex 32`.
|
|
||||||
2. **Проверьте `docker-compose.*`:** оставьте публикацию только порта `8080` у сервиса `remnawave_bot`. Все значения `*_WEBHOOK_PORT` теперь используются лишь для обратной совместимости и могут быть удалены.
|
|
||||||
3. **Обновите прокси:** перенаправляйте *все* пути (`/webhook`, `/yookassa-webhook`, `/cryptobot-webhook`, `/miniapp/static`, `/app-config.json` и т.д.) на один upstream `remnawave_bot:8080`. Примеры Caddy/nginx ниже можно адаптировать к текущей инфраструктуре.
|
|
||||||
4. **Перезапустите сервисы:** `docker compose up -d --force-recreate bot` и затем перезагрузите прокси. После запуска бот автоматически зарегистрирует новый webhook.
|
|
||||||
5. **Проверьте здоровье:** `curl http://localhost:8080/health/unified` (или `/health`, если административное API отключено) и `curl http://localhost:8080/health/telegram-webhook`. Убедитесь, что в логах нет ошибок регистрации webhook.
|
|
||||||
|
|
||||||
После миграции старые контейнеры/сервисы для отдельных вебхуков можно удалить.
|
|
||||||
|
|
||||||
### 1. Выбор режима запуска
|
### 1. Выбор режима запуска
|
||||||
|
|
||||||
@@ -144,9 +134,41 @@ docker compose logs
|
|||||||
|----------------|------------|---------------------|
|
|----------------|------------|---------------------|
|
||||||
| `polling` | Бот опрашивает Telegram через long polling. HTTP-сервер можно не поднимать. | Локальная отладка или отсутствие внешнего HTTPS. |
|
| `polling` | Бот опрашивает Telegram через long polling. HTTP-сервер можно не поднимать. | Локальная отладка или отсутствие внешнего HTTPS. |
|
||||||
| `webhook` | Aiogram получает апдейты только через вебхук. | Продакшн и серверы за HTTPS-прокси. |
|
| `webhook` | Aiogram получает апдейты только через вебхук. | Продакшн и серверы за HTTPS-прокси. |
|
||||||
| `both` | Одновременно работают polling и webhook. | Миграция с polling на webhook или повышенная отказоустойчивость. |
|
| `both` | Одновременно работают polling и webhook. | Тестирование или повышенная отказоустойчивость. |
|
||||||
|
|
||||||
### 2. Переменные окружения для webhook
|
### 2. Минимальные настройки для webhook
|
||||||
|
|
||||||
|
Для запуска бота в режиме webhook минимально необходимы следующие параметры в `.env`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Обязательные базовые настройки
|
||||||
|
BOT_TOKEN=1234567890:AABBCCdd...
|
||||||
|
ADMIN_IDS=123456789,987654321
|
||||||
|
|
||||||
|
# Настройки webhook
|
||||||
|
BOT_RUN_MODE=webhook
|
||||||
|
WEBHOOK_URL=https://hooks.domain.com
|
||||||
|
WEBHOOK_PATH=/webhook
|
||||||
|
WEBHOOK_SECRET_TOKEN=super-secret-token
|
||||||
|
|
||||||
|
# Настройки Web API
|
||||||
|
WEB_API_ENABLED=true
|
||||||
|
WEB_API_PORT=8080
|
||||||
|
WEB_API_ALLOWED_ORIGINS=https://miniapp.example.com
|
||||||
|
WEB_API_DEFAULT_TOKEN=super-secret-token
|
||||||
|
|
||||||
|
# Настройки Remnawave
|
||||||
|
REMNAWAVE_API_URL=https://your-panel.com
|
||||||
|
REMNAWAVE_API_KEY=your_api_key
|
||||||
|
```
|
||||||
|
|
||||||
|
**Важно:**
|
||||||
|
- `WEBHOOK_SECRET_TOKEN` - сгенерируйте командой `openssl rand -hex 32`
|
||||||
|
- `WEB_API_DEFAULT_TOKEN` - сгенерируйте командой `openssl rand -hex 32`
|
||||||
|
- `WEBHOOK_URL` должен быть доступен извне по HTTPS
|
||||||
|
- `WEB_API_ALLOWED_ORIGINS` - укажите домен, где будет размещена миниапка
|
||||||
|
|
||||||
|
### 3. Полные настройки webhook (опционально)
|
||||||
|
|
||||||
```env
|
```env
|
||||||
BOT_RUN_MODE=webhook
|
BOT_RUN_MODE=webhook
|
||||||
@@ -173,47 +195,107 @@ MINIAPP_CUSTOM_URL=https://bot.example.com/miniapp
|
|||||||
|
|
||||||
После изменения `.env` перезапустите сервис: `docker compose up -d remnawave_bot`.
|
После изменения `.env` перезапустите сервис: `docker compose up -d remnawave_bot`.
|
||||||
|
|
||||||
### 3. Подготовка Docker-сети
|
### 4. Настройка Docker-сетей
|
||||||
|
|
||||||
`docker-compose.yml` создаёт сеть `bot_network`. Прокси должен находиться в той же сети, чтобы обращаться к контейнеру по имени `remnawave_bot`.
|
#### Вариант 1: Бот на отдельном сервере
|
||||||
|
|
||||||
```bash
|
Если бот запускается на отдельном сервере (не там, где панель Remnawave), используйте стандартную конфигурацию:
|
||||||
docker network ls | grep bot_network || docker network create bot_network
|
|
||||||
docker network connect bot_network <proxy_container_name>
|
|
||||||
```
|
|
||||||
|
|
||||||
Если прокси стартует отдельным compose-файлом, объявите сеть внешней:
|
|
||||||
|
|
||||||
|
**docker-compose.yml бота:**
|
||||||
```yaml
|
```yaml
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
container_name: remnawave_bot_db
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-remnawave_bot}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-remnawave_user}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-secure_password_123}
|
||||||
|
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C"
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
networks:
|
||||||
|
- bot_network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-remnawave_user} -d ${POSTGRES_DB:-remnawave_bot}"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: remnawave_bot_redis
|
||||||
|
restart: unless-stopped
|
||||||
|
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
networks:
|
||||||
|
- bot_network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
bot:
|
||||||
|
build: .
|
||||||
|
container_name: remnawave_bot
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
DOCKER_ENV: "true"
|
||||||
|
DATABASE_MODE: "auto"
|
||||||
|
POSTGRES_HOST: "postgres"
|
||||||
|
POSTGRES_PORT: "5432"
|
||||||
|
POSTGRES_DB: "${POSTGRES_DB:-remnawave_bot}"
|
||||||
|
POSTGRES_USER: "${POSTGRES_USER:-remnawave_user}"
|
||||||
|
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:-secure_password_123}"
|
||||||
|
REDIS_URL: "redis://redis:6379/0"
|
||||||
|
TZ: "Europe/Moscow"
|
||||||
|
LOCALES_PATH: "${LOCALES_PATH:-/app/locales}"
|
||||||
|
volumes:
|
||||||
|
- ./logs:/app/logs:rw
|
||||||
|
- ./data:/app/data:rw
|
||||||
|
- ./locales:/app/locales:rw
|
||||||
|
- /etc/timezone:/etc/timezone:ro
|
||||||
|
- /etc/localtime:/etc/localtime:ro
|
||||||
|
- ./vpn_logo.png:/app/vpn_logo.png:ro
|
||||||
|
ports:
|
||||||
|
- "${WEB_API_PORT:-8080}:8080"
|
||||||
|
networks:
|
||||||
|
- bot_network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "python -c 'import requests; requests.get(\"http://localhost:8080/health\", timeout=5)' || exit 1"]
|
||||||
|
interval: 60s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
driver: local
|
||||||
|
redis_data:
|
||||||
|
driver: local
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
bot_network:
|
bot_network:
|
||||||
external: true
|
name: remnawave_bot_network
|
||||||
|
driver: bridge
|
||||||
|
ipam:
|
||||||
|
config:
|
||||||
|
- subnet: 172.20.0.0/16
|
||||||
|
gateway: 172.20.0.1
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. Проверка здоровья
|
**docker-compose.yml для Caddy (отдельный файл):**
|
||||||
|
|
||||||
Статические файлы миниапки автоматически монтируются из каталога `MINIAPP_STATIC_PATH` (по умолчанию `miniapp/`) и доступны по пути `/miniapp/static`.
|
|
||||||
|
|
||||||
Проверьте, что единый сервер отвечает:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -s https://bot.example.com/health/unified | jq
|
|
||||||
```
|
|
||||||
|
|
||||||
Полезные диагностические endpoints:
|
|
||||||
|
|
||||||
- `/health/unified` — агрегированный статус (режим бота, очередь Telegram, наличие миниапки и платежей). Когда административное API отключено, тот же статус доступен по `/health`.
|
|
||||||
- `/health/telegram-webhook` — состояние очереди Telegram webhook.
|
|
||||||
- `/health/payment-webhooks` — какие платёжные интеграции активированы.
|
|
||||||
|
|
||||||
### 5. Swagger и документация
|
|
||||||
|
|
||||||
- Включите `WEB_API_DOCS_ENABLED=true`, если нужно открыть Swagger UI и OpenAPI. После перезапуска сервиса станут доступны эндпоинты `/docs`, `/doc` (редирект для обратной совместимости), `/redoc` и `/openapi.json`.
|
|
||||||
- Не забудьте проксировать эти пути через внешний HTTPS-прокси вместе с остальными эндпоинтами бота.
|
|
||||||
- В продакшене держите `WEB_API_DOCS_ENABLED=false`, чтобы документация не была публичной. При необходимости включайте временно или защищайте прокси базовой авторизацией/IP-фильтрацией.
|
|
||||||
|
|
||||||
### 6. Пример Caddy-конфига
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
caddy:
|
caddy:
|
||||||
@@ -236,9 +318,143 @@ volumes:
|
|||||||
|
|
||||||
networks:
|
networks:
|
||||||
bot_network:
|
bot_network:
|
||||||
|
name: remnawave_bot_network
|
||||||
external: true
|
external: true
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Вариант 2: Бот на одном сервере с панелью Remnawave
|
||||||
|
|
||||||
|
Если бот и панель Remnawave запускаются на одном сервере, подключите бота к сети панели:
|
||||||
|
|
||||||
|
**docker-compose.yml бота:**
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
container_name: remnawave_bot_db
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-remnawave_bot}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-remnawave_user}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-secure_password_123}
|
||||||
|
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C"
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
networks:
|
||||||
|
- bot_network
|
||||||
|
- remnawave-network # Подключаем к сети панели
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-remnawave_user} -d ${POSTGRES_DB:-remnawave_bot}"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: remnawave_bot_redis
|
||||||
|
restart: unless-stopped
|
||||||
|
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
networks:
|
||||||
|
- bot_network
|
||||||
|
- remnawave-network # Подключаем к сети панели
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
bot:
|
||||||
|
build: .
|
||||||
|
container_name: remnawave_bot
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
DOCKER_ENV: "true"
|
||||||
|
DATABASE_MODE: "auto"
|
||||||
|
POSTGRES_HOST: "postgres"
|
||||||
|
POSTGRES_PORT: "5432"
|
||||||
|
POSTGRES_DB: "${POSTGRES_DB:-remnawave_bot}"
|
||||||
|
POSTGRES_USER: "${POSTGRES_USER:-remnawave_user}"
|
||||||
|
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:-secure_password_123}"
|
||||||
|
REDIS_URL: "redis://redis:6379/0"
|
||||||
|
TZ: "Europe/Moscow"
|
||||||
|
LOCALES_PATH: "${LOCALES_PATH:-/app/locales}"
|
||||||
|
volumes:
|
||||||
|
- ./logs:/app/logs:rw
|
||||||
|
- ./data:/app/data:rw
|
||||||
|
- ./locales:/app/locales:rw
|
||||||
|
- /etc/timezone:/etc/timezone:ro
|
||||||
|
- /etc/localtime:/etc/localtime:ro
|
||||||
|
- ./vpn_logo.png:/app/vpn_logo.png:ro
|
||||||
|
ports:
|
||||||
|
- "${WEB_API_PORT:-8080}:8080"
|
||||||
|
networks:
|
||||||
|
- bot_network
|
||||||
|
- remnawave-network # Подключаем к сети панели
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "python -c 'import requests; requests.get(\"http://localhost:8080/health\", timeout=5)' || exit 1"]
|
||||||
|
interval: 60s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
driver: local
|
||||||
|
redis_data:
|
||||||
|
driver: local
|
||||||
|
|
||||||
|
networks:
|
||||||
|
bot_network:
|
||||||
|
driver: bridge
|
||||||
|
ipam:
|
||||||
|
config:
|
||||||
|
- subnet: 172.20.0.0/16
|
||||||
|
gateway: 172.20.0.1
|
||||||
|
|
||||||
|
remnawave-network:
|
||||||
|
name: remnawave-network
|
||||||
|
external: true # Используем существующую сеть панели
|
||||||
|
```
|
||||||
|
|
||||||
|
**Важно:**
|
||||||
|
- Сеть `remnawave-network` должна быть создана панелью Remnawave
|
||||||
|
- В `.env` укажите `REMNAWAVE_API_URL=http://remnawave:3000` (обращение внутри Docker-сети)
|
||||||
|
- Бот сможет напрямую общаться с панелью без внешних запросов
|
||||||
|
|
||||||
|
### 5. Проверка здоровья
|
||||||
|
|
||||||
|
Статические файлы миниапки автоматически монтируются из каталога `MINIAPP_STATIC_PATH` (по умолчанию `miniapp/`) и доступны по пути `/miniapp/static`.
|
||||||
|
|
||||||
|
Проверьте, что единый сервер отвечает:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s https://bot.example.com/health/unified | jq
|
||||||
|
```
|
||||||
|
|
||||||
|
Полезные диагностические endpoints:
|
||||||
|
|
||||||
|
- `/health/unified` — агрегированный статус (режим бота, очередь Telegram, наличие миниапки и платежей). Когда административное API отключено, тот же статус доступен по `/health`.
|
||||||
|
- `/health/telegram-webhook` — состояние очереди Telegram webhook.
|
||||||
|
- `/health/payment-webhooks` — какие платёжные интеграции активированы.
|
||||||
|
|
||||||
|
### 6. Swagger и документация
|
||||||
|
|
||||||
|
- Включите `WEB_API_DOCS_ENABLED=true`, если нужно открыть Swagger UI и OpenAPI. После перезапуска сервиса станут доступны эндпоинты `/docs`, `/doc` (редирект для обратной совместимости), `/redoc` и `/openapi.json`.
|
||||||
|
- Не забудьте проксировать эти пути через внешний HTTPS-прокси вместе с остальными эндпоинтами бота.
|
||||||
|
- В продакшене держите `WEB_API_DOCS_ENABLED=false`, чтобы документация не была публичной. При необходимости включайте временно или защищайте прокси базовой авторизацией/IP-фильтрацией.
|
||||||
|
|
||||||
|
### 7. Пример Caddy-конфига
|
||||||
|
|
||||||
`Caddyfile`:
|
`Caddyfile`:
|
||||||
|
|
||||||
```caddy
|
```caddy
|
||||||
@@ -249,7 +465,7 @@ api.domain.com {
|
|||||||
@config path /app-config.json
|
@config path /app-config.json
|
||||||
header @config Access-Control-Allow-Origin "*"
|
header @config Access-Control-Allow-Origin "*"
|
||||||
|
|
||||||
reverse_proxy localhost:8080 {
|
reverse_proxy remnawave_bot:8080 {
|
||||||
header_up Host {host}
|
header_up Host {host}
|
||||||
header_up X-Real-IP {remote_host}
|
header_up X-Real-IP {remote_host}
|
||||||
transport http {
|
transport http {
|
||||||
@@ -264,7 +480,7 @@ podpiska.domain.com {
|
|||||||
|
|
||||||
# API эндпоинты /miniapp/* в приложение
|
# API эндпоинты /miniapp/* в приложение
|
||||||
handle /miniapp/* {
|
handle /miniapp/* {
|
||||||
reverse_proxy localhost:8080 {
|
reverse_proxy remnawave_bot:8080 {
|
||||||
header_up Host {host}
|
header_up Host {host}
|
||||||
header_up X-Real-IP {remote_host}
|
header_up X-Real-IP {remote_host}
|
||||||
transport http {
|
transport http {
|
||||||
@@ -276,7 +492,7 @@ podpiska.domain.com {
|
|||||||
# app-config.json с CORS
|
# app-config.json с CORS
|
||||||
handle /app-config.json {
|
handle /app-config.json {
|
||||||
header Access-Control-Allow-Origin "*"
|
header Access-Control-Allow-Origin "*"
|
||||||
reverse_proxy localhost:8080 {
|
reverse_proxy remnawave_bot:8080 {
|
||||||
header_up Host {host}
|
header_up Host {host}
|
||||||
header_up X-Real-IP {remote_host}
|
header_up X-Real-IP {remote_host}
|
||||||
transport http {
|
transport http {
|
||||||
@@ -294,31 +510,9 @@ podpiska.domain.com {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
####
|
**Примечание:** Если прокси запускается на том же сервере, замените `remnawave_bot:8080` на `localhost:8080`
|
||||||
|
|
||||||
Либо вместо `remnawave_bot:8080` используйте `localhost:8080`
|
### 8. Пример nginx-конфига
|
||||||
|
|
||||||
|
|
||||||
### 6. Пример nginx-конфига
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
nginx:
|
|
||||||
image: nginx:1.25-alpine
|
|
||||||
container_name: remnawave_nginx
|
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
- "80:80"
|
|
||||||
- "443:443"
|
|
||||||
volumes:
|
|
||||||
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
|
||||||
networks:
|
|
||||||
- bot_network
|
|
||||||
|
|
||||||
networks:
|
|
||||||
bot_network:
|
|
||||||
external: true
|
|
||||||
```
|
|
||||||
|
|
||||||
`nginx.conf`:
|
`nginx.conf`:
|
||||||
|
|
||||||
@@ -354,7 +548,7 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Все остальное
|
# Всё остальное
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://remnawave_bot_unified;
|
proxy_pass http://remnawave_bot_unified;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
@@ -415,13 +609,11 @@ http {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
####
|
**Примечание:** Если прокси запускается на том же сервере, замените `remnawave_bot:8080` на `localhost:8080`
|
||||||
|
|
||||||
Либо вместо `remnawave_bot:8080` используйте `localhost:8080`
|
|
||||||
|
|
||||||
Рекомендации:
|
Рекомендации:
|
||||||
|
|
||||||
- Откройте входящие 80/443 в фаерволе.
|
- Откройте входящие 80/443 в файерволе.
|
||||||
- Если используете Cloudflare/анти-DDoS, разрешите методы `POST` и заголовок `X-Telegram-Bot-Api-Secret-Token`.
|
- Если используете Cloudflare/анти-DDoS, разрешите методы `POST` и заголовок `X-Telegram-Bot-Api-Secret-Token`.
|
||||||
- После развёртывания перезапустите бот (`make reload`), чтобы он заново зарегистрировал webhook.
|
- После развёртывания перезапустите бот (`make reload`), чтобы он заново зарегистрировал webhook.
|
||||||
|
|
||||||
@@ -448,7 +640,7 @@ http {
|
|||||||
- `BOT_RUN_MODE` — определяет способ приёма обновлений: `polling`, `webhook` или `both`, чтобы одновременно использовать оба режима.
|
- `BOT_RUN_MODE` — определяет способ приёма обновлений: `polling`, `webhook` или `both`, чтобы одновременно использовать оба режима.
|
||||||
- `WEBHOOK_SECRET_TOKEN` — секрет для проверки заголовка `X-Telegram-Bot-Api-Secret-Token` при работе через вебхуки.
|
- `WEBHOOK_SECRET_TOKEN` — секрет для проверки заголовка `X-Telegram-Bot-Api-Secret-Token` при работе через вебхуки.
|
||||||
- `WEBHOOK_DROP_PENDING_UPDATES` — управляет очисткой очереди сообщений при установке вебхука.
|
- `WEBHOOK_DROP_PENDING_UPDATES` — управляет очисткой очереди сообщений при установке вебхука.
|
||||||
- `WEBHOOK_MAX_QUEUE_SIZE` — ограничивает длину очереди входящих обновлений, чтобы защищаться от перегрузок.
|
- `WEBHOOK_MAX_QUEUE_SIZE` — ограничивает длину очереди входящих обновлений, чтобы защащаться от перегрузок.
|
||||||
- `WEBHOOK_WORKERS` — количество фоновых воркеров, параллельно обрабатывающих обновления Telegram.
|
- `WEBHOOK_WORKERS` — количество фоновых воркеров, параллельно обрабатывающих обновления Telegram.
|
||||||
- `WEBHOOK_ENQUEUE_TIMEOUT` — сколько секунд ждать свободного места в очереди перед отказом (0 — немедленный отказ).
|
- `WEBHOOK_ENQUEUE_TIMEOUT` — сколько секунд ждать свободного места в очереди перед отказом (0 — немедленный отказ).
|
||||||
- `WEBHOOK_WORKER_SHUTDOWN_TIMEOUT` — таймаут корректного завершения воркеров при остановке приложения.
|
- `WEBHOOK_WORKER_SHUTDOWN_TIMEOUT` — таймаут корректного завершения воркеров при остановке приложения.
|
||||||
@@ -464,7 +656,7 @@ http {
|
|||||||
| Переменная | Описание | Пример |
|
| Переменная | Описание | Пример |
|
||||||
|------------|----------|--------|
|
|------------|----------|--------|
|
||||||
| `SERVER_STATUS_MODE` | Режим работы кнопки: `disabled`, `external_link`, `external_link_miniapp` или `xray` | `xray` |
|
| `SERVER_STATUS_MODE` | Режим работы кнопки: `disabled`, `external_link`, `external_link_miniapp` или `xray` | `xray` |
|
||||||
| `SERVER_STATUS_EXTERNAL_URL` | Прямая ссылка на внешний мониторинг | `https://status.example.com` |
|
| `SERVER_STATUS_EXTERNAL_URL` | Прямаяссылка на внешний мониторинг | `https://status.example.com` |
|
||||||
| `SERVER_STATUS_METRICS_URL` | URL страницы метрик XrayChecker | `https://sub.example.com/metrics` |
|
| `SERVER_STATUS_METRICS_URL` | URL страницы метрик XrayChecker | `https://sub.example.com/metrics` |
|
||||||
|
|
||||||
### 🛡️ Защита панели Remnawave
|
### 🛡️ Защита панели Remnawave
|
||||||
@@ -551,7 +743,7 @@ DEFAULT_DEVICE_LIMIT=3
|
|||||||
MAX_DEVICES_LIMIT=15
|
MAX_DEVICES_LIMIT=15
|
||||||
```
|
```
|
||||||
|
|
||||||
### 👥 Реферальная система
|
### 💥 Реферальная система
|
||||||
|
|
||||||
```env
|
```env
|
||||||
# Включение/выключение реферальной программы
|
# Включение/выключение реферальной программы
|
||||||
@@ -609,7 +801,7 @@ REDIS_URL=redis://redis:6379/0
|
|||||||
<tr>
|
<tr>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
|
|
||||||
### 👤 **Для пользователей**
|
### 💤 **Для пользователей**
|
||||||
|
|
||||||
🧭 **Онбординг и доступ**
|
🧭 **Онбординг и доступ**
|
||||||
- 🌐 Выбор языка интерфейса (RU/EN), динамическая локализация
|
- 🌐 Выбор языка интерфейса (RU/EN), динамическая локализация
|
||||||
@@ -618,9 +810,9 @@ REDIS_URL=redis://redis:6379/0
|
|||||||
- 🔗 Deeplink-инвайты, UTM-кампании и реферальные коды
|
- 🔗 Deeplink-инвайты, UTM-кампании и реферальные коды
|
||||||
|
|
||||||
🛒 **Умная покупка подписок**
|
🛒 **Умная покупка подписок**
|
||||||
- 📅 Гибкие периоды (14–360 дней) со скидками
|
- 📅 Гибкие периоды (14—360 дней) со скидками
|
||||||
- 📊 Выбор трафика: фиксированный лимит, пакеты или безлимит
|
- 📊 Выбор трафика: фиксированный лимит, пакеты или безлимит
|
||||||
- 🌍 Автоматический выбор сервера или мультивыбор
|
- 🌐 Автоматический выбор сервера или мультивыбор
|
||||||
- 📱 Настройка количества устройств и серверов
|
- 📱 Настройка количества устройств и серверов
|
||||||
- 🧾 Динамический калькулятор стоимости
|
- 🧾 Динамический калькулятор стоимости
|
||||||
- 💾 **Сквозная корзина** - сохранение параметров при недостатке баланса
|
- 💾 **Сквозная корзина** - сохранение параметров при недостатке баланса
|
||||||
@@ -640,10 +832,10 @@ REDIS_URL=redis://redis:6379/0
|
|||||||
- 💰 CryptoBot (USDT, TON, BTC, ETH и др.)
|
- 💰 CryptoBot (USDT, TON, BTC, ETH и др.)
|
||||||
- 🪙 Heleket (криптовалюта с наценкой)
|
- 🪙 Heleket (криптовалюта с наценкой)
|
||||||
- 💳 MulenPay (СБП)
|
- 💳 MulenPay (СБП)
|
||||||
- 🏦 PayPalych/Pal24 (СБП + карты)
|
- 🦐 PayPalych/Pal24 (СБП + карты)
|
||||||
- 💳 Platega (СБП + банковские карты)
|
- 💳 Platega (СБП + банковские карты)
|
||||||
- 💳 **WATA**
|
- 💳 **WATA**
|
||||||
- 📥 Автогенерация счетов и webhook-уведомления
|
- 🔥 Автогенерация счетов и webhook-уведомления
|
||||||
- 💼 История операций
|
- 💼 История операций
|
||||||
- 🔄 Автоплатёж с настройкой дня списания
|
- 🔄 Автоплатёж с настройкой дня списания
|
||||||
- 🎁 Реферальные и промо-бонусы
|
- 🎁 Реферальные и промо-бонусы
|
||||||
@@ -651,7 +843,7 @@ REDIS_URL=redis://redis:6379/0
|
|||||||
|
|
||||||
📱 **Управление подписками**
|
📱 **Управление подписками**
|
||||||
- 📈 Реальный трафик, устройства и серверы
|
- 📈 Реальный трафик, устройства и серверы
|
||||||
- 🌍 Переключение серверов и стран
|
- 🌐 Переключение серверов и стран
|
||||||
- 📱 Сброс HWID
|
- 📱 Сброс HWID
|
||||||
- 🧩 Смена языка, промогруппы и параметров
|
- 🧩 Смена языка, промогруппы и параметров
|
||||||
- 🧾 Просмотр активных услуг и статуса
|
- 🧾 Просмотр активных услуг и статуса
|
||||||
@@ -667,7 +859,7 @@ REDIS_URL=redis://redis:6379/0
|
|||||||
- 🎁 **Персональные промо-предложения** от админов
|
- 🎁 **Персональные промо-предложения** от админов
|
||||||
- 💰 **Тестовый доступ к серверам** через промо-акции
|
- 💰 **Тестовый доступ к серверам** через промо-акции
|
||||||
- 💸 **Автоматические скидки** при оплате и автопродлении
|
- 💸 **Автоматические скидки** при оплате и автопродлении
|
||||||
- 👥 Реферальная программа с комиссиями и бонусами
|
- 💥 Реферальная программа с комиссиями и бонусами
|
||||||
- 📊 Аналитика доходов и конверсии рефералов
|
- 📊 Аналитика доходов и конверсии рефералов
|
||||||
- 🔗 Генерация реферальных ссылок и QR кодов
|
- 🔗 Генерация реферальных ссылок и QR кодов
|
||||||
|
|
||||||
@@ -682,9 +874,9 @@ REDIS_URL=redis://redis:6379/0
|
|||||||
- 🖥️ **Полноценный личный кабинет** в Telegram WebApp
|
- 🖥️ **Полноценный личный кабинет** в Telegram WebApp
|
||||||
- 📊 Управление подпиской и параметрами
|
- 📊 Управление подпиской и параметрами
|
||||||
- 💳 Интегрированные платежи
|
- 💳 Интегрированные платежи
|
||||||
- 🎁 Активация промо-оферов и промокодов
|
- 🎁 Активация промо-офферов и промокодов
|
||||||
- 📱 Управление устройствами
|
- 📱 Управление устройствами
|
||||||
- 👥 Реферальная статистика
|
- 💥 Реферальная статистика
|
||||||
- 📋 FAQ и юридические документы
|
- 📋 FAQ и юридические документы
|
||||||
- 📥 Библиотека загрузочных ссылок для клиентов
|
- 📥 Библиотека загрузочных ссылок для клиентов
|
||||||
- 🛰️ Web API для внешних интеграций
|
- 🛰️ Web API для внешних интеграций
|
||||||
@@ -702,7 +894,7 @@ REDIS_URL=redis://redis:6379/0
|
|||||||
- 🎯 **Статистика по промо-группам** и скидочным уровням
|
- 🎯 **Статистика по промо-группам** и скидочным уровням
|
||||||
- 📊 **Расширенная фильтрация** пользователей (баланс, траты, активность)
|
- 📊 **Расширенная фильтрация** пользователей (баланс, траты, активность)
|
||||||
|
|
||||||
👥 **Управление пользователями**
|
💥 **Управление пользователями**
|
||||||
- 🔍 Поиск по ID, имени, юзернейму, Telegram ID и фильтры
|
- 🔍 Поиск по ID, имени, юзернейму, Telegram ID и фильтры
|
||||||
- 💰 Ручное изменение баланса
|
- 💰 Ручное изменение баланса
|
||||||
- 📱 Изменение лимитов устройств, трафика, серверов
|
- 📱 Изменение лимитов устройств, трафика, серверов
|
||||||
@@ -722,7 +914,7 @@ REDIS_URL=redis://redis:6379/0
|
|||||||
- 📣 **Рекламные кампании** с deeplink и бонусами (Автовыдача подписки / баланса при переходе)
|
- 📣 **Рекламные кампании** с deeplink и бонусами (Автовыдача подписки / баланса при переходе)
|
||||||
- 📨 Рассылки по сегментам с медиа и кнопка
|
- 📨 Рассылки по сегментам с медиа и кнопка
|
||||||
- 🎨 **Кастомные кнопки** для рассылок (подключение, подписка, поддержка, партнертка и тд)
|
- 🎨 **Кастомные кнопки** для рассылок (подключение, подписка, поддержка, партнертка и тд)
|
||||||
- 🔘 Настройка главного меню и приветственных экранов
|
- 📘 Настройка главного меню и приветственных экранов
|
||||||
|
|
||||||
🛟 **Поддержка и модерация**
|
🛟 **Поддержка и модерация**
|
||||||
- 🎫 **Центр тикетов** с приоритетами и статусами
|
- 🎫 **Центр тикетов** с приоритетами и статусами
|
||||||
@@ -749,18 +941,18 @@ REDIS_URL=redis://redis:6379/0
|
|||||||
- 🗓️ **Умные автобекапы** с гибким расписанием
|
- 🗓️ **Умные автобекапы** с гибким расписанием
|
||||||
- 📦 Ручные бекапы с выбором содержимого
|
- 📦 Ручные бекапы с выбором содержимого
|
||||||
- 📤 Отправка архивов в выделенный чат/топик
|
- 📤 Отправка архивов в выделенный чат/топик
|
||||||
- 🔁 Восстановление без остановки бота
|
- 🔓 Восстановление без остановки бота
|
||||||
- ✅ Автоматическая синхронизация sequences после восстановления
|
- ✅ Автоматическая синхронизация sequences после восстановления
|
||||||
|
|
||||||
💳 **Биллинг и настройки**
|
💳 **Биллинг и настройки**
|
||||||
- ⚙️ **Управление ценами** без перезапуска бота
|
- ⚙️ **Управление ценами** без перезапуска бота
|
||||||
- 🔘 **Управление пакетами трафика** (включение/отключение)
|
- 📘 **Управление пакетами трафика** (включение/отключение)
|
||||||
- 🧪 Тестовые платежи для каждого провайдера
|
- 🧪 Тестовые платежи для каждого провайдера
|
||||||
- 🪝 Управление вебхуками всех платёжных систем
|
- 🪙 Управление вебхуками всех платёжных систем
|
||||||
|
|
||||||
🏗️ **REST API для интеграций**
|
🗃️ **REST API для интеграций**
|
||||||
- 🔌 **FastAPI Web API** с полной документацией
|
- 🔌 **FastAPI Web API** с полной документацией
|
||||||
- 🔑 Управление API-ключами и токенами
|
- 🔒 Управление API-ключами и токенами
|
||||||
- 📊 Эндпоинты для подписок, пользователей, транзакций
|
- 📊 Эндпоинты для подписок, пользователей, транзакций
|
||||||
- 🎁 API промо-системы и рассылок
|
- 🎁 API промо-системы и рассылок
|
||||||
- 📋 API управления контентом и настройками
|
- 📋 API управления контентом и настройками
|
||||||
@@ -775,22 +967,22 @@ REDIS_URL=redis://redis:6379/0
|
|||||||
- 🔄 **Автосинхронизация серверов** - фоновая синхронизация по расписанию
|
- 🔄 **Автосинхронизация серверов** - фоновая синхронизация по расписанию
|
||||||
- 🛒 **Умная корзина** - сохранение параметров подписки в Redis при недостатке баланса
|
- 🛒 **Умная корзина** - сохранение параметров подписки в Redis при недостатке баланса
|
||||||
- 🛡️ **Антифрод** - валидация подписки на канал
|
- 🛡️ **Антифрод** - валидация подписки на канал
|
||||||
- 🚫 **Защита от блокировок** - автоблокировка подозрительных никнеймов и имитации фишинг аккаунтов
|
- 🚫 **Защита от блокировок** - автоблокировка подозрительных никнеймов и имитация фишинг аккаунтов
|
||||||
- 🧠 **Асинхронная архитектура** - aiogram 3, PostgreSQL/SQLite, Redis и очереди задач
|
- 🧠 **Асинхронная архитектура** - aiogram 3, PostgreSQL/SQLite, Redis и очереди задач
|
||||||
- 🌐 **Мультиязычность** - локализации RU/EN, быстрый выбор языка
|
- 🌐 **Мультиязычность** - локализации RU/EN, быстрый выбор языка
|
||||||
- 📦 **Интеграция с Remnawave API** - автоматическое создание пользователей и синхронизация
|
- 📦 **Интеграция с Remnawave API** - автоматическое создание пользователей и синхронизация
|
||||||
- 🔄 **Миграция сквадов** - массовый перенос пользователей между сквадами
|
- 🔄 **Миграция сквадов** - массовый перенос пользователей между сквадами
|
||||||
- 🧾 **История операций** - хранение всех транзакций и действий для аудита
|
- 🧾 **История операций** - хранение всех транзакций и действий для аудита
|
||||||
- 💸 **Сервис автопрвоерки транзакций** - автоматическая проверка транзакций в статусе "В ожидании оплаты" за последние 24ч
|
- 💸 **Сервис автопрворки транзакций** - автоматическая проверка транзакций в статусе "В ожидании оплаты" за последние 24ч
|
||||||
|
|
||||||
### 🌐 Веб-API и мини-приложение
|
### 🌐 Веб-API и мини-приложение
|
||||||
|
|
||||||
- ⚙️ **FastAPI Web API** с эндпоинтами для управления всеми аспектами бота
|
- ⚙️ **FastAPI Web API** с эндпоинтами для управления всеми аспектами бота
|
||||||
- 🔑 **Управление API-ключами** - выпуск, отзыв, реактивация токенов
|
- 🔒 **Управление API-ключами** - выпуск, отзыв, реактивация токенов
|
||||||
- 🛰️ **Mini App** - полноценный личный кабинет внутри Telegram
|
- 🛰️ **Mini App** - полноценный личный кабинет внутри Telegram
|
||||||
- 💳 **Интегрированные платежи** в Mini App (Stars, Pal24, YooKassa, WATA)
|
- 💳 **Интегрированные платежи** в Mini App (Stars, Pal24, YooKassa, WATA)
|
||||||
- 🧭 **Единый стандартный app-config.json** - централизованная раздача ссылок на клиенты
|
- 🧭 **Единый стандартный app-config.json** - централизованная раздача ссылок на клиенты
|
||||||
- 🪝 **Платёжные вебхуки** - встроенные серверы для всех платёжных систем
|
- 🪙 **Платёжные вебхуки** - встроенные серверы для всех платёжных систем
|
||||||
- 📡 **Мониторинг серверов** - REST-эндпоинты для просмотра нод и статистики
|
- 📡 **Мониторинг серверов** - REST-эндпоинты для просмотра нод и статистики
|
||||||
|
|
||||||
## 🚀 Производительность
|
## 🚀 Производительность
|
||||||
@@ -804,14 +996,14 @@ REDIS_URL=redis://redis:6379/0
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🏗️ Технологический стек
|
## 🗃️ Технологический стек
|
||||||
|
|
||||||
### 💪 Современные технологии
|
### 💪 Современные технологии
|
||||||
|
|
||||||
- **🐍 Python 3.13+** с AsyncIO - максимальная производительность
|
- **🐍 Python 3.13+** с AsyncIO - максимальная производительность
|
||||||
- **🗄️ PostgreSQL 15+** - надежное хранение данных
|
- **🗄️ PostgreSQL 15+** - надежное хранение данных
|
||||||
- **⚡ Redis** - быстрое кеширование и сессии (для корзины)
|
- **⚡ Redis** - быстрое кеширование и сессии (для корзины)
|
||||||
- **🐳 Docker** - простое развертывание в любой среде
|
- **Docker** - простое развертывание в любой среде
|
||||||
- **🔗 SQLAlchemy ORM** - безопасная работа с БД
|
- **🔗 SQLAlchemy ORM** - безопасная работа с БД
|
||||||
- **🚀 aiogram 3** - современная Telegram Bot API
|
- **🚀 aiogram 3** - современная Telegram Bot API
|
||||||
- **⚡ FastAPI** - высокопроизводительный REST API
|
- **⚡ FastAPI** - высокопроизводительный REST API
|
||||||
@@ -828,7 +1020,7 @@ REDIS_URL=redis://redis:6379/0
|
|||||||
- Нажми **Синхронизация** и дождись завершения
|
- Нажми **Синхронизация** и дождись завершения
|
||||||
- Без этого пользователи не смогут выбирать страны!
|
- Без этого пользователи не смогут выбирать страны!
|
||||||
|
|
||||||
2. **👥 Синхронизация пользователей** (если есть база)
|
2. **💥 Синхронизация пользователей** (если есть база)
|
||||||
- **Админ панель** → **Remnawave** → **Синхронизация**
|
- **Админ панель** → **Remnawave** → **Синхронизация**
|
||||||
- **Синхронизировать всех** → дождись импорта
|
- **Синхронизировать всех** → дождись импорта
|
||||||
|
|
||||||
@@ -880,9 +1072,9 @@ ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID=126 # ID топика для тикет
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🐛 Устранение неполадок
|
## 🛠 Устранение неполадок
|
||||||
|
|
||||||
### 🏥 Health Checks
|
### 🥼 Health Checks
|
||||||
- **Unified сервер**: `http://localhost:8080/health/unified` (или `/health`, если административное API отключено)
|
- **Unified сервер**: `http://localhost:8080/health/unified` (или `/health`, если административное API отключено)
|
||||||
- **Telegram webhook**: `http://localhost:8080/health/telegram-webhook`
|
- **Telegram webhook**: `http://localhost:8080/health/telegram-webhook`
|
||||||
- **Платёжные webhooks**: `http://localhost:8080/health/payment-webhooks`
|
- **Платёжные webhooks**: `http://localhost:8080/health/payment-webhooks`
|
||||||
@@ -929,7 +1121,7 @@ docker system prune
|
|||||||
|
|
||||||
## 💡 Использование
|
## 💡 Использование
|
||||||
|
|
||||||
### 👤 **Для пользователей**
|
### 💤 **Для пользователей**
|
||||||
|
|
||||||
1. **🚀 Старт** → Найди бота и нажми `/start`
|
1. **🚀 Старт** → Найди бота и нажми `/start`
|
||||||
2. **🌐 Язык** → Выбери язык интерфейса (RU/EN)
|
2. **🌐 Язык** → Выбери язык интерфейса (RU/EN)
|
||||||
@@ -937,21 +1129,21 @@ docker system prune
|
|||||||
4. **💰 Баланс** → Пополни через любой удобный способ
|
4. **💰 Баланс** → Пополни через любой удобный способ
|
||||||
5. **🛒 Подписка** → Выбери тариф и параметры
|
5. **🛒 Подписка** → Выбери тариф и параметры
|
||||||
6. **📱 Подключение** → Получи ссылку или конфиг
|
6. **📱 Подключение** → Получи ссылку или конфиг
|
||||||
7. **👥 Партнерка** → Поделись ссылкой и получай бонусы
|
7. **💥 Партнерка** → Поделись ссылкой и получай бонусы
|
||||||
|
|
||||||
### ⚙️ **Для администраторов**
|
### ⚙️ **Для администраторов**
|
||||||
|
|
||||||
Доступ через **"⚙️ Админ панель"**:
|
Доступ через **"⚙️ Админ панель"**:
|
||||||
|
|
||||||
- **📦 Подписки** → настройка серверов, цен, синхронизация
|
- **📦 Подписки** → настройка серверов, цен, синхронизация
|
||||||
- **👥 Пользователи** → поиск, редактирование, блокировка
|
- **💥 Пользователи** → поиск, редактирование, блокировка
|
||||||
- **💎 Промо-группы** → управление скидочными группами и уровнями
|
- **💎 Промо-группы** → управление скидочными группами и уровнями
|
||||||
- **🎁 Промокоды** → создание и статистика
|
- **🎁 Промокоды** → создание и статистика
|
||||||
- **🎯 Промо-предложения** → персональные акции и скидки
|
- **🎯 Промо-предложения** → персональные акции и скидки
|
||||||
- **📨 Рассылки** → уведомления по сегментам
|
- **📨 Рассылки** → уведомления по сегментам
|
||||||
- **📣 Кампании** → управление рекламными кампаниями
|
- **📣 Кампании** → управление рекламными кампаниями
|
||||||
- **🎫 Тикеты** → система поддержки
|
- **🎫 Тикеты** → система поддержки
|
||||||
- **📄 Контент** → политика, оферта, FAQ
|
- **🔄 Контент** → политика, оферта, FAQ
|
||||||
- **🖥 Remnawave** → мониторинг, синхронизация
|
- **🖥 Remnawave** → мониторинг, синхронизация
|
||||||
- **📊 Статистика** → детальная аналитика
|
- **📊 Статистика** → детальная аналитика
|
||||||
|
|
||||||
@@ -959,7 +1151,7 @@ docker system prune
|
|||||||
|
|
||||||
## 🛡️ Безопасность
|
## 🛡️ Безопасность
|
||||||
|
|
||||||
### 🔐 Защита панели Remnawave
|
### 🔍 Защита панели Remnawave
|
||||||
|
|
||||||
Бот поддерживает интеграцию с системой защиты панели:
|
Бот поддерживает интеграцию с системой защиты панели:
|
||||||
|
|
||||||
@@ -985,11 +1177,11 @@ REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD
|
|||||||
|
|
||||||
## 🤝 Как помочь проекту
|
## 🤝 Как помочь проекту
|
||||||
|
|
||||||
- 🔍 [**Сообщай о багах**](https://github.com/Fr1ngg/remnawave-bedolaga-telegram-bot/issues) с подробным описанием
|
- 🐛 [**Сообщай о багах**](https://github.com/Fr1ngg/remnawave-bedolaga-telegram-bot/issues) с подробным описанием
|
||||||
- 💡 [**Предлагай идеи**](https://github.com/Fr1ngg/remnawave-bedolaga-telegram-bot/discussions) для улучшения
|
- 💡 [**Предлагай идеи**](https://github.com/Fr1ngg/remnawave-bedolaga-telegram-bot/discussions) для улучшения
|
||||||
- ⭐ **Ставь звезды** проекту - это мотивирует разработку!
|
- ⭐ **Ставь звезды** проекту - это мотивирует разработку!
|
||||||
- 📢 **Рассказывай друзьям** о проекте
|
- 📢 **Рассказывай друзьям** о проекте
|
||||||
- 💝 **[Поддержи разработку](https://t.me/tribute/app?startapp=duUO)** - помоги проекту расти
|
- 👍 **[Поддержи разработку](https://t.me/tribute/app?startapp=duUO)** - помоги проекту расти
|
||||||
- 🔧 **Отправляй Pull Requests** - внеси свой вклад в код
|
- 🔧 **Отправляй Pull Requests** - внеси свой вклад в код
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -1366,8 +1558,6 @@ docker compose up -d
|
|||||||
|
|
||||||
**Made with ❤️ by [@fringg](https://t.me/fringg) and amazing [contributors](https://github.com/Fr1ngg/remnawave-bedolaga-telegram-bot/graphs/contributors)**
|
**Made with ❤️ by [@fringg](https://t.me/fringg) and amazing [contributors](https://github.com/Fr1ngg/remnawave-bedolaga-telegram-bot/graphs/contributors)**
|
||||||
|
|
||||||
**Версия:** v2.5.2 | **Последнее обновление:** 2024
|
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
### ⭐ Не забудь поставить звезду проекту!
|
### ⭐ Не забудь поставить звезду проекту!
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ from app.handlers.admin import (
|
|||||||
public_offer as admin_public_offer,
|
public_offer as admin_public_offer,
|
||||||
faq as admin_faq,
|
faq as admin_faq,
|
||||||
payments as admin_payments,
|
payments as admin_payments,
|
||||||
|
trials as admin_trials,
|
||||||
)
|
)
|
||||||
from app.handlers.stars_payments import register_stars_handlers
|
from app.handlers.stars_payments import register_stars_handlers
|
||||||
|
|
||||||
@@ -174,6 +175,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
|||||||
admin_public_offer.register_handlers(dp)
|
admin_public_offer.register_handlers(dp)
|
||||||
admin_faq.register_handlers(dp)
|
admin_faq.register_handlers(dp)
|
||||||
admin_payments.register_handlers(dp)
|
admin_payments.register_handlers(dp)
|
||||||
|
admin_trials.register_handlers(dp)
|
||||||
common.register_handlers(dp)
|
common.register_handlers(dp)
|
||||||
register_stars_handlers(dp)
|
register_stars_handlers(dp)
|
||||||
user_polls.register_handlers(dp)
|
user_polls.register_handlers(dp)
|
||||||
|
|||||||
+48
-7
@@ -6,7 +6,8 @@ import re
|
|||||||
import html
|
import html
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from datetime import time
|
from datetime import time
|
||||||
from typing import List, Optional, Union, Dict
|
from typing import Dict, List, Optional, Union
|
||||||
|
from urllib.parse import urlparse
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
from pydantic_settings import BaseSettings
|
from pydantic_settings import BaseSettings
|
||||||
from pydantic import field_validator, Field
|
from pydantic import field_validator, Field
|
||||||
@@ -254,6 +255,7 @@ class Settings(BaseSettings):
|
|||||||
MULENPAY_PAYMENT_MODE: int = 4
|
MULENPAY_PAYMENT_MODE: int = 4
|
||||||
MULENPAY_MIN_AMOUNT_KOPEKS: int = 10000
|
MULENPAY_MIN_AMOUNT_KOPEKS: int = 10000
|
||||||
MULENPAY_MAX_AMOUNT_KOPEKS: int = 10000000
|
MULENPAY_MAX_AMOUNT_KOPEKS: int = 10000000
|
||||||
|
MULENPAY_IFRAME_EXPECTED_ORIGIN: Optional[str] = None
|
||||||
|
|
||||||
PAL24_ENABLED: bool = False
|
PAL24_ENABLED: bool = False
|
||||||
PAL24_API_TOKEN: Optional[str] = None
|
PAL24_API_TOKEN: Optional[str] = None
|
||||||
@@ -697,15 +699,40 @@ class Settings(BaseSettings):
|
|||||||
return bool(value)
|
return bool(value)
|
||||||
|
|
||||||
def get_available_languages(self) -> List[str]:
|
def get_available_languages(self) -> List[str]:
|
||||||
|
defaults = ["ru", "en", "ua", "zh"]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
langs = self.AVAILABLE_LANGUAGES
|
langs = self.AVAILABLE_LANGUAGES
|
||||||
if isinstance(langs, str):
|
|
||||||
if not langs.strip():
|
|
||||||
return ["ru", "en"]
|
|
||||||
return [x.strip() for x in langs.split(',') if x.strip()]
|
|
||||||
return ["ru", "en"]
|
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
return ["ru", "en"]
|
return defaults
|
||||||
|
|
||||||
|
candidates: List[str]
|
||||||
|
|
||||||
|
if isinstance(langs, str):
|
||||||
|
if not langs.strip():
|
||||||
|
return defaults
|
||||||
|
candidates = [chunk.strip() for chunk in langs.split(',')]
|
||||||
|
elif isinstance(langs, (list, tuple, set)):
|
||||||
|
candidates = [str(item).strip() for item in langs]
|
||||||
|
else:
|
||||||
|
return defaults
|
||||||
|
|
||||||
|
cleaned: List[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
|
||||||
|
for code in candidates:
|
||||||
|
if not code:
|
||||||
|
continue
|
||||||
|
|
||||||
|
normalized = code.lower()
|
||||||
|
|
||||||
|
if normalized in seen:
|
||||||
|
continue
|
||||||
|
|
||||||
|
seen.add(normalized)
|
||||||
|
cleaned.append(code)
|
||||||
|
|
||||||
|
return cleaned or defaults
|
||||||
|
|
||||||
def is_language_selection_enabled(self) -> bool:
|
def is_language_selection_enabled(self) -> bool:
|
||||||
return bool(getattr(self, "LANGUAGE_SELECTION_ENABLED", True))
|
return bool(getattr(self, "LANGUAGE_SELECTION_ENABLED", True))
|
||||||
@@ -943,6 +970,20 @@ class Settings(BaseSettings):
|
|||||||
def get_mulenpay_display_name_html(self) -> str:
|
def get_mulenpay_display_name_html(self) -> str:
|
||||||
return html.escape(self.get_mulenpay_display_name())
|
return html.escape(self.get_mulenpay_display_name())
|
||||||
|
|
||||||
|
def get_mulenpay_expected_origin(self) -> Optional[str]:
|
||||||
|
override = (self.MULENPAY_IFRAME_EXPECTED_ORIGIN or "").strip()
|
||||||
|
if override:
|
||||||
|
return override
|
||||||
|
|
||||||
|
base_url = (self.MULENPAY_BASE_URL or "").strip()
|
||||||
|
if not base_url:
|
||||||
|
return None
|
||||||
|
|
||||||
|
parsed = urlparse(base_url)
|
||||||
|
if parsed.scheme and parsed.netloc:
|
||||||
|
return f"{parsed.scheme}://{parsed.netloc}"
|
||||||
|
return None
|
||||||
|
|
||||||
def is_pal24_enabled(self) -> bool:
|
def is_pal24_enabled(self) -> bool:
|
||||||
return (
|
return (
|
||||||
self.PAL24_ENABLED
|
self.PAL24_ENABLED
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from datetime import datetime
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
from datetime import datetime
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
@@ -90,6 +91,7 @@ async def update_mulenpay_payment_status(
|
|||||||
paid_at: Optional[datetime] = None,
|
paid_at: Optional[datetime] = None,
|
||||||
callback_payload: Optional[dict] = None,
|
callback_payload: Optional[dict] = None,
|
||||||
mulen_payment_id: Optional[int] = None,
|
mulen_payment_id: Optional[int] = None,
|
||||||
|
metadata: Optional[dict] = None,
|
||||||
) -> MulenPayPayment:
|
) -> MulenPayPayment:
|
||||||
payment.status = status
|
payment.status = status
|
||||||
if is_paid is not None:
|
if is_paid is not None:
|
||||||
@@ -100,6 +102,8 @@ async def update_mulenpay_payment_status(
|
|||||||
payment.callback_payload = callback_payload
|
payment.callback_payload = callback_payload
|
||||||
if mulen_payment_id is not None and not payment.mulen_payment_id:
|
if mulen_payment_id is not None and not payment.mulen_payment_id:
|
||||||
payment.mulen_payment_id = mulen_payment_id
|
payment.mulen_payment_id = mulen_payment_id
|
||||||
|
if metadata is not None:
|
||||||
|
payment.metadata_json = metadata
|
||||||
|
|
||||||
payment.updated_at = datetime.utcnow()
|
payment.updated_at = datetime.utcnow()
|
||||||
await db.commit()
|
await db.commit()
|
||||||
@@ -107,6 +111,19 @@ async def update_mulenpay_payment_status(
|
|||||||
return payment
|
return payment
|
||||||
|
|
||||||
|
|
||||||
|
async def update_mulenpay_payment_metadata(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
payment: MulenPayPayment,
|
||||||
|
metadata: dict,
|
||||||
|
) -> MulenPayPayment:
|
||||||
|
payment.metadata_json = metadata
|
||||||
|
payment.updated_at = datetime.utcnow()
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(payment)
|
||||||
|
return payment
|
||||||
|
|
||||||
|
|
||||||
async def link_mulenpay_payment_to_transaction(
|
async def link_mulenpay_payment_to_transaction(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ async def update_pal24_payment_status(
|
|||||||
balance_currency: Optional[str] = None,
|
balance_currency: Optional[str] = None,
|
||||||
payer_account: Optional[str] = None,
|
payer_account: Optional[str] = None,
|
||||||
callback_payload: Optional[Dict[str, Any]] = None,
|
callback_payload: Optional[Dict[str, Any]] = None,
|
||||||
|
metadata: Optional[Dict[str, Any]] = None,
|
||||||
) -> Pal24Payment:
|
) -> Pal24Payment:
|
||||||
update_values: Dict[str, Any] = {
|
update_values: Dict[str, Any] = {
|
||||||
"status": status,
|
"status": status,
|
||||||
@@ -121,6 +122,8 @@ async def update_pal24_payment_status(
|
|||||||
update_values["payer_account"] = payer_account
|
update_values["payer_account"] = payer_account
|
||||||
if callback_payload is not None:
|
if callback_payload is not None:
|
||||||
update_values["callback_payload"] = callback_payload
|
update_values["callback_payload"] = callback_payload
|
||||||
|
if metadata is not None:
|
||||||
|
update_values["metadata_json"] = metadata
|
||||||
|
|
||||||
update_values["last_status"] = status
|
update_values["last_status"] = status
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Iterable, Optional, List, Tuple
|
from typing import Iterable, Optional, List, Tuple
|
||||||
from sqlalchemy import select, and_, func
|
from sqlalchemy import select, and_, func, delete
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
@@ -129,7 +129,7 @@ async def create_paid_subscription(
|
|||||||
connected_squads: List[str] = None,
|
connected_squads: List[str] = None,
|
||||||
update_server_counters: bool = False,
|
update_server_counters: bool = False,
|
||||||
) -> Subscription:
|
) -> Subscription:
|
||||||
|
|
||||||
end_date = datetime.utcnow() + timedelta(days=duration_days)
|
end_date = datetime.utcnow() + timedelta(days=duration_days)
|
||||||
|
|
||||||
if device_limit is None:
|
if device_limit is None:
|
||||||
@@ -186,6 +186,91 @@ async def create_paid_subscription(
|
|||||||
return subscription
|
return subscription
|
||||||
|
|
||||||
|
|
||||||
|
async def replace_subscription(
|
||||||
|
db: AsyncSession,
|
||||||
|
subscription: Subscription,
|
||||||
|
*,
|
||||||
|
duration_days: int,
|
||||||
|
traffic_limit_gb: int,
|
||||||
|
device_limit: int,
|
||||||
|
connected_squads: List[str],
|
||||||
|
is_trial: bool,
|
||||||
|
autopay_enabled: Optional[bool] = None,
|
||||||
|
autopay_days_before: Optional[int] = None,
|
||||||
|
update_server_counters: bool = False,
|
||||||
|
) -> Subscription:
|
||||||
|
"""Перезаписывает параметры существующей подписки пользователя."""
|
||||||
|
|
||||||
|
current_time = datetime.utcnow()
|
||||||
|
old_squads = set(subscription.connected_squads or [])
|
||||||
|
new_squads = set(connected_squads or [])
|
||||||
|
|
||||||
|
new_autopay_enabled = (
|
||||||
|
subscription.autopay_enabled
|
||||||
|
if autopay_enabled is None
|
||||||
|
else autopay_enabled
|
||||||
|
)
|
||||||
|
new_autopay_days_before = (
|
||||||
|
subscription.autopay_days_before
|
||||||
|
if autopay_days_before is None
|
||||||
|
else autopay_days_before
|
||||||
|
)
|
||||||
|
|
||||||
|
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||||
|
subscription.is_trial = is_trial
|
||||||
|
subscription.start_date = current_time
|
||||||
|
subscription.end_date = current_time + timedelta(days=duration_days)
|
||||||
|
subscription.traffic_limit_gb = traffic_limit_gb
|
||||||
|
subscription.traffic_used_gb = 0.0
|
||||||
|
subscription.device_limit = device_limit
|
||||||
|
subscription.connected_squads = list(new_squads)
|
||||||
|
subscription.subscription_url = None
|
||||||
|
subscription.subscription_crypto_link = None
|
||||||
|
subscription.remnawave_short_uuid = None
|
||||||
|
subscription.autopay_enabled = new_autopay_enabled
|
||||||
|
subscription.autopay_days_before = new_autopay_days_before
|
||||||
|
subscription.updated_at = current_time
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(subscription)
|
||||||
|
|
||||||
|
if update_server_counters:
|
||||||
|
try:
|
||||||
|
from app.database.crud.server_squad import (
|
||||||
|
add_user_to_servers,
|
||||||
|
get_server_ids_by_uuids,
|
||||||
|
remove_user_from_servers,
|
||||||
|
)
|
||||||
|
|
||||||
|
squads_to_remove = old_squads - new_squads
|
||||||
|
squads_to_add = new_squads - old_squads
|
||||||
|
|
||||||
|
if squads_to_remove:
|
||||||
|
server_ids = await get_server_ids_by_uuids(db, list(squads_to_remove))
|
||||||
|
if server_ids:
|
||||||
|
await remove_user_from_servers(db, sorted(server_ids))
|
||||||
|
|
||||||
|
if squads_to_add:
|
||||||
|
server_ids = await get_server_ids_by_uuids(db, list(squads_to_add))
|
||||||
|
if server_ids:
|
||||||
|
await add_user_to_servers(db, sorted(server_ids))
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"♻️ Обновлены параметры подписки %s: удалено сквадов %s, добавлено %s",
|
||||||
|
subscription.id,
|
||||||
|
len(squads_to_remove),
|
||||||
|
len(squads_to_add),
|
||||||
|
)
|
||||||
|
except Exception as error:
|
||||||
|
logger.error(
|
||||||
|
"⚠️ Ошибка обновления счетчиков серверов при замене подписки %s: %s",
|
||||||
|
subscription.id,
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
|
||||||
|
return subscription
|
||||||
|
|
||||||
|
|
||||||
async def extend_subscription(
|
async def extend_subscription(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
subscription: Subscription,
|
subscription: Subscription,
|
||||||
@@ -591,10 +676,113 @@ async def get_subscriptions_statistics(db: AsyncSession) -> dict:
|
|||||||
"purchased_today": purchased_today,
|
"purchased_today": purchased_today,
|
||||||
"purchased_week": purchased_week,
|
"purchased_week": purchased_week,
|
||||||
"purchased_month": purchased_month,
|
"purchased_month": purchased_month,
|
||||||
"trial_to_paid_conversion": trial_to_paid_conversion,
|
"trial_to_paid_conversion": trial_to_paid_conversion,
|
||||||
"renewals_count": renewals_count
|
"renewals_count": renewals_count
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_trial_statistics(db: AsyncSession) -> dict:
|
||||||
|
now = datetime.utcnow()
|
||||||
|
|
||||||
|
total_trials_result = await db.execute(
|
||||||
|
select(func.count(Subscription.id)).where(Subscription.is_trial.is_(True))
|
||||||
|
)
|
||||||
|
total_trials = total_trials_result.scalar() or 0
|
||||||
|
|
||||||
|
active_trials_result = await db.execute(
|
||||||
|
select(func.count(Subscription.id)).where(
|
||||||
|
Subscription.is_trial.is_(True),
|
||||||
|
Subscription.end_date > now,
|
||||||
|
Subscription.status.in_(
|
||||||
|
[SubscriptionStatus.TRIAL.value, SubscriptionStatus.ACTIVE.value]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
active_trials = active_trials_result.scalar() or 0
|
||||||
|
|
||||||
|
resettable_trials_result = await db.execute(
|
||||||
|
select(func.count(Subscription.id))
|
||||||
|
.join(User, Subscription.user_id == User.id)
|
||||||
|
.where(
|
||||||
|
Subscription.is_trial.is_(True),
|
||||||
|
Subscription.end_date <= now,
|
||||||
|
User.has_had_paid_subscription.is_(False),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
resettable_trials = resettable_trials_result.scalar() or 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"used_trials": total_trials,
|
||||||
|
"active_trials": active_trials,
|
||||||
|
"resettable_trials": resettable_trials,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def reset_trials_for_users_without_paid_subscription(db: AsyncSession) -> int:
|
||||||
|
now = datetime.utcnow()
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(Subscription)
|
||||||
|
.options(
|
||||||
|
selectinload(Subscription.user),
|
||||||
|
selectinload(Subscription.subscription_servers),
|
||||||
|
)
|
||||||
|
.join(User, Subscription.user_id == User.id)
|
||||||
|
.where(
|
||||||
|
Subscription.is_trial.is_(True),
|
||||||
|
Subscription.end_date <= now,
|
||||||
|
User.has_had_paid_subscription.is_(False),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
subscriptions = result.scalars().unique().all()
|
||||||
|
if not subscriptions:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
reset_count = len(subscriptions)
|
||||||
|
for subscription in subscriptions:
|
||||||
|
try:
|
||||||
|
await decrement_subscription_server_counts(
|
||||||
|
db,
|
||||||
|
subscription,
|
||||||
|
subscription_servers=subscription.subscription_servers,
|
||||||
|
)
|
||||||
|
except Exception as error: # pragma: no cover - defensive logging
|
||||||
|
logger.error(
|
||||||
|
"Не удалось обновить счётчики серверов при сбросе триала %s: %s",
|
||||||
|
subscription.id,
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
|
||||||
|
subscription_ids = [subscription.id for subscription in subscriptions]
|
||||||
|
|
||||||
|
if subscription_ids:
|
||||||
|
try:
|
||||||
|
await db.execute(
|
||||||
|
delete(SubscriptionServer).where(
|
||||||
|
SubscriptionServer.subscription_id.in_(subscription_ids)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception as error: # pragma: no cover - defensive logging
|
||||||
|
logger.error(
|
||||||
|
"Ошибка удаления серверных связей триалов %s: %s",
|
||||||
|
subscription_ids,
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
await db.execute(delete(Subscription).where(Subscription.id.in_(subscription_ids)))
|
||||||
|
|
||||||
|
try:
|
||||||
|
await db.commit()
|
||||||
|
except Exception as error: # pragma: no cover - defensive logging
|
||||||
|
await db.rollback()
|
||||||
|
logger.error("Ошибка сохранения сброса триалов: %s", error)
|
||||||
|
raise
|
||||||
|
|
||||||
|
logger.info("♻️ Сброшено триальных подписок: %s", reset_count)
|
||||||
|
return reset_count
|
||||||
|
|
||||||
async def update_subscription_usage(
|
async def update_subscription_usage(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
subscription: Subscription,
|
subscription: Subscription,
|
||||||
|
|||||||
@@ -379,11 +379,11 @@ async def subtract_user_balance(
|
|||||||
*,
|
*,
|
||||||
consume_promo_offer: bool = False,
|
consume_promo_offer: bool = False,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
logger.error(f"💸 ОТЛАДКА subtract_user_balance:")
|
logger.info(f"💸 ОТЛАДКА subtract_user_balance:")
|
||||||
logger.error(f" 👤 User ID: {user.id} (TG: {user.telegram_id})")
|
logger.info(f" 👤 User ID: {user.id} (TG: {user.telegram_id})")
|
||||||
logger.error(f" 💰 Баланс до списания: {user.balance_kopeks} копеек")
|
logger.info(f" 💰 Баланс до списания: {user.balance_kopeks} копеек")
|
||||||
logger.error(f" 💸 Сумма к списанию: {amount_kopeks} копеек")
|
logger.info(f" 💸 Сумма к списанию: {amount_kopeks} копеек")
|
||||||
logger.error(f" 📝 Описание: {description}")
|
logger.info(f" 📝 Описание: {description}")
|
||||||
|
|
||||||
log_context: Optional[Dict[str, object]] = None
|
log_context: Optional[Dict[str, object]] = None
|
||||||
if consume_promo_offer:
|
if consume_promo_offer:
|
||||||
|
|||||||
Vendored
+24
-6
@@ -28,11 +28,28 @@ class HeleketService:
|
|||||||
def is_configured(self) -> bool:
|
def is_configured(self) -> bool:
|
||||||
return bool(self.merchant_id and self.api_key)
|
return bool(self.merchant_id and self.api_key)
|
||||||
|
|
||||||
def _prepare_body(self, payload: Dict[str, Any]) -> str:
|
def _prepare_body(
|
||||||
cleaned = {key: value for key, value in payload.items() if value is not None}
|
self,
|
||||||
serialized = json.dumps(cleaned, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
|
payload: Dict[str, Any],
|
||||||
|
*,
|
||||||
|
ignore_none: bool,
|
||||||
|
sort_keys: bool,
|
||||||
|
) -> str:
|
||||||
|
if ignore_none:
|
||||||
|
cleaned = {key: value for key, value in payload.items() if value is not None}
|
||||||
|
else:
|
||||||
|
cleaned = dict(payload)
|
||||||
|
|
||||||
|
serialized = json.dumps(
|
||||||
|
cleaned,
|
||||||
|
ensure_ascii=False,
|
||||||
|
separators=(",", ":"),
|
||||||
|
sort_keys=sort_keys,
|
||||||
|
)
|
||||||
|
|
||||||
if "/" in serialized:
|
if "/" in serialized:
|
||||||
serialized = serialized.replace("/", "\\/")
|
serialized = serialized.replace("/", "\\/")
|
||||||
|
|
||||||
return serialized
|
return serialized
|
||||||
|
|
||||||
def _generate_signature(self, body: str) -> str:
|
def _generate_signature(self, body: str) -> str:
|
||||||
@@ -52,7 +69,7 @@ class HeleketService:
|
|||||||
logger.error("Heleket сервис не настроен: merchant или api_key отсутствуют")
|
logger.error("Heleket сервис не настроен: merchant или api_key отсутствуют")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
body = self._prepare_body(payload)
|
body = self._prepare_body(payload, ignore_none=True, sort_keys=True)
|
||||||
signature = self._generate_signature(body)
|
signature = self._generate_signature(body)
|
||||||
|
|
||||||
url = f"{self.base_url}/{endpoint.lstrip('/')}"
|
url = f"{self.base_url}/{endpoint.lstrip('/')}"
|
||||||
@@ -145,8 +162,9 @@ class HeleketService:
|
|||||||
logger.error("Heleket webhook без подписи")
|
logger.error("Heleket webhook без подписи")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
data = {key: value for key, value in payload.items() if key != "sign"}
|
data = dict(payload)
|
||||||
body = self._prepare_body(data)
|
data.pop("sign", None)
|
||||||
|
body = self._prepare_body(data, ignore_none=False, sort_keys=False)
|
||||||
expected = self._generate_signature(body)
|
expected = self._generate_signature(body)
|
||||||
|
|
||||||
is_valid = hmac.compare_digest(expected, str(signature))
|
is_valid = hmac.compare_digest(expected, str(signature))
|
||||||
|
|||||||
Vendored
+10
-8
@@ -254,17 +254,17 @@ class YooKassaWebhookHandler:
|
|||||||
logger.info(f"📊 Обработка webhook YooKassa: {webhook_data.get('event', 'unknown_event')}")
|
logger.info(f"📊 Обработка webhook YooKassa: {webhook_data.get('event', 'unknown_event')}")
|
||||||
logger.debug(f"🔍 Полные данные webhook: {webhook_data}")
|
logger.debug(f"🔍 Полные данные webhook: {webhook_data}")
|
||||||
|
|
||||||
# Извлекаем ID платежа из вебхука для предотвращения дублирования
|
|
||||||
yookassa_payment_id = webhook_data.get("object", {}).get("id")
|
|
||||||
if not yookassa_payment_id:
|
|
||||||
logger.warning("⚠️ Webhook YooKassa без ID платежа")
|
|
||||||
return web.Response(status=400, text="No payment ID")
|
|
||||||
|
|
||||||
event_type = webhook_data.get("event")
|
event_type = webhook_data.get("event")
|
||||||
if not event_type:
|
if not event_type:
|
||||||
logger.warning("⚠️ Webhook YooKassa без типа события")
|
logger.warning("⚠️ Webhook YooKassa без типа события")
|
||||||
return web.Response(status=400, text="No event type")
|
return web.Response(status=400, text="No event type")
|
||||||
|
|
||||||
|
# Извлекаем ID платежа из вебхука для предотвращения дублирования
|
||||||
|
yookassa_payment_id = webhook_data.get("object", {}).get("id")
|
||||||
|
if not yookassa_payment_id:
|
||||||
|
logger.warning("⚠️ Webhook YooKassa без ID платежа")
|
||||||
|
return web.Response(status=400, text="No payment id")
|
||||||
|
|
||||||
if event_type not in YOOKASSA_ALLOWED_EVENTS:
|
if event_type not in YOOKASSA_ALLOWED_EVENTS:
|
||||||
logger.info(f"ℹ️ Игнорируем событие YooKassa: {event_type}")
|
logger.info(f"ℹ️ Игнорируем событие YooKassa: {event_type}")
|
||||||
return web.Response(status=200, text="OK")
|
return web.Response(status=200, text="OK")
|
||||||
@@ -274,8 +274,10 @@ class YooKassaWebhookHandler:
|
|||||||
# Проверяем, не обрабатывается ли этот платеж уже (защита от дублирования)
|
# Проверяем, не обрабатывается ли этот платеж уже (защита от дублирования)
|
||||||
from app.database.models import PaymentMethod
|
from app.database.models import PaymentMethod
|
||||||
from app.database.crud.transaction import get_transaction_by_external_id
|
from app.database.crud.transaction import get_transaction_by_external_id
|
||||||
existing_transaction = await get_transaction_by_external_id(db, yookassa_payment_id, PaymentMethod.YOOKASSA)
|
existing_transaction = None
|
||||||
|
if yookassa_payment_id and hasattr(db, "execute"):
|
||||||
|
existing_transaction = await get_transaction_by_external_id(db, yookassa_payment_id, PaymentMethod.YOOKASSA)
|
||||||
|
|
||||||
if existing_transaction and event_type == "payment.succeeded":
|
if existing_transaction and event_type == "payment.succeeded":
|
||||||
logger.info(f"ℹ️ Платеж YooKassa {yookassa_payment_id} уже был обработан. Пропускаем дублирующий вебхук.")
|
logger.info(f"ℹ️ Платеж YooKassa {yookassa_payment_id} уже был обработан. Пропускаем дублирующий вебхук.")
|
||||||
return web.Response(status=200, text="OK")
|
return web.Response(status=200, text="OK")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import asyncio
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from aiogram import Dispatcher, types, F
|
from aiogram import Dispatcher, types, F
|
||||||
|
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramRetryAfter
|
||||||
from aiogram.fsm.context import FSMContext
|
from aiogram.fsm.context import FSMContext
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, func, and_, or_
|
from sqlalchemy import select, func, and_, or_
|
||||||
@@ -764,52 +765,77 @@ async def confirm_broadcast(
|
|||||||
|
|
||||||
broadcast_keyboard = create_broadcast_keyboard(selected_buttons, db_user.language)
|
broadcast_keyboard = create_broadcast_keyboard(selected_buttons, db_user.language)
|
||||||
|
|
||||||
# Ограничение на количество одновременных отправок
|
# Ограничение на количество одновременных отправок и базовая задержка между сообщениями,
|
||||||
semaphore = asyncio.Semaphore(20)
|
# чтобы избежать перегрузки бота и лимитов Telegram при больших рассылках
|
||||||
|
max_concurrent_sends = 5
|
||||||
|
per_message_delay = 0.05
|
||||||
|
semaphore = asyncio.Semaphore(max_concurrent_sends)
|
||||||
|
|
||||||
async def send_single_broadcast(user):
|
async def send_single_broadcast(user):
|
||||||
"""Отправляет одно сообщение рассылки с семафором ограничения"""
|
"""Отправляет одно сообщение рассылки с семафором ограничения"""
|
||||||
async with semaphore:
|
async with semaphore:
|
||||||
try:
|
for attempt in range(3):
|
||||||
if has_media and media_file_id:
|
try:
|
||||||
if media_type == "photo":
|
if has_media and media_file_id:
|
||||||
await callback.bot.send_photo(
|
if media_type == "photo":
|
||||||
|
await callback.bot.send_photo(
|
||||||
|
chat_id=user.telegram_id,
|
||||||
|
photo=media_file_id,
|
||||||
|
caption=message_text,
|
||||||
|
parse_mode="HTML",
|
||||||
|
reply_markup=broadcast_keyboard
|
||||||
|
)
|
||||||
|
elif media_type == "video":
|
||||||
|
await callback.bot.send_video(
|
||||||
|
chat_id=user.telegram_id,
|
||||||
|
video=media_file_id,
|
||||||
|
caption=message_text,
|
||||||
|
parse_mode="HTML",
|
||||||
|
reply_markup=broadcast_keyboard
|
||||||
|
)
|
||||||
|
elif media_type == "document":
|
||||||
|
await callback.bot.send_document(
|
||||||
|
chat_id=user.telegram_id,
|
||||||
|
document=media_file_id,
|
||||||
|
caption=message_text,
|
||||||
|
parse_mode="HTML",
|
||||||
|
reply_markup=broadcast_keyboard
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await callback.bot.send_message(
|
||||||
chat_id=user.telegram_id,
|
chat_id=user.telegram_id,
|
||||||
photo=media_file_id,
|
text=message_text,
|
||||||
caption=message_text,
|
|
||||||
parse_mode="HTML",
|
parse_mode="HTML",
|
||||||
reply_markup=broadcast_keyboard
|
reply_markup=broadcast_keyboard
|
||||||
)
|
)
|
||||||
elif media_type == "video":
|
|
||||||
await callback.bot.send_video(
|
await asyncio.sleep(per_message_delay)
|
||||||
chat_id=user.telegram_id,
|
return True, user.telegram_id
|
||||||
video=media_file_id,
|
except TelegramRetryAfter as e:
|
||||||
caption=message_text,
|
retry_delay = min(e.retry_after + 1, 30)
|
||||||
parse_mode="HTML",
|
logger.warning(
|
||||||
reply_markup=broadcast_keyboard
|
f"Превышен лимит Telegram для {user.telegram_id}, ожидание {retry_delay} сек."
|
||||||
)
|
|
||||||
elif media_type == "document":
|
|
||||||
await callback.bot.send_document(
|
|
||||||
chat_id=user.telegram_id,
|
|
||||||
document=media_file_id,
|
|
||||||
caption=message_text,
|
|
||||||
parse_mode="HTML",
|
|
||||||
reply_markup=broadcast_keyboard
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
await callback.bot.send_message(
|
|
||||||
chat_id=user.telegram_id,
|
|
||||||
text=message_text,
|
|
||||||
parse_mode="HTML",
|
|
||||||
reply_markup=broadcast_keyboard
|
|
||||||
)
|
)
|
||||||
return True, user.telegram_id
|
await asyncio.sleep(retry_delay)
|
||||||
except Exception as e:
|
except TelegramForbiddenError:
|
||||||
logger.error(f"Ошибка отправки рассылки пользователю {user.telegram_id}: {e}")
|
# Пользователь мог удалить бота или запретить сообщения
|
||||||
return False, user.telegram_id
|
logger.info(f"Рассылка недоступна для пользователя {user.telegram_id}: Forbidden")
|
||||||
|
return False, user.telegram_id
|
||||||
|
except TelegramBadRequest as e:
|
||||||
|
logger.error(
|
||||||
|
f"Некорректный запрос при рассылке пользователю {user.telegram_id}: {e}"
|
||||||
|
)
|
||||||
|
return False, user.telegram_id
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Ошибка отправки рассылки пользователю {user.telegram_id} (попытка {attempt + 1}/3): {e}"
|
||||||
|
)
|
||||||
|
await asyncio.sleep(0.5 * (attempt + 1))
|
||||||
|
|
||||||
|
return False, user.telegram_id
|
||||||
|
|
||||||
# Отправляем сообщения пакетами для эффективности
|
# Отправляем сообщения пакетами для эффективности
|
||||||
batch_size = 100
|
batch_size = 50
|
||||||
for i in range(0, len(users), batch_size):
|
for i in range(0, len(users), batch_size):
|
||||||
batch = users[i:i + batch_size]
|
batch = users[i:i + batch_size]
|
||||||
tasks = [send_single_broadcast(user) for user in batch]
|
tasks = [send_single_broadcast(user) for user in batch]
|
||||||
@@ -826,7 +852,7 @@ async def confirm_broadcast(
|
|||||||
failed_count += 1
|
failed_count += 1
|
||||||
|
|
||||||
# Небольшая задержка между пакетами для снижения нагрузки на API
|
# Небольшая задержка между пакетами для снижения нагрузки на API
|
||||||
await asyncio.sleep(0.1)
|
await asyncio.sleep(0.25)
|
||||||
|
|
||||||
broadcast_history.sent_count = sent_count
|
broadcast_history.sent_count = sent_count
|
||||||
broadcast_history.failed_count = failed_count
|
broadcast_history.failed_count = failed_count
|
||||||
@@ -868,7 +894,24 @@ async def get_target_users_count(db: AsyncSession, target: str) -> int:
|
|||||||
|
|
||||||
|
|
||||||
async def get_target_users(db: AsyncSession, target: str) -> list:
|
async def get_target_users(db: AsyncSession, target: str) -> list:
|
||||||
users = await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE)
|
# Загружаем всех активных пользователей батчами, чтобы не ограничиваться 10к
|
||||||
|
users: list[User] = []
|
||||||
|
offset = 0
|
||||||
|
batch_size = 5000
|
||||||
|
|
||||||
|
while True:
|
||||||
|
batch = await get_users_list(
|
||||||
|
db,
|
||||||
|
offset=offset,
|
||||||
|
limit=batch_size,
|
||||||
|
status=UserStatus.ACTIVE,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not batch:
|
||||||
|
break
|
||||||
|
|
||||||
|
users.extend(batch)
|
||||||
|
offset += batch_size
|
||||||
|
|
||||||
if target == "all":
|
if target == "all":
|
||||||
return users
|
return users
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ MIGRATION_PAGE_SIZE = 8
|
|||||||
|
|
||||||
def _format_duration(seconds: float) -> str:
|
def _format_duration(seconds: float) -> str:
|
||||||
if seconds < 1:
|
if seconds < 1:
|
||||||
return "<1с"
|
return "менее 1с"
|
||||||
|
|
||||||
minutes, sec = divmod(int(seconds), 60)
|
minutes, sec = divmod(int(seconds), 60)
|
||||||
if minutes:
|
if minutes:
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from aiogram import Dispatcher, F, types
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database.crud.subscription import (
|
||||||
|
get_trial_statistics,
|
||||||
|
reset_trials_for_users_without_paid_subscription,
|
||||||
|
)
|
||||||
|
from app.database.models import User
|
||||||
|
from app.keyboards.admin import get_admin_trials_keyboard
|
||||||
|
from app.localization.texts import get_texts
|
||||||
|
from app.utils.decorators import admin_required, error_handler
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_required
|
||||||
|
@error_handler
|
||||||
|
async def show_trials_panel(
|
||||||
|
callback: types.CallbackQuery,
|
||||||
|
db_user: User,
|
||||||
|
db: AsyncSession,
|
||||||
|
):
|
||||||
|
texts = get_texts(db_user.language)
|
||||||
|
|
||||||
|
stats = await get_trial_statistics(db)
|
||||||
|
message = texts.t("ADMIN_TRIALS_TITLE", "🧪 Управление триалами") + "\n\n" + texts.t(
|
||||||
|
"ADMIN_TRIALS_STATS",
|
||||||
|
"• Использовано всего: {used}\n"
|
||||||
|
"• Активно сейчас: {active}\n"
|
||||||
|
"• Доступно к сбросу: {resettable}",
|
||||||
|
).format(
|
||||||
|
used=stats.get("used_trials", 0),
|
||||||
|
active=stats.get("active_trials", 0),
|
||||||
|
resettable=stats.get("resettable_trials", 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
message,
|
||||||
|
reply_markup=get_admin_trials_keyboard(db_user.language),
|
||||||
|
)
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
|
@admin_required
|
||||||
|
@error_handler
|
||||||
|
async def reset_trials(
|
||||||
|
callback: types.CallbackQuery,
|
||||||
|
db_user: User,
|
||||||
|
db: AsyncSession,
|
||||||
|
):
|
||||||
|
texts = get_texts(db_user.language)
|
||||||
|
|
||||||
|
reset_count = await reset_trials_for_users_without_paid_subscription(db)
|
||||||
|
stats = await get_trial_statistics(db)
|
||||||
|
|
||||||
|
message = texts.t(
|
||||||
|
"ADMIN_TRIALS_RESET_RESULT",
|
||||||
|
"♻️ Сбросили {reset_count} триалов.\n\n"
|
||||||
|
"• Использовано всего: {used}\n"
|
||||||
|
"• Активно сейчас: {active}\n"
|
||||||
|
"• Доступно к сбросу: {resettable}",
|
||||||
|
).format(
|
||||||
|
reset_count=reset_count,
|
||||||
|
used=stats.get("used_trials", 0),
|
||||||
|
active=stats.get("active_trials", 0),
|
||||||
|
resettable=stats.get("resettable_trials", 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
message,
|
||||||
|
reply_markup=get_admin_trials_keyboard(db_user.language),
|
||||||
|
)
|
||||||
|
await callback.answer(texts.t("ADMIN_TRIALS_RESET_TOAST", "✅ Сброс завершен"))
|
||||||
|
|
||||||
|
|
||||||
|
def register_handlers(dp: Dispatcher) -> None:
|
||||||
|
dp.callback_query.register(
|
||||||
|
show_trials_panel,
|
||||||
|
F.data == "admin_trials",
|
||||||
|
)
|
||||||
|
dp.callback_query.register(
|
||||||
|
reset_trials,
|
||||||
|
F.data == "admin_trials_reset",
|
||||||
|
)
|
||||||
@@ -79,7 +79,12 @@ async def start_cryptobot_payment(
|
|||||||
)
|
)
|
||||||
|
|
||||||
await state.set_state(BalanceStates.waiting_for_amount)
|
await state.set_state(BalanceStates.waiting_for_amount)
|
||||||
await state.update_data(payment_method="cryptobot", current_rate=current_rate)
|
await state.update_data(
|
||||||
|
payment_method="cryptobot",
|
||||||
|
current_rate=current_rate,
|
||||||
|
cryptobot_prompt_message_id=callback.message.message_id,
|
||||||
|
cryptobot_prompt_chat_id=callback.message.chat.id,
|
||||||
|
)
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
@@ -158,8 +163,29 @@ async def process_cryptobot_payment_amount(
|
|||||||
[types.InlineKeyboardButton(text="📊 Проверить статус", callback_data=f"check_cryptobot_{payment_result['local_payment_id']}")],
|
[types.InlineKeyboardButton(text="📊 Проверить статус", callback_data=f"check_cryptobot_{payment_result['local_payment_id']}")],
|
||||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
|
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
|
||||||
])
|
])
|
||||||
|
|
||||||
await message.answer(
|
state_data = await state.get_data()
|
||||||
|
prompt_message_id = state_data.get("cryptobot_prompt_message_id")
|
||||||
|
prompt_chat_id = state_data.get("cryptobot_prompt_chat_id", message.chat.id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await message.delete()
|
||||||
|
except Exception as delete_error: # pragma: no cover - depends on bot rights
|
||||||
|
logger.warning(
|
||||||
|
"Не удалось удалить сообщение с суммой CryptoBot: %s",
|
||||||
|
delete_error,
|
||||||
|
)
|
||||||
|
|
||||||
|
if prompt_message_id:
|
||||||
|
try:
|
||||||
|
await message.bot.delete_message(prompt_chat_id, prompt_message_id)
|
||||||
|
except Exception as delete_error: # pragma: no cover - diagnostics
|
||||||
|
logger.warning(
|
||||||
|
"Не удалось удалить сообщение с запросом суммы CryptoBot: %s",
|
||||||
|
delete_error,
|
||||||
|
)
|
||||||
|
|
||||||
|
invoice_message = await message.answer(
|
||||||
f"🪙 <b>Оплата криптовалютой</b>\n\n"
|
f"🪙 <b>Оплата криптовалютой</b>\n\n"
|
||||||
f"💰 Сумма к зачислению: {amount_rubles:.0f} ₽\n"
|
f"💰 Сумма к зачислению: {amount_rubles:.0f} ₽\n"
|
||||||
f"💵 К оплате: {amount_usd:.2f} USD\n"
|
f"💵 К оплате: {amount_usd:.2f} USD\n"
|
||||||
@@ -177,7 +203,12 @@ async def process_cryptobot_payment_amount(
|
|||||||
reply_markup=keyboard,
|
reply_markup=keyboard,
|
||||||
parse_mode="HTML"
|
parse_mode="HTML"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
await state.update_data(
|
||||||
|
cryptobot_invoice_message_id=invoice_message.message_id,
|
||||||
|
cryptobot_invoice_chat_id=invoice_message.chat.id,
|
||||||
|
)
|
||||||
|
|
||||||
await state.clear()
|
await state.clear()
|
||||||
|
|
||||||
logger.info(f"Создан CryptoBot платеж для пользователя {db_user.telegram_id}: "
|
logger.info(f"Создан CryptoBot платеж для пользователя {db_user.telegram_id}: "
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import logging
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from aiogram import types
|
from aiogram import types
|
||||||
from aiogram.fsm.context import FSMContext
|
from aiogram.fsm.context import FSMContext
|
||||||
|
from sqlalchemy import update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
@@ -66,7 +68,11 @@ async def start_heleket_payment(
|
|||||||
)
|
)
|
||||||
|
|
||||||
await state.set_state(BalanceStates.waiting_for_amount)
|
await state.set_state(BalanceStates.waiting_for_amount)
|
||||||
await state.update_data(payment_method="heleket")
|
await state.update_data(
|
||||||
|
payment_method="heleket",
|
||||||
|
heleket_prompt_message_id=callback.message.message_id,
|
||||||
|
heleket_prompt_chat_id=callback.message.chat.id,
|
||||||
|
)
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
@@ -181,7 +187,52 @@ async def process_heleket_payment_amount(
|
|||||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")],
|
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")],
|
||||||
])
|
])
|
||||||
|
|
||||||
await message.answer("\n".join(details), parse_mode="HTML", reply_markup=keyboard)
|
state_data = await state.get_data()
|
||||||
|
prompt_message_id = state_data.get("heleket_prompt_message_id")
|
||||||
|
prompt_chat_id = state_data.get("heleket_prompt_chat_id", message.chat.id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await message.delete()
|
||||||
|
except Exception as delete_error: # pragma: no cover - depends on bot rights
|
||||||
|
logger.warning("Не удалось удалить сообщение с суммой Heleket: %s", delete_error)
|
||||||
|
|
||||||
|
if prompt_message_id:
|
||||||
|
try:
|
||||||
|
await message.bot.delete_message(prompt_chat_id, prompt_message_id)
|
||||||
|
except Exception as delete_error: # pragma: no cover - diagnostic
|
||||||
|
logger.warning(
|
||||||
|
"Не удалось удалить сообщение с запросом суммы Heleket: %s",
|
||||||
|
delete_error,
|
||||||
|
)
|
||||||
|
|
||||||
|
invoice_message = await message.answer(
|
||||||
|
"\n".join(details), parse_mode="HTML", reply_markup=keyboard
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.services import payment_service as payment_module
|
||||||
|
|
||||||
|
payment = await payment_module.get_heleket_payment_by_id(db, result["local_payment_id"])
|
||||||
|
if payment:
|
||||||
|
metadata = dict(getattr(payment, "metadata_json", {}) or {})
|
||||||
|
metadata["invoice_message"] = {
|
||||||
|
"chat_id": invoice_message.chat.id,
|
||||||
|
"message_id": invoice_message.message_id,
|
||||||
|
}
|
||||||
|
await db.execute(
|
||||||
|
update(payment.__class__)
|
||||||
|
.where(payment.__class__.id == payment.id)
|
||||||
|
.values(metadata_json=metadata, updated_at=datetime.utcnow())
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
except Exception as error: # pragma: no cover - diagnostics
|
||||||
|
logger.warning("Не удалось сохранить сообщение Heleket: %s", error)
|
||||||
|
|
||||||
|
await state.update_data(
|
||||||
|
heleket_invoice_message_id=invoice_message.message_id,
|
||||||
|
heleket_invoice_chat_id=invoice_message.chat.id,
|
||||||
|
)
|
||||||
|
|
||||||
await state.clear()
|
await state.clear()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -748,11 +748,19 @@ async def handle_topup_amount_callback(
|
|||||||
)
|
)
|
||||||
elif method == "platega":
|
elif method == "platega":
|
||||||
from app.database.database import AsyncSessionLocal
|
from app.database.database import AsyncSessionLocal
|
||||||
from .platega import process_platega_payment_amount
|
from .platega import process_platega_payment_amount, start_platega_payment
|
||||||
async with AsyncSessionLocal() as db:
|
|
||||||
await process_platega_payment_amount(
|
data = await state.get_data()
|
||||||
callback.message, db_user, db, amount_kopeks, state
|
method_code = int(data.get("platega_method", 0)) if data else 0
|
||||||
)
|
|
||||||
|
if method_code > 0:
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
await process_platega_payment_amount(
|
||||||
|
callback.message, db_user, db, amount_kopeks, state
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await state.update_data(platega_pending_amount=amount_kopeks)
|
||||||
|
await start_platega_payment(callback, db_user, state)
|
||||||
elif method == "pal24":
|
elif method == "pal24":
|
||||||
from app.database.database import AsyncSessionLocal
|
from app.database.database import AsyncSessionLocal
|
||||||
from .pal24 import process_pal24_payment_amount
|
from .pal24 import process_pal24_payment_amount
|
||||||
|
|||||||
@@ -59,7 +59,11 @@ async def start_mulenpay_payment(
|
|||||||
)
|
)
|
||||||
|
|
||||||
await state.set_state(BalanceStates.waiting_for_amount)
|
await state.set_state(BalanceStates.waiting_for_amount)
|
||||||
await state.update_data(payment_method="mulenpay")
|
await state.update_data(
|
||||||
|
payment_method="mulenpay",
|
||||||
|
mulenpay_prompt_message_id=callback.message.message_id,
|
||||||
|
mulenpay_prompt_chat_id=callback.message.chat.id,
|
||||||
|
)
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
@@ -93,6 +97,26 @@ async def process_mulenpay_payment_amount(
|
|||||||
|
|
||||||
amount_rubles = amount_kopeks / 100
|
amount_rubles = amount_kopeks / 100
|
||||||
|
|
||||||
|
state_data = await state.get_data()
|
||||||
|
prompt_message_id = state_data.get("mulenpay_prompt_message_id")
|
||||||
|
prompt_chat_id = state_data.get("mulenpay_prompt_chat_id", message.chat.id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await message.delete()
|
||||||
|
except Exception as delete_error: # pragma: no cover - depends on bot permissions
|
||||||
|
logger.warning(
|
||||||
|
"Не удалось удалить сообщение с суммой MulenPay: %s", delete_error
|
||||||
|
)
|
||||||
|
|
||||||
|
if prompt_message_id:
|
||||||
|
try:
|
||||||
|
await message.bot.delete_message(prompt_chat_id, prompt_message_id)
|
||||||
|
except Exception as delete_error: # pragma: no cover - diagnostic
|
||||||
|
logger.warning(
|
||||||
|
"Не удалось удалить сообщение с запросом суммы MulenPay: %s",
|
||||||
|
delete_error,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
payment_service = PaymentService(message.bot)
|
payment_service = PaymentService(message.bot)
|
||||||
payment_result = await payment_service.create_mulenpay_payment(
|
payment_result = await payment_service.create_mulenpay_payment(
|
||||||
@@ -163,12 +187,39 @@ async def process_mulenpay_payment_amount(
|
|||||||
mulenpay_name_html=mulenpay_name_html,
|
mulenpay_name_html=mulenpay_name_html,
|
||||||
)
|
)
|
||||||
|
|
||||||
await message.answer(
|
invoice_message = await message.answer(
|
||||||
message_text,
|
message_text,
|
||||||
reply_markup=keyboard,
|
reply_markup=keyboard,
|
||||||
parse_mode="HTML",
|
parse_mode="HTML",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.services import payment_service as payment_module
|
||||||
|
|
||||||
|
payment = await payment_module.get_mulenpay_payment_by_local_id(
|
||||||
|
db, local_payment_id
|
||||||
|
)
|
||||||
|
if payment:
|
||||||
|
payment_metadata = dict(
|
||||||
|
getattr(payment, "metadata_json", {}) or {}
|
||||||
|
)
|
||||||
|
payment_metadata["invoice_message"] = {
|
||||||
|
"chat_id": invoice_message.chat.id,
|
||||||
|
"message_id": invoice_message.message_id,
|
||||||
|
}
|
||||||
|
await payment_module.update_mulenpay_payment_metadata(
|
||||||
|
db,
|
||||||
|
payment=payment,
|
||||||
|
metadata=payment_metadata,
|
||||||
|
)
|
||||||
|
except Exception as error: # pragma: no cover - diagnostic logging only
|
||||||
|
logger.warning("Не удалось сохранить данные сообщения MulenPay: %s", error)
|
||||||
|
|
||||||
|
await state.update_data(
|
||||||
|
mulenpay_invoice_message_id=invoice_message.message_id,
|
||||||
|
mulenpay_invoice_chat_id=invoice_message.chat.id,
|
||||||
|
)
|
||||||
|
|
||||||
await state.clear()
|
await state.clear()
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import html
|
import html
|
||||||
import logging
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
from aiogram import types
|
from aiogram import types
|
||||||
from aiogram.exceptions import TelegramBadRequest
|
from aiogram.exceptions import TelegramBadRequest
|
||||||
from aiogram.fsm.context import FSMContext
|
from aiogram.fsm.context import FSMContext
|
||||||
|
from sqlalchemy import update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
@@ -204,12 +206,36 @@ async def _send_pal24_payment_message(
|
|||||||
support=settings.get_support_contact_display_html(),
|
support=settings.get_support_contact_display_html(),
|
||||||
)
|
)
|
||||||
|
|
||||||
await message.answer(
|
invoice_message = await message.answer(
|
||||||
message_text,
|
message_text,
|
||||||
reply_markup=keyboard,
|
reply_markup=keyboard,
|
||||||
parse_mode="HTML",
|
parse_mode="HTML",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.services import payment_service as payment_module
|
||||||
|
|
||||||
|
payment = await payment_module.get_pal24_payment_by_id(db, local_payment_id)
|
||||||
|
if payment:
|
||||||
|
metadata = dict(getattr(payment, "metadata_json", {}) or {})
|
||||||
|
metadata["invoice_message"] = {
|
||||||
|
"chat_id": invoice_message.chat.id,
|
||||||
|
"message_id": invoice_message.message_id,
|
||||||
|
}
|
||||||
|
await db.execute(
|
||||||
|
update(payment.__class__)
|
||||||
|
.where(payment.__class__.id == payment.id)
|
||||||
|
.values(metadata_json=metadata, updated_at=datetime.utcnow())
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
except Exception as error: # pragma: no cover - diagnostics
|
||||||
|
logger.warning("Не удалось сохранить сообщение PayPalych: %s", error)
|
||||||
|
|
||||||
|
await state.update_data(
|
||||||
|
pal24_invoice_message_id=invoice_message.message_id,
|
||||||
|
pal24_invoice_chat_id=invoice_message.chat.id,
|
||||||
|
)
|
||||||
|
|
||||||
await state.clear()
|
await state.clear()
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -277,7 +303,11 @@ async def start_pal24_payment(
|
|||||||
)
|
)
|
||||||
|
|
||||||
await state.set_state(BalanceStates.waiting_for_amount)
|
await state.set_state(BalanceStates.waiting_for_amount)
|
||||||
await state.update_data(payment_method="pal24")
|
await state.update_data(
|
||||||
|
payment_method="pal24",
|
||||||
|
pal24_prompt_message_id=callback.message.message_id,
|
||||||
|
pal24_prompt_chat_id=callback.message.chat.id,
|
||||||
|
)
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
@@ -309,6 +339,24 @@ async def process_pal24_payment_amount(
|
|||||||
|
|
||||||
available_methods = _get_available_pal24_methods()
|
available_methods = _get_available_pal24_methods()
|
||||||
|
|
||||||
|
state_data = await state.get_data()
|
||||||
|
prompt_message_id = state_data.get("pal24_prompt_message_id")
|
||||||
|
prompt_chat_id = state_data.get("pal24_prompt_chat_id", message.chat.id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await message.delete()
|
||||||
|
except Exception as delete_error: # pragma: no cover - depends on bot rights
|
||||||
|
logger.warning("Не удалось удалить сообщение с суммой PayPalych: %s", delete_error)
|
||||||
|
|
||||||
|
if prompt_message_id:
|
||||||
|
try:
|
||||||
|
await message.bot.delete_message(prompt_chat_id, prompt_message_id)
|
||||||
|
except Exception as delete_error: # pragma: no cover - diagnostic
|
||||||
|
logger.warning(
|
||||||
|
"Не удалось удалить сообщение с запросом суммы PayPalych: %s",
|
||||||
|
delete_error,
|
||||||
|
)
|
||||||
|
|
||||||
if len(available_methods) == 1:
|
if len(available_methods) == 1:
|
||||||
await _send_pal24_payment_message(
|
await _send_pal24_payment_message(
|
||||||
message,
|
message,
|
||||||
|
|||||||
@@ -32,6 +32,29 @@ async def _prompt_amount(
|
|||||||
texts = get_texts(db_user.language)
|
texts = get_texts(db_user.language)
|
||||||
method_name = settings.get_platega_method_display_title(method_code)
|
method_name = settings.get_platega_method_display_title(method_code)
|
||||||
|
|
||||||
|
# Всегда фиксируем выбранный метод для последующей обработки
|
||||||
|
await state.update_data(payment_method="platega", platega_method=method_code)
|
||||||
|
|
||||||
|
data = await state.get_data()
|
||||||
|
pending_amount = int(data.get("platega_pending_amount") or 0)
|
||||||
|
|
||||||
|
if pending_amount > 0:
|
||||||
|
# Если сумма уже известна (например, после быстрого выбора),
|
||||||
|
# сразу создаём платеж и сбрасываем временное значение.
|
||||||
|
await state.update_data(platega_pending_amount=None)
|
||||||
|
|
||||||
|
from app.database.database import AsyncSessionLocal
|
||||||
|
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
await process_platega_payment_amount(
|
||||||
|
message,
|
||||||
|
db_user,
|
||||||
|
db,
|
||||||
|
pending_amount,
|
||||||
|
state,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
min_amount_label = settings.format_price(settings.PLATEGA_MIN_AMOUNT_KOPEKS)
|
min_amount_label = settings.format_price(settings.PLATEGA_MIN_AMOUNT_KOPEKS)
|
||||||
max_amount_kopeks = settings.PLATEGA_MAX_AMOUNT_KOPEKS
|
max_amount_kopeks = settings.PLATEGA_MAX_AMOUNT_KOPEKS
|
||||||
max_amount_label = (
|
max_amount_label = (
|
||||||
@@ -75,7 +98,10 @@ async def _prompt_amount(
|
|||||||
)
|
)
|
||||||
|
|
||||||
await state.set_state(BalanceStates.waiting_for_amount)
|
await state.set_state(BalanceStates.waiting_for_amount)
|
||||||
await state.update_data(payment_method="platega", platega_method=method_code)
|
await state.update_data(
|
||||||
|
platega_prompt_message_id=message.message_id,
|
||||||
|
platega_prompt_chat_id=message.chat.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@error_handler
|
@error_handler
|
||||||
@@ -108,6 +134,8 @@ async def start_platega_payment(
|
|||||||
return
|
return
|
||||||
|
|
||||||
await state.update_data(payment_method="platega")
|
await state.update_data(payment_method="platega")
|
||||||
|
data = await state.get_data()
|
||||||
|
has_pending_amount = bool(int(data.get("platega_pending_amount") or 0))
|
||||||
|
|
||||||
if len(active_methods) == 1:
|
if len(active_methods) == 1:
|
||||||
await _prompt_amount(callback.message, db_user, state, active_methods[0])
|
await _prompt_amount(callback.message, db_user, state, active_methods[0])
|
||||||
@@ -137,7 +165,8 @@ async def start_platega_payment(
|
|||||||
),
|
),
|
||||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=method_buttons),
|
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=method_buttons),
|
||||||
)
|
)
|
||||||
await state.set_state(BalanceStates.waiting_for_platega_method)
|
if not has_pending_amount:
|
||||||
|
await state.set_state(BalanceStates.waiting_for_platega_method)
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
@@ -275,7 +304,25 @@ async def process_platega_payment_amount(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
await message.answer(
|
state_data = await state.get_data()
|
||||||
|
prompt_message_id = state_data.get("platega_prompt_message_id")
|
||||||
|
prompt_chat_id = state_data.get("platega_prompt_chat_id", message.chat.id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await message.delete()
|
||||||
|
except Exception as delete_error: # pragma: no cover - зависит от прав бота
|
||||||
|
logger.warning("Не удалось удалить сообщение с суммой Platega: %s", delete_error)
|
||||||
|
|
||||||
|
if prompt_message_id:
|
||||||
|
try:
|
||||||
|
await message.bot.delete_message(prompt_chat_id, prompt_message_id)
|
||||||
|
except Exception as delete_error: # pragma: no cover - диагностический лог
|
||||||
|
logger.warning(
|
||||||
|
"Не удалось удалить сообщение с запросом суммы Platega: %s",
|
||||||
|
delete_error,
|
||||||
|
)
|
||||||
|
|
||||||
|
invoice_message = await message.answer(
|
||||||
instructions_template.format(
|
instructions_template.format(
|
||||||
method=method_title,
|
method=method_title,
|
||||||
amount=settings.format_price(amount_kopeks),
|
amount=settings.format_price(amount_kopeks),
|
||||||
@@ -286,6 +333,29 @@ async def process_platega_payment_amount(
|
|||||||
parse_mode="HTML",
|
parse_mode="HTML",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.services import payment_service as payment_module
|
||||||
|
|
||||||
|
payment = await payment_module.get_platega_payment_by_id(db, local_payment_id)
|
||||||
|
if payment:
|
||||||
|
payment_metadata = dict(getattr(payment, "metadata_json", {}) or {})
|
||||||
|
payment_metadata["invoice_message"] = {
|
||||||
|
"chat_id": invoice_message.chat.id,
|
||||||
|
"message_id": invoice_message.message_id,
|
||||||
|
}
|
||||||
|
await payment_module.update_platega_payment(
|
||||||
|
db,
|
||||||
|
payment=payment,
|
||||||
|
metadata=payment_metadata,
|
||||||
|
)
|
||||||
|
except Exception as error: # pragma: no cover - диагностический лог
|
||||||
|
logger.warning("Не удалось сохранить данные сообщения Platega: %s", error)
|
||||||
|
|
||||||
|
await state.update_data(
|
||||||
|
platega_invoice_message_id=invoice_message.message_id,
|
||||||
|
platega_invoice_chat_id=invoice_message.chat.id,
|
||||||
|
)
|
||||||
|
|
||||||
await state.clear()
|
await state.clear()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import logging
|
import logging
|
||||||
from aiogram import types
|
from aiogram import types
|
||||||
from aiogram.fsm.context import FSMContext
|
from aiogram.fsm.context import FSMContext
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.database.models import User
|
from app.database.models import User
|
||||||
from app.keyboards.inline import get_back_keyboard, get_payment_methods_keyboard
|
from app.keyboards.inline import get_back_keyboard
|
||||||
from app.localization.texts import get_texts
|
from app.localization.texts import get_texts
|
||||||
from app.services.payment_service import PaymentService
|
from app.services.payment_service import PaymentService
|
||||||
from app.states import BalanceStates
|
from app.states import BalanceStates
|
||||||
@@ -22,11 +21,11 @@ async def start_stars_payment(
|
|||||||
state: FSMContext
|
state: FSMContext
|
||||||
):
|
):
|
||||||
texts = get_texts(db_user.language)
|
texts = get_texts(db_user.language)
|
||||||
|
|
||||||
if not settings.TELEGRAM_STARS_ENABLED:
|
if not settings.TELEGRAM_STARS_ENABLED:
|
||||||
await callback.answer("❌ Пополнение через Stars временно недоступно", show_alert=True)
|
await callback.answer("❌ Пополнение через Stars временно недоступно", show_alert=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Формируем текст сообщения в зависимости от настройки
|
# Формируем текст сообщения в зависимости от настройки
|
||||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
|
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
|
||||||
message_text = (
|
message_text = (
|
||||||
@@ -35,10 +34,10 @@ async def start_stars_payment(
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
message_text = texts.TOP_UP_AMOUNT
|
message_text = texts.TOP_UP_AMOUNT
|
||||||
|
|
||||||
# Создаем клавиатуру
|
# Создаем клавиатуру
|
||||||
keyboard = get_back_keyboard(db_user.language)
|
keyboard = get_back_keyboard(db_user.language)
|
||||||
|
|
||||||
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
|
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
|
||||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
|
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
|
||||||
from .main import get_quick_amount_buttons
|
from .main import get_quick_amount_buttons
|
||||||
@@ -46,12 +45,17 @@ async def start_stars_payment(
|
|||||||
if quick_amount_buttons:
|
if quick_amount_buttons:
|
||||||
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
|
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
|
||||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||||
|
|
||||||
await callback.message.edit_text(
|
await callback.message.edit_text(
|
||||||
message_text,
|
message_text,
|
||||||
reply_markup=keyboard
|
reply_markup=keyboard
|
||||||
)
|
)
|
||||||
|
|
||||||
|
await state.update_data(
|
||||||
|
stars_prompt_message_id=callback.message.message_id,
|
||||||
|
stars_prompt_chat_id=callback.message.chat.id,
|
||||||
|
)
|
||||||
|
|
||||||
await state.set_state(BalanceStates.waiting_for_amount)
|
await state.set_state(BalanceStates.waiting_for_amount)
|
||||||
await state.update_data(payment_method="stars")
|
await state.update_data(payment_method="stars")
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
@@ -65,29 +69,48 @@ async def process_stars_payment_amount(
|
|||||||
state: FSMContext
|
state: FSMContext
|
||||||
):
|
):
|
||||||
texts = get_texts(db_user.language)
|
texts = get_texts(db_user.language)
|
||||||
|
|
||||||
if not settings.TELEGRAM_STARS_ENABLED:
|
if not settings.TELEGRAM_STARS_ENABLED:
|
||||||
await message.answer("⚠️ Оплата Stars временно недоступна")
|
await message.answer("⚠️ Оплата Stars временно недоступна")
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
amount_rubles = amount_kopeks / 100
|
amount_rubles = amount_kopeks / 100
|
||||||
stars_amount = TelegramStarsService.calculate_stars_from_rubles(amount_rubles)
|
stars_amount = TelegramStarsService.calculate_stars_from_rubles(amount_rubles)
|
||||||
stars_rate = settings.get_stars_rate()
|
stars_rate = settings.get_stars_rate()
|
||||||
|
|
||||||
payment_service = PaymentService(message.bot)
|
payment_service = PaymentService(message.bot)
|
||||||
invoice_link = await payment_service.create_stars_invoice(
|
invoice_link = await payment_service.create_stars_invoice(
|
||||||
amount_kopeks=amount_kopeks,
|
amount_kopeks=amount_kopeks,
|
||||||
description=f"Пополнение баланса на {texts.format_price(amount_kopeks)}",
|
description=f"Пополнение баланса на {texts.format_price(amount_kopeks)}",
|
||||||
payload=f"balance_{db_user.id}_{amount_kopeks}"
|
payload=f"balance_{db_user.id}_{amount_kopeks}"
|
||||||
)
|
)
|
||||||
|
|
||||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||||
[types.InlineKeyboardButton(text="⭐ Оплатить", url=invoice_link)],
|
[types.InlineKeyboardButton(text="⭐ Оплатить", url=invoice_link)],
|
||||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
|
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
|
||||||
])
|
])
|
||||||
|
|
||||||
await message.answer(
|
state_data = await state.get_data()
|
||||||
|
|
||||||
|
prompt_message_id = state_data.get("stars_prompt_message_id")
|
||||||
|
prompt_chat_id = state_data.get("stars_prompt_chat_id", message.chat.id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await message.delete()
|
||||||
|
except Exception as delete_error: # pragma: no cover - зависит от прав бота
|
||||||
|
logger.warning("Не удалось удалить сообщение с суммой Stars: %s", delete_error)
|
||||||
|
|
||||||
|
if prompt_message_id:
|
||||||
|
try:
|
||||||
|
await message.bot.delete_message(prompt_chat_id, prompt_message_id)
|
||||||
|
except Exception as delete_error: # pragma: no cover - диагностический лог
|
||||||
|
logger.warning(
|
||||||
|
"Не удалось удалить сообщение с запросом суммы Stars: %s",
|
||||||
|
delete_error,
|
||||||
|
)
|
||||||
|
|
||||||
|
invoice_message = await message.answer(
|
||||||
f"⭐ <b>Оплата через Telegram Stars</b>\n\n"
|
f"⭐ <b>Оплата через Telegram Stars</b>\n\n"
|
||||||
f"💰 Сумма: {texts.format_price(amount_kopeks)}\n"
|
f"💰 Сумма: {texts.format_price(amount_kopeks)}\n"
|
||||||
f"⭐ К оплате: {stars_amount} звезд\n"
|
f"⭐ К оплате: {stars_amount} звезд\n"
|
||||||
@@ -96,9 +119,14 @@ async def process_stars_payment_amount(
|
|||||||
reply_markup=keyboard,
|
reply_markup=keyboard,
|
||||||
parse_mode="HTML"
|
parse_mode="HTML"
|
||||||
)
|
)
|
||||||
|
|
||||||
await state.clear()
|
await state.update_data(
|
||||||
|
stars_invoice_message_id=invoice_message.message_id,
|
||||||
|
stars_invoice_chat_id=invoice_message.chat.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
await state.set_state(None)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка создания Stars invoice: {e}")
|
logger.error(f"Ошибка создания Stars invoice: {e}")
|
||||||
await message.answer("⚠️ Ошибка создания платежа")
|
await message.answer("⚠️ Ошибка создания платежа")
|
||||||
|
|||||||
@@ -13,47 +13,59 @@ logger = logging.getLogger(__name__)
|
|||||||
@error_handler
|
@error_handler
|
||||||
async def start_tribute_payment(
|
async def start_tribute_payment(
|
||||||
callback: types.CallbackQuery,
|
callback: types.CallbackQuery,
|
||||||
db_user: User
|
db_user: User,
|
||||||
):
|
):
|
||||||
texts = get_texts(db_user.language)
|
texts = get_texts(db_user.language)
|
||||||
|
|
||||||
if not settings.TRIBUTE_ENABLED:
|
if not settings.TRIBUTE_ENABLED:
|
||||||
await callback.answer("❌ Оплата картой временно недоступна", show_alert=True)
|
await callback.answer("❌ Оплата картой временно недоступна", show_alert=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from app.services.tribute_service import TributeService
|
from app.services.tribute_service import TributeService
|
||||||
|
|
||||||
tribute_service = TributeService(callback.bot)
|
tribute_service = TributeService(callback.bot)
|
||||||
payment_url = await tribute_service.create_payment_link(
|
payment_url = await tribute_service.create_payment_link(
|
||||||
user_id=db_user.telegram_id,
|
user_id=db_user.telegram_id,
|
||||||
amount_kopeks=0,
|
amount_kopeks=0,
|
||||||
description="Пополнение баланса VPN"
|
description="Пополнение баланса VPN",
|
||||||
)
|
)
|
||||||
|
|
||||||
if not payment_url:
|
if not payment_url:
|
||||||
await callback.answer("❌ Ошибка создания платежа", show_alert=True)
|
await callback.answer("❌ Ошибка создания платежа", show_alert=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
keyboard = types.InlineKeyboardMarkup(
|
||||||
[types.InlineKeyboardButton(text="💳 Перейти к оплате", url=payment_url)],
|
inline_keyboard=[
|
||||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
|
[types.InlineKeyboardButton(text="💳 Перейти к оплате", url=payment_url)],
|
||||||
])
|
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")],
|
||||||
|
]
|
||||||
await callback.message.edit_text(
|
|
||||||
f"💳 <b>Пополнение банковской картой</b>\n\n"
|
|
||||||
f"• Введите любую сумму от 100₽\n"
|
|
||||||
f"• Безопасная оплата через Tribute\n"
|
|
||||||
f"• Мгновенное зачисление на баланс\n"
|
|
||||||
f"• Принимаем карты Visa, MasterCard, МИР\n\n"
|
|
||||||
f"• 🚨 НЕ ОТПРАВЛЯТЬ ПЛАТЕЖ АНОНИМНО!\n\n"
|
|
||||||
f"Нажмите кнопку для перехода к оплате:",
|
|
||||||
reply_markup=keyboard,
|
|
||||||
parse_mode="HTML"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
message_text = (
|
||||||
|
"💳 <b>Пополнение банковской картой</b>\n\n"
|
||||||
|
"• Введите любую сумму от 100₽\n"
|
||||||
|
"• Безопасная оплата через Tribute\n"
|
||||||
|
"• Мгновенное зачисление на баланс\n"
|
||||||
|
"• Принимаем карты Visa, MasterCard, МИР\n\n"
|
||||||
|
"• 🚨 НЕ ОТПРАВЛЯТЬ ПЛАТЕЖ АНОНИМНО!\n\n"
|
||||||
|
"Нажмите кнопку для перехода к оплате:"
|
||||||
|
)
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
message_text,
|
||||||
|
reply_markup=keyboard,
|
||||||
|
parse_mode="HTML",
|
||||||
|
)
|
||||||
|
|
||||||
|
TributeService.remember_invoice_message(
|
||||||
|
db_user.telegram_id,
|
||||||
|
callback.message.chat.id,
|
||||||
|
callback.message.message_id,
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка создания Tribute платежа: {e}")
|
logger.error(f"Ошибка создания Tribute платежа: {e}")
|
||||||
await callback.answer("❌ Ошибка создания платежа", show_alert=True)
|
await callback.answer("❌ Ошибка создания платежа", show_alert=True)
|
||||||
|
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import logging
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
|
|
||||||
from aiogram import types
|
from aiogram import types
|
||||||
from aiogram.fsm.context import FSMContext
|
from aiogram.fsm.context import FSMContext
|
||||||
|
from sqlalchemy import update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
@@ -56,7 +58,11 @@ async def start_wata_payment(
|
|||||||
)
|
)
|
||||||
|
|
||||||
await state.set_state(BalanceStates.waiting_for_amount)
|
await state.set_state(BalanceStates.waiting_for_amount)
|
||||||
await state.update_data(payment_method="wata")
|
await state.update_data(
|
||||||
|
payment_method="wata",
|
||||||
|
wata_prompt_message_id=callback.message.message_id,
|
||||||
|
wata_prompt_chat_id=callback.message.chat.id,
|
||||||
|
)
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
@@ -159,12 +165,54 @@ async def process_wata_payment_amount(
|
|||||||
support=settings.get_support_contact_display_html(),
|
support=settings.get_support_contact_display_html(),
|
||||||
)
|
)
|
||||||
|
|
||||||
await message.answer(
|
state_data = await state.get_data()
|
||||||
|
prompt_message_id = state_data.get("wata_prompt_message_id")
|
||||||
|
prompt_chat_id = state_data.get("wata_prompt_chat_id", message.chat.id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await message.delete()
|
||||||
|
except Exception as delete_error: # pragma: no cover - depends on bot rights
|
||||||
|
logger.warning("Не удалось удалить сообщение с суммой WATA: %s", delete_error)
|
||||||
|
|
||||||
|
if prompt_message_id:
|
||||||
|
try:
|
||||||
|
await message.bot.delete_message(prompt_chat_id, prompt_message_id)
|
||||||
|
except Exception as delete_error: # pragma: no cover - diagnostic
|
||||||
|
logger.warning(
|
||||||
|
"Не удалось удалить сообщение с запросом суммы WATA: %s",
|
||||||
|
delete_error,
|
||||||
|
)
|
||||||
|
|
||||||
|
invoice_message = await message.answer(
|
||||||
message_text,
|
message_text,
|
||||||
reply_markup=keyboard,
|
reply_markup=keyboard,
|
||||||
parse_mode="HTML",
|
parse_mode="HTML",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.services import payment_service as payment_module
|
||||||
|
|
||||||
|
payment = await payment_module.get_wata_payment_by_local_id(db, local_payment_id)
|
||||||
|
if payment:
|
||||||
|
metadata = dict(getattr(payment, "metadata_json", {}) or {})
|
||||||
|
metadata["invoice_message"] = {
|
||||||
|
"chat_id": invoice_message.chat.id,
|
||||||
|
"message_id": invoice_message.message_id,
|
||||||
|
}
|
||||||
|
await db.execute(
|
||||||
|
update(payment.__class__)
|
||||||
|
.where(payment.__class__.id == payment.id)
|
||||||
|
.values(metadata_json=metadata, updated_at=datetime.utcnow())
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
except Exception as error: # pragma: no cover - diagnostics
|
||||||
|
logger.warning("Не удалось сохранить сообщение WATA: %s", error)
|
||||||
|
|
||||||
|
await state.update_data(
|
||||||
|
wata_invoice_message_id=invoice_message.message_id,
|
||||||
|
wata_invoice_chat_id=invoice_message.chat.id,
|
||||||
|
)
|
||||||
|
|
||||||
await state.clear()
|
await state.clear()
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import logging
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from aiogram import types
|
from aiogram import types
|
||||||
from aiogram.fsm.context import FSMContext
|
from aiogram.fsm.context import FSMContext
|
||||||
|
from sqlalchemy import update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
@@ -58,9 +61,13 @@ async def start_yookassa_payment(
|
|||||||
reply_markup=keyboard,
|
reply_markup=keyboard,
|
||||||
parse_mode="HTML"
|
parse_mode="HTML"
|
||||||
)
|
)
|
||||||
|
|
||||||
await state.set_state(BalanceStates.waiting_for_amount)
|
await state.set_state(BalanceStates.waiting_for_amount)
|
||||||
await state.update_data(payment_method="yookassa")
|
await state.update_data(payment_method="yookassa")
|
||||||
|
await state.update_data(
|
||||||
|
yookassa_prompt_message_id=callback.message.message_id,
|
||||||
|
yookassa_prompt_chat_id=callback.message.chat.id,
|
||||||
|
)
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
@@ -108,9 +115,13 @@ async def start_yookassa_sbp_payment(
|
|||||||
reply_markup=keyboard,
|
reply_markup=keyboard,
|
||||||
parse_mode="HTML"
|
parse_mode="HTML"
|
||||||
)
|
)
|
||||||
|
|
||||||
await state.set_state(BalanceStates.waiting_for_amount)
|
await state.set_state(BalanceStates.waiting_for_amount)
|
||||||
await state.update_data(payment_method="yookassa_sbp")
|
await state.update_data(payment_method="yookassa_sbp")
|
||||||
|
await state.update_data(
|
||||||
|
yookassa_prompt_message_id=callback.message.message_id,
|
||||||
|
yookassa_prompt_chat_id=callback.message.chat.id,
|
||||||
|
)
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
@@ -172,7 +183,25 @@ async def process_yookassa_payment_amount(
|
|||||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
|
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
|
||||||
])
|
])
|
||||||
|
|
||||||
await message.answer(
|
state_data = await state.get_data()
|
||||||
|
prompt_message_id = state_data.get("yookassa_prompt_message_id")
|
||||||
|
prompt_chat_id = state_data.get("yookassa_prompt_chat_id", message.chat.id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await message.delete()
|
||||||
|
except Exception as delete_error: # pragma: no cover - зависит от прав бота
|
||||||
|
logger.warning("Не удалось удалить сообщение с суммой YooKassa: %s", delete_error)
|
||||||
|
|
||||||
|
if prompt_message_id:
|
||||||
|
try:
|
||||||
|
await message.bot.delete_message(prompt_chat_id, prompt_message_id)
|
||||||
|
except Exception as delete_error: # pragma: no cover - диагностический лог
|
||||||
|
logger.warning(
|
||||||
|
"Не удалось удалить сообщение с запросом суммы YooKassa: %s",
|
||||||
|
delete_error,
|
||||||
|
)
|
||||||
|
|
||||||
|
invoice_message = await message.answer(
|
||||||
f"💳 <b>Оплата банковской картой</b>\n\n"
|
f"💳 <b>Оплата банковской картой</b>\n\n"
|
||||||
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
|
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
|
||||||
f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...\n\n"
|
f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...\n\n"
|
||||||
@@ -187,9 +216,34 @@ async def process_yookassa_payment_amount(
|
|||||||
reply_markup=keyboard,
|
reply_markup=keyboard,
|
||||||
parse_mode="HTML"
|
parse_mode="HTML"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.services import payment_service as payment_module
|
||||||
|
|
||||||
|
payment = await payment_module.get_yookassa_payment_by_local_id(
|
||||||
|
db, payment_result["local_payment_id"]
|
||||||
|
)
|
||||||
|
if payment:
|
||||||
|
metadata = dict(getattr(payment, "metadata_json", {}) or {})
|
||||||
|
metadata["invoice_message"] = {
|
||||||
|
"chat_id": invoice_message.chat.id,
|
||||||
|
"message_id": invoice_message.message_id,
|
||||||
|
}
|
||||||
|
await db.execute(
|
||||||
|
update(payment.__class__)
|
||||||
|
.where(payment.__class__.id == payment.id)
|
||||||
|
.values(metadata_json=metadata, updated_at=datetime.utcnow())
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
except Exception as error: # pragma: no cover - диагностический лог
|
||||||
|
logger.warning("Не удалось сохранить сообщение YooKassa: %s", error)
|
||||||
|
|
||||||
|
await state.update_data(
|
||||||
|
yookassa_invoice_message_id=invoice_message.message_id,
|
||||||
|
yookassa_invoice_chat_id=invoice_message.chat.id,
|
||||||
|
)
|
||||||
|
|
||||||
await state.clear()
|
await state.clear()
|
||||||
|
|
||||||
logger.info(f"Создан платеж YooKassa для пользователя {db_user.telegram_id}: "
|
logger.info(f"Создан платеж YooKassa для пользователя {db_user.telegram_id}: "
|
||||||
f"{amount_kopeks//100}₽, ID: {payment_result['yookassa_payment_id']}")
|
f"{amount_kopeks//100}₽, ID: {payment_result['yookassa_payment_id']}")
|
||||||
|
|
||||||
@@ -310,27 +364,45 @@ async def process_yookassa_sbp_payment_amount(
|
|||||||
|
|
||||||
# Создаем клавиатуру с кнопками для оплаты по ссылке и проверки статуса
|
# Создаем клавиатуру с кнопками для оплаты по ссылке и проверки статуса
|
||||||
keyboard_buttons = []
|
keyboard_buttons = []
|
||||||
|
|
||||||
# Добавляем кнопку оплаты, если доступна ссылка
|
# Добавляем кнопку оплаты, если доступна ссылка
|
||||||
if confirmation_url:
|
if confirmation_url:
|
||||||
keyboard_buttons.append([types.InlineKeyboardButton(text="🔗 Перейти к оплате", url=confirmation_url)])
|
keyboard_buttons.append([types.InlineKeyboardButton(text="🔗 Перейти к оплате", url=confirmation_url)])
|
||||||
else:
|
else:
|
||||||
# Если ссылка недоступна, предлагаем оплатить через ID платежа в приложении банка
|
# Если ссылка недоступна, предлагаем оплатить через ID платежа в приложении банка
|
||||||
keyboard_buttons.append([types.InlineKeyboardButton(text="📱 Оплатить в приложении банка", callback_data="temp_disabled")])
|
keyboard_buttons.append([types.InlineKeyboardButton(text="📱 Оплатить в приложении банка", callback_data="temp_disabled")])
|
||||||
|
|
||||||
# Добавляем общие кнопки
|
# Добавляем общие кнопки
|
||||||
keyboard_buttons.append([types.InlineKeyboardButton(text="📊 Проверить статус", callback_data=f"check_yookassa_{payment_result['local_payment_id']}")])
|
keyboard_buttons.append([types.InlineKeyboardButton(text="📊 Проверить статус", callback_data=f"check_yookassa_{payment_result['local_payment_id']}")])
|
||||||
keyboard_buttons.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")])
|
keyboard_buttons.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")])
|
||||||
|
|
||||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_buttons)
|
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_buttons)
|
||||||
|
|
||||||
|
state_data = await state.get_data()
|
||||||
|
prompt_message_id = state_data.get("yookassa_prompt_message_id")
|
||||||
|
prompt_chat_id = state_data.get("yookassa_prompt_chat_id", message.chat.id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await message.delete()
|
||||||
|
except Exception as delete_error: # pragma: no cover - зависит от прав бота
|
||||||
|
logger.warning("Не удалось удалить сообщение с суммой YooKassa (СБП): %s", delete_error)
|
||||||
|
|
||||||
|
if prompt_message_id:
|
||||||
|
try:
|
||||||
|
await message.bot.delete_message(prompt_chat_id, prompt_message_id)
|
||||||
|
except Exception as delete_error: # pragma: no cover - диагностический лог
|
||||||
|
logger.warning(
|
||||||
|
"Не удалось удалить сообщение с запросом суммы YooKassa (СБП): %s",
|
||||||
|
delete_error,
|
||||||
|
)
|
||||||
|
|
||||||
# Подготавливаем текст сообщения
|
# Подготавливаем текст сообщения
|
||||||
message_text = (
|
message_text = (
|
||||||
f"🔗 <b>Оплата через СБП</b>\n\n"
|
f"🔗 <b>Оплата через СБП</b>\n\n"
|
||||||
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
|
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
|
||||||
f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...\n\n"
|
f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...\n\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Добавляем инструкции в зависимости от доступных способов оплаты
|
# Добавляем инструкции в зависимости от доступных способов оплаты
|
||||||
if not confirmation_url:
|
if not confirmation_url:
|
||||||
message_text += (
|
message_text += (
|
||||||
@@ -341,18 +413,18 @@ async def process_yookassa_sbp_payment_amount(
|
|||||||
f"4. Подтвердите платеж в приложении банка\n"
|
f"4. Подтвердите платеж в приложении банка\n"
|
||||||
f"5. Деньги поступят на баланс автоматически\n\n"
|
f"5. Деньги поступят на баланс автоматически\n\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
message_text += (
|
message_text += (
|
||||||
f"🔒 Оплата происходит через защищенную систему YooKassa\n"
|
f"🔒 Оплата происходит через защищенную систему YooKassa\n"
|
||||||
f"✅ Принимаем СБП от всех банков-участников\n\n"
|
f"✅ Принимаем СБП от всех банков-участников\n\n"
|
||||||
f"❓ Если возникнут проблемы, обратитесь в {settings.get_support_contact_display_html()}"
|
f"❓ Если возникнут проблемы, обратитесь в {settings.get_support_contact_display_html()}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Отправляем сообщение с инструкциями и клавиатурой
|
# Отправляем сообщение с инструкциями и клавиатурой
|
||||||
# Если есть QR-код, отправляем его как медиа-сообщение
|
# Если есть QR-код, отправляем его как медиа-сообщение
|
||||||
if qr_photo:
|
if qr_photo:
|
||||||
# Используем метод отправки медиа-группы или фото с описанием
|
# Используем метод отправки медиа-группы или фото с описанием
|
||||||
await message.answer_photo(
|
invoice_message = await message.answer_photo(
|
||||||
photo=qr_photo,
|
photo=qr_photo,
|
||||||
caption=message_text,
|
caption=message_text,
|
||||||
reply_markup=keyboard,
|
reply_markup=keyboard,
|
||||||
@@ -360,12 +432,39 @@ async def process_yookassa_sbp_payment_amount(
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Если QR-код недоступен, отправляем обычное текстовое сообщение
|
# Если QR-код недоступен, отправляем обычное текстовое сообщение
|
||||||
await message.answer(
|
invoice_message = await message.answer(
|
||||||
message_text,
|
message_text,
|
||||||
reply_markup=keyboard,
|
reply_markup=keyboard,
|
||||||
parse_mode="HTML"
|
parse_mode="HTML"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.services import payment_service as payment_module
|
||||||
|
|
||||||
|
payment = await payment_module.get_yookassa_payment_by_local_id(
|
||||||
|
db, payment_result["local_payment_id"]
|
||||||
|
)
|
||||||
|
if payment:
|
||||||
|
metadata = dict(getattr(payment, "metadata_json", {}) or {})
|
||||||
|
metadata["invoice_message"] = {
|
||||||
|
"chat_id": invoice_message.chat.id,
|
||||||
|
"message_id": invoice_message.message_id,
|
||||||
|
}
|
||||||
|
await db.execute(
|
||||||
|
update(payment.__class__)
|
||||||
|
.where(payment.__class__.id == payment.id)
|
||||||
|
.values(metadata_json=metadata, updated_at=datetime.utcnow())
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
except Exception as error: # pragma: no cover - диагностический лог
|
||||||
|
logger.warning("Не удалось сохранить сообщение YooKassa (СБП): %s", error)
|
||||||
|
|
||||||
|
await state.update_data(
|
||||||
|
yookassa_invoice_message_id=invoice_message.message_id,
|
||||||
|
yookassa_invoice_chat_id=invoice_message.chat.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
await state.clear()
|
||||||
logger.info(f"Создан платеж YooKassa СБП для пользователя {db_user.telegram_id}: "
|
logger.info(f"Создан платеж YooKassa СБП для пользователя {db_user.telegram_id}: "
|
||||||
f"{amount_kopeks//100}₽, ID: {payment_result['yookassa_payment_id']}")
|
f"{amount_kopeks//100}₽, ID: {payment_result['yookassa_payment_id']}")
|
||||||
|
|
||||||
|
|||||||
@@ -972,7 +972,13 @@ def _get_subscription_status(user: User, texts) -> str:
|
|||||||
"🔴 Истекла\n📅 {end_date}",
|
"🔴 Истекла\n📅 {end_date}",
|
||||||
).format(end_date=end_date_text or "—")
|
).format(end_date=end_date_text or "—")
|
||||||
|
|
||||||
if actual_status == "trial":
|
is_trial_subscription = getattr(subscription, "is_trial", False)
|
||||||
|
|
||||||
|
is_trial_like_status = actual_status == "trial" or (
|
||||||
|
is_trial_subscription and actual_status in {"active", "trial"}
|
||||||
|
)
|
||||||
|
|
||||||
|
if is_trial_like_status:
|
||||||
if days_left > 1 and end_date_text:
|
if days_left > 1 and end_date_text:
|
||||||
return texts.t(
|
return texts.t(
|
||||||
"SUB_STATUS_TRIAL_ACTIVE",
|
"SUB_STATUS_TRIAL_ACTIVE",
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import logging
|
import logging
|
||||||
from decimal import Decimal, ROUND_HALF_UP
|
from decimal import Decimal, ROUND_HALF_UP
|
||||||
from aiogram import Dispatcher, types, F
|
from aiogram import Dispatcher, types, F
|
||||||
|
from aiogram.fsm.context import FSMContext
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.database.models import User
|
|
||||||
from app.services.payment_service import PaymentService
|
from app.services.payment_service import PaymentService
|
||||||
from app.external.telegram_stars import TelegramStarsService
|
from app.external.telegram_stars import TelegramStarsService
|
||||||
from app.database.crud.user import get_user_by_telegram_id
|
from app.database.crud.user import get_user_by_telegram_id
|
||||||
@@ -18,7 +18,9 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
|
|||||||
texts = get_texts(DEFAULT_LANGUAGE)
|
texts = get_texts(DEFAULT_LANGUAGE)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logger.info(f"📋 Pre-checkout query от {query.from_user.id}: {query.total_amount} XTR, payload: {query.invoice_payload}")
|
logger.info(
|
||||||
|
f"📋 Pre-checkout query от {query.from_user.id}: {query.total_amount} XTR, payload: {query.invoice_payload}"
|
||||||
|
)
|
||||||
|
|
||||||
allowed_prefixes = ("balance_", "admin_stars_test_", "simple_sub_")
|
allowed_prefixes = ("balance_", "admin_stars_test_", "simple_sub_")
|
||||||
|
|
||||||
@@ -35,6 +37,7 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
from app.database.database import get_db
|
from app.database.database import get_db
|
||||||
|
|
||||||
async for db in get_db():
|
async for db in get_db():
|
||||||
user = await get_user_by_telegram_id(db, query.from_user.id)
|
user = await get_user_by_telegram_id(db, query.from_user.id)
|
||||||
if not user:
|
if not user:
|
||||||
@@ -77,6 +80,7 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
|
|||||||
async def handle_successful_payment(
|
async def handle_successful_payment(
|
||||||
message: types.Message,
|
message: types.Message,
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
|
state: FSMContext,
|
||||||
**kwargs
|
**kwargs
|
||||||
):
|
):
|
||||||
texts = get_texts(DEFAULT_LANGUAGE)
|
texts = get_texts(DEFAULT_LANGUAGE)
|
||||||
@@ -106,6 +110,27 @@ async def handle_successful_payment(
|
|||||||
return
|
return
|
||||||
|
|
||||||
payment_service = PaymentService(message.bot)
|
payment_service = PaymentService(message.bot)
|
||||||
|
|
||||||
|
state_data = await state.get_data()
|
||||||
|
prompt_message_id = state_data.get("stars_prompt_message_id")
|
||||||
|
prompt_chat_id = state_data.get("stars_prompt_chat_id", message.chat.id)
|
||||||
|
invoice_message_id = state_data.get("stars_invoice_message_id")
|
||||||
|
invoice_chat_id = state_data.get("stars_invoice_chat_id", message.chat.id)
|
||||||
|
|
||||||
|
for chat_id, message_id, label in [
|
||||||
|
(prompt_chat_id, prompt_message_id, "запрос суммы"),
|
||||||
|
(invoice_chat_id, invoice_message_id, "инвойс Stars"),
|
||||||
|
]:
|
||||||
|
if message_id:
|
||||||
|
try:
|
||||||
|
await message.bot.delete_message(chat_id, message_id)
|
||||||
|
except Exception as delete_error: # pragma: no cover - зависит от прав бота
|
||||||
|
logger.warning(
|
||||||
|
"Не удалось удалить сообщение %s после оплаты Stars: %s",
|
||||||
|
label,
|
||||||
|
delete_error,
|
||||||
|
)
|
||||||
|
|
||||||
success = await payment_service.process_stars_payment(
|
success = await payment_service.process_stars_payment(
|
||||||
db=db,
|
db=db,
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
@@ -113,7 +138,14 @@ async def handle_successful_payment(
|
|||||||
payload=payment.invoice_payload,
|
payload=payment.invoice_payload,
|
||||||
telegram_payment_charge_id=payment.telegram_payment_charge_id
|
telegram_payment_charge_id=payment.telegram_payment_charge_id
|
||||||
)
|
)
|
||||||
|
|
||||||
|
await state.update_data(
|
||||||
|
stars_prompt_message_id=None,
|
||||||
|
stars_prompt_chat_id=None,
|
||||||
|
stars_invoice_message_id=None,
|
||||||
|
stars_invoice_chat_id=None,
|
||||||
|
)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
rubles_amount = TelegramStarsService.calculate_rubles_from_stars(payment.total_amount)
|
rubles_amount = TelegramStarsService.calculate_rubles_from_stars(payment.total_amount)
|
||||||
amount_kopeks = int((rubles_amount * Decimal(100)).to_integral_value(rounding=ROUND_HALF_UP))
|
amount_kopeks = int((rubles_amount * Decimal(100)).to_integral_value(rounding=ROUND_HALF_UP))
|
||||||
@@ -172,15 +204,15 @@ async def handle_successful_payment(
|
|||||||
|
|
||||||
|
|
||||||
def register_stars_handlers(dp: Dispatcher):
|
def register_stars_handlers(dp: Dispatcher):
|
||||||
|
|
||||||
dp.pre_checkout_query.register(
|
dp.pre_checkout_query.register(
|
||||||
handle_pre_checkout_query,
|
handle_pre_checkout_query,
|
||||||
F.currency == "XTR"
|
F.currency == "XTR"
|
||||||
)
|
)
|
||||||
|
|
||||||
dp.message.register(
|
dp.message.register(
|
||||||
handle_successful_payment,
|
handle_successful_payment,
|
||||||
F.successful_payment
|
F.successful_payment
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info("🌟 Зарегистрированы обработчики Telegram Stars платежей")
|
logger.info("🌟 Зарегистрированы обработчики Telegram Stars платежей")
|
||||||
|
|||||||
@@ -1767,13 +1767,25 @@ async def select_devices(
|
|||||||
|
|
||||||
devices_price = max(0, devices - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
|
devices_price = max(0, devices - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
|
||||||
|
|
||||||
|
previous_devices = data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
|
||||||
|
|
||||||
data['devices'] = devices
|
data['devices'] = devices
|
||||||
data['total_price'] = base_price + countries_price + devices_price
|
data['total_price'] = base_price + countries_price + devices_price
|
||||||
await state.set_data(data)
|
await state.set_data(data)
|
||||||
|
|
||||||
await callback.message.edit_reply_markup(
|
if devices != previous_devices:
|
||||||
reply_markup=get_devices_keyboard(devices, db_user.language)
|
try:
|
||||||
)
|
await callback.message.edit_reply_markup(
|
||||||
|
reply_markup=get_devices_keyboard(devices, db_user.language)
|
||||||
|
)
|
||||||
|
except TelegramBadRequest as error:
|
||||||
|
if "message is not modified" in str(error).lower():
|
||||||
|
logger.debug(
|
||||||
|
"ℹ️ Пропускаем обновление клавиатуры устройств: содержимое не изменилось"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
|
|
||||||
async def devices_continue(
|
async def devices_continue(
|
||||||
|
|||||||
@@ -54,6 +54,10 @@ def get_admin_main_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=_t(texts, "ADMIN_MAIN_TRIALS", "🧪 Триалы"),
|
||||||
|
callback_data="admin_trials",
|
||||||
|
),
|
||||||
InlineKeyboardButton(
|
InlineKeyboardButton(
|
||||||
text=_t(texts, "ADMIN_MAIN_PAYMENTS", "💳 Пополнения"),
|
text=_t(texts, "ADMIN_MAIN_PAYMENTS", "💳 Пополнения"),
|
||||||
callback_data="admin_payments",
|
callback_data="admin_payments",
|
||||||
@@ -241,6 +245,20 @@ def get_admin_system_submenu_keyboard(language: str = "ru") -> InlineKeyboardMar
|
|||||||
])
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def get_admin_trials_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||||
|
texts = get_texts(language)
|
||||||
|
|
||||||
|
return InlineKeyboardMarkup(inline_keyboard=[
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=_t(texts, "ADMIN_TRIALS_RESET_BUTTON", "♻️ Сбросить все триалы"),
|
||||||
|
callback_data="admin_trials_reset",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
[InlineKeyboardButton(text=texts.BACK, callback_data="admin_panel")],
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
def get_admin_reports_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
def get_admin_reports_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||||
texts = get_texts(language)
|
texts = get_texts(language)
|
||||||
|
|
||||||
|
|||||||
+50
-10
@@ -82,7 +82,40 @@ def _build_additional_buttons(additional_section, language: str) -> List[InlineK
|
|||||||
|
|
||||||
_LANGUAGE_DISPLAY_NAMES = {
|
_LANGUAGE_DISPLAY_NAMES = {
|
||||||
"ru": "🇷🇺 Русский",
|
"ru": "🇷🇺 Русский",
|
||||||
|
"ru-ru": "🇷🇺 Русский",
|
||||||
"en": "🇬🇧 English",
|
"en": "🇬🇧 English",
|
||||||
|
"en-us": "🇺🇸 English",
|
||||||
|
"en-gb": "🇬🇧 English",
|
||||||
|
"ua": "🇺🇦 Українська",
|
||||||
|
"uk": "🇺🇦 Українська",
|
||||||
|
"uk-ua": "🇺🇦 Українська",
|
||||||
|
"kk": "🇰🇿 Қазақша",
|
||||||
|
"kk-kz": "🇰🇿 Қазақша",
|
||||||
|
"kz": "🇰🇿 Қазақша",
|
||||||
|
"uz": "🇺🇿 Oʻzbekcha",
|
||||||
|
"uz-uz": "🇺🇿 Oʻzbekcha",
|
||||||
|
"tr": "🇹🇷 Türkçe",
|
||||||
|
"tr-tr": "🇹🇷 Türkçe",
|
||||||
|
"pl": "🇵🇱 Polski",
|
||||||
|
"pl-pl": "🇵🇱 Polski",
|
||||||
|
"de": "🇩🇪 Deutsch",
|
||||||
|
"de-de": "🇩🇪 Deutsch",
|
||||||
|
"fr": "🇫🇷 Français",
|
||||||
|
"fr-fr": "🇫🇷 Français",
|
||||||
|
"es": "🇪🇸 Español",
|
||||||
|
"es-es": "🇪🇸 Español",
|
||||||
|
"it": "🇮🇹 Italiano",
|
||||||
|
"it-it": "🇮🇹 Italiano",
|
||||||
|
"pt": "🇵🇹 Português",
|
||||||
|
"pt-pt": "🇵🇹 Português",
|
||||||
|
"pt-br": "🇧🇷 Português",
|
||||||
|
"zh": "🇨🇳 中文",
|
||||||
|
"zh-cn": "🇨🇳 中文 (简体)",
|
||||||
|
"zh-hans": "🇨🇳 中文 (简体)",
|
||||||
|
"zh-tw": "🇹🇼 中文 (繁體)",
|
||||||
|
"zh-hant": "🇹🇼 中文 (繁體)",
|
||||||
|
"vi": "🇻🇳 Tiếng Việt",
|
||||||
|
"vi-vn": "🇻🇳 Tiếng Việt",
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_rules_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
|
def get_rules_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
|
||||||
@@ -95,27 +128,34 @@ def get_rules_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup
|
|||||||
])
|
])
|
||||||
|
|
||||||
def get_channel_sub_keyboard(
|
def get_channel_sub_keyboard(
|
||||||
channel_link: str,
|
channel_link: Optional[str],
|
||||||
language: str = DEFAULT_LANGUAGE,
|
language: str = DEFAULT_LANGUAGE,
|
||||||
) -> InlineKeyboardMarkup:
|
) -> InlineKeyboardMarkup:
|
||||||
texts = get_texts(language)
|
texts = get_texts(language)
|
||||||
return InlineKeyboardMarkup(
|
|
||||||
inline_keyboard=[
|
buttons: List[List[InlineKeyboardButton]] = []
|
||||||
|
|
||||||
|
if channel_link:
|
||||||
|
buttons.append(
|
||||||
[
|
[
|
||||||
InlineKeyboardButton(
|
InlineKeyboardButton(
|
||||||
text=texts.t("CHANNEL_SUBSCRIBE_BUTTON", "🔗 Подписаться"),
|
text=texts.t("CHANNEL_SUBSCRIBE_BUTTON", "🔗 Подписаться"),
|
||||||
url=channel_link,
|
url=channel_link,
|
||||||
)
|
)
|
||||||
],
|
]
|
||||||
[
|
)
|
||||||
InlineKeyboardButton(
|
|
||||||
text=texts.t("CHANNEL_CHECK_BUTTON", "✅ Я подписался"),
|
buttons.append(
|
||||||
callback_data="sub_channel_check",
|
[
|
||||||
)
|
InlineKeyboardButton(
|
||||||
],
|
text=texts.t("CHANNEL_CHECK_BUTTON", "✅ Я подписался"),
|
||||||
|
callback_data="sub_channel_check",
|
||||||
|
)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||||
|
|
||||||
|
|
||||||
def get_post_registration_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
|
def get_post_registration_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
|
||||||
texts = get_texts(language)
|
texts = get_texts(language)
|
||||||
|
|||||||
@@ -130,6 +130,7 @@
|
|||||||
"ADMIN_MAIN_SETTINGS": "⚙️ Settings",
|
"ADMIN_MAIN_SETTINGS": "⚙️ Settings",
|
||||||
"ADMIN_MAIN_SUPPORT": "🛟 Support",
|
"ADMIN_MAIN_SUPPORT": "🛟 Support",
|
||||||
"ADMIN_MAIN_SYSTEM": "🛠️ System",
|
"ADMIN_MAIN_SYSTEM": "🛠️ System",
|
||||||
|
"ADMIN_MAIN_TRIALS": "🧪 Trials",
|
||||||
"ADMIN_MAIN_PAYMENTS": "💳 Top-ups",
|
"ADMIN_MAIN_PAYMENTS": "💳 Top-ups",
|
||||||
"ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Users / Subscriptions",
|
"ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Users / Subscriptions",
|
||||||
"ADMIN_MESSAGES": "📨 Broadcasts",
|
"ADMIN_MESSAGES": "📨 Broadcasts",
|
||||||
@@ -168,6 +169,11 @@
|
|||||||
"ADMIN_PAYMENTS_TITLE": "💳 <b>Top-up verification</b>",
|
"ADMIN_PAYMENTS_TITLE": "💳 <b>Top-up verification</b>",
|
||||||
"ADMIN_PAYMENTS_DESCRIPTION": "Pending top-up invoices created during the last 24 hours.",
|
"ADMIN_PAYMENTS_DESCRIPTION": "Pending top-up invoices created during the last 24 hours.",
|
||||||
"ADMIN_PAYMENTS_NOTICE": "Only invoices younger than 24 hours and waiting for payment can be checked.",
|
"ADMIN_PAYMENTS_NOTICE": "Only invoices younger than 24 hours and waiting for payment can be checked.",
|
||||||
|
"ADMIN_TRIALS_TITLE": "🧪 Trial management",
|
||||||
|
"ADMIN_TRIALS_STATS": "• Total trials used: {used}\n• Active now: {active}\n• Eligible for reset: {resettable}",
|
||||||
|
"ADMIN_TRIALS_RESET_BUTTON": "♻️ Reset all trials",
|
||||||
|
"ADMIN_TRIALS_RESET_RESULT": "♻️ Reset {reset_count} trials.\n\n• Total trials used: {used}\n• Active now: {active}\n• Eligible for reset: {resettable}",
|
||||||
|
"ADMIN_TRIALS_RESET_TOAST": "✅ Reset completed",
|
||||||
"ADMIN_PAYMENTS_EMPTY": "No pending top-up invoices found in the last 24 hours.",
|
"ADMIN_PAYMENTS_EMPTY": "No pending top-up invoices found in the last 24 hours.",
|
||||||
"ADMIN_PAYMENTS_ITEM_DETAILS": "📄 #{number}",
|
"ADMIN_PAYMENTS_ITEM_DETAILS": "📄 #{number}",
|
||||||
"ADMIN_PAYMENT_STATUS_PENDING": "Pending",
|
"ADMIN_PAYMENT_STATUS_PENDING": "Pending",
|
||||||
@@ -1017,7 +1023,6 @@
|
|||||||
"PAL24_INSTRUCTION_FOLLOW": "{step}. Follow the payment page instructions",
|
"PAL24_INSTRUCTION_FOLLOW": "{step}. Follow the payment page instructions",
|
||||||
"PAL24_PAYMENT_ERROR": "❌ Failed to create a PayPalych payment. Please try again later or contact support.",
|
"PAL24_PAYMENT_ERROR": "❌ Failed to create a PayPalych payment. Please try again later or contact support.",
|
||||||
"PAL24_PAYMENT_INSTRUCTIONS": "🏦 <b>PayPalych (SBP) payment</b>\n\n💰 Amount: {amount}\n🆔 Invoice ID: {bill_id}\n\n📱 <b>How to pay:</b>\n1. Press ‘Pay with PayPalych (SBP)’\n2. Follow the system prompts\n3. Confirm the transfer\n4. Funds will be credited automatically\n\n❓ Need help? Contact {support}",
|
"PAL24_PAYMENT_INSTRUCTIONS": "🏦 <b>PayPalych (SBP) payment</b>\n\n💰 Amount: {amount}\n🆔 Invoice ID: {bill_id}\n\n📱 <b>How to pay:</b>\n1. Press ‘Pay with PayPalych (SBP)’\n2. Follow the system prompts\n3. Confirm the transfer\n4. Funds will be credited automatically\n\n❓ Need help? Contact {support}",
|
||||||
"PAL24_PAY_BUTTON": "🏦 Pay with PayPalych (SBP)",
|
|
||||||
"PAL24_SBP_PAY_BUTTON": "🏦 Pay with PayPalych (SBP)",
|
"PAL24_SBP_PAY_BUTTON": "🏦 Pay with PayPalych (SBP)",
|
||||||
"PAL24_SELECT_PAYMENT_METHOD": "Choose a PayPalych payment method:",
|
"PAL24_SELECT_PAYMENT_METHOD": "Choose a PayPalych payment method:",
|
||||||
"PAL24_TOPUP_PROMPT": "🏦 <b>PayPalych (SBP) payment</b>\n\nEnter an amount between 100 and 1,000,000 ₽.\nThe payment is processed via the PayPalych Faster Payments System.",
|
"PAL24_TOPUP_PROMPT": "🏦 <b>PayPalych (SBP) payment</b>\n\nEnter an amount between 100 and 1,000,000 ₽.\nThe payment is processed via the PayPalych Faster Payments System.",
|
||||||
@@ -1055,12 +1060,8 @@
|
|||||||
"PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ Automated payment methods are temporarily unavailable. Contact support to top up your balance.",
|
"PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ Automated payment methods are temporarily unavailable. Contact support to top up your balance.",
|
||||||
"PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "via CryptoBot",
|
"PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "via CryptoBot",
|
||||||
"PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 <b>Cryptocurrency</b>",
|
"PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 <b>Cryptocurrency</b>",
|
||||||
"PAYMENT_METHOD_HELEKET_DESCRIPTION": "via Heleket",
|
|
||||||
"PAYMENT_METHOD_HELEKET_NAME": "🪙 <b>Cryptocurrency (Heleket)</b>",
|
|
||||||
"PAYMENT_METHOD_MULENPAY_DESCRIPTION": "via {mulenpay_name}",
|
"PAYMENT_METHOD_MULENPAY_DESCRIPTION": "via {mulenpay_name}",
|
||||||
"PAYMENT_METHOD_MULENPAY_NAME": "💳 <b>Bank card ({mulenpay_name})</b>",
|
"PAYMENT_METHOD_MULENPAY_NAME": "💳 <b>Bank card ({mulenpay_name})</b>",
|
||||||
"PAYMENT_METHOD_PAL24_DESCRIPTION": "via Faster Payments System",
|
|
||||||
"PAYMENT_METHOD_PAL24_NAME": "🏦 <b>SBP (PayPalych)</b>",
|
|
||||||
"PAYMENT_METHOD_PLATEGA_DESCRIPTION": "via Platega (cards + SBP)",
|
"PAYMENT_METHOD_PLATEGA_DESCRIPTION": "via Platega (cards + SBP)",
|
||||||
"PAYMENT_METHOD_PLATEGA_NAME": "💳 <b>Bank card (Platega)</b>",
|
"PAYMENT_METHOD_PLATEGA_NAME": "💳 <b>Bank card (Platega)</b>",
|
||||||
"PAYMENT_METHOD_STARS_DESCRIPTION": "fast and convenient",
|
"PAYMENT_METHOD_STARS_DESCRIPTION": "fast and convenient",
|
||||||
@@ -1069,8 +1070,6 @@
|
|||||||
"PAYMENT_METHOD_SUPPORT_NAME": "🛠️ <b>Support team</b>",
|
"PAYMENT_METHOD_SUPPORT_NAME": "🛠️ <b>Support team</b>",
|
||||||
"PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "via Tribute",
|
"PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "via Tribute",
|
||||||
"PAYMENT_METHOD_TRIBUTE_NAME": "💳 <b>Bank card</b>",
|
"PAYMENT_METHOD_TRIBUTE_NAME": "💳 <b>Bank card</b>",
|
||||||
"PAYMENT_METHOD_WATA_DESCRIPTION": "via WATA",
|
|
||||||
"PAYMENT_METHOD_WATA_NAME": "💳 <b>Bank card (WATA)</b>",
|
|
||||||
"PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "via YooKassa",
|
"PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "via YooKassa",
|
||||||
"PAYMENT_METHOD_YOOKASSA_NAME": "💳 <b>Bank card</b>",
|
"PAYMENT_METHOD_YOOKASSA_NAME": "💳 <b>Bank card</b>",
|
||||||
"PAYMENT_METHOD_YOOKASSA_SBP_DESCRIPTION": "via YooKassa Fast Payment System",
|
"PAYMENT_METHOD_YOOKASSA_SBP_DESCRIPTION": "via YooKassa Fast Payment System",
|
||||||
|
|||||||
@@ -130,6 +130,7 @@
|
|||||||
"ADMIN_MAIN_SETTINGS": "⚙️ Настройки",
|
"ADMIN_MAIN_SETTINGS": "⚙️ Настройки",
|
||||||
"ADMIN_MAIN_SUPPORT": "🛟 Поддержка",
|
"ADMIN_MAIN_SUPPORT": "🛟 Поддержка",
|
||||||
"ADMIN_MAIN_SYSTEM": "🛠️ Система",
|
"ADMIN_MAIN_SYSTEM": "🛠️ Система",
|
||||||
|
"ADMIN_MAIN_TRIALS": "🧪 Триалы",
|
||||||
"ADMIN_MAIN_PAYMENTS": "💳 Пополнения",
|
"ADMIN_MAIN_PAYMENTS": "💳 Пополнения",
|
||||||
"ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Юзеры/Подписки",
|
"ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Юзеры/Подписки",
|
||||||
"ADMIN_MESSAGES": "📨 Рассылки",
|
"ADMIN_MESSAGES": "📨 Рассылки",
|
||||||
@@ -168,6 +169,11 @@
|
|||||||
"ADMIN_PAYMENTS_TITLE": "💳 <b>Проверка пополнений</b>",
|
"ADMIN_PAYMENTS_TITLE": "💳 <b>Проверка пополнений</b>",
|
||||||
"ADMIN_PAYMENTS_DESCRIPTION": "Список счетов на пополнение, созданных за последние 24 часа и ожидающих оплаты.",
|
"ADMIN_PAYMENTS_DESCRIPTION": "Список счетов на пополнение, созданных за последние 24 часа и ожидающих оплаты.",
|
||||||
"ADMIN_PAYMENTS_NOTICE": "Проверять можно только счета моложе 24 часов и со статусом ожидания.",
|
"ADMIN_PAYMENTS_NOTICE": "Проверять можно только счета моложе 24 часов и со статусом ожидания.",
|
||||||
|
"ADMIN_TRIALS_TITLE": "🧪 Управление триалами",
|
||||||
|
"ADMIN_TRIALS_STATS": "• Использовано всего: {used}\n• Активно сейчас: {active}\n• Доступно к сбросу: {resettable}",
|
||||||
|
"ADMIN_TRIALS_RESET_BUTTON": "♻️ Сбросить все триалы",
|
||||||
|
"ADMIN_TRIALS_RESET_RESULT": "♻️ Сбросили {reset_count} триалов.\n\n• Использовано всего: {used}\n• Активно сейчас: {active}\n• Доступно к сбросу: {resettable}",
|
||||||
|
"ADMIN_TRIALS_RESET_TOAST": "✅ Сброс завершен",
|
||||||
"ADMIN_PAYMENTS_EMPTY": "За последние 24 часа не найдено счетов на пополнение в ожидании.",
|
"ADMIN_PAYMENTS_EMPTY": "За последние 24 часа не найдено счетов на пополнение в ожидании.",
|
||||||
"ADMIN_PAYMENTS_ITEM_DETAILS": "📄 №{number}",
|
"ADMIN_PAYMENTS_ITEM_DETAILS": "📄 №{number}",
|
||||||
"ADMIN_PAYMENT_STATUS_PENDING": "Ожидает оплаты",
|
"ADMIN_PAYMENT_STATUS_PENDING": "Ожидает оплаты",
|
||||||
@@ -1037,7 +1043,6 @@
|
|||||||
"PAL24_INSTRUCTION_FOLLOW": "{step}. Следуйте подсказкам платёжной системы",
|
"PAL24_INSTRUCTION_FOLLOW": "{step}. Следуйте подсказкам платёжной системы",
|
||||||
"PAL24_PAYMENT_ERROR": "❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.",
|
"PAL24_PAYMENT_ERROR": "❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.",
|
||||||
"PAL24_PAYMENT_INSTRUCTIONS": "🏦 <b>Оплата через PayPalych (СБП)</b>\n\n💰 Сумма: {amount}\n🆔 ID счета: {bill_id}\n\n📱 <b>Инструкция:</b>\n1. Нажмите кнопку ‘Оплатить через PayPalych (СБП)’\n2. Следуйте подсказкам платежной системы\n3. Подтвердите перевод\n4. Средства зачислятся автоматически\n\n❓ Если возникнут проблемы, обратитесь в {support}",
|
"PAL24_PAYMENT_INSTRUCTIONS": "🏦 <b>Оплата через PayPalych (СБП)</b>\n\n💰 Сумма: {amount}\n🆔 ID счета: {bill_id}\n\n📱 <b>Инструкция:</b>\n1. Нажмите кнопку ‘Оплатить через PayPalych (СБП)’\n2. Следуйте подсказкам платежной системы\n3. Подтвердите перевод\n4. Средства зачислятся автоматически\n\n❓ Если возникнут проблемы, обратитесь в {support}",
|
||||||
"PAL24_PAY_BUTTON": "🏦 Оплатить через PayPalych (СБП)",
|
|
||||||
"PAL24_SBP_PAY_BUTTON": "🏦 Оплатить через PayPalych (СБП)",
|
"PAL24_SBP_PAY_BUTTON": "🏦 Оплатить через PayPalych (СБП)",
|
||||||
"PAL24_SELECT_PAYMENT_METHOD": "Выберите способ оплаты PayPalych:",
|
"PAL24_SELECT_PAYMENT_METHOD": "Выберите способ оплаты PayPalych:",
|
||||||
"PAL24_TOPUP_PROMPT": "🏦 <b>Оплата через PayPalych (СБП)</b>\n\nВведите сумму для пополнения от 100 до 1 000 000 ₽.\nОплата проходит через систему быстрых платежей PayPalych.",
|
"PAL24_TOPUP_PROMPT": "🏦 <b>Оплата через PayPalych (СБП)</b>\n\nВведите сумму для пополнения от 100 до 1 000 000 ₽.\nОплата проходит через систему быстрых платежей PayPalych.",
|
||||||
@@ -1075,12 +1080,8 @@
|
|||||||
"PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку.",
|
"PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку.",
|
||||||
"PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "через CryptoBot",
|
"PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "через CryptoBot",
|
||||||
"PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 <b>Криптовалюта</b>",
|
"PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 <b>Криптовалюта</b>",
|
||||||
"PAYMENT_METHOD_HELEKET_DESCRIPTION": "через Heleket",
|
|
||||||
"PAYMENT_METHOD_HELEKET_NAME": "🪙 <b>Криптовалюта (Heleket)</b>",
|
|
||||||
"PAYMENT_METHOD_MULENPAY_DESCRIPTION": "через {mulenpay_name}",
|
"PAYMENT_METHOD_MULENPAY_DESCRIPTION": "через {mulenpay_name}",
|
||||||
"PAYMENT_METHOD_MULENPAY_NAME": "💳 <b>Банковская карта ({mulenpay_name})</b>",
|
"PAYMENT_METHOD_MULENPAY_NAME": "💳 <b>Банковская карта ({mulenpay_name})</b>",
|
||||||
"PAYMENT_METHOD_PAL24_DESCRIPTION": "через систему быстрых платежей",
|
|
||||||
"PAYMENT_METHOD_PAL24_NAME": "🏦 <b>СБП (PayPalych)</b>",
|
|
||||||
"PAYMENT_METHOD_PLATEGA_DESCRIPTION": "через Platega (карты + СБП)",
|
"PAYMENT_METHOD_PLATEGA_DESCRIPTION": "через Platega (карты + СБП)",
|
||||||
"PAYMENT_METHOD_PLATEGA_NAME": "💳 <b>Банковская карта (Platega)</b>",
|
"PAYMENT_METHOD_PLATEGA_NAME": "💳 <b>Банковская карта (Platega)</b>",
|
||||||
"PAYMENT_METHOD_STARS_DESCRIPTION": "быстро и удобно",
|
"PAYMENT_METHOD_STARS_DESCRIPTION": "быстро и удобно",
|
||||||
@@ -1089,8 +1090,6 @@
|
|||||||
"PAYMENT_METHOD_SUPPORT_NAME": "🛠️ <b>Через поддержку</b>",
|
"PAYMENT_METHOD_SUPPORT_NAME": "🛠️ <b>Через поддержку</b>",
|
||||||
"PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "через Tribute",
|
"PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "через Tribute",
|
||||||
"PAYMENT_METHOD_TRIBUTE_NAME": "💳 <b>Банковская карта</b>",
|
"PAYMENT_METHOD_TRIBUTE_NAME": "💳 <b>Банковская карта</b>",
|
||||||
"PAYMENT_METHOD_WATA_DESCRIPTION": "через WATA",
|
|
||||||
"PAYMENT_METHOD_WATA_NAME": "💳 <b>Банковская карта (WATA)</b>",
|
|
||||||
"PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "через YooKassa",
|
"PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "через YooKassa",
|
||||||
"PAYMENT_METHOD_YOOKASSA_NAME": "💳 <b>Банковская карта</b>",
|
"PAYMENT_METHOD_YOOKASSA_NAME": "💳 <b>Банковская карта</b>",
|
||||||
"PAYMENT_METHOD_YOOKASSA_SBP_DESCRIPTION": "через систему быстрых платежей YooKassa",
|
"PAYMENT_METHOD_YOOKASSA_SBP_DESCRIPTION": "через систему быстрых платежей YooKassa",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+97
-37
@@ -16,6 +16,79 @@ _logger = logging.getLogger(__name__)
|
|||||||
_cached_rules: Dict[str, str] = {}
|
_cached_rules: Dict[str, str] = {}
|
||||||
|
|
||||||
|
|
||||||
|
_LANGUAGE_ALIASES = {
|
||||||
|
"uk": "ua",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_DYNAMIC_LANGUAGE_CONFIGS = {
|
||||||
|
"ru": {
|
||||||
|
"traffic_pattern": "📊 {size} ГБ - {price}",
|
||||||
|
"unlimited_pattern": "📊 Безлимит - {price}",
|
||||||
|
"support_info": (
|
||||||
|
"\n🛟 <b>Поддержка</b>\n\n"
|
||||||
|
"Это центр тикетов: создавайте обращения, просматривайте ответы и историю.\n\n"
|
||||||
|
"• 🎫 Создать тикет — опишите проблему или вопрос\n"
|
||||||
|
"• 📋 Мои тикеты — статус и переписка\n"
|
||||||
|
"• 💬 Связаться — написать напрямую (если нужно)\n\n"
|
||||||
|
"Старайтесь использовать тикеты — так мы быстрее поможем и ничего не потеряется.\n"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"traffic_pattern": "📊 {size} GB - {price}",
|
||||||
|
"unlimited_pattern": "📊 Unlimited - {price}",
|
||||||
|
"support_info": (
|
||||||
|
"\n🛟 <b>RemnaWave Support</b>\n\n"
|
||||||
|
"This is the ticket center: create requests, view replies and history.\n\n"
|
||||||
|
"• 🎫 Create ticket — describe your issue or question\n"
|
||||||
|
"• 📋 My tickets — status and conversation\n"
|
||||||
|
"• 💬 Contact — message directly if needed\n\n"
|
||||||
|
"Prefer tickets — it helps us respond faster and keep context.\n"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"ua": {
|
||||||
|
"traffic_pattern": "📊 {size} ГБ - {price}",
|
||||||
|
"unlimited_pattern": "📊 Безліміт - {price}",
|
||||||
|
"support_info": (
|
||||||
|
"\n🛠️ <b>Технічна підтримка</b>\n\n"
|
||||||
|
"З усіх питань звертайтеся до нашої підтримки:\n\n"
|
||||||
|
"👤 {support_username}\n\n"
|
||||||
|
"Ми допоможемо з:\n"
|
||||||
|
"• Налаштуванням підключення\n"
|
||||||
|
"• Вирішенням технічних проблем\n"
|
||||||
|
"• Питаннями щодо оплати\n"
|
||||||
|
"• Іншими питаннями\n\n"
|
||||||
|
"⏰ Час відповіді: зазвичай протягом 1-2 годин\n"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"zh": {
|
||||||
|
"traffic_pattern": "📊{size}GB-{price}",
|
||||||
|
"unlimited_pattern": "📊无限-{price}",
|
||||||
|
"support_info": (
|
||||||
|
"\n🛠️ <b>技术支持</b>\n\n"
|
||||||
|
"如有任何问题,请联系我们的支持团队:\n\n"
|
||||||
|
"👤 {support_username}\n\n"
|
||||||
|
"我们将帮助您:\n"
|
||||||
|
"• 设置连接\n"
|
||||||
|
"• 解决技术问题\n"
|
||||||
|
"• 付款问题\n"
|
||||||
|
"• 其他问题\n\n"
|
||||||
|
"⏰ 响应时间:通常在 1-2 小时内\n"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_TRAFFIC_TIERS = (
|
||||||
|
("TRAFFIC_5GB", "5", "PRICE_TRAFFIC_5GB"),
|
||||||
|
("TRAFFIC_10GB", "10", "PRICE_TRAFFIC_10GB"),
|
||||||
|
("TRAFFIC_25GB", "25", "PRICE_TRAFFIC_25GB"),
|
||||||
|
("TRAFFIC_50GB", "50", "PRICE_TRAFFIC_50GB"),
|
||||||
|
("TRAFFIC_100GB", "100", "PRICE_TRAFFIC_100GB"),
|
||||||
|
("TRAFFIC_250GB", "250", "PRICE_TRAFFIC_250GB"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _get_cached_rules_value(language: str) -> str:
|
def _get_cached_rules_value(language: str) -> str:
|
||||||
if language in _cached_rules:
|
if language in _cached_rules:
|
||||||
return _cached_rules[language]
|
return _cached_rules[language]
|
||||||
@@ -28,45 +101,32 @@ def _get_cached_rules_value(language: str) -> str:
|
|||||||
def _build_dynamic_values(language: str) -> Dict[str, Any]:
|
def _build_dynamic_values(language: str) -> Dict[str, Any]:
|
||||||
language_code = (language or DEFAULT_LANGUAGE).split("-")[0].lower()
|
language_code = (language or DEFAULT_LANGUAGE).split("-")[0].lower()
|
||||||
|
|
||||||
if language_code == "ru":
|
language_code = _LANGUAGE_ALIASES.get(language_code, language_code)
|
||||||
return {
|
config = _DYNAMIC_LANGUAGE_CONFIGS.get(language_code)
|
||||||
"TRAFFIC_5GB": f"📊 5 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}",
|
|
||||||
"TRAFFIC_10GB": f"📊 10 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}",
|
|
||||||
"TRAFFIC_25GB": f"📊 25 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_25GB)}",
|
|
||||||
"TRAFFIC_50GB": f"📊 50 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_50GB)}",
|
|
||||||
"TRAFFIC_100GB": f"📊 100 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_100GB)}",
|
|
||||||
"TRAFFIC_250GB": f"📊 250 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_250GB)}",
|
|
||||||
"TRAFFIC_UNLIMITED": f"📊 Безлимит - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}",
|
|
||||||
"SUPPORT_INFO": (
|
|
||||||
"\n🛟 <b>Поддержка</b>\n\n"
|
|
||||||
"Это центр тикетов: создавайте обращения, просматривайте ответы и историю.\n\n"
|
|
||||||
"• 🎫 Создать тикет — опишите проблему или вопрос\n"
|
|
||||||
"• 📋 Мои тикеты — статус и переписка\n"
|
|
||||||
"• 💬 Связаться — написать напрямую (если нужно)\n\n"
|
|
||||||
"Старайтесь использовать тикеты — так мы быстрее поможем и ничего не потеряется.\n"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
if language_code == "en":
|
if not config:
|
||||||
return {
|
return {}
|
||||||
"TRAFFIC_5GB": f"📊 5 GB - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}",
|
|
||||||
"TRAFFIC_10GB": f"📊 10 GB - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}",
|
|
||||||
"TRAFFIC_25GB": f"📊 25 GB - {settings.format_price(settings.PRICE_TRAFFIC_25GB)}",
|
|
||||||
"TRAFFIC_50GB": f"📊 50 GB - {settings.format_price(settings.PRICE_TRAFFIC_50GB)}",
|
|
||||||
"TRAFFIC_100GB": f"📊 100 GB - {settings.format_price(settings.PRICE_TRAFFIC_100GB)}",
|
|
||||||
"TRAFFIC_250GB": f"📊 250 GB - {settings.format_price(settings.PRICE_TRAFFIC_250GB)}",
|
|
||||||
"TRAFFIC_UNLIMITED": f"📊 Unlimited - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}",
|
|
||||||
"SUPPORT_INFO": (
|
|
||||||
"\n🛟 <b>RemnaWave Support</b>\n\n"
|
|
||||||
"This is the ticket center: create requests, view replies and history.\n\n"
|
|
||||||
"• 🎫 Create ticket — describe your issue or question\n"
|
|
||||||
"• 📋 My tickets — status and conversation\n"
|
|
||||||
"• 💬 Contact — message directly if needed\n\n"
|
|
||||||
"Prefer tickets — it helps us respond faster and keep context.\n"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
return {}
|
values: Dict[str, Any] = {}
|
||||||
|
traffic_pattern = config["traffic_pattern"]
|
||||||
|
for key, size, price_attr in _TRAFFIC_TIERS:
|
||||||
|
price_value = getattr(settings, price_attr)
|
||||||
|
values[key] = traffic_pattern.format(
|
||||||
|
size=size,
|
||||||
|
price=settings.format_price(price_value),
|
||||||
|
)
|
||||||
|
|
||||||
|
values["TRAFFIC_UNLIMITED"] = config["unlimited_pattern"].format(
|
||||||
|
price=settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)
|
||||||
|
)
|
||||||
|
|
||||||
|
support_template = config.get("support_info")
|
||||||
|
if support_template:
|
||||||
|
values["SUPPORT_INFO"] = support_template.format(
|
||||||
|
support_username=settings.SUPPORT_USERNAME
|
||||||
|
)
|
||||||
|
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
class Texts:
|
class Texts:
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
|||||||
bot: Bot = data["bot"]
|
bot: Bot = data["bot"]
|
||||||
|
|
||||||
channel_id = settings.CHANNEL_SUB_ID
|
channel_id = settings.CHANNEL_SUB_ID
|
||||||
|
|
||||||
if not channel_id:
|
if not channel_id:
|
||||||
logger.warning("⚠️ CHANNEL_SUB_ID не установлен, пропускаем проверку")
|
logger.warning("⚠️ CHANNEL_SUB_ID не установлен, пропускаем проверку")
|
||||||
return await handler(event, data)
|
return await handler(event, data)
|
||||||
@@ -93,7 +93,12 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
|||||||
logger.debug("⚠️ Обязательная подписка отключена, пропускаем проверку")
|
logger.debug("⚠️ Обязательная подписка отключена, пропускаем проверку")
|
||||||
return await handler(event, data)
|
return await handler(event, data)
|
||||||
|
|
||||||
channel_link = settings.CHANNEL_LINK
|
channel_link = self._normalize_channel_link(settings.CHANNEL_LINK, channel_id)
|
||||||
|
|
||||||
|
if not channel_link:
|
||||||
|
logger.warning(
|
||||||
|
"⚠️ CHANNEL_LINK не задан или невалиден, кнопка подписки будет скрыта"
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
member = await bot.get_chat_member(chat_id=channel_id, user_id=telegram_id)
|
member = await bot.get_chat_member(chat_id=channel_id, user_id=telegram_id)
|
||||||
@@ -112,16 +117,16 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
|||||||
await event.answer("❌ Вы еще не подписались на канал! Подпишитесь и попробуйте снова.", show_alert=True)
|
await event.answer("❌ Вы еще не подписались на канал! Подпишитесь и попробуйте снова.", show_alert=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
return await self._deny_message(event, bot, channel_link)
|
return await self._deny_message(event, bot, channel_link, channel_id)
|
||||||
else:
|
else:
|
||||||
logger.warning(f"⚠️ Неожиданный статус пользователя {telegram_id}: {member.status}")
|
logger.warning(f"⚠️ Неожиданный статус пользователя {telegram_id}: {member.status}")
|
||||||
await self._capture_start_payload(state, event, bot)
|
await self._capture_start_payload(state, event, bot)
|
||||||
return await self._deny_message(event, bot, channel_link)
|
return await self._deny_message(event, bot, channel_link, channel_id)
|
||||||
|
|
||||||
except TelegramForbiddenError as e:
|
except TelegramForbiddenError as e:
|
||||||
logger.error(f"❌ Бот заблокирован в канале {channel_id}: {e}")
|
logger.error(f"❌ Бот заблокирован в канале {channel_id}: {e}")
|
||||||
await self._capture_start_payload(state, event, bot)
|
await self._capture_start_payload(state, event, bot)
|
||||||
return await self._deny_message(event, bot, channel_link)
|
return await self._deny_message(event, bot, channel_link, channel_id)
|
||||||
except TelegramBadRequest as e:
|
except TelegramBadRequest as e:
|
||||||
if "chat not found" in str(e).lower():
|
if "chat not found" in str(e).lower():
|
||||||
logger.error(f"❌ Канал {channel_id} не найден: {e}")
|
logger.error(f"❌ Канал {channel_id} не найден: {e}")
|
||||||
@@ -130,11 +135,29 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
|||||||
else:
|
else:
|
||||||
logger.error(f"❌ Ошибка запроса к каналу {channel_id}: {e}")
|
logger.error(f"❌ Ошибка запроса к каналу {channel_id}: {e}")
|
||||||
await self._capture_start_payload(state, event, bot)
|
await self._capture_start_payload(state, event, bot)
|
||||||
return await self._deny_message(event, bot, channel_link)
|
return await self._deny_message(event, bot, channel_link, channel_id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ Неожиданная ошибка при проверке подписки: {e}")
|
logger.error(f"❌ Неожиданная ошибка при проверке подписки: {e}")
|
||||||
return await handler(event, data)
|
return await handler(event, data)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_channel_link(channel_link: Optional[str], channel_id: Optional[str]) -> Optional[str]:
|
||||||
|
link = (channel_link or "").strip()
|
||||||
|
|
||||||
|
if link.startswith("@"): # raw username
|
||||||
|
return f"https://t.me/{link.lstrip('@')}"
|
||||||
|
|
||||||
|
if link and not link.lower().startswith(("http://", "https://", "tg://")):
|
||||||
|
return f"https://{link}"
|
||||||
|
|
||||||
|
if link:
|
||||||
|
return link
|
||||||
|
|
||||||
|
if channel_id and str(channel_id).startswith("@"):
|
||||||
|
return f"https://t.me/{str(channel_id).lstrip('@')}"
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
async def _capture_start_payload(
|
async def _capture_start_payload(
|
||||||
self,
|
self,
|
||||||
state: Optional[FSMContext],
|
state: Optional[FSMContext],
|
||||||
@@ -278,7 +301,12 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
|||||||
break
|
break
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def _deny_message(event: TelegramObject, bot: Bot, channel_link: str):
|
async def _deny_message(
|
||||||
|
event: TelegramObject,
|
||||||
|
bot: Bot,
|
||||||
|
channel_link: Optional[str],
|
||||||
|
channel_id: Optional[str],
|
||||||
|
):
|
||||||
logger.debug("🚫 Отправляем сообщение о необходимости подписки")
|
logger.debug("🚫 Отправляем сообщение о необходимости подписки")
|
||||||
|
|
||||||
user = None
|
user = None
|
||||||
@@ -301,11 +329,26 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
|||||||
"🔒 Для использования бота подпишитесь на новостной канал, чтобы получать уведомления о новых возможностях и обновлениях бота. Спасибо!",
|
"🔒 Для использования бота подпишитесь на новостной канал, чтобы получать уведомления о новых возможностях и обновлениях бота. Спасибо!",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if not channel_link and channel_id:
|
||||||
|
channel_hint = None
|
||||||
|
|
||||||
|
if str(channel_id).startswith("@"): # username-based channel id
|
||||||
|
channel_hint = f"@{str(channel_id).lstrip('@')}"
|
||||||
|
|
||||||
|
if channel_hint:
|
||||||
|
text = f"{text}\n\n{channel_hint}"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if isinstance(event, Message):
|
if isinstance(event, Message):
|
||||||
return await event.answer(text, reply_markup=channel_sub_kb)
|
return await event.answer(text, reply_markup=channel_sub_kb)
|
||||||
elif isinstance(event, CallbackQuery):
|
elif isinstance(event, CallbackQuery):
|
||||||
return await event.message.edit_text(text, reply_markup=channel_sub_kb)
|
try:
|
||||||
|
return await event.message.edit_text(text, reply_markup=channel_sub_kb)
|
||||||
|
except TelegramBadRequest as e:
|
||||||
|
if "message is not modified" in str(e).lower():
|
||||||
|
logger.debug("ℹ️ Сообщение уже содержит текст проверки подписки, пропускаем редактирование")
|
||||||
|
return await event.answer(text, show_alert=True)
|
||||||
|
raise
|
||||||
elif isinstance(event, Update) and event.message:
|
elif isinstance(event, Update) and event.message:
|
||||||
return await bot.send_message(event.message.chat.id, text, reply_markup=channel_sub_kb)
|
return await bot.send_message(event.message.chat.id, text, reply_markup=channel_sub_kb)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -912,12 +912,17 @@ class MonitoringService:
|
|||||||
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Subscription)
|
select(Subscription)
|
||||||
.options(selectinload(Subscription.user))
|
.options(
|
||||||
|
selectinload(Subscription.user).options(
|
||||||
|
selectinload(User.promo_group),
|
||||||
|
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
|
||||||
|
)
|
||||||
|
)
|
||||||
.where(
|
.where(
|
||||||
and_(
|
and_(
|
||||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||||
Subscription.autopay_enabled == True,
|
Subscription.autopay_enabled == True,
|
||||||
Subscription.is_trial == False
|
Subscription.is_trial == False
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,12 +16,24 @@ from app.database.models import PaymentMethod, TransactionType
|
|||||||
from app.services.subscription_auto_purchase_service import (
|
from app.services.subscription_auto_purchase_service import (
|
||||||
auto_purchase_saved_cart_after_topup,
|
auto_purchase_saved_cart_after_topup,
|
||||||
)
|
)
|
||||||
|
from app.services.subscription_renewal_service import (
|
||||||
|
SubscriptionRenewalChargeError,
|
||||||
|
SubscriptionRenewalPricing,
|
||||||
|
SubscriptionRenewalService,
|
||||||
|
RenewalPaymentDescriptor,
|
||||||
|
build_renewal_period_id,
|
||||||
|
decode_payment_payload,
|
||||||
|
parse_payment_metadata,
|
||||||
|
)
|
||||||
from app.utils.currency_converter import currency_converter
|
from app.utils.currency_converter import currency_converter
|
||||||
from app.utils.user_utils import format_referrer_info
|
from app.utils.user_utils import format_referrer_info
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
renewal_service = SubscriptionRenewalService()
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class _AdminNotificationContext:
|
class _AdminNotificationContext:
|
||||||
user_id: int
|
user_id: int
|
||||||
@@ -173,6 +185,36 @@ class CryptoBotPaymentMixin:
|
|||||||
db, invoice_id, status, paid_at
|
db, invoice_id, status, paid_at
|
||||||
)
|
)
|
||||||
|
|
||||||
|
descriptor = decode_payment_payload(
|
||||||
|
getattr(updated_payment, "payload", "") or "",
|
||||||
|
expected_user_id=updated_payment.user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if descriptor is None:
|
||||||
|
inline_payload = payload.get("payload")
|
||||||
|
if isinstance(inline_payload, str) and inline_payload:
|
||||||
|
descriptor = decode_payment_payload(
|
||||||
|
inline_payload,
|
||||||
|
expected_user_id=updated_payment.user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if descriptor is None:
|
||||||
|
metadata = payload.get("metadata")
|
||||||
|
if isinstance(metadata, dict) and metadata:
|
||||||
|
descriptor = parse_payment_metadata(
|
||||||
|
metadata,
|
||||||
|
expected_user_id=updated_payment.user_id,
|
||||||
|
)
|
||||||
|
if descriptor:
|
||||||
|
renewal_handled = await self._process_subscription_renewal_payment(
|
||||||
|
db,
|
||||||
|
updated_payment,
|
||||||
|
descriptor,
|
||||||
|
cryptobot_crud,
|
||||||
|
)
|
||||||
|
if renewal_handled:
|
||||||
|
return True
|
||||||
|
|
||||||
if not updated_payment.transaction_id:
|
if not updated_payment.transaction_id:
|
||||||
amount_usd = updated_payment.amount_float
|
amount_usd = updated_payment.amount_float
|
||||||
|
|
||||||
@@ -394,6 +436,161 @@ class CryptoBotPaymentMixin:
|
|||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
async def _process_subscription_renewal_payment(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
payment: Any,
|
||||||
|
descriptor: RenewalPaymentDescriptor,
|
||||||
|
cryptobot_crud: Any,
|
||||||
|
) -> bool:
|
||||||
|
try:
|
||||||
|
payment_service_module = import_module("app.services.payment_service")
|
||||||
|
user = await payment_service_module.get_user_by_id(db, payment.user_id)
|
||||||
|
except Exception as error:
|
||||||
|
logger.error(
|
||||||
|
"Не удалось загрузить пользователя %s для продления через CryptoBot: %s",
|
||||||
|
getattr(payment, "user_id", None),
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
logger.error(
|
||||||
|
"Пользователь %s не найден при обработке продления через CryptoBot",
|
||||||
|
getattr(payment, "user_id", None),
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
subscription = getattr(user, "subscription", None)
|
||||||
|
if not subscription or subscription.id != descriptor.subscription_id:
|
||||||
|
logger.warning(
|
||||||
|
"Продление через CryptoBot отклонено: подписка %s не совпадает с ожидаемой %s",
|
||||||
|
getattr(subscription, "id", None),
|
||||||
|
descriptor.subscription_id,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
pricing_model: Optional[SubscriptionRenewalPricing] = None
|
||||||
|
if descriptor.pricing_snapshot:
|
||||||
|
try:
|
||||||
|
pricing_model = SubscriptionRenewalPricing.from_payload(
|
||||||
|
descriptor.pricing_snapshot
|
||||||
|
)
|
||||||
|
except Exception as error:
|
||||||
|
logger.warning(
|
||||||
|
"Не удалось восстановить сохраненную стоимость продления из payload %s: %s",
|
||||||
|
payment.invoice_id,
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
|
||||||
|
if pricing_model is None:
|
||||||
|
try:
|
||||||
|
pricing_model = await renewal_service.calculate_pricing(
|
||||||
|
db,
|
||||||
|
user,
|
||||||
|
subscription,
|
||||||
|
descriptor.period_days,
|
||||||
|
)
|
||||||
|
except Exception as error:
|
||||||
|
logger.error(
|
||||||
|
"Не удалось пересчитать стоимость продления для CryptoBot %s: %s",
|
||||||
|
payment.invoice_id,
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if pricing_model.final_total != descriptor.total_amount_kopeks:
|
||||||
|
logger.warning(
|
||||||
|
"Сумма продления через CryptoBot %s изменилась (ожидалось %s, получено %s)",
|
||||||
|
payment.invoice_id,
|
||||||
|
descriptor.total_amount_kopeks,
|
||||||
|
pricing_model.final_total,
|
||||||
|
)
|
||||||
|
pricing_model.final_total = descriptor.total_amount_kopeks
|
||||||
|
pricing_model.per_month = (
|
||||||
|
descriptor.total_amount_kopeks // pricing_model.months
|
||||||
|
if pricing_model.months
|
||||||
|
else descriptor.total_amount_kopeks
|
||||||
|
)
|
||||||
|
|
||||||
|
pricing_model.period_days = descriptor.period_days
|
||||||
|
pricing_model.period_id = build_renewal_period_id(descriptor.period_days)
|
||||||
|
|
||||||
|
required_balance = max(
|
||||||
|
0,
|
||||||
|
min(
|
||||||
|
pricing_model.final_total,
|
||||||
|
descriptor.balance_component_kopeks,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
current_balance = getattr(user, "balance_kopeks", 0)
|
||||||
|
if current_balance < required_balance:
|
||||||
|
logger.warning(
|
||||||
|
"Недостаточно средств на балансе пользователя %s для завершения продления: нужно %s, доступно %s",
|
||||||
|
user.id,
|
||||||
|
required_balance,
|
||||||
|
current_balance,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
description = f"Продление подписки на {descriptor.period_days} дней"
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await renewal_service.finalize(
|
||||||
|
db,
|
||||||
|
user,
|
||||||
|
subscription,
|
||||||
|
pricing_model,
|
||||||
|
charge_balance_amount=required_balance,
|
||||||
|
description=description,
|
||||||
|
payment_method=PaymentMethod.CRYPTOBOT,
|
||||||
|
)
|
||||||
|
except SubscriptionRenewalChargeError as error:
|
||||||
|
logger.error(
|
||||||
|
"Списание баланса не выполнено при продлении через CryptoBot %s: %s",
|
||||||
|
payment.invoice_id,
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
except Exception as error:
|
||||||
|
logger.error(
|
||||||
|
"Ошибка завершения продления через CryptoBot %s: %s",
|
||||||
|
payment.invoice_id,
|
||||||
|
error,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
transaction = result.transaction
|
||||||
|
if transaction:
|
||||||
|
try:
|
||||||
|
await cryptobot_crud.link_cryptobot_payment_to_transaction(
|
||||||
|
db,
|
||||||
|
payment.invoice_id,
|
||||||
|
transaction.id,
|
||||||
|
)
|
||||||
|
except Exception as error:
|
||||||
|
logger.warning(
|
||||||
|
"Не удалось связать платеж CryptoBot %s с транзакцией %s: %s",
|
||||||
|
payment.invoice_id,
|
||||||
|
transaction.id,
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
|
||||||
|
external_amount_label = settings.format_price(descriptor.missing_amount_kopeks)
|
||||||
|
balance_amount_label = settings.format_price(required_balance)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Подписка %s продлена через CryptoBot invoice %s (внешний платеж %s, списано с баланса %s)",
|
||||||
|
subscription.id,
|
||||||
|
payment.invoice_id,
|
||||||
|
external_amount_label,
|
||||||
|
balance_amount_label,
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
async def _deliver_admin_topup_notification(
|
async def _deliver_admin_topup_notification(
|
||||||
self, context: _AdminNotificationContext
|
self, context: _AdminNotificationContext
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -255,6 +255,42 @@ class HeleketPaymentMixin:
|
|||||||
if updated_payment is None:
|
if updated_payment is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
metadata = dict(getattr(updated_payment, "metadata_json", {}) or {})
|
||||||
|
invoice_message = metadata.get("invoice_message") or {}
|
||||||
|
invoice_message_removed = False
|
||||||
|
|
||||||
|
if getattr(self, "bot", None) and invoice_message:
|
||||||
|
chat_id = invoice_message.get("chat_id")
|
||||||
|
message_id = invoice_message.get("message_id")
|
||||||
|
if chat_id and message_id:
|
||||||
|
try:
|
||||||
|
await self.bot.delete_message(chat_id, message_id)
|
||||||
|
except Exception as delete_error: # pragma: no cover - depends on rights
|
||||||
|
logger.warning(
|
||||||
|
"Не удалось удалить счёт Heleket %s: %s",
|
||||||
|
message_id,
|
||||||
|
delete_error,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
metadata.pop("invoice_message", None)
|
||||||
|
invoice_message_removed = True
|
||||||
|
|
||||||
|
if invoice_message_removed:
|
||||||
|
try:
|
||||||
|
from app.database.crud import heleket as heleket_crud
|
||||||
|
|
||||||
|
await heleket_crud.update_heleket_payment(
|
||||||
|
db,
|
||||||
|
updated_payment.uuid,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
updated_payment.metadata_json = metadata
|
||||||
|
except Exception as error: # pragma: no cover - diagnostics
|
||||||
|
logger.warning(
|
||||||
|
"Не удалось обновить метаданные Heleket после удаления счёта: %s",
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
|
||||||
if updated_payment.transaction_id:
|
if updated_payment.transaction_id:
|
||||||
logger.info(
|
logger.info(
|
||||||
"Heleket платеж %s уже связан с транзакцией %s",
|
"Heleket платеж %s уже связан с транзакцией %s",
|
||||||
|
|||||||
@@ -182,7 +182,43 @@ class MulenPayPaymentMixin:
|
|||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
metadata = dict(getattr(payment, "metadata_json", {}) or {})
|
||||||
|
invoice_message = metadata.get("invoice_message") or {}
|
||||||
|
|
||||||
|
invoice_message_removed = False
|
||||||
|
|
||||||
|
if getattr(self, "bot", None):
|
||||||
|
chat_id = invoice_message.get("chat_id")
|
||||||
|
message_id = invoice_message.get("message_id")
|
||||||
|
if chat_id and message_id:
|
||||||
|
try:
|
||||||
|
await self.bot.delete_message(chat_id, message_id)
|
||||||
|
except Exception as delete_error: # pragma: no cover - depends on bot rights
|
||||||
|
logger.warning(
|
||||||
|
"Не удалось удалить %s счёт %s: %s",
|
||||||
|
display_name,
|
||||||
|
message_id,
|
||||||
|
delete_error,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
metadata.pop("invoice_message", None)
|
||||||
|
invoice_message_removed = True
|
||||||
|
|
||||||
if payment.is_paid:
|
if payment.is_paid:
|
||||||
|
if invoice_message_removed:
|
||||||
|
try:
|
||||||
|
await payment_module.update_mulenpay_payment_metadata(
|
||||||
|
db,
|
||||||
|
payment=payment,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
except Exception as error: # pragma: no cover - diagnostics
|
||||||
|
logger.warning(
|
||||||
|
"Не удалось обновить метаданные %s после удаления счёта: %s",
|
||||||
|
display_name,
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"%s платеж %s уже обработан, игнорируем повторный callback",
|
"%s платеж %s уже обработан, игнорируем повторный callback",
|
||||||
display_name,
|
display_name,
|
||||||
@@ -197,6 +233,7 @@ class MulenPayPaymentMixin:
|
|||||||
status="success",
|
status="success",
|
||||||
callback_payload=callback_data,
|
callback_payload=callback_data,
|
||||||
mulen_payment_id=mulen_payment_id_int,
|
mulen_payment_id=mulen_payment_id_int,
|
||||||
|
metadata=metadata,
|
||||||
)
|
)
|
||||||
|
|
||||||
if payment.transaction_id:
|
if payment.transaction_id:
|
||||||
|
|||||||
@@ -333,6 +333,41 @@ class Pal24PaymentMixin:
|
|||||||
|
|
||||||
payment_module = import_module("app.services.payment_service")
|
payment_module = import_module("app.services.payment_service")
|
||||||
|
|
||||||
|
metadata = dict(getattr(payment, "metadata_json", {}) or {})
|
||||||
|
invoice_message = metadata.get("invoice_message") or {}
|
||||||
|
invoice_message_removed = False
|
||||||
|
|
||||||
|
if getattr(self, "bot", None) and invoice_message:
|
||||||
|
chat_id = invoice_message.get("chat_id")
|
||||||
|
message_id = invoice_message.get("message_id")
|
||||||
|
if chat_id and message_id:
|
||||||
|
try:
|
||||||
|
await self.bot.delete_message(chat_id, message_id)
|
||||||
|
except Exception as delete_error: # pragma: no cover - depends on rights
|
||||||
|
logger.warning(
|
||||||
|
"Не удалось удалить счёт PayPalych %s: %s",
|
||||||
|
message_id,
|
||||||
|
delete_error,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
metadata.pop("invoice_message", None)
|
||||||
|
invoice_message_removed = True
|
||||||
|
|
||||||
|
if invoice_message_removed:
|
||||||
|
try:
|
||||||
|
await payment_module.update_pal24_payment_status(
|
||||||
|
db,
|
||||||
|
payment,
|
||||||
|
status=payment.status,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
payment.metadata_json = metadata
|
||||||
|
except Exception as error: # pragma: no cover - diagnostics
|
||||||
|
logger.warning(
|
||||||
|
"Не удалось обновить метаданные PayPalych после удаления счёта: %s",
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
|
||||||
if payment.transaction_id:
|
if payment.transaction_id:
|
||||||
logger.info(
|
logger.info(
|
||||||
"Pal24 платеж %s уже привязан к транзакции (trigger=%s)",
|
"Pal24 платеж %s уже привязан к транзакции (trigger=%s)",
|
||||||
|
|||||||
@@ -306,6 +306,22 @@ class PlategaPaymentMixin:
|
|||||||
metadata = dict(getattr(payment, "metadata_json", {}) or {})
|
metadata = dict(getattr(payment, "metadata_json", {}) or {})
|
||||||
balance_already_credited = bool(metadata.get("balance_credited"))
|
balance_already_credited = bool(metadata.get("balance_credited"))
|
||||||
|
|
||||||
|
invoice_message = metadata.get("invoice_message") or {}
|
||||||
|
if getattr(self, "bot", None):
|
||||||
|
chat_id = invoice_message.get("chat_id")
|
||||||
|
message_id = invoice_message.get("message_id")
|
||||||
|
if chat_id and message_id:
|
||||||
|
try:
|
||||||
|
await self.bot.delete_message(chat_id, message_id)
|
||||||
|
except Exception as delete_error: # pragma: no cover - depends on bot rights
|
||||||
|
logger.warning(
|
||||||
|
"Не удалось удалить Platega счёт %s: %s",
|
||||||
|
message_id,
|
||||||
|
delete_error,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
metadata.pop("invoice_message", None)
|
||||||
|
|
||||||
if payment.transaction_id:
|
if payment.transaction_id:
|
||||||
logger.info(
|
logger.info(
|
||||||
"Platega платеж %s уже связан с транзакцией %s",
|
"Platega платеж %s уже связан с транзакцией %s",
|
||||||
|
|||||||
@@ -515,36 +515,6 @@ class TelegramStarsMixin:
|
|||||||
exc_info=True,
|
exc_info=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
if getattr(self, "bot", None):
|
|
||||||
try:
|
|
||||||
keyboard = await self.build_topup_success_keyboard(user)
|
|
||||||
|
|
||||||
charge_id_short = (telegram_payment_charge_id or getattr(transaction, "external_id", ""))[:8]
|
|
||||||
|
|
||||||
await self.bot.send_message(
|
|
||||||
user.telegram_id,
|
|
||||||
(
|
|
||||||
"✅ <b>Пополнение успешно!</b>\n\n"
|
|
||||||
f"⭐ Звезд: {stars_amount}\n"
|
|
||||||
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
|
|
||||||
"🦊 Способ: Telegram Stars\n"
|
|
||||||
f"🆔 Транзакция: {charge_id_short}...\n\n"
|
|
||||||
"Баланс пополнен автоматически!"
|
|
||||||
),
|
|
||||||
parse_mode="HTML",
|
|
||||||
reply_markup=keyboard,
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
"✅ Отправлено уведомление пользователю %s о пополнении на %s",
|
|
||||||
user.telegram_id,
|
|
||||||
settings.format_price(amount_kopeks),
|
|
||||||
)
|
|
||||||
except Exception as error: # pragma: no cover - диагностический лог
|
|
||||||
logger.error(
|
|
||||||
"Ошибка отправки уведомления о пополнении Stars: %s",
|
|
||||||
error,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Проверяем наличие сохраненной корзины для возврата к оформлению подписки
|
# Проверяем наличие сохраненной корзины для возврата к оформлению подписки
|
||||||
try:
|
try:
|
||||||
from aiogram import types
|
from aiogram import types
|
||||||
|
|||||||
@@ -415,6 +415,25 @@ class WataPaymentMixin:
|
|||||||
if not paid_at and getattr(payment, "paid_at", None):
|
if not paid_at and getattr(payment, "paid_at", None):
|
||||||
paid_at = payment.paid_at
|
paid_at = payment.paid_at
|
||||||
existing_metadata = dict(getattr(payment, "metadata_json", {}) or {})
|
existing_metadata = dict(getattr(payment, "metadata_json", {}) or {})
|
||||||
|
|
||||||
|
invoice_message = existing_metadata.get("invoice_message") or {}
|
||||||
|
invoice_message_removed = False
|
||||||
|
if getattr(self, "bot", None) and invoice_message:
|
||||||
|
chat_id = invoice_message.get("chat_id")
|
||||||
|
message_id = invoice_message.get("message_id")
|
||||||
|
if chat_id and message_id:
|
||||||
|
try:
|
||||||
|
await self.bot.delete_message(chat_id, message_id)
|
||||||
|
except Exception as delete_error: # pragma: no cover - depends on rights
|
||||||
|
logger.warning(
|
||||||
|
"Не удалось удалить счёт WATA %s: %s",
|
||||||
|
message_id,
|
||||||
|
delete_error,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
invoice_message_removed = True
|
||||||
|
existing_metadata.pop("invoice_message", None)
|
||||||
|
|
||||||
existing_metadata["transaction"] = transaction_payload
|
existing_metadata["transaction"] = transaction_payload
|
||||||
|
|
||||||
await payment_module.update_wata_payment_status(
|
await payment_module.update_wata_payment_status(
|
||||||
|
|||||||
@@ -383,11 +383,19 @@ class YooKassaPaymentMixin:
|
|||||||
payment_module = import_module("app.services.payment_service")
|
payment_module = import_module("app.services.payment_service")
|
||||||
|
|
||||||
# Проверяем, не обрабатывается ли уже этот платеж (защита от дублирования)
|
# Проверяем, не обрабатывается ли уже этот платеж (защита от дублирования)
|
||||||
existing_transaction = await payment_module.get_transaction_by_external_id( # type: ignore[attr-defined]
|
get_transaction_by_external_id = getattr(
|
||||||
db,
|
payment_module, "get_transaction_by_external_id", None
|
||||||
payment.yookassa_payment_id,
|
|
||||||
PaymentMethod.YOOKASSA,
|
|
||||||
)
|
)
|
||||||
|
existing_transaction = None
|
||||||
|
if get_transaction_by_external_id:
|
||||||
|
try:
|
||||||
|
existing_transaction = await get_transaction_by_external_id( # type: ignore[attr-defined]
|
||||||
|
db,
|
||||||
|
payment.yookassa_payment_id,
|
||||||
|
PaymentMethod.YOOKASSA,
|
||||||
|
)
|
||||||
|
except AttributeError:
|
||||||
|
logger.debug("get_transaction_by_external_id недоступен, пропускаем проверку дубликатов")
|
||||||
|
|
||||||
if existing_transaction:
|
if existing_transaction:
|
||||||
# Если транзакция уже существует, просто завершаем обработку
|
# Если транзакция уже существует, просто завершаем обработку
|
||||||
@@ -437,6 +445,22 @@ class YooKassaPaymentMixin:
|
|||||||
except Exception as parse_error:
|
except Exception as parse_error:
|
||||||
logger.error(f"Ошибка парсинга метаданных платежа: {parse_error}")
|
logger.error(f"Ошибка парсинга метаданных платежа: {parse_error}")
|
||||||
|
|
||||||
|
invoice_message = payment_metadata.get("invoice_message") or {}
|
||||||
|
if getattr(self, "bot", None):
|
||||||
|
chat_id = invoice_message.get("chat_id")
|
||||||
|
message_id = invoice_message.get("message_id")
|
||||||
|
if chat_id and message_id:
|
||||||
|
try:
|
||||||
|
await self.bot.delete_message(chat_id, message_id)
|
||||||
|
except Exception as delete_error: # pragma: no cover - depends on bot rights
|
||||||
|
logger.warning(
|
||||||
|
"Не удалось удалить сообщение YooKassa %s: %s",
|
||||||
|
message_id,
|
||||||
|
delete_error,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
payment_metadata.pop("invoice_message", None)
|
||||||
|
|
||||||
processing_completed = bool(payment_metadata.get("processing_completed"))
|
processing_completed = bool(payment_metadata.get("processing_completed"))
|
||||||
|
|
||||||
transaction = None
|
transaction = None
|
||||||
@@ -472,11 +496,20 @@ class YooKassaPaymentMixin:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if transaction is None:
|
if transaction is None:
|
||||||
existing_transaction = await payment_module.get_transaction_by_external_id( # type: ignore[attr-defined]
|
get_transaction_by_external_id = getattr(
|
||||||
db,
|
payment_module, "get_transaction_by_external_id", None
|
||||||
payment.yookassa_payment_id,
|
|
||||||
PaymentMethod.YOOKASSA,
|
|
||||||
)
|
)
|
||||||
|
existing_transaction = None
|
||||||
|
|
||||||
|
if get_transaction_by_external_id:
|
||||||
|
try:
|
||||||
|
existing_transaction = await get_transaction_by_external_id( # type: ignore[attr-defined]
|
||||||
|
db,
|
||||||
|
payment.yookassa_payment_id,
|
||||||
|
PaymentMethod.YOOKASSA,
|
||||||
|
)
|
||||||
|
except AttributeError:
|
||||||
|
logger.debug("get_transaction_by_external_id недоступен, пропускаем проверку дубликатов")
|
||||||
|
|
||||||
if existing_transaction:
|
if existing_transaction:
|
||||||
# Если транзакция уже существует, пропускаем обработку
|
# Если транзакция уже существует, пропускаем обработку
|
||||||
|
|||||||
@@ -112,6 +112,11 @@ async def update_mulenpay_payment_status(*args, **kwargs):
|
|||||||
return await mulenpay_crud.update_mulenpay_payment_status(*args, **kwargs)
|
return await mulenpay_crud.update_mulenpay_payment_status(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_mulenpay_payment_metadata(*args, **kwargs):
|
||||||
|
mulenpay_crud = import_module("app.database.crud.mulenpay")
|
||||||
|
return await mulenpay_crud.update_mulenpay_payment_metadata(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
async def link_mulenpay_payment_to_transaction(*args, **kwargs):
|
async def link_mulenpay_payment_to_transaction(*args, **kwargs):
|
||||||
mulenpay_crud = import_module("app.database.crud.mulenpay")
|
mulenpay_crud = import_module("app.database.crud.mulenpay")
|
||||||
return await mulenpay_crud.link_mulenpay_payment_to_transaction(*args, **kwargs)
|
return await mulenpay_crud.link_mulenpay_payment_to_transaction(*args, **kwargs)
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ class PlategaService:
|
|||||||
self._max_retries = 3
|
self._max_retries = 3
|
||||||
self._retry_delay = 0.5
|
self._retry_delay = 0.5
|
||||||
self._retryable_statuses = {500, 502, 503, 504}
|
self._retryable_statuses = {500, 502, 503, 504}
|
||||||
|
self._description_max_length = 64
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_configured(self) -> bool:
|
def is_configured(self) -> bool:
|
||||||
@@ -51,7 +52,10 @@ class PlategaService:
|
|||||||
}
|
}
|
||||||
|
|
||||||
if description:
|
if description:
|
||||||
body["description"] = description
|
sanitized_description = self._sanitize_description(
|
||||||
|
description, self._description_max_length
|
||||||
|
)
|
||||||
|
body["description"] = sanitized_description
|
||||||
if return_url:
|
if return_url:
|
||||||
body["return"] = return_url
|
body["return"] = return_url
|
||||||
if failed_url:
|
if failed_url:
|
||||||
@@ -176,6 +180,32 @@ class PlategaService:
|
|||||||
|
|
||||||
return None, raw_text
|
return None, raw_text
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _sanitize_description(description: str, max_bytes: int) -> str:
|
||||||
|
"""Обрезает описание с учётом байтового лимита Platega."""
|
||||||
|
|
||||||
|
cleaned = (description or "").strip()
|
||||||
|
if not max_bytes:
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
encoded = cleaned.encode("utf-8")
|
||||||
|
if len(encoded) <= max_bytes:
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"Platega description trimmed from %s to %s bytes",
|
||||||
|
len(encoded),
|
||||||
|
max_bytes,
|
||||||
|
)
|
||||||
|
|
||||||
|
trimmed_bytes = encoded[:max_bytes]
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
return trimmed_bytes.decode("utf-8")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
trimmed_bytes = trimmed_bytes[:-1]
|
||||||
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def parse_expires_at(expires_in: Optional[str]) -> Optional[datetime]:
|
def parse_expires_at(expires_in: Optional[str]) -> Optional[datetime]:
|
||||||
if not expires_in:
|
if not expires_in:
|
||||||
|
|||||||
@@ -0,0 +1,564 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Awaitable, Callable, Dict, List, Optional
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from aiogram import Bot
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.database.crud.server_squad import get_server_ids_by_uuids
|
||||||
|
from app.database.crud.subscription import (
|
||||||
|
add_subscription_servers,
|
||||||
|
calculate_subscription_total_cost,
|
||||||
|
extend_subscription,
|
||||||
|
)
|
||||||
|
from app.database.crud.transaction import create_transaction
|
||||||
|
from app.database.crud.user import subtract_user_balance
|
||||||
|
from app.database.models import PaymentMethod, Subscription, Transaction, TransactionType, User
|
||||||
|
from app.services.admin_notification_service import AdminNotificationService
|
||||||
|
from app.services.remnawave_service import RemnaWaveConfigurationError
|
||||||
|
from app.services.subscription_service import SubscriptionService
|
||||||
|
from app.utils.pricing_utils import (
|
||||||
|
apply_percentage_discount,
|
||||||
|
calculate_months_from_days,
|
||||||
|
format_period_description,
|
||||||
|
validate_pricing_calculation,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionRenewalError(Exception):
|
||||||
|
"""Base class for subscription renewal related errors."""
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionRenewalChargeError(SubscriptionRenewalError):
|
||||||
|
"""Raised when the balance charge step fails."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class SubscriptionRenewalPricing:
|
||||||
|
period_days: int
|
||||||
|
period_id: str
|
||||||
|
months: int
|
||||||
|
base_original_total: int
|
||||||
|
discounted_total: int
|
||||||
|
final_total: int
|
||||||
|
promo_discount_value: int
|
||||||
|
promo_discount_percent: int
|
||||||
|
overall_discount_percent: int
|
||||||
|
per_month: int
|
||||||
|
server_ids: List[int]
|
||||||
|
details: Dict[str, Any]
|
||||||
|
|
||||||
|
def to_payload(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"period_id": self.period_id,
|
||||||
|
"period_days": self.period_days,
|
||||||
|
"months": self.months,
|
||||||
|
"base_original_total": self.base_original_total,
|
||||||
|
"discounted_total": self.discounted_total,
|
||||||
|
"final_total": self.final_total,
|
||||||
|
"promo_discount_value": self.promo_discount_value,
|
||||||
|
"promo_discount_percent": self.promo_discount_percent,
|
||||||
|
"overall_discount_percent": self.overall_discount_percent,
|
||||||
|
"per_month": self.per_month,
|
||||||
|
"server_ids": list(self.server_ids),
|
||||||
|
"details": dict(self.details),
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_payload(cls, payload: Dict[str, Any]) -> "SubscriptionRenewalPricing":
|
||||||
|
return cls(
|
||||||
|
period_days=int(payload.get("period_days", 0) or 0),
|
||||||
|
period_id=str(payload.get("period_id") or build_renewal_period_id(int(payload.get("period_days", 0) or 0))),
|
||||||
|
months=int(payload.get("months", 0) or 0),
|
||||||
|
base_original_total=int(payload.get("base_original_total", 0) or 0),
|
||||||
|
discounted_total=int(payload.get("discounted_total", 0) or 0),
|
||||||
|
final_total=int(payload.get("final_total", 0) or 0),
|
||||||
|
promo_discount_value=int(payload.get("promo_discount_value", 0) or 0),
|
||||||
|
promo_discount_percent=int(payload.get("promo_discount_percent", 0) or 0),
|
||||||
|
overall_discount_percent=int(payload.get("overall_discount_percent", 0) or 0),
|
||||||
|
per_month=int(payload.get("per_month", 0) or 0),
|
||||||
|
server_ids=list(payload.get("server_ids", []) or []),
|
||||||
|
details=dict(payload.get("details", {}) or {}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class SubscriptionRenewalResult:
|
||||||
|
subscription: Subscription
|
||||||
|
transaction: Optional[Transaction]
|
||||||
|
total_amount_kopeks: int
|
||||||
|
charged_from_balance_kopeks: int
|
||||||
|
old_end_date: Optional[datetime]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class RenewalPaymentDescriptor:
|
||||||
|
user_id: int
|
||||||
|
subscription_id: int
|
||||||
|
period_days: int
|
||||||
|
total_amount_kopeks: int
|
||||||
|
missing_amount_kopeks: int
|
||||||
|
payload_id: str
|
||||||
|
pricing_snapshot: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def balance_component_kopeks(self) -> int:
|
||||||
|
remaining = self.total_amount_kopeks - self.missing_amount_kopeks
|
||||||
|
return max(0, remaining)
|
||||||
|
|
||||||
|
|
||||||
|
_PAYLOAD_PREFIX = "subscription_renewal"
|
||||||
|
|
||||||
|
|
||||||
|
def build_renewal_period_id(period_days: int) -> str:
|
||||||
|
return f"days:{period_days}"
|
||||||
|
|
||||||
|
|
||||||
|
def build_payment_descriptor(
|
||||||
|
user_id: int,
|
||||||
|
subscription_id: int,
|
||||||
|
period_days: int,
|
||||||
|
total_amount_kopeks: int,
|
||||||
|
missing_amount_kopeks: int,
|
||||||
|
*,
|
||||||
|
pricing_snapshot: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> RenewalPaymentDescriptor:
|
||||||
|
return RenewalPaymentDescriptor(
|
||||||
|
user_id=user_id,
|
||||||
|
subscription_id=subscription_id,
|
||||||
|
period_days=period_days,
|
||||||
|
total_amount_kopeks=max(0, int(total_amount_kopeks)),
|
||||||
|
missing_amount_kopeks=max(0, int(missing_amount_kopeks)),
|
||||||
|
payload_id=uuid4().hex[:8],
|
||||||
|
pricing_snapshot=pricing_snapshot or None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def encode_payment_payload(descriptor: RenewalPaymentDescriptor) -> str:
|
||||||
|
snapshot_segment = ""
|
||||||
|
if descriptor.pricing_snapshot:
|
||||||
|
try:
|
||||||
|
raw_snapshot = json.dumps(
|
||||||
|
descriptor.pricing_snapshot,
|
||||||
|
separators=(",", ":"),
|
||||||
|
ensure_ascii=False,
|
||||||
|
).encode("utf-8")
|
||||||
|
snapshot_segment = base64.urlsafe_b64encode(raw_snapshot).decode("ascii").rstrip("=")
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
snapshot_segment = ""
|
||||||
|
|
||||||
|
payload = (
|
||||||
|
f"{_PAYLOAD_PREFIX}|{descriptor.user_id}|{descriptor.subscription_id}|"
|
||||||
|
f"{descriptor.period_days}|{descriptor.total_amount_kopeks}|"
|
||||||
|
f"{descriptor.missing_amount_kopeks}|{descriptor.payload_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if snapshot_segment:
|
||||||
|
payload = f"{payload}|{snapshot_segment}"
|
||||||
|
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def decode_payment_payload(payload: str, expected_user_id: Optional[int] = None) -> Optional[RenewalPaymentDescriptor]:
|
||||||
|
if not payload or not payload.startswith(f"{_PAYLOAD_PREFIX}|"):
|
||||||
|
return None
|
||||||
|
|
||||||
|
parts = payload.split("|")
|
||||||
|
if len(parts) < 7:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
(
|
||||||
|
_,
|
||||||
|
user_id_raw,
|
||||||
|
subscription_raw,
|
||||||
|
period_raw,
|
||||||
|
total_raw,
|
||||||
|
missing_raw,
|
||||||
|
payload_id,
|
||||||
|
*snapshot_parts,
|
||||||
|
) = parts
|
||||||
|
user_id = int(user_id_raw)
|
||||||
|
subscription_id = int(subscription_raw)
|
||||||
|
period_days = int(period_raw)
|
||||||
|
total_amount = int(total_raw)
|
||||||
|
missing_amount = int(missing_raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
pricing_snapshot: Optional[Dict[str, Any]] = None
|
||||||
|
if snapshot_parts:
|
||||||
|
encoded_snapshot = snapshot_parts[0]
|
||||||
|
if encoded_snapshot:
|
||||||
|
padding = "=" * (-len(encoded_snapshot) % 4)
|
||||||
|
try:
|
||||||
|
decoded = base64.urlsafe_b64decode((encoded_snapshot + padding).encode("ascii"))
|
||||||
|
snapshot_data = json.loads(decoded.decode("utf-8"))
|
||||||
|
if isinstance(snapshot_data, dict):
|
||||||
|
pricing_snapshot = snapshot_data
|
||||||
|
except (ValueError, json.JSONDecodeError, UnicodeDecodeError):
|
||||||
|
logger.warning("Failed to decode renewal pricing snapshot from payload")
|
||||||
|
|
||||||
|
if expected_user_id is not None and user_id != expected_user_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return RenewalPaymentDescriptor(
|
||||||
|
user_id=user_id,
|
||||||
|
subscription_id=subscription_id,
|
||||||
|
period_days=period_days,
|
||||||
|
total_amount_kopeks=max(0, total_amount),
|
||||||
|
missing_amount_kopeks=max(0, missing_amount),
|
||||||
|
payload_id=payload_id,
|
||||||
|
pricing_snapshot=pricing_snapshot,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_payment_metadata(descriptor: RenewalPaymentDescriptor) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"payment_purpose": _PAYLOAD_PREFIX,
|
||||||
|
"subscription_id": str(descriptor.subscription_id),
|
||||||
|
"period_days": str(descriptor.period_days),
|
||||||
|
"total_amount_kopeks": str(descriptor.total_amount_kopeks),
|
||||||
|
"missing_amount_kopeks": str(descriptor.missing_amount_kopeks),
|
||||||
|
"payload_id": descriptor.payload_id,
|
||||||
|
"pricing_snapshot": descriptor.pricing_snapshot or {},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_payment_metadata(
|
||||||
|
metadata: Optional[Dict[str, Any]],
|
||||||
|
*,
|
||||||
|
expected_user_id: Optional[int] = None,
|
||||||
|
) -> Optional[RenewalPaymentDescriptor]:
|
||||||
|
if not metadata:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if metadata.get("payment_purpose") != _PAYLOAD_PREFIX:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
subscription_id = int(metadata.get("subscription_id"))
|
||||||
|
period_days = int(metadata.get("period_days"))
|
||||||
|
total_amount = int(metadata.get("total_amount_kopeks"))
|
||||||
|
missing_amount = int(metadata.get("missing_amount_kopeks"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
payload_id = str(metadata.get("payload_id") or "")
|
||||||
|
user_id = metadata.get("user_id")
|
||||||
|
if user_id is not None:
|
||||||
|
try:
|
||||||
|
user_id_int = int(user_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
user_id_int = None
|
||||||
|
else:
|
||||||
|
user_id_int = None
|
||||||
|
|
||||||
|
if expected_user_id is not None and user_id_int is not None and user_id_int != expected_user_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
pricing_snapshot = metadata.get("pricing_snapshot")
|
||||||
|
if isinstance(pricing_snapshot, dict):
|
||||||
|
snapshot_dict = pricing_snapshot
|
||||||
|
else:
|
||||||
|
snapshot_dict = None
|
||||||
|
|
||||||
|
return RenewalPaymentDescriptor(
|
||||||
|
user_id=user_id_int or expected_user_id or 0,
|
||||||
|
subscription_id=subscription_id,
|
||||||
|
period_days=period_days,
|
||||||
|
total_amount_kopeks=max(0, total_amount),
|
||||||
|
missing_amount_kopeks=max(0, missing_amount),
|
||||||
|
payload_id=payload_id,
|
||||||
|
pricing_snapshot=snapshot_dict,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def with_admin_notification_service(
|
||||||
|
handler: Callable[[AdminNotificationService], Awaitable[Any]],
|
||||||
|
) -> None:
|
||||||
|
if not getattr(settings, "ADMIN_NOTIFICATIONS_ENABLED", False):
|
||||||
|
return
|
||||||
|
if not settings.BOT_TOKEN:
|
||||||
|
logger.debug("Skipping admin notification: bot token is not configured")
|
||||||
|
return
|
||||||
|
|
||||||
|
bot: Bot | None = None
|
||||||
|
try:
|
||||||
|
bot = Bot(token=settings.BOT_TOKEN)
|
||||||
|
service = AdminNotificationService(bot)
|
||||||
|
await handler(service)
|
||||||
|
except Exception as error: # pragma: no cover - defensive logging
|
||||||
|
logger.error("Failed to send admin notification from renewal service: %s", error)
|
||||||
|
finally:
|
||||||
|
if bot is not None:
|
||||||
|
await bot.session.close()
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionRenewalService:
|
||||||
|
"""Shared helpers for subscription renewal pricing and processing."""
|
||||||
|
|
||||||
|
async def calculate_pricing(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
user: User,
|
||||||
|
subscription: Subscription,
|
||||||
|
period_days: int,
|
||||||
|
) -> SubscriptionRenewalPricing:
|
||||||
|
connected_uuids = [str(uuid) for uuid in list(subscription.connected_squads or [])]
|
||||||
|
server_ids: List[int] = []
|
||||||
|
if connected_uuids:
|
||||||
|
server_ids = await get_server_ids_by_uuids(db, connected_uuids)
|
||||||
|
|
||||||
|
traffic_limit = subscription.traffic_limit_gb
|
||||||
|
if traffic_limit is None:
|
||||||
|
traffic_limit = settings.DEFAULT_TRAFFIC_LIMIT_GB
|
||||||
|
|
||||||
|
devices_limit = subscription.device_limit
|
||||||
|
if devices_limit is None:
|
||||||
|
devices_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||||
|
|
||||||
|
total_cost, details = await calculate_subscription_total_cost(
|
||||||
|
db,
|
||||||
|
period_days,
|
||||||
|
int(traffic_limit or 0),
|
||||||
|
server_ids,
|
||||||
|
int(devices_limit or 0),
|
||||||
|
user=user,
|
||||||
|
)
|
||||||
|
|
||||||
|
months = details.get("months_in_period") or calculate_months_from_days(period_days)
|
||||||
|
|
||||||
|
base_original_total = (
|
||||||
|
details.get("base_price_original", 0)
|
||||||
|
+ details.get("traffic_price_per_month", 0) * months
|
||||||
|
+ details.get("servers_price_per_month", 0) * months
|
||||||
|
+ details.get("devices_price_per_month", 0) * months
|
||||||
|
)
|
||||||
|
|
||||||
|
discounted_total = total_cost
|
||||||
|
|
||||||
|
monthly_additions = 0
|
||||||
|
if months > 0:
|
||||||
|
monthly_additions = (
|
||||||
|
details.get("total_servers_price", 0) // months
|
||||||
|
+ details.get("total_devices_price", 0) // months
|
||||||
|
+ details.get("total_traffic_price", 0) // months
|
||||||
|
)
|
||||||
|
|
||||||
|
if not validate_pricing_calculation(
|
||||||
|
details.get("base_price", 0),
|
||||||
|
monthly_additions,
|
||||||
|
months,
|
||||||
|
discounted_total,
|
||||||
|
):
|
||||||
|
logger.warning(
|
||||||
|
"Renewal pricing validation failed for subscription %s (period %s)",
|
||||||
|
subscription.id,
|
||||||
|
period_days,
|
||||||
|
)
|
||||||
|
|
||||||
|
from app.utils.promo_offer import get_user_active_promo_discount_percent
|
||||||
|
|
||||||
|
promo_percent = get_user_active_promo_discount_percent(user)
|
||||||
|
|
||||||
|
final_total = discounted_total
|
||||||
|
promo_discount_value = 0
|
||||||
|
if promo_percent > 0 and discounted_total > 0:
|
||||||
|
final_total, promo_discount_value = apply_percentage_discount(
|
||||||
|
discounted_total,
|
||||||
|
promo_percent,
|
||||||
|
)
|
||||||
|
|
||||||
|
overall_discount_value = max(0, base_original_total - final_total)
|
||||||
|
overall_discount_percent = 0
|
||||||
|
if base_original_total > 0 and overall_discount_value > 0:
|
||||||
|
overall_discount_percent = int(
|
||||||
|
round(overall_discount_value * 100 / base_original_total)
|
||||||
|
)
|
||||||
|
|
||||||
|
per_month = final_total // months if months else final_total
|
||||||
|
|
||||||
|
return SubscriptionRenewalPricing(
|
||||||
|
period_days=period_days,
|
||||||
|
period_id=build_renewal_period_id(period_days),
|
||||||
|
months=months,
|
||||||
|
base_original_total=base_original_total,
|
||||||
|
discounted_total=discounted_total,
|
||||||
|
final_total=final_total,
|
||||||
|
promo_discount_value=promo_discount_value,
|
||||||
|
promo_discount_percent=promo_percent if promo_discount_value else 0,
|
||||||
|
overall_discount_percent=overall_discount_percent,
|
||||||
|
per_month=per_month,
|
||||||
|
server_ids=list(server_ids),
|
||||||
|
details=details,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def finalize(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
user: User,
|
||||||
|
subscription: Subscription,
|
||||||
|
pricing: SubscriptionRenewalPricing,
|
||||||
|
*,
|
||||||
|
charge_balance_amount: Optional[int] = None,
|
||||||
|
description: Optional[str] = None,
|
||||||
|
payment_method: Optional[PaymentMethod] = None,
|
||||||
|
) -> SubscriptionRenewalResult:
|
||||||
|
final_total = int(pricing.final_total)
|
||||||
|
if final_total < 0:
|
||||||
|
final_total = 0
|
||||||
|
|
||||||
|
period_days = int(pricing.period_days)
|
||||||
|
charge_from_balance = charge_balance_amount
|
||||||
|
if charge_from_balance is None:
|
||||||
|
charge_from_balance = final_total
|
||||||
|
charge_from_balance = max(0, min(charge_from_balance, final_total))
|
||||||
|
|
||||||
|
consume_promo_offer = bool(pricing.promo_discount_value)
|
||||||
|
|
||||||
|
description_text = description or f"Продление подписки на {period_days} дней"
|
||||||
|
|
||||||
|
if charge_from_balance > 0 or consume_promo_offer:
|
||||||
|
success = await subtract_user_balance(
|
||||||
|
db,
|
||||||
|
user,
|
||||||
|
charge_from_balance,
|
||||||
|
description_text,
|
||||||
|
consume_promo_offer=consume_promo_offer,
|
||||||
|
)
|
||||||
|
if not success:
|
||||||
|
raise SubscriptionRenewalChargeError("Failed to charge balance")
|
||||||
|
await db.refresh(user)
|
||||||
|
|
||||||
|
subscription_before = subscription
|
||||||
|
old_end_date = subscription_before.end_date
|
||||||
|
|
||||||
|
subscription_after = await extend_subscription(db, subscription_before, period_days)
|
||||||
|
|
||||||
|
server_ids = pricing.server_ids or []
|
||||||
|
server_prices_for_period = pricing.details.get("servers_individual_prices", [])
|
||||||
|
if server_ids:
|
||||||
|
try:
|
||||||
|
await add_subscription_servers(
|
||||||
|
db,
|
||||||
|
subscription_after,
|
||||||
|
server_ids,
|
||||||
|
server_prices_for_period,
|
||||||
|
)
|
||||||
|
except Exception as error: # pragma: no cover - defensive logging
|
||||||
|
logger.warning(
|
||||||
|
"Failed to record renewal server prices for subscription %s: %s",
|
||||||
|
subscription_after.id,
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
|
||||||
|
subscription_service = SubscriptionService()
|
||||||
|
try:
|
||||||
|
await subscription_service.update_remnawave_user(
|
||||||
|
db,
|
||||||
|
subscription_after,
|
||||||
|
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
|
||||||
|
reset_reason="subscription renewal",
|
||||||
|
)
|
||||||
|
except RemnaWaveConfigurationError as error: # pragma: no cover - configuration issues
|
||||||
|
logger.warning("RemnaWave update skipped: %s", error)
|
||||||
|
except Exception as error: # pragma: no cover - defensive logging
|
||||||
|
logger.error(
|
||||||
|
"Failed to update RemnaWave user for subscription %s: %s",
|
||||||
|
subscription_after.id,
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
|
||||||
|
transaction: Optional[Transaction] = None
|
||||||
|
try:
|
||||||
|
transaction = await create_transaction(
|
||||||
|
db=db,
|
||||||
|
user_id=user.id,
|
||||||
|
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||||
|
amount_kopeks=final_total,
|
||||||
|
description=description_text,
|
||||||
|
payment_method=payment_method,
|
||||||
|
)
|
||||||
|
except Exception as error: # pragma: no cover - defensive logging
|
||||||
|
logger.warning(
|
||||||
|
"Failed to create renewal transaction for subscription %s: %s",
|
||||||
|
subscription_after.id,
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.refresh(user)
|
||||||
|
await db.refresh(subscription_after)
|
||||||
|
|
||||||
|
if transaction and old_end_date and subscription_after.end_date:
|
||||||
|
await with_admin_notification_service(
|
||||||
|
lambda service: service.send_subscription_extension_notification(
|
||||||
|
db,
|
||||||
|
user,
|
||||||
|
subscription_after,
|
||||||
|
transaction,
|
||||||
|
period_days,
|
||||||
|
old_end_date,
|
||||||
|
new_end_date=subscription_after.end_date,
|
||||||
|
balance_after=user.balance_kopeks,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return SubscriptionRenewalResult(
|
||||||
|
subscription=subscription_after,
|
||||||
|
transaction=transaction,
|
||||||
|
total_amount_kopeks=final_total,
|
||||||
|
charged_from_balance_kopeks=charge_from_balance,
|
||||||
|
old_end_date=old_end_date,
|
||||||
|
)
|
||||||
|
|
||||||
|
def build_option_payload(
|
||||||
|
self,
|
||||||
|
pricing: SubscriptionRenewalPricing,
|
||||||
|
*,
|
||||||
|
language: str,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
label = format_period_description(pricing.period_days, language)
|
||||||
|
price_label = settings.format_price(pricing.final_total)
|
||||||
|
original_label = None
|
||||||
|
if (
|
||||||
|
pricing.base_original_total
|
||||||
|
and pricing.base_original_total != pricing.final_total
|
||||||
|
):
|
||||||
|
original_label = settings.format_price(pricing.base_original_total)
|
||||||
|
|
||||||
|
per_month_label = settings.format_price(pricing.per_month)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"id": pricing.period_id,
|
||||||
|
"days": pricing.period_days,
|
||||||
|
"months": pricing.months,
|
||||||
|
"price_kopeks": pricing.final_total,
|
||||||
|
"price_label": price_label,
|
||||||
|
"original_price_kopeks": pricing.base_original_total,
|
||||||
|
"original_price_label": original_label,
|
||||||
|
"discount_percent": pricing.overall_discount_percent,
|
||||||
|
"price_per_month_kopeks": pricing.per_month,
|
||||||
|
"price_per_month_label": per_month_label,
|
||||||
|
"title": label,
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_missing_amount(balance_kopeks: int, total_kopeks: int) -> int:
|
||||||
|
if total_kopeks <= 0:
|
||||||
|
return 0
|
||||||
|
if balance_kopeks <= 0:
|
||||||
|
return total_kopeks
|
||||||
|
return max(0, total_kopeks - min(balance_kopeks, total_kopeks))
|
||||||
|
|
||||||
@@ -23,10 +23,30 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
class TributeService:
|
class TributeService:
|
||||||
|
_invoice_messages: Dict[int, Dict[str, int]] = {}
|
||||||
|
|
||||||
def __init__(self, bot: Bot):
|
def __init__(self, bot: Bot):
|
||||||
self.bot = bot
|
self.bot = bot
|
||||||
self.tribute_api = TributeAPI()
|
self.tribute_api = TributeAPI()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def remember_invoice_message(cls, user_id: int, chat_id: int, message_id: int) -> None:
|
||||||
|
cls._invoice_messages[user_id] = {"chat_id": chat_id, "message_id": message_id}
|
||||||
|
|
||||||
|
async def _cleanup_invoice_message(self, user_id: int) -> None:
|
||||||
|
invoice_message = self._invoice_messages.pop(user_id, None)
|
||||||
|
if not invoice_message or not getattr(self, "bot", None):
|
||||||
|
return
|
||||||
|
|
||||||
|
chat_id = invoice_message.get("chat_id")
|
||||||
|
message_id = invoice_message.get("message_id")
|
||||||
|
if not chat_id or not message_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.bot.delete_message(chat_id, message_id)
|
||||||
|
except Exception as error: # pragma: no cover - depends on bot rights
|
||||||
|
logger.warning("Не удалось удалить Tribute счёт %s: %s", message_id, error)
|
||||||
|
|
||||||
async def create_payment_link(
|
async def create_payment_link(
|
||||||
self,
|
self,
|
||||||
@@ -174,7 +194,8 @@ class TributeService:
|
|||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка отправки уведомления о Tribute пополнении: {e}")
|
logger.error(f"Ошибка отправки уведомления о Tribute пополнении: {e}")
|
||||||
|
|
||||||
|
await self._cleanup_invoice_message(user_telegram_id)
|
||||||
await self._send_success_notification(user_telegram_id, amount_kopeks)
|
await self._send_success_notification(user_telegram_id, amount_kopeks)
|
||||||
|
|
||||||
logger.info(f"🎉 Успешно обработан Tribute платеж: {amount_kopeks/100}₽ для пользователя {user_telegram_id}")
|
logger.info(f"🎉 Успешно обработан Tribute платеж: {amount_kopeks/100}₽ для пользователя {user_telegram_id}")
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from app.database.crud.subscription import (
|
|||||||
from app.database.models import (
|
from app.database.models import (
|
||||||
User, UserStatus, Subscription, Transaction, PromoCode, PromoCodeUse,
|
User, UserStatus, Subscription, Transaction, PromoCode, PromoCodeUse,
|
||||||
ReferralEarning, SubscriptionServer, YooKassaPayment, BroadcastHistory,
|
ReferralEarning, SubscriptionServer, YooKassaPayment, BroadcastHistory,
|
||||||
CryptoBotPayment, SubscriptionConversion, UserMessage, WelcomeText,
|
CryptoBotPayment, PlategaPayment, SubscriptionConversion, UserMessage, WelcomeText,
|
||||||
SentNotification, PromoGroup, MulenPayPayment, Pal24Payment, HeleketPayment,
|
SentNotification, PromoGroup, MulenPayPayment, Pal24Payment, HeleketPayment,
|
||||||
AdvertisingCampaign, AdvertisingCampaignRegistration, PaymentMethod,
|
AdvertisingCampaign, AdvertisingCampaignRegistration, PaymentMethod,
|
||||||
TransactionType
|
TransactionType
|
||||||
@@ -732,6 +732,27 @@ class UserService:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ Ошибка удаления CryptoBot платежей: {e}")
|
logger.error(f"❌ Ошибка удаления CryptoBot платежей: {e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
platega_result = await db.execute(
|
||||||
|
select(PlategaPayment).where(PlategaPayment.user_id == user_id)
|
||||||
|
)
|
||||||
|
platega_payments = platega_result.scalars().all()
|
||||||
|
|
||||||
|
if platega_payments:
|
||||||
|
logger.info(f"🔄 Удаляем {len(platega_payments)} Platega платежей")
|
||||||
|
await db.execute(
|
||||||
|
update(PlategaPayment)
|
||||||
|
.where(PlategaPayment.user_id == user_id)
|
||||||
|
.values(transaction_id=None)
|
||||||
|
)
|
||||||
|
await db.flush()
|
||||||
|
await db.execute(
|
||||||
|
delete(PlategaPayment).where(PlategaPayment.user_id == user_id)
|
||||||
|
)
|
||||||
|
await db.flush()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Ошибка удаления Platega платежей: {e}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
mulenpay_result = await db.execute(
|
mulenpay_result = await db.execute(
|
||||||
select(MulenPayPayment).where(MulenPayPayment.user_id == user_id)
|
select(MulenPayPayment).where(MulenPayPayment.user_id == user_id)
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from ..schemas.logs import (
|
|||||||
SupportAuditLogEntry,
|
SupportAuditLogEntry,
|
||||||
SupportAuditLogListResponse,
|
SupportAuditLogListResponse,
|
||||||
SystemLogPreviewResponse,
|
SystemLogPreviewResponse,
|
||||||
|
SystemLogFullResponse,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -141,6 +142,33 @@ async def download_system_log(
|
|||||||
raise HTTPException(status_code=500, detail="Не удалось отправить лог-файл") from error
|
raise HTTPException(status_code=500, detail="Не удалось отправить лог-файл") from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/system/full", response_model=SystemLogFullResponse)
|
||||||
|
async def get_system_log_full(
|
||||||
|
_: Any = Security(require_api_token),
|
||||||
|
) -> SystemLogFullResponse:
|
||||||
|
"""Получить полный системный лог-файл бота."""
|
||||||
|
|
||||||
|
log_path = _resolve_system_log_path()
|
||||||
|
|
||||||
|
if not log_path.exists() or not log_path.is_file():
|
||||||
|
raise HTTPException(status_code=404, detail="Лог-файл не найден")
|
||||||
|
|
||||||
|
try:
|
||||||
|
content, size_bytes, mtime = await _read_system_log(log_path)
|
||||||
|
except Exception as error: # pragma: no cover - защита от неожиданных ошибок чтения
|
||||||
|
logger.error("Ошибка чтения лог-файла %s: %s", log_path, error)
|
||||||
|
raise HTTPException(status_code=500, detail="Не удалось прочитать лог-файл") from error
|
||||||
|
|
||||||
|
return SystemLogFullResponse(
|
||||||
|
path=str(log_path),
|
||||||
|
exists=True,
|
||||||
|
updated_at=_format_timestamp(mtime),
|
||||||
|
size_bytes=size_bytes,
|
||||||
|
size_chars=len(content),
|
||||||
|
content=content,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/monitoring", response_model=MonitoringLogListResponse)
|
@router.get("/monitoring", response_model=MonitoringLogListResponse)
|
||||||
async def list_monitoring_logs(
|
async def list_monitoring_logs(
|
||||||
_: Any = Security(require_api_token),
|
_: Any = Security(require_api_token),
|
||||||
|
|||||||
+360
-267
@@ -3,13 +3,14 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import math
|
import math
|
||||||
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP, ROUND_FLOOR
|
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP, ROUND_FLOOR, ROUND_UP
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple, Union
|
from typing import Any, Callable, Collection, Dict, List, Optional, Tuple, Union
|
||||||
|
|
||||||
from aiogram import Bot
|
from aiogram import Bot
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from pydantic import ValidationError
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
@@ -27,13 +28,11 @@ from app.database.crud.promo_offer_template import get_promo_offer_template_by_i
|
|||||||
from app.database.crud.server_squad import (
|
from app.database.crud.server_squad import (
|
||||||
add_user_to_servers,
|
add_user_to_servers,
|
||||||
get_available_server_squads,
|
get_available_server_squads,
|
||||||
get_server_ids_by_uuids,
|
|
||||||
get_server_squad_by_uuid,
|
get_server_squad_by_uuid,
|
||||||
remove_user_from_servers,
|
remove_user_from_servers,
|
||||||
)
|
)
|
||||||
from app.database.crud.subscription import (
|
from app.database.crud.subscription import (
|
||||||
add_subscription_servers,
|
add_subscription_servers,
|
||||||
calculate_subscription_total_cost,
|
|
||||||
create_trial_subscription,
|
create_trial_subscription,
|
||||||
extend_subscription,
|
extend_subscription,
|
||||||
remove_subscription_servers,
|
remove_subscription_servers,
|
||||||
@@ -54,7 +53,6 @@ from app.database.models import (
|
|||||||
PaymentMethod,
|
PaymentMethod,
|
||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
from app.services.admin_notification_service import AdminNotificationService
|
|
||||||
from app.services.faq_service import FaqService
|
from app.services.faq_service import FaqService
|
||||||
from app.services.privacy_policy_service import PrivacyPolicyService
|
from app.services.privacy_policy_service import PrivacyPolicyService
|
||||||
from app.services.public_offer_service import PublicOfferService
|
from app.services.public_offer_service import PublicOfferService
|
||||||
@@ -67,6 +65,16 @@ from app.services.payment_service import PaymentService, get_wata_payment_by_lin
|
|||||||
from app.services.promo_offer_service import promo_offer_service
|
from app.services.promo_offer_service import promo_offer_service
|
||||||
from app.services.promocode_service import PromoCodeService
|
from app.services.promocode_service import PromoCodeService
|
||||||
from app.services.subscription_service import SubscriptionService
|
from app.services.subscription_service import SubscriptionService
|
||||||
|
from app.services.subscription_renewal_service import (
|
||||||
|
SubscriptionRenewalChargeError,
|
||||||
|
SubscriptionRenewalService,
|
||||||
|
build_payment_descriptor,
|
||||||
|
build_renewal_period_id,
|
||||||
|
decode_payment_payload,
|
||||||
|
calculate_missing_amount,
|
||||||
|
encode_payment_payload,
|
||||||
|
with_admin_notification_service,
|
||||||
|
)
|
||||||
from app.services.trial_activation_service import (
|
from app.services.trial_activation_service import (
|
||||||
TrialPaymentChargeFailed,
|
TrialPaymentChargeFailed,
|
||||||
TrialPaymentInsufficientFunds,
|
TrialPaymentInsufficientFunds,
|
||||||
@@ -93,11 +101,9 @@ from app.utils.user_utils import (
|
|||||||
)
|
)
|
||||||
from app.utils.pricing_utils import (
|
from app.utils.pricing_utils import (
|
||||||
apply_percentage_discount,
|
apply_percentage_discount,
|
||||||
calculate_months_from_days,
|
|
||||||
calculate_prorated_price,
|
calculate_prorated_price,
|
||||||
format_period_description,
|
format_period_description,
|
||||||
get_remaining_months,
|
get_remaining_months,
|
||||||
validate_pricing_calculation,
|
|
||||||
)
|
)
|
||||||
from app.utils.promo_offer import get_user_active_promo_discount_percent
|
from app.utils.promo_offer import get_user_active_promo_discount_percent
|
||||||
|
|
||||||
@@ -113,6 +119,8 @@ from ..schemas.miniapp import (
|
|||||||
MiniAppLegalDocuments,
|
MiniAppLegalDocuments,
|
||||||
MiniAppPaymentCreateRequest,
|
MiniAppPaymentCreateRequest,
|
||||||
MiniAppPaymentCreateResponse,
|
MiniAppPaymentCreateResponse,
|
||||||
|
MiniAppPaymentIframeConfig,
|
||||||
|
MiniAppPaymentIntegrationType,
|
||||||
MiniAppPaymentMethod,
|
MiniAppPaymentMethod,
|
||||||
MiniAppPaymentMethodsRequest,
|
MiniAppPaymentMethodsRequest,
|
||||||
MiniAppPaymentMethodsResponse,
|
MiniAppPaymentMethodsResponse,
|
||||||
@@ -177,27 +185,7 @@ logger = logging.getLogger(__name__)
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
promo_code_service = PromoCodeService()
|
promo_code_service = PromoCodeService()
|
||||||
|
renewal_service = SubscriptionRenewalService()
|
||||||
|
|
||||||
async def _with_admin_notification_service(
|
|
||||||
handler: Callable[[AdminNotificationService], Awaitable[Any]],
|
|
||||||
) -> None:
|
|
||||||
if not getattr(settings, "ADMIN_NOTIFICATIONS_ENABLED", False):
|
|
||||||
return
|
|
||||||
if not settings.BOT_TOKEN:
|
|
||||||
logger.debug("Skipping admin notification: bot token is not configured")
|
|
||||||
return
|
|
||||||
|
|
||||||
bot: Bot | None = None
|
|
||||||
try:
|
|
||||||
bot = Bot(token=settings.BOT_TOKEN)
|
|
||||||
service = AdminNotificationService(bot)
|
|
||||||
await handler(service)
|
|
||||||
except Exception as error: # pragma: no cover - defensive logging
|
|
||||||
logger.error("Failed to send admin notification from miniapp: %s", error)
|
|
||||||
finally:
|
|
||||||
if bot:
|
|
||||||
await bot.session.close()
|
|
||||||
|
|
||||||
|
|
||||||
_CRYPTOBOT_MIN_USD = 1.0
|
_CRYPTOBOT_MIN_USD = 1.0
|
||||||
@@ -624,6 +612,18 @@ def _normalize_amount_kopeks(
|
|||||||
return normalized if normalized >= 0 else None
|
return normalized if normalized >= 0 else None
|
||||||
|
|
||||||
|
|
||||||
|
def _build_mulenpay_iframe_config() -> Optional[MiniAppPaymentIframeConfig]:
|
||||||
|
expected_origin = settings.get_mulenpay_expected_origin()
|
||||||
|
if not expected_origin:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
return MiniAppPaymentIframeConfig(expected_origin=expected_origin)
|
||||||
|
except ValidationError as error: # pragma: no cover - defensive logging
|
||||||
|
logger.error("Invalid MulenPay expected origin '%s': %s", expected_origin, error)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/payments/methods",
|
"/payments/methods",
|
||||||
response_model=MiniAppPaymentMethodsResponse,
|
response_model=MiniAppPaymentMethodsResponse,
|
||||||
@@ -646,6 +646,7 @@ async def get_payment_methods(
|
|||||||
currency="RUB",
|
currency="RUB",
|
||||||
min_amount_kopeks=stars_min_amount,
|
min_amount_kopeks=stars_min_amount,
|
||||||
amount_step_kopeks=stars_min_amount,
|
amount_step_kopeks=stars_min_amount,
|
||||||
|
integration_type=MiniAppPaymentIntegrationType.REDIRECT,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -659,6 +660,7 @@ async def get_payment_methods(
|
|||||||
currency="RUB",
|
currency="RUB",
|
||||||
min_amount_kopeks=settings.YOOKASSA_MIN_AMOUNT_KOPEKS,
|
min_amount_kopeks=settings.YOOKASSA_MIN_AMOUNT_KOPEKS,
|
||||||
max_amount_kopeks=settings.YOOKASSA_MAX_AMOUNT_KOPEKS,
|
max_amount_kopeks=settings.YOOKASSA_MAX_AMOUNT_KOPEKS,
|
||||||
|
integration_type=MiniAppPaymentIntegrationType.REDIRECT,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -670,10 +672,17 @@ async def get_payment_methods(
|
|||||||
currency="RUB",
|
currency="RUB",
|
||||||
min_amount_kopeks=settings.YOOKASSA_MIN_AMOUNT_KOPEKS,
|
min_amount_kopeks=settings.YOOKASSA_MIN_AMOUNT_KOPEKS,
|
||||||
max_amount_kopeks=settings.YOOKASSA_MAX_AMOUNT_KOPEKS,
|
max_amount_kopeks=settings.YOOKASSA_MAX_AMOUNT_KOPEKS,
|
||||||
|
integration_type=MiniAppPaymentIntegrationType.REDIRECT,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
if settings.is_mulenpay_enabled():
|
if settings.is_mulenpay_enabled():
|
||||||
|
mulenpay_iframe_config = _build_mulenpay_iframe_config()
|
||||||
|
mulenpay_integration = (
|
||||||
|
MiniAppPaymentIntegrationType.IFRAME
|
||||||
|
if mulenpay_iframe_config
|
||||||
|
else MiniAppPaymentIntegrationType.REDIRECT
|
||||||
|
)
|
||||||
methods.append(
|
methods.append(
|
||||||
MiniAppPaymentMethod(
|
MiniAppPaymentMethod(
|
||||||
id="mulenpay",
|
id="mulenpay",
|
||||||
@@ -683,6 +692,8 @@ async def get_payment_methods(
|
|||||||
currency="RUB",
|
currency="RUB",
|
||||||
min_amount_kopeks=settings.MULENPAY_MIN_AMOUNT_KOPEKS,
|
min_amount_kopeks=settings.MULENPAY_MIN_AMOUNT_KOPEKS,
|
||||||
max_amount_kopeks=settings.MULENPAY_MAX_AMOUNT_KOPEKS,
|
max_amount_kopeks=settings.MULENPAY_MAX_AMOUNT_KOPEKS,
|
||||||
|
integration_type=mulenpay_integration,
|
||||||
|
iframe_config=mulenpay_iframe_config,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -695,6 +706,7 @@ async def get_payment_methods(
|
|||||||
currency="RUB",
|
currency="RUB",
|
||||||
min_amount_kopeks=settings.PAL24_MIN_AMOUNT_KOPEKS,
|
min_amount_kopeks=settings.PAL24_MIN_AMOUNT_KOPEKS,
|
||||||
max_amount_kopeks=settings.PAL24_MAX_AMOUNT_KOPEKS,
|
max_amount_kopeks=settings.PAL24_MAX_AMOUNT_KOPEKS,
|
||||||
|
integration_type=MiniAppPaymentIntegrationType.REDIRECT,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -707,6 +719,7 @@ async def get_payment_methods(
|
|||||||
currency="RUB",
|
currency="RUB",
|
||||||
min_amount_kopeks=settings.WATA_MIN_AMOUNT_KOPEKS,
|
min_amount_kopeks=settings.WATA_MIN_AMOUNT_KOPEKS,
|
||||||
max_amount_kopeks=settings.WATA_MAX_AMOUNT_KOPEKS,
|
max_amount_kopeks=settings.WATA_MAX_AMOUNT_KOPEKS,
|
||||||
|
integration_type=MiniAppPaymentIntegrationType.REDIRECT,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -721,6 +734,7 @@ async def get_payment_methods(
|
|||||||
currency="RUB",
|
currency="RUB",
|
||||||
min_amount_kopeks=min_amount_kopeks,
|
min_amount_kopeks=min_amount_kopeks,
|
||||||
max_amount_kopeks=max_amount_kopeks,
|
max_amount_kopeks=max_amount_kopeks,
|
||||||
|
integration_type=MiniAppPaymentIntegrationType.REDIRECT,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -733,6 +747,7 @@ async def get_payment_methods(
|
|||||||
currency="RUB",
|
currency="RUB",
|
||||||
min_amount_kopeks=100 * 100,
|
min_amount_kopeks=100 * 100,
|
||||||
max_amount_kopeks=100_000 * 100,
|
max_amount_kopeks=100_000 * 100,
|
||||||
|
integration_type=MiniAppPaymentIntegrationType.REDIRECT,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -743,6 +758,7 @@ async def get_payment_methods(
|
|||||||
icon="💎",
|
icon="💎",
|
||||||
requires_amount=False,
|
requires_amount=False,
|
||||||
currency="RUB",
|
currency="RUB",
|
||||||
|
integration_type=MiniAppPaymentIntegrationType.REDIRECT,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1613,6 +1629,9 @@ async def _resolve_cryptobot_payment_status(
|
|||||||
except (InvalidOperation, TypeError):
|
except (InvalidOperation, TypeError):
|
||||||
amount_kopeks = None
|
amount_kopeks = None
|
||||||
|
|
||||||
|
descriptor = decode_payment_payload(getattr(payment, "payload", "") or "", expected_user_id=user.id)
|
||||||
|
purpose = "subscription_renewal" if descriptor else "balance_topup"
|
||||||
|
|
||||||
return MiniAppPaymentStatusResult(
|
return MiniAppPaymentStatusResult(
|
||||||
method="cryptobot",
|
method="cryptobot",
|
||||||
status=status,
|
status=status,
|
||||||
@@ -1629,6 +1648,9 @@ async def _resolve_cryptobot_payment_status(
|
|||||||
"invoice_id": payment.invoice_id,
|
"invoice_id": payment.invoice_id,
|
||||||
"payload": query.payload,
|
"payload": query.payload,
|
||||||
"started_at": query.started_at,
|
"started_at": query.started_at,
|
||||||
|
"purpose": purpose,
|
||||||
|
"subscription_id": descriptor.subscription_id if descriptor else None,
|
||||||
|
"period_days": descriptor.period_days if descriptor else None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -3337,7 +3359,7 @@ async def activate_subscription_trial_endpoint(
|
|||||||
else:
|
else:
|
||||||
message = f"{message}\n\n💳 {charged_amount_label} has been deducted from your balance."
|
message = f"{message}\n\n💳 {charged_amount_label} has been deducted from your balance."
|
||||||
|
|
||||||
await _with_admin_notification_service(
|
await with_admin_notification_service(
|
||||||
lambda service: service.send_trial_activation_notification(
|
lambda service: service.send_trial_activation_notification(
|
||||||
db,
|
db,
|
||||||
user,
|
user,
|
||||||
@@ -3772,10 +3794,93 @@ def _build_promo_offer_payload(user: Optional[User]) -> Optional[Dict[str, Any]]
|
|||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
def _build_renewal_period_id(period_days: int) -> str:
|
def _format_payment_method_title(method: str) -> str:
|
||||||
return f"days:{period_days}"
|
mapping = {
|
||||||
|
"cryptobot": "CryptoBot",
|
||||||
|
"yookassa": "YooKassa",
|
||||||
|
"yookassa_sbp": "YooKassa СБП",
|
||||||
|
"mulenpay": "MulenPay",
|
||||||
|
"pal24": "Pal24",
|
||||||
|
"wata": "WataPay",
|
||||||
|
"heleket": "Heleket",
|
||||||
|
"tribute": "Tribute",
|
||||||
|
"stars": "Telegram Stars",
|
||||||
|
}
|
||||||
|
key = (method or "").lower()
|
||||||
|
return mapping.get(key, method.title() if method else "")
|
||||||
|
|
||||||
|
|
||||||
|
def _build_renewal_success_message(
|
||||||
|
user: User,
|
||||||
|
subscription: Subscription,
|
||||||
|
charged_amount: int,
|
||||||
|
promo_discount_value: int = 0,
|
||||||
|
) -> str:
|
||||||
|
language_code = _normalize_language_code(user)
|
||||||
|
amount_label = settings.format_price(max(0, charged_amount))
|
||||||
|
date_label = (
|
||||||
|
format_local_datetime(subscription.end_date, "%d.%m.%Y %H:%M")
|
||||||
|
if subscription.end_date
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
|
||||||
|
if language_code == "ru":
|
||||||
|
if charged_amount > 0:
|
||||||
|
message = (
|
||||||
|
f"Подписка продлена до {date_label}. " if date_label else "Подписка продлена. "
|
||||||
|
) + f"Списано {amount_label}."
|
||||||
|
else:
|
||||||
|
message = (
|
||||||
|
f"Подписка продлена до {date_label}."
|
||||||
|
if date_label
|
||||||
|
else "Подписка успешно продлена."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if charged_amount > 0:
|
||||||
|
message = (
|
||||||
|
f"Subscription renewed until {date_label}. " if date_label else "Subscription renewed. "
|
||||||
|
) + f"Charged {amount_label}."
|
||||||
|
else:
|
||||||
|
message = (
|
||||||
|
f"Subscription renewed until {date_label}."
|
||||||
|
if date_label
|
||||||
|
else "Subscription renewed successfully."
|
||||||
|
)
|
||||||
|
|
||||||
|
if promo_discount_value > 0:
|
||||||
|
discount_label = settings.format_price(promo_discount_value)
|
||||||
|
if language_code == "ru":
|
||||||
|
message += f" Применена дополнительная скидка {discount_label}."
|
||||||
|
else:
|
||||||
|
message += f" Promo discount applied: {discount_label}."
|
||||||
|
|
||||||
|
return message
|
||||||
|
|
||||||
|
|
||||||
|
def _build_renewal_pending_message(
|
||||||
|
user: User,
|
||||||
|
missing_amount: int,
|
||||||
|
method: str,
|
||||||
|
) -> str:
|
||||||
|
language_code = _normalize_language_code(user)
|
||||||
|
amount_label = settings.format_price(max(0, missing_amount))
|
||||||
|
method_title = _format_payment_method_title(method)
|
||||||
|
|
||||||
|
if language_code == "ru":
|
||||||
|
if method_title:
|
||||||
|
return (
|
||||||
|
f"Недостаточно средств на балансе. Доплатите {amount_label} через {method_title}, "
|
||||||
|
"чтобы завершить продление."
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
f"Недостаточно средств на балансе. Доплатите {amount_label}, чтобы завершить продление."
|
||||||
|
)
|
||||||
|
|
||||||
|
if method_title:
|
||||||
|
return (
|
||||||
|
f"Not enough balance. Pay the remaining {amount_label} via {method_title} to finish the renewal."
|
||||||
|
)
|
||||||
|
return f"Not enough balance. Pay the remaining {amount_label} to finish the renewal."
|
||||||
def _parse_period_identifier(identifier: Optional[str]) -> Optional[int]:
|
def _parse_period_identifier(identifier: Optional[str]) -> Optional[int]:
|
||||||
if not identifier:
|
if not identifier:
|
||||||
return None
|
return None
|
||||||
@@ -3795,95 +3900,14 @@ async def _calculate_subscription_renewal_pricing(
|
|||||||
user: User,
|
user: User,
|
||||||
subscription: Subscription,
|
subscription: Subscription,
|
||||||
period_days: int,
|
period_days: int,
|
||||||
) -> Dict[str, Any]:
|
):
|
||||||
connected_uuids = [str(uuid) for uuid in list(subscription.connected_squads or [])]
|
return await renewal_service.calculate_pricing(
|
||||||
server_ids: List[int] = []
|
|
||||||
if connected_uuids:
|
|
||||||
server_ids = await get_server_ids_by_uuids(db, connected_uuids)
|
|
||||||
|
|
||||||
traffic_limit = subscription.traffic_limit_gb
|
|
||||||
if traffic_limit is None:
|
|
||||||
traffic_limit = settings.DEFAULT_TRAFFIC_LIMIT_GB
|
|
||||||
|
|
||||||
devices_limit = subscription.device_limit
|
|
||||||
if devices_limit is None:
|
|
||||||
devices_limit = settings.DEFAULT_DEVICE_LIMIT
|
|
||||||
|
|
||||||
total_cost, details = await calculate_subscription_total_cost(
|
|
||||||
db,
|
db,
|
||||||
|
user,
|
||||||
|
subscription,
|
||||||
period_days,
|
period_days,
|
||||||
int(traffic_limit or 0),
|
|
||||||
server_ids,
|
|
||||||
int(devices_limit or 0),
|
|
||||||
user=user,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
months = details.get("months_in_period") or calculate_months_from_days(period_days)
|
|
||||||
|
|
||||||
base_original_total = (
|
|
||||||
details.get("base_price_original", 0)
|
|
||||||
+ details.get("traffic_price_per_month", 0) * months
|
|
||||||
+ details.get("servers_price_per_month", 0) * months
|
|
||||||
+ details.get("devices_price_per_month", 0) * months
|
|
||||||
)
|
|
||||||
|
|
||||||
discounted_total = total_cost
|
|
||||||
|
|
||||||
monthly_additions = 0
|
|
||||||
if months > 0:
|
|
||||||
monthly_additions = (
|
|
||||||
details.get("total_servers_price", 0) // months
|
|
||||||
+ details.get("total_devices_price", 0) // months
|
|
||||||
+ details.get("total_traffic_price", 0) // months
|
|
||||||
)
|
|
||||||
|
|
||||||
if not validate_pricing_calculation(
|
|
||||||
details.get("base_price", 0),
|
|
||||||
monthly_additions,
|
|
||||||
months,
|
|
||||||
discounted_total,
|
|
||||||
):
|
|
||||||
logger.warning(
|
|
||||||
"Renewal pricing validation failed for subscription %s (period %s)",
|
|
||||||
subscription.id,
|
|
||||||
period_days,
|
|
||||||
)
|
|
||||||
|
|
||||||
promo_percent = get_user_active_promo_discount_percent(user)
|
|
||||||
final_total = discounted_total
|
|
||||||
promo_discount_value = 0
|
|
||||||
if promo_percent > 0 and discounted_total > 0:
|
|
||||||
final_total, promo_discount_value = apply_percentage_discount(
|
|
||||||
discounted_total,
|
|
||||||
promo_percent,
|
|
||||||
)
|
|
||||||
|
|
||||||
overall_discount_value = max(0, base_original_total - final_total)
|
|
||||||
overall_discount_percent = 0
|
|
||||||
if base_original_total > 0 and overall_discount_value > 0:
|
|
||||||
overall_discount_percent = int(
|
|
||||||
round(overall_discount_value * 100 / base_original_total)
|
|
||||||
)
|
|
||||||
|
|
||||||
per_month = final_total // months if months else final_total
|
|
||||||
|
|
||||||
pricing_payload: Dict[str, Any] = {
|
|
||||||
"period_id": _build_renewal_period_id(period_days),
|
|
||||||
"period_days": period_days,
|
|
||||||
"months": months,
|
|
||||||
"base_original_total": base_original_total,
|
|
||||||
"discounted_total": discounted_total,
|
|
||||||
"final_total": final_total,
|
|
||||||
"promo_discount_value": promo_discount_value,
|
|
||||||
"promo_discount_percent": promo_percent if promo_discount_value else 0,
|
|
||||||
"overall_discount_percent": overall_discount_percent,
|
|
||||||
"per_month": per_month,
|
|
||||||
"server_ids": list(server_ids),
|
|
||||||
"details": details,
|
|
||||||
}
|
|
||||||
|
|
||||||
return pricing_payload
|
|
||||||
|
|
||||||
|
|
||||||
async def _prepare_subscription_renewal_options(
|
async def _prepare_subscription_renewal_options(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
@@ -3898,12 +3922,13 @@ async def _prepare_subscription_renewal_options(
|
|||||||
|
|
||||||
for period_days in available_periods:
|
for period_days in available_periods:
|
||||||
try:
|
try:
|
||||||
pricing = await _calculate_subscription_renewal_pricing(
|
pricing_model = await _calculate_subscription_renewal_pricing(
|
||||||
db,
|
db,
|
||||||
user,
|
user,
|
||||||
subscription,
|
subscription,
|
||||||
period_days,
|
period_days,
|
||||||
)
|
)
|
||||||
|
pricing = pricing_model.to_payload()
|
||||||
except Exception as error: # pragma: no cover - defensive logging
|
except Exception as error: # pragma: no cover - defensive logging
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Failed to calculate renewal pricing for subscription %s (period %s): %s",
|
"Failed to calculate renewal pricing for subscription %s (period %s): %s",
|
||||||
@@ -4077,7 +4102,11 @@ async def _authorize_miniapp_user(
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
def _ensure_paid_subscription(user: User) -> Subscription:
|
def _ensure_paid_subscription(
|
||||||
|
user: User,
|
||||||
|
*,
|
||||||
|
allowed_statuses: Optional[Collection[str]] = None,
|
||||||
|
) -> Subscription:
|
||||||
subscription = getattr(user, "subscription", None)
|
subscription = getattr(user, "subscription", None)
|
||||||
if not subscription:
|
if not subscription:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -4085,7 +4114,9 @@ def _ensure_paid_subscription(user: User) -> Subscription:
|
|||||||
detail={"code": "subscription_not_found", "message": "Subscription not found"},
|
detail={"code": "subscription_not_found", "message": "Subscription not found"},
|
||||||
)
|
)
|
||||||
|
|
||||||
if getattr(subscription, "is_trial", False):
|
normalized_allowed_statuses = set(allowed_statuses or {"active"})
|
||||||
|
|
||||||
|
if getattr(subscription, "is_trial", False) and "trial" not in normalized_allowed_statuses:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status.HTTP_403_FORBIDDEN,
|
status.HTTP_403_FORBIDDEN,
|
||||||
detail={
|
detail={
|
||||||
@@ -4094,7 +4125,28 @@ def _ensure_paid_subscription(user: User) -> Subscription:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
if not getattr(subscription, "is_active", False):
|
actual_status = getattr(subscription, "actual_status", None) or ""
|
||||||
|
|
||||||
|
if actual_status not in normalized_allowed_statuses:
|
||||||
|
if actual_status == "trial":
|
||||||
|
detail = {
|
||||||
|
"code": "paid_subscription_required",
|
||||||
|
"message": "This action is available only for paid subscriptions",
|
||||||
|
}
|
||||||
|
elif actual_status == "disabled":
|
||||||
|
detail = {
|
||||||
|
"code": "subscription_disabled",
|
||||||
|
"message": "Subscription is disabled",
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
detail = {
|
||||||
|
"code": "subscription_inactive",
|
||||||
|
"message": "Subscription must be active to manage settings",
|
||||||
|
}
|
||||||
|
|
||||||
|
raise HTTPException(status.HTTP_403_FORBIDDEN, detail=detail)
|
||||||
|
|
||||||
|
if not getattr(subscription, "is_active", False) and "expired" not in normalized_allowed_statuses:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status.HTTP_403_FORBIDDEN,
|
status.HTTP_403_FORBIDDEN,
|
||||||
detail={
|
detail={
|
||||||
@@ -4367,7 +4419,10 @@ async def get_subscription_renewal_options_endpoint(
|
|||||||
db: AsyncSession = Depends(get_db_session),
|
db: AsyncSession = Depends(get_db_session),
|
||||||
) -> MiniAppSubscriptionRenewalOptionsResponse:
|
) -> MiniAppSubscriptionRenewalOptionsResponse:
|
||||||
user = await _authorize_miniapp_user(payload.init_data, db)
|
user = await _authorize_miniapp_user(payload.init_data, db)
|
||||||
subscription = _ensure_paid_subscription(user)
|
subscription = _ensure_paid_subscription(
|
||||||
|
user,
|
||||||
|
allowed_statuses={"active", "trial", "expired"},
|
||||||
|
)
|
||||||
_validate_subscription_id(payload.subscription_id, subscription)
|
_validate_subscription_id(payload.subscription_id, subscription)
|
||||||
|
|
||||||
periods, pricing_map, default_period_id = await _prepare_subscription_renewal_options(
|
periods, pricing_map, default_period_id = await _prepare_subscription_renewal_options(
|
||||||
@@ -4446,7 +4501,10 @@ async def submit_subscription_renewal_endpoint(
|
|||||||
db: AsyncSession = Depends(get_db_session),
|
db: AsyncSession = Depends(get_db_session),
|
||||||
) -> MiniAppSubscriptionRenewalResponse:
|
) -> MiniAppSubscriptionRenewalResponse:
|
||||||
user = await _authorize_miniapp_user(payload.init_data, db)
|
user = await _authorize_miniapp_user(payload.init_data, db)
|
||||||
subscription = _ensure_paid_subscription(user)
|
subscription = _ensure_paid_subscription(
|
||||||
|
user,
|
||||||
|
allowed_statuses={"active", "trial", "expired"},
|
||||||
|
)
|
||||||
_validate_subscription_id(payload.subscription_id, subscription)
|
_validate_subscription_id(payload.subscription_id, subscription)
|
||||||
|
|
||||||
period_days: Optional[int] = None
|
period_days: Optional[int] = None
|
||||||
@@ -4477,8 +4535,10 @@ async def submit_subscription_renewal_endpoint(
|
|||||||
detail={"code": "period_unavailable", "message": "Selected renewal period is not available"},
|
detail={"code": "period_unavailable", "message": "Selected renewal period is not available"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
method = (payload.method or "").strip().lower()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
pricing = await _calculate_subscription_renewal_pricing(
|
pricing_model = await _calculate_subscription_renewal_pricing(
|
||||||
db,
|
db,
|
||||||
user,
|
user,
|
||||||
subscription,
|
subscription,
|
||||||
@@ -4498,156 +4558,177 @@ async def submit_subscription_renewal_endpoint(
|
|||||||
detail={"code": "pricing_failed", "message": "Failed to calculate renewal pricing"},
|
detail={"code": "pricing_failed", "message": "Failed to calculate renewal pricing"},
|
||||||
) from error
|
) from error
|
||||||
|
|
||||||
final_total = int(pricing.get("final_total") or 0)
|
pricing = pricing_model.to_payload()
|
||||||
|
final_total = int(pricing_model.final_total)
|
||||||
balance_kopeks = getattr(user, "balance_kopeks", 0)
|
balance_kopeks = getattr(user, "balance_kopeks", 0)
|
||||||
|
missing_amount = calculate_missing_amount(balance_kopeks, final_total)
|
||||||
if final_total > 0 and balance_kopeks < final_total:
|
|
||||||
missing = final_total - balance_kopeks
|
|
||||||
raise HTTPException(
|
|
||||||
status.HTTP_402_PAYMENT_REQUIRED,
|
|
||||||
detail={
|
|
||||||
"code": "insufficient_funds",
|
|
||||||
"message": "Not enough funds to renew the subscription",
|
|
||||||
"missing_amount_kopeks": missing,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
consume_promo_offer = bool(pricing.get("promo_discount_value"))
|
|
||||||
description = f"Продление подписки на {period_days} дней"
|
description = f"Продление подписки на {period_days} дней"
|
||||||
old_end_date = subscription.end_date
|
|
||||||
|
|
||||||
if final_total > 0 or consume_promo_offer:
|
if missing_amount <= 0:
|
||||||
success = await subtract_user_balance(
|
|
||||||
db,
|
|
||||||
user,
|
|
||||||
final_total,
|
|
||||||
description,
|
|
||||||
consume_promo_offer=consume_promo_offer,
|
|
||||||
)
|
|
||||||
if not success:
|
|
||||||
raise HTTPException(
|
|
||||||
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail={"code": "charge_failed", "message": "Failed to charge balance"},
|
|
||||||
)
|
|
||||||
await db.refresh(user)
|
|
||||||
|
|
||||||
subscription = await extend_subscription(db, subscription, period_days)
|
|
||||||
|
|
||||||
server_ids = pricing.get("server_ids") or []
|
|
||||||
server_prices_for_period = pricing.get("details", {}).get(
|
|
||||||
"servers_individual_prices",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
if server_ids:
|
|
||||||
try:
|
try:
|
||||||
await add_subscription_servers(
|
result = await renewal_service.finalize(
|
||||||
db,
|
|
||||||
subscription,
|
|
||||||
server_ids,
|
|
||||||
server_prices_for_period,
|
|
||||||
)
|
|
||||||
except Exception as error: # pragma: no cover - defensive logging
|
|
||||||
logger.warning(
|
|
||||||
"Failed to record renewal server prices for subscription %s: %s",
|
|
||||||
subscription.id,
|
|
||||||
error,
|
|
||||||
)
|
|
||||||
|
|
||||||
subscription_service = SubscriptionService()
|
|
||||||
try:
|
|
||||||
await subscription_service.update_remnawave_user(
|
|
||||||
db,
|
|
||||||
subscription,
|
|
||||||
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
|
|
||||||
reset_reason="subscription renewal",
|
|
||||||
)
|
|
||||||
except RemnaWaveConfigurationError as error: # pragma: no cover - configuration issues
|
|
||||||
logger.warning("RemnaWave update skipped: %s", error)
|
|
||||||
except Exception as error: # pragma: no cover - defensive logging
|
|
||||||
logger.error(
|
|
||||||
"Failed to update RemnaWave user for subscription %s: %s",
|
|
||||||
subscription.id,
|
|
||||||
error,
|
|
||||||
)
|
|
||||||
|
|
||||||
transaction: Optional[Transaction] = None
|
|
||||||
try:
|
|
||||||
transaction = await create_transaction(
|
|
||||||
db=db,
|
|
||||||
user_id=user.id,
|
|
||||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
|
||||||
amount_kopeks=final_total,
|
|
||||||
description=description,
|
|
||||||
)
|
|
||||||
except Exception as error: # pragma: no cover - defensive logging
|
|
||||||
logger.warning(
|
|
||||||
"Failed to create renewal transaction for subscription %s: %s",
|
|
||||||
subscription.id,
|
|
||||||
error,
|
|
||||||
)
|
|
||||||
|
|
||||||
await db.refresh(user)
|
|
||||||
await db.refresh(subscription)
|
|
||||||
|
|
||||||
if transaction and old_end_date and subscription.end_date:
|
|
||||||
await _with_admin_notification_service(
|
|
||||||
lambda service: service.send_subscription_extension_notification(
|
|
||||||
db,
|
db,
|
||||||
user,
|
user,
|
||||||
subscription,
|
subscription,
|
||||||
transaction,
|
pricing_model,
|
||||||
period_days,
|
description=description,
|
||||||
old_end_date,
|
|
||||||
new_end_date=subscription.end_date,
|
|
||||||
balance_after=user.balance_kopeks,
|
|
||||||
)
|
)
|
||||||
|
except SubscriptionRenewalChargeError as error:
|
||||||
|
logger.error(
|
||||||
|
"Failed to charge balance for subscription renewal %s: %s",
|
||||||
|
subscription.id,
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail={"code": "charge_failed", "message": "Failed to charge balance"},
|
||||||
|
) from error
|
||||||
|
|
||||||
|
updated_subscription = result.subscription
|
||||||
|
message = _build_renewal_success_message(
|
||||||
|
user,
|
||||||
|
updated_subscription,
|
||||||
|
result.total_amount_kopeks,
|
||||||
|
pricing_model.promo_discount_value,
|
||||||
)
|
)
|
||||||
|
|
||||||
language_code = _normalize_language_code(user)
|
return MiniAppSubscriptionRenewalResponse(
|
||||||
amount_label = settings.format_price(final_total)
|
message=message,
|
||||||
date_label = (
|
balance_kopeks=user.balance_kopeks,
|
||||||
format_local_datetime(subscription.end_date, "%d.%m.%Y %H:%M")
|
balance_label=settings.format_price(user.balance_kopeks),
|
||||||
if subscription.end_date
|
subscription_id=updated_subscription.id,
|
||||||
else ""
|
renewed_until=updated_subscription.end_date,
|
||||||
)
|
)
|
||||||
|
|
||||||
if language_code == "ru":
|
if not method:
|
||||||
if final_total > 0:
|
if final_total > 0 and balance_kopeks < final_total:
|
||||||
message = (
|
missing = final_total - balance_kopeks
|
||||||
f"Подписка продлена до {date_label}. " if date_label else "Подписка продлена. "
|
raise HTTPException(
|
||||||
) + f"Списано {amount_label}."
|
status.HTTP_402_PAYMENT_REQUIRED,
|
||||||
else:
|
detail={
|
||||||
message = (
|
"code": "insufficient_funds",
|
||||||
f"Подписка продлена до {date_label}."
|
"message": "Not enough funds to renew the subscription",
|
||||||
if date_label
|
"missing_amount_kopeks": missing,
|
||||||
else "Подписка успешно продлена."
|
},
|
||||||
)
|
|
||||||
else:
|
|
||||||
if final_total > 0:
|
|
||||||
message = (
|
|
||||||
f"Subscription renewed until {date_label}. " if date_label else "Subscription renewed. "
|
|
||||||
) + f"Charged {amount_label}."
|
|
||||||
else:
|
|
||||||
message = (
|
|
||||||
f"Subscription renewed until {date_label}."
|
|
||||||
if date_label
|
|
||||||
else "Subscription renewed successfully."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
promo_discount_value = pricing.get("promo_discount_value") or 0
|
raise HTTPException(
|
||||||
if consume_promo_offer and promo_discount_value > 0:
|
status.HTTP_400_BAD_REQUEST,
|
||||||
discount_label = settings.format_price(promo_discount_value)
|
detail={
|
||||||
if language_code == "ru":
|
"code": "payment_method_required",
|
||||||
message += f" Применена дополнительная скидка {discount_label}."
|
"message": "Payment method is required when balance is insufficient",
|
||||||
else:
|
},
|
||||||
message += f" Promo discount applied: {discount_label}."
|
)
|
||||||
|
|
||||||
return MiniAppSubscriptionRenewalResponse(
|
supported_methods = {"cryptobot"}
|
||||||
message=message,
|
if method not in supported_methods:
|
||||||
balance_kopeks=user.balance_kopeks,
|
raise HTTPException(
|
||||||
balance_label=settings.format_price(user.balance_kopeks),
|
status.HTTP_400_BAD_REQUEST,
|
||||||
subscription_id=subscription.id,
|
detail={"code": "unsupported_method", "message": "Payment method is not supported for renewal"},
|
||||||
renewed_until=subscription.end_date,
|
)
|
||||||
|
|
||||||
|
if method == "cryptobot":
|
||||||
|
if not settings.is_cryptobot_enabled():
|
||||||
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Payment method is unavailable")
|
||||||
|
|
||||||
|
rate = await _get_usd_to_rub_rate()
|
||||||
|
min_amount_kopeks, max_amount_kopeks = _compute_cryptobot_limits(rate)
|
||||||
|
if missing_amount < min_amount_kopeks:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail={
|
||||||
|
"code": "amount_below_minimum",
|
||||||
|
"message": f"Amount is below minimum ({min_amount_kopeks / 100:.2f} RUB)",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if missing_amount > max_amount_kopeks:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail={
|
||||||
|
"code": "amount_above_maximum",
|
||||||
|
"message": f"Amount exceeds maximum ({max_amount_kopeks / 100:.2f} RUB)",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
decimal_amount = (Decimal(missing_amount) / Decimal(100) / Decimal(str(rate)))
|
||||||
|
amount_usd = float(
|
||||||
|
decimal_amount.quantize(Decimal("0.01"), rounding=ROUND_UP)
|
||||||
|
)
|
||||||
|
except (InvalidOperation, ValueError) as error:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail={"code": "conversion_failed", "message": "Unable to convert amount to USD"},
|
||||||
|
) from error
|
||||||
|
|
||||||
|
if amount_usd <= 0:
|
||||||
|
amount_usd = float(
|
||||||
|
decimal_amount.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||||
|
)
|
||||||
|
|
||||||
|
descriptor = build_payment_descriptor(
|
||||||
|
user.id,
|
||||||
|
subscription.id,
|
||||||
|
period_days,
|
||||||
|
final_total,
|
||||||
|
missing_amount,
|
||||||
|
pricing_snapshot=pricing,
|
||||||
|
)
|
||||||
|
payload_value = encode_payment_payload(descriptor)
|
||||||
|
|
||||||
|
payment_service = PaymentService()
|
||||||
|
result = await payment_service.create_cryptobot_payment(
|
||||||
|
db=db,
|
||||||
|
user_id=user.id,
|
||||||
|
amount_usd=amount_usd,
|
||||||
|
asset=settings.CRYPTOBOT_DEFAULT_ASSET,
|
||||||
|
description=description,
|
||||||
|
payload=payload_value,
|
||||||
|
)
|
||||||
|
if not result:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail={"code": "payment_creation_failed", "message": "Failed to create payment"},
|
||||||
|
)
|
||||||
|
|
||||||
|
payment_url = (
|
||||||
|
result.get("mini_app_invoice_url")
|
||||||
|
or result.get("bot_invoice_url")
|
||||||
|
or result.get("web_app_invoice_url")
|
||||||
|
)
|
||||||
|
if not payment_url:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail={"code": "payment_url_missing", "message": "Failed to obtain payment url"},
|
||||||
|
)
|
||||||
|
|
||||||
|
extra_payload = {
|
||||||
|
"bot_invoice_url": result.get("bot_invoice_url"),
|
||||||
|
"mini_app_invoice_url": result.get("mini_app_invoice_url"),
|
||||||
|
"web_app_invoice_url": result.get("web_app_invoice_url"),
|
||||||
|
}
|
||||||
|
|
||||||
|
message = _build_renewal_pending_message(user, missing_amount, method)
|
||||||
|
|
||||||
|
return MiniAppSubscriptionRenewalResponse(
|
||||||
|
success=False,
|
||||||
|
message=message,
|
||||||
|
balance_kopeks=user.balance_kopeks,
|
||||||
|
balance_label=settings.format_price(user.balance_kopeks),
|
||||||
|
subscription_id=subscription.id,
|
||||||
|
requires_payment=True,
|
||||||
|
payment_method=method,
|
||||||
|
payment_url=payment_url,
|
||||||
|
payment_amount_kopeks=missing_amount,
|
||||||
|
payment_id=result.get("local_payment_id"),
|
||||||
|
invoice_id=result.get("invoice_id"),
|
||||||
|
payment_payload=payload_value,
|
||||||
|
payment_extra={key: value for key, value in extra_payload.items() if value},
|
||||||
|
)
|
||||||
|
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail={"code": "unsupported_method", "message": "Payment method is not supported for renewal"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -4760,7 +4841,7 @@ async def subscription_purchase_endpoint(
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
if subscription and transaction and period_days:
|
if subscription and transaction and period_days:
|
||||||
await _with_admin_notification_service(
|
await with_admin_notification_service(
|
||||||
lambda service: service.send_subscription_purchase_notification(
|
lambda service: service.send_subscription_purchase_notification(
|
||||||
db,
|
db,
|
||||||
user,
|
user,
|
||||||
@@ -4790,7 +4871,10 @@ async def get_subscription_settings_endpoint(
|
|||||||
db: AsyncSession = Depends(get_db_session),
|
db: AsyncSession = Depends(get_db_session),
|
||||||
) -> MiniAppSubscriptionSettingsResponse:
|
) -> MiniAppSubscriptionSettingsResponse:
|
||||||
user = await _authorize_miniapp_user(payload.init_data, db)
|
user = await _authorize_miniapp_user(payload.init_data, db)
|
||||||
subscription = _ensure_paid_subscription(user)
|
subscription = _ensure_paid_subscription(
|
||||||
|
user,
|
||||||
|
allowed_statuses={"active", "trial"},
|
||||||
|
)
|
||||||
_validate_subscription_id(payload.subscription_id, subscription)
|
_validate_subscription_id(payload.subscription_id, subscription)
|
||||||
|
|
||||||
settings_payload = await _build_subscription_settings(db, user, subscription)
|
settings_payload = await _build_subscription_settings(db, user, subscription)
|
||||||
@@ -4807,7 +4891,10 @@ async def update_subscription_servers_endpoint(
|
|||||||
db: AsyncSession = Depends(get_db_session),
|
db: AsyncSession = Depends(get_db_session),
|
||||||
) -> MiniAppSubscriptionUpdateResponse:
|
) -> MiniAppSubscriptionUpdateResponse:
|
||||||
user = await _authorize_miniapp_user(payload.init_data, db)
|
user = await _authorize_miniapp_user(payload.init_data, db)
|
||||||
subscription = _ensure_paid_subscription(user)
|
subscription = _ensure_paid_subscription(
|
||||||
|
user,
|
||||||
|
allowed_statuses={"active", "trial"},
|
||||||
|
)
|
||||||
_validate_subscription_id(payload.subscription_id, subscription)
|
_validate_subscription_id(payload.subscription_id, subscription)
|
||||||
old_servers = list(getattr(subscription, "connected_squads", []) or [])
|
old_servers = list(getattr(subscription, "connected_squads", []) or [])
|
||||||
|
|
||||||
@@ -4989,7 +5076,7 @@ async def update_subscription_servers_endpoint(
|
|||||||
service = SubscriptionService()
|
service = SubscriptionService()
|
||||||
await service.update_remnawave_user(db, subscription)
|
await service.update_remnawave_user(db, subscription)
|
||||||
|
|
||||||
await _with_admin_notification_service(
|
await with_admin_notification_service(
|
||||||
lambda service: service.send_subscription_update_notification(
|
lambda service: service.send_subscription_update_notification(
|
||||||
db,
|
db,
|
||||||
user,
|
user,
|
||||||
@@ -5013,7 +5100,10 @@ async def update_subscription_traffic_endpoint(
|
|||||||
db: AsyncSession = Depends(get_db_session),
|
db: AsyncSession = Depends(get_db_session),
|
||||||
) -> MiniAppSubscriptionUpdateResponse:
|
) -> MiniAppSubscriptionUpdateResponse:
|
||||||
user = await _authorize_miniapp_user(payload.init_data, db)
|
user = await _authorize_miniapp_user(payload.init_data, db)
|
||||||
subscription = _ensure_paid_subscription(user)
|
subscription = _ensure_paid_subscription(
|
||||||
|
user,
|
||||||
|
allowed_statuses={"active", "trial"},
|
||||||
|
)
|
||||||
_validate_subscription_id(payload.subscription_id, subscription)
|
_validate_subscription_id(payload.subscription_id, subscription)
|
||||||
old_traffic = subscription.traffic_limit_gb
|
old_traffic = subscription.traffic_limit_gb
|
||||||
|
|
||||||
@@ -5153,7 +5243,7 @@ async def update_subscription_traffic_endpoint(
|
|||||||
service = SubscriptionService()
|
service = SubscriptionService()
|
||||||
await service.update_remnawave_user(db, subscription)
|
await service.update_remnawave_user(db, subscription)
|
||||||
|
|
||||||
await _with_admin_notification_service(
|
await with_admin_notification_service(
|
||||||
lambda service: service.send_subscription_update_notification(
|
lambda service: service.send_subscription_update_notification(
|
||||||
db,
|
db,
|
||||||
user,
|
user,
|
||||||
@@ -5177,7 +5267,10 @@ async def update_subscription_devices_endpoint(
|
|||||||
db: AsyncSession = Depends(get_db_session),
|
db: AsyncSession = Depends(get_db_session),
|
||||||
) -> MiniAppSubscriptionUpdateResponse:
|
) -> MiniAppSubscriptionUpdateResponse:
|
||||||
user = await _authorize_miniapp_user(payload.init_data, db)
|
user = await _authorize_miniapp_user(payload.init_data, db)
|
||||||
subscription = _ensure_paid_subscription(user)
|
subscription = _ensure_paid_subscription(
|
||||||
|
user,
|
||||||
|
allowed_statuses={"active", "trial"},
|
||||||
|
)
|
||||||
_validate_subscription_id(payload.subscription_id, subscription)
|
_validate_subscription_id(payload.subscription_id, subscription)
|
||||||
|
|
||||||
raw_value = payload.devices if payload.devices is not None else payload.device_limit
|
raw_value = payload.devices if payload.devices is not None else payload.device_limit
|
||||||
@@ -5304,7 +5397,7 @@ async def update_subscription_devices_endpoint(
|
|||||||
service = SubscriptionService()
|
service = SubscriptionService()
|
||||||
await service.update_remnawave_user(db, subscription)
|
await service.update_remnawave_user(db, subscription)
|
||||||
|
|
||||||
await _with_admin_notification_service(
|
await with_admin_notification_service(
|
||||||
lambda service: service.send_subscription_update_notification(
|
lambda service: service.send_subscription_update_notification(
|
||||||
db,
|
db,
|
||||||
user,
|
user,
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from aiogram import Bot
|
||||||
|
from aiogram.client.default import DefaultBotProperties
|
||||||
|
from aiogram.enums import ParseMode
|
||||||
from fastapi import (
|
from fastapi import (
|
||||||
APIRouter,
|
APIRouter,
|
||||||
Depends,
|
Depends,
|
||||||
@@ -23,6 +26,8 @@ from app.database.crud.poll import (
|
|||||||
get_poll_statistics,
|
get_poll_statistics,
|
||||||
)
|
)
|
||||||
from app.database.models import Poll, PollAnswer, PollOption, PollQuestion, PollResponse
|
from app.database.models import Poll, PollAnswer, PollOption, PollQuestion, PollResponse
|
||||||
|
from app.handlers.admin.messages import get_custom_users, get_target_users
|
||||||
|
from app.services.poll_service import send_poll_to_users
|
||||||
|
|
||||||
from ..dependencies import get_db_session, require_api_token
|
from ..dependencies import get_db_session, require_api_token
|
||||||
from ..schemas.polls import (
|
from ..schemas.polls import (
|
||||||
@@ -38,6 +43,8 @@ from ..schemas.polls import (
|
|||||||
PollStatisticsResponse,
|
PollStatisticsResponse,
|
||||||
PollSummaryResponse,
|
PollSummaryResponse,
|
||||||
PollUserResponse,
|
PollUserResponse,
|
||||||
|
PollSendRequest,
|
||||||
|
PollSendResponse,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -306,3 +313,55 @@ async def get_poll_responses(
|
|||||||
limit=limit,
|
limit=limit,
|
||||||
offset=offset,
|
offset=offset,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{poll_id}/send", response_model=PollSendResponse)
|
||||||
|
async def send_poll(
|
||||||
|
poll_id: int,
|
||||||
|
payload: PollSendRequest,
|
||||||
|
_: Any = Security(require_api_token),
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> PollSendResponse:
|
||||||
|
poll = await get_poll_by_id(db, poll_id)
|
||||||
|
if not poll:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Poll not found")
|
||||||
|
|
||||||
|
target = payload.target.strip()
|
||||||
|
if not target:
|
||||||
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Target must not be empty")
|
||||||
|
|
||||||
|
if target.startswith("custom_"):
|
||||||
|
users = await get_custom_users(db, target.replace("custom_", ""))
|
||||||
|
else:
|
||||||
|
users = await get_target_users(db, target)
|
||||||
|
|
||||||
|
if not users:
|
||||||
|
return PollSendResponse(
|
||||||
|
poll_id=poll_id,
|
||||||
|
target=target,
|
||||||
|
sent=0,
|
||||||
|
failed=0,
|
||||||
|
skipped=0,
|
||||||
|
total=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
bot = Bot(
|
||||||
|
token=settings.BOT_TOKEN,
|
||||||
|
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await send_poll_to_users(bot, db, poll, users)
|
||||||
|
finally:
|
||||||
|
await bot.session.close()
|
||||||
|
|
||||||
|
return PollSendResponse(
|
||||||
|
poll_id=poll_id,
|
||||||
|
target=target,
|
||||||
|
sent=result.get("sent", 0),
|
||||||
|
failed=result.get("failed", 0),
|
||||||
|
skipped=result.get("skipped", 0),
|
||||||
|
total=result.get("total", 0),
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Security, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, Security, status
|
||||||
@@ -8,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
from app.database.crud.server_squad import get_random_trial_squad_uuid
|
||||||
from app.database.crud.subscription import (
|
from app.database.crud.subscription import (
|
||||||
add_subscription_devices,
|
add_subscription_devices,
|
||||||
add_subscription_squad,
|
add_subscription_squad,
|
||||||
@@ -16,6 +18,7 @@ from app.database.crud.subscription import (
|
|||||||
create_trial_subscription,
|
create_trial_subscription,
|
||||||
extend_subscription,
|
extend_subscription,
|
||||||
get_subscription_by_user_id,
|
get_subscription_by_user_id,
|
||||||
|
replace_subscription,
|
||||||
remove_subscription_squad,
|
remove_subscription_squad,
|
||||||
)
|
)
|
||||||
from app.database.models import Subscription, SubscriptionStatus
|
from app.database.models import Subscription, SubscriptionStatus
|
||||||
@@ -30,6 +33,8 @@ from ..schemas.subscriptions import (
|
|||||||
SubscriptionTrafficRequest,
|
SubscriptionTrafficRequest,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@@ -55,6 +60,28 @@ def _serialize_subscription(subscription: Subscription) -> SubscriptionResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _choose_trial_squads(
|
||||||
|
db: AsyncSession, requested_squad_uuid: Optional[str], fallback_squads: list[str]
|
||||||
|
) -> list[str]:
|
||||||
|
if requested_squad_uuid:
|
||||||
|
return [requested_squad_uuid]
|
||||||
|
|
||||||
|
if fallback_squads:
|
||||||
|
return fallback_squads
|
||||||
|
|
||||||
|
try:
|
||||||
|
squad_uuid = await get_random_trial_squad_uuid(db)
|
||||||
|
except Exception as error:
|
||||||
|
logger.error("Failed to select trial squad: %s", error)
|
||||||
|
squad_uuid = None
|
||||||
|
|
||||||
|
if not squad_uuid:
|
||||||
|
return []
|
||||||
|
|
||||||
|
logger.debug("Selected trial squad %s for subscription replacement", squad_uuid)
|
||||||
|
return [squad_uuid]
|
||||||
|
|
||||||
|
|
||||||
async def _get_subscription(db: AsyncSession, subscription_id: int) -> Subscription:
|
async def _get_subscription(db: AsyncSession, subscription_id: int) -> Subscription:
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Subscription)
|
select(Subscription)
|
||||||
@@ -109,7 +136,7 @@ async def create_subscription(
|
|||||||
db: AsyncSession = Depends(get_db_session),
|
db: AsyncSession = Depends(get_db_session),
|
||||||
) -> SubscriptionResponse:
|
) -> SubscriptionResponse:
|
||||||
existing = await get_subscription_by_user_id(db, payload.user_id)
|
existing = await get_subscription_by_user_id(db, payload.user_id)
|
||||||
if existing:
|
if existing and not payload.replace_existing:
|
||||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "User already has a subscription")
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "User already has a subscription")
|
||||||
|
|
||||||
forced_devices = None
|
forced_devices = None
|
||||||
@@ -120,15 +147,36 @@ async def create_subscription(
|
|||||||
trial_device_limit = payload.device_limit
|
trial_device_limit = payload.device_limit
|
||||||
if trial_device_limit is None:
|
if trial_device_limit is None:
|
||||||
trial_device_limit = forced_devices
|
trial_device_limit = forced_devices
|
||||||
|
duration_days = payload.duration_days or settings.TRIAL_DURATION_DAYS
|
||||||
|
traffic_limit_gb = payload.traffic_limit_gb or settings.TRIAL_TRAFFIC_LIMIT_GB
|
||||||
|
|
||||||
subscription = await create_trial_subscription(
|
if existing:
|
||||||
db,
|
connected_squads = await _choose_trial_squads(
|
||||||
user_id=payload.user_id,
|
db, payload.squad_uuid, list(existing.connected_squads or [])
|
||||||
duration_days=payload.duration_days,
|
)
|
||||||
traffic_limit_gb=payload.traffic_limit_gb,
|
subscription = await replace_subscription(
|
||||||
device_limit=trial_device_limit,
|
db,
|
||||||
squad_uuid=payload.squad_uuid,
|
existing,
|
||||||
)
|
duration_days=duration_days,
|
||||||
|
traffic_limit_gb=traffic_limit_gb,
|
||||||
|
device_limit=(
|
||||||
|
trial_device_limit
|
||||||
|
if trial_device_limit is not None
|
||||||
|
else settings.TRIAL_DEVICE_LIMIT
|
||||||
|
),
|
||||||
|
connected_squads=connected_squads,
|
||||||
|
is_trial=True,
|
||||||
|
update_server_counters=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
subscription = await create_trial_subscription(
|
||||||
|
db,
|
||||||
|
user_id=payload.user_id,
|
||||||
|
duration_days=duration_days,
|
||||||
|
traffic_limit_gb=traffic_limit_gb,
|
||||||
|
device_limit=trial_device_limit,
|
||||||
|
squad_uuid=payload.squad_uuid,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
if payload.duration_days is None:
|
if payload.duration_days is None:
|
||||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "duration_days is required for paid subscriptions")
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "duration_days is required for paid subscriptions")
|
||||||
@@ -138,15 +186,27 @@ async def create_subscription(
|
|||||||
device_limit = forced_devices
|
device_limit = forced_devices
|
||||||
else:
|
else:
|
||||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||||
subscription = await create_paid_subscription(
|
if existing:
|
||||||
db,
|
subscription = await replace_subscription(
|
||||||
user_id=payload.user_id,
|
db,
|
||||||
duration_days=payload.duration_days,
|
existing,
|
||||||
traffic_limit_gb=payload.traffic_limit_gb or settings.DEFAULT_TRAFFIC_LIMIT_GB,
|
duration_days=payload.duration_days,
|
||||||
device_limit=device_limit,
|
traffic_limit_gb=payload.traffic_limit_gb or settings.DEFAULT_TRAFFIC_LIMIT_GB,
|
||||||
connected_squads=payload.connected_squads or [],
|
device_limit=device_limit,
|
||||||
update_server_counters=True,
|
connected_squads=payload.connected_squads or [],
|
||||||
)
|
is_trial=False,
|
||||||
|
update_server_counters=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
subscription = await create_paid_subscription(
|
||||||
|
db,
|
||||||
|
user_id=payload.user_id,
|
||||||
|
duration_days=payload.duration_days,
|
||||||
|
traffic_limit_gb=payload.traffic_limit_gb or settings.DEFAULT_TRAFFIC_LIMIT_GB,
|
||||||
|
device_limit=device_limit,
|
||||||
|
connected_squads=payload.connected_squads or [],
|
||||||
|
update_server_counters=True,
|
||||||
|
)
|
||||||
|
|
||||||
subscription = await _get_subscription(db, subscription.id)
|
subscription = await _get_subscription(db, subscription.id)
|
||||||
return _serialize_subscription(subscription)
|
return _serialize_subscription(subscription)
|
||||||
|
|||||||
@@ -6,7 +6,12 @@ from typing import Any, Optional
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Security, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, Security, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database.crud.ticket import TicketCRUD
|
from aiogram import Bot
|
||||||
|
from aiogram.client.default import DefaultBotProperties
|
||||||
|
from aiogram.enums import ParseMode
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.database.crud.ticket import TicketCRUD, TicketMessageCRUD
|
||||||
from app.database.models import Ticket, TicketMessage, TicketStatus
|
from app.database.models import Ticket, TicketMessage, TicketStatus
|
||||||
|
|
||||||
from ..dependencies import get_db_session, require_api_token
|
from ..dependencies import get_db_session, require_api_token
|
||||||
@@ -14,7 +19,10 @@ from ..schemas.tickets import (
|
|||||||
TicketMessageResponse,
|
TicketMessageResponse,
|
||||||
TicketPriorityUpdateRequest,
|
TicketPriorityUpdateRequest,
|
||||||
TicketReplyBlockRequest,
|
TicketReplyBlockRequest,
|
||||||
|
TicketReplyRequest,
|
||||||
|
TicketReplyResponse,
|
||||||
TicketResponse,
|
TicketResponse,
|
||||||
|
TicketMediaResponse,
|
||||||
TicketStatusUpdateRequest,
|
TicketStatusUpdateRequest,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -29,6 +37,7 @@ def _serialize_message(message: TicketMessage) -> TicketMessageResponse:
|
|||||||
is_from_admin=message.is_from_admin,
|
is_from_admin=message.is_from_admin,
|
||||||
has_media=message.has_media,
|
has_media=message.has_media,
|
||||||
media_type=message.media_type,
|
media_type=message.media_type,
|
||||||
|
media_file_id=message.media_file_id,
|
||||||
media_caption=message.media_caption,
|
media_caption=message.media_caption,
|
||||||
created_at=message.created_at,
|
created_at=message.created_at,
|
||||||
)
|
)
|
||||||
@@ -183,3 +192,80 @@ async def clear_reply_block(
|
|||||||
|
|
||||||
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True, load_user=False)
|
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True, load_user=False)
|
||||||
return _serialize_ticket(ticket, include_messages=True)
|
return _serialize_ticket(ticket, include_messages=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{ticket_id}/reply", response_model=TicketReplyResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def reply_to_ticket(
|
||||||
|
ticket_id: int,
|
||||||
|
payload: TicketReplyRequest,
|
||||||
|
_: Any = Security(require_api_token),
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> TicketReplyResponse:
|
||||||
|
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=False, load_user=True)
|
||||||
|
if not ticket:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Ticket not found")
|
||||||
|
|
||||||
|
message_text = (payload.message_text or "").strip()
|
||||||
|
if not message_text and not payload.media_file_id:
|
||||||
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Message text or media is required")
|
||||||
|
|
||||||
|
final_message_text = message_text or (payload.media_caption or "").strip() or "[media]"
|
||||||
|
|
||||||
|
message = await TicketMessageCRUD.add_message(
|
||||||
|
db,
|
||||||
|
ticket_id=ticket_id,
|
||||||
|
user_id=ticket.user_id,
|
||||||
|
message_text=final_message_text,
|
||||||
|
is_from_admin=True,
|
||||||
|
media_type=payload.media_type,
|
||||||
|
media_file_id=payload.media_file_id,
|
||||||
|
media_caption=payload.media_caption,
|
||||||
|
)
|
||||||
|
|
||||||
|
bot = Bot(
|
||||||
|
token=settings.BOT_TOKEN,
|
||||||
|
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
from app.handlers.admin.tickets import notify_user_about_ticket_reply
|
||||||
|
|
||||||
|
await notify_user_about_ticket_reply(bot, ticket, final_message_text, db)
|
||||||
|
finally:
|
||||||
|
await bot.session.close()
|
||||||
|
|
||||||
|
ticket_with_messages = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True, load_user=False)
|
||||||
|
|
||||||
|
return TicketReplyResponse(
|
||||||
|
ticket=_serialize_ticket(ticket_with_messages, include_messages=True),
|
||||||
|
message=_serialize_message(message),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{ticket_id}/messages/{message_id}/media",
|
||||||
|
response_model=TicketMediaResponse,
|
||||||
|
)
|
||||||
|
async def get_ticket_message_media(
|
||||||
|
ticket_id: int,
|
||||||
|
message_id: int,
|
||||||
|
_: Any = Security(require_api_token),
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> TicketMediaResponse:
|
||||||
|
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True, load_user=False)
|
||||||
|
if not ticket:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Ticket not found")
|
||||||
|
|
||||||
|
message = next((m for m in ticket.messages if m.id == message_id), None)
|
||||||
|
if not message:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Message not found")
|
||||||
|
|
||||||
|
if not message.has_media or not message.media_file_id or not message.media_type:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Media not found for this message")
|
||||||
|
|
||||||
|
return TicketMediaResponse(
|
||||||
|
id=message.id,
|
||||||
|
ticket_id=ticket.id,
|
||||||
|
media_type=message.media_type,
|
||||||
|
media_file_id=message.media_file_id,
|
||||||
|
media_caption=message.media_caption,
|
||||||
|
)
|
||||||
|
|||||||
@@ -171,6 +171,22 @@ async def get_user(
|
|||||||
return _serialize_user(user)
|
return _serialize_user(user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/by-telegram-id/{telegram_id}", response_model=UserResponse)
|
||||||
|
async def get_user_by_telegram_id_endpoint(
|
||||||
|
telegram_id: int,
|
||||||
|
_: Any = Security(require_api_token),
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> UserResponse:
|
||||||
|
"""
|
||||||
|
Get user by Telegram ID
|
||||||
|
"""
|
||||||
|
user = await get_user_by_telegram_id(db, telegram_id)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
|
||||||
|
|
||||||
|
return _serialize_user(user)
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
@router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||||||
async def create_user_endpoint(
|
async def create_user_endpoint(
|
||||||
payload: UserCreateRequest,
|
payload: UserCreateRequest,
|
||||||
|
|||||||
@@ -88,3 +88,14 @@ class SystemLogPreviewResponse(BaseModel):
|
|||||||
default=None,
|
default=None,
|
||||||
description="Относительный путь до endpoint для скачивания лог-файла",
|
description="Относительный путь до endpoint для скачивания лог-файла",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SystemLogFullResponse(BaseModel):
|
||||||
|
"""Полное содержимое системного лог-файла."""
|
||||||
|
|
||||||
|
path: str
|
||||||
|
exists: bool
|
||||||
|
updated_at: Optional[datetime] = None
|
||||||
|
size_bytes: int
|
||||||
|
size_chars: int
|
||||||
|
content: str
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, ConfigDict, model_validator
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||||
|
|
||||||
|
|
||||||
class MiniAppBranding(BaseModel):
|
class MiniAppBranding(BaseModel):
|
||||||
@@ -200,6 +202,7 @@ class MiniAppSubscriptionRenewalRequest(BaseModel):
|
|||||||
subscription_id: Optional[int] = Field(default=None, alias="subscriptionId")
|
subscription_id: Optional[int] = Field(default=None, alias="subscriptionId")
|
||||||
period_id: Optional[str] = Field(default=None, alias="periodId")
|
period_id: Optional[str] = Field(default=None, alias="periodId")
|
||||||
period_days: Optional[int] = Field(default=None, alias="periodDays")
|
period_days: Optional[int] = Field(default=None, alias="periodDays")
|
||||||
|
method: Optional[str] = None
|
||||||
|
|
||||||
model_config = ConfigDict(populate_by_name=True)
|
model_config = ConfigDict(populate_by_name=True)
|
||||||
|
|
||||||
@@ -211,6 +214,14 @@ class MiniAppSubscriptionRenewalResponse(BaseModel):
|
|||||||
balance_label: Optional[str] = Field(default=None, alias="balanceLabel")
|
balance_label: Optional[str] = Field(default=None, alias="balanceLabel")
|
||||||
subscription_id: Optional[int] = Field(default=None, alias="subscriptionId")
|
subscription_id: Optional[int] = Field(default=None, alias="subscriptionId")
|
||||||
renewed_until: Optional[datetime] = Field(default=None, alias="renewedUntil")
|
renewed_until: Optional[datetime] = Field(default=None, alias="renewedUntil")
|
||||||
|
requires_payment: bool = Field(default=False, alias="requiresPayment")
|
||||||
|
payment_method: Optional[str] = Field(default=None, alias="paymentMethod")
|
||||||
|
payment_url: Optional[str] = Field(default=None, alias="paymentUrl")
|
||||||
|
payment_amount_kopeks: Optional[int] = Field(default=None, alias="paymentAmountKopeks")
|
||||||
|
payment_id: Optional[int] = Field(default=None, alias="paymentId")
|
||||||
|
invoice_id: Optional[str] = Field(default=None, alias="invoiceId")
|
||||||
|
payment_payload: Optional[str] = Field(default=None, alias="paymentPayload")
|
||||||
|
payment_extra: Optional[Dict[str, Any]] = Field(default=None, alias="paymentExtra")
|
||||||
|
|
||||||
model_config = ConfigDict(populate_by_name=True)
|
model_config = ConfigDict(populate_by_name=True)
|
||||||
|
|
||||||
@@ -357,6 +368,30 @@ class MiniAppPaymentMethodsRequest(BaseModel):
|
|||||||
init_data: str = Field(..., alias="initData")
|
init_data: str = Field(..., alias="initData")
|
||||||
|
|
||||||
|
|
||||||
|
class MiniAppPaymentIntegrationType(str, Enum):
|
||||||
|
IFRAME = "iframe"
|
||||||
|
REDIRECT = "redirect"
|
||||||
|
|
||||||
|
|
||||||
|
class MiniAppPaymentIframeConfig(BaseModel):
|
||||||
|
expected_origin: str
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _normalize_expected_origin(
|
||||||
|
cls, values: "MiniAppPaymentIframeConfig"
|
||||||
|
) -> "MiniAppPaymentIframeConfig":
|
||||||
|
origin = (values.expected_origin or "").strip()
|
||||||
|
if not origin:
|
||||||
|
raise ValueError("expected_origin must not be empty")
|
||||||
|
|
||||||
|
parsed = urlparse(origin)
|
||||||
|
if not parsed.scheme or not parsed.netloc:
|
||||||
|
raise ValueError("expected_origin must include scheme and host")
|
||||||
|
|
||||||
|
values.expected_origin = f"{parsed.scheme}://{parsed.netloc}"
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
class MiniAppPaymentMethod(BaseModel):
|
class MiniAppPaymentMethod(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
@@ -366,6 +401,17 @@ class MiniAppPaymentMethod(BaseModel):
|
|||||||
min_amount_kopeks: Optional[int] = None
|
min_amount_kopeks: Optional[int] = None
|
||||||
max_amount_kopeks: Optional[int] = None
|
max_amount_kopeks: Optional[int] = None
|
||||||
amount_step_kopeks: Optional[int] = None
|
amount_step_kopeks: Optional[int] = None
|
||||||
|
integration_type: MiniAppPaymentIntegrationType
|
||||||
|
iframe_config: Optional[MiniAppPaymentIframeConfig] = None
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _ensure_iframe_config(cls, values: "MiniAppPaymentMethod") -> "MiniAppPaymentMethod":
|
||||||
|
if (
|
||||||
|
values.integration_type == MiniAppPaymentIntegrationType.IFRAME
|
||||||
|
and values.iframe_config is None
|
||||||
|
):
|
||||||
|
raise ValueError("iframe_config is required when integration_type is 'iframe'")
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
class MiniAppPaymentMethodsResponse(BaseModel):
|
class MiniAppPaymentMethodsResponse(BaseModel):
|
||||||
|
|||||||
@@ -169,3 +169,23 @@ class PollResponsesListResponse(BaseModel):
|
|||||||
total: int
|
total: int
|
||||||
limit: int
|
limit: int
|
||||||
offset: int
|
offset: int
|
||||||
|
|
||||||
|
|
||||||
|
class PollSendRequest(BaseModel):
|
||||||
|
target: str = Field(
|
||||||
|
...,
|
||||||
|
description=(
|
||||||
|
"Аудитория для отправки опроса (например: all, active, trial, "
|
||||||
|
"custom_today и т.д.)"
|
||||||
|
),
|
||||||
|
max_length=100,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PollSendResponse(BaseModel):
|
||||||
|
poll_id: int
|
||||||
|
target: str
|
||||||
|
sent: int
|
||||||
|
failed: int
|
||||||
|
skipped: int
|
||||||
|
total: int
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ class SubscriptionCreateRequest(BaseModel):
|
|||||||
device_limit: Optional[int] = None
|
device_limit: Optional[int] = None
|
||||||
squad_uuid: Optional[str] = None
|
squad_uuid: Optional[str] = None
|
||||||
connected_squads: Optional[List[str]] = None
|
connected_squads: Optional[List[str]] = None
|
||||||
|
replace_existing: bool = False
|
||||||
|
|
||||||
|
|
||||||
class SubscriptionExtendRequest(BaseModel):
|
class SubscriptionExtendRequest(BaseModel):
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ class TicketMessageResponse(BaseModel):
|
|||||||
is_from_admin: bool
|
is_from_admin: bool
|
||||||
has_media: bool
|
has_media: bool
|
||||||
media_type: Optional[str] = None
|
media_type: Optional[str] = None
|
||||||
|
media_file_id: Optional[str] = None
|
||||||
media_caption: Optional[str] = None
|
media_caption: Optional[str] = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
@@ -42,3 +43,27 @@ class TicketPriorityUpdateRequest(BaseModel):
|
|||||||
class TicketReplyBlockRequest(BaseModel):
|
class TicketReplyBlockRequest(BaseModel):
|
||||||
permanent: bool = False
|
permanent: bool = False
|
||||||
until: Optional[datetime] = None
|
until: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class TicketReplyRequest(BaseModel):
|
||||||
|
message_text: Optional[str] = Field(default=None, max_length=4000)
|
||||||
|
media_type: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Тип медиа (photo, video, document, voice и т.д.)",
|
||||||
|
max_length=32,
|
||||||
|
)
|
||||||
|
media_file_id: Optional[str] = Field(default=None, max_length=255)
|
||||||
|
media_caption: Optional[str] = Field(default=None, max_length=4000)
|
||||||
|
|
||||||
|
|
||||||
|
class TicketReplyResponse(BaseModel):
|
||||||
|
ticket: TicketResponse
|
||||||
|
message: TicketMessageResponse
|
||||||
|
|
||||||
|
|
||||||
|
class TicketMediaResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
ticket_id: int
|
||||||
|
media_type: str
|
||||||
|
media_file_id: str
|
||||||
|
media_caption: Optional[str] = None
|
||||||
|
|||||||
@@ -151,4 +151,36 @@ Caddy автоматически выпустит сертификаты чер
|
|||||||
| Mini App не открывается в Telegram | URL не соответствует HTTPS или отсутствует сертификат. | Обновите сертификаты и убедитесь, что домен доступен по HTTPS. |
|
| Mini App не открывается в Telegram | URL не соответствует HTTPS или отсутствует сертификат. | Обновите сертификаты и убедитесь, что домен доступен по HTTPS. |
|
||||||
| Нет ссылок подписки | Не настроена интеграция с RemnaWave или у пользователя нет активной подписки. | Проверьте `REMNAWAVE_API_URL/KEY` и статус подписки пользователя. |
|
| Нет ссылок подписки | Не настроена интеграция с RemnaWave или у пользователя нет активной подписки. | Проверьте `REMNAWAVE_API_URL/KEY` и статус подписки пользователя. |
|
||||||
|
|
||||||
|
## 11. Формат ответа `/miniapp/payments/methods`
|
||||||
|
|
||||||
|
Эндпоинт `POST /miniapp/payments/methods` возвращает список доступных способов оплаты. Каждый элемент массива `methods` содержит базовые поля (`id`, `title`, `description`, ограничения по сумме) и дополнительные атрибуты, управляющие интеграцией во фронтенде:
|
||||||
|
|
||||||
|
- `integration_type` — тип интеграции, обязательное поле. Возможные значения:
|
||||||
|
- `iframe` — форму оплаты нужно отображать внутри мини-приложения в `<iframe>`.
|
||||||
|
- `redirect` — необходимо выполнить переход на внешнюю страницу.
|
||||||
|
- `iframe_config` — объект с настройками, присутствует только при `integration_type = "iframe"`.
|
||||||
|
- `expected_origin` — origin страницы платёжного провайдера. Значение используется при проверке `event.origin` для сообщений, полученных через `postMessage` от платежного iframe.
|
||||||
|
|
||||||
|
Пример фрагмента ответа:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"methods": [
|
||||||
|
{
|
||||||
|
"id": "mulenpay",
|
||||||
|
"title": "Банковская карта",
|
||||||
|
"integration_type": "iframe",
|
||||||
|
"iframe_config": {
|
||||||
|
"expected_origin": "https://checkout.example"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cryptobot",
|
||||||
|
"title": "CryptoBot",
|
||||||
|
"integration_type": "redirect"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
После настройки вы сможете использовать мини-приложение в бот-меню и отправлять ссылку пользователям напрямую.
|
После настройки вы сможете использовать мини-приложение в бот-меню и отправлять ссылку пользователям напрямую.
|
||||||
|
|||||||
-1451
File diff suppressed because it is too large
Load Diff
+5
-5
@@ -13955,7 +13955,7 @@
|
|||||||
function ensureSubscriptionRenewalData(options = {}) {
|
function ensureSubscriptionRenewalData(options = {}) {
|
||||||
const { force = false } = options;
|
const { force = false } = options;
|
||||||
|
|
||||||
if (!hasPaidSubscription()) {
|
if (!hasActiveSubscription()) {
|
||||||
resetSubscriptionRenewalState(null);
|
resetSubscriptionRenewalState(null);
|
||||||
renderSubscriptionRenewalCard();
|
renderSubscriptionRenewalCard();
|
||||||
return Promise.resolve(null);
|
return Promise.resolve(null);
|
||||||
@@ -14484,7 +14484,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const shouldShow = hasPaidSubscription();
|
const shouldShow = hasActiveSubscription();
|
||||||
card.classList.toggle('hidden', !shouldShow);
|
card.classList.toggle('hidden', !shouldShow);
|
||||||
if (!shouldShow) {
|
if (!shouldShow) {
|
||||||
return;
|
return;
|
||||||
@@ -14601,7 +14601,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasPaidSubscription()) {
|
if (!hasActiveSubscription()) {
|
||||||
handleSubscriptionRenewalError(createError('Subscription renewal', t('subscription_renewal.error.validation')));
|
handleSubscriptionRenewalError(createError('Subscription renewal', t('subscription_renewal.error.validation')));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -14784,7 +14784,7 @@
|
|||||||
|
|
||||||
function ensureSubscriptionSettingsLoaded(options = {}) {
|
function ensureSubscriptionSettingsLoaded(options = {}) {
|
||||||
const { force = false } = options;
|
const { force = false } = options;
|
||||||
if (!hasPaidSubscription()) {
|
if (!hasActiveSubscription()) {
|
||||||
subscriptionSettingsData = null;
|
subscriptionSettingsData = null;
|
||||||
subscriptionSettingsPromise = null;
|
subscriptionSettingsPromise = null;
|
||||||
subscriptionSettingsError = null;
|
subscriptionSettingsError = null;
|
||||||
@@ -14912,7 +14912,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const shouldShow = hasPaidSubscription();
|
const shouldShow = hasActiveSubscription();
|
||||||
card.classList.toggle('hidden', !shouldShow);
|
card.classList.toggle('hidden', !shouldShow);
|
||||||
if (!shouldShow) {
|
if (!shouldShow) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -0,0 +1,282 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Оплата не прошла</title>
|
||||||
|
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||||
|
<style>
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap');
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@700&display=swap');
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--cyber-grid-color: rgba(0, 200, 255, 0.1);
|
||||||
|
--cyber-line-color: rgba(0, 255, 200, 0.1);
|
||||||
|
--brand-neon-color: #00aaff;
|
||||||
|
--error-neon-color: #ff4d4d;
|
||||||
|
--bg-color: #1a1a2e;
|
||||||
|
--container-bg-color: rgba(20, 20, 35, 0.85);
|
||||||
|
--text-primary: #ffffff;
|
||||||
|
--text-secondary: #c0c0d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
/* [!] ИСПРАВЛЕНИЕ: Уменьшен горизонтальный отступ для body */
|
||||||
|
padding: 20px 15px;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
/* [!] ИСПРАВЛЕНИЕ: height -> min-height для лучшей совместимости */
|
||||||
|
min-height: 100vh;
|
||||||
|
|
||||||
|
box-sizing: border-box;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
body::before,
|
||||||
|
body::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
body::before {
|
||||||
|
background:
|
||||||
|
linear-gradient(to right, var(--cyber-grid-color) 1px, transparent 1px),
|
||||||
|
linear-gradient(to bottom, var(--cyber-grid-color) 1px, transparent 1px);
|
||||||
|
background-size: 50px 50px;
|
||||||
|
animation: gridScroll 20s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes gridScroll {
|
||||||
|
from { background-position: 0 0; }
|
||||||
|
to { background-position: -50px -50px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
body::after {
|
||||||
|
background:
|
||||||
|
linear-gradient(45deg, transparent 49%, var(--cyber-line-color) 50%, transparent 51%),
|
||||||
|
linear-gradient(-45deg, transparent 49%, var(--cyber-line-color) 50%, transparent 51%);
|
||||||
|
background-size: 100% 100%;
|
||||||
|
animation: cyberScan 10s linear infinite alternate;
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cyberScan {
|
||||||
|
0% { background-position: 0% 0%; }
|
||||||
|
100% { background-position: 100% 100%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
background-color: var(--container-bg-color);
|
||||||
|
border-radius: 16px;
|
||||||
|
|
||||||
|
/* [!] ИСПРАВЛЕНИЕ: Уменьшен горизонтальный отступ для .container */
|
||||||
|
padding: 30px 25px;
|
||||||
|
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
|
||||||
|
max-width: 400px;
|
||||||
|
width: 100%;
|
||||||
|
transform: scale(0.95);
|
||||||
|
opacity: 0;
|
||||||
|
animation: fadeInScale 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) 0.2s forwards;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
border: 1px solid rgba(0, 200, 255, 0.2);
|
||||||
|
backdrop-filter: blur(5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInScale {
|
||||||
|
to {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-container {
|
||||||
|
margin: 0 auto 25px auto;
|
||||||
|
line-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
height: 60px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-text {
|
||||||
|
font-family: 'Orbitron', sans-serif;
|
||||||
|
font-size: 48px;
|
||||||
|
color: var(--brand-neon-color);
|
||||||
|
text-shadow: 0 0 10px var(--brand-neon-color), 0 0 20px var(--brand-neon-color);
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-20px);
|
||||||
|
animation: logoSlideIn 0.8s ease-out 0.4s forwards, logoPulse 2s infinite ease-in-out 1.5s forwards;
|
||||||
|
white-space: nowrap;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes logoSlideIn {
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes logoPulse {
|
||||||
|
0% { transform: scale(1); text-shadow: 0 0 10px var(--brand-neon-color), 0 0 20px var(--brand-neon-color); }
|
||||||
|
50% { transform: scale(1.02); text-shadow: 0 0 15px var(--brand-neon-color), 0 0 25px var(--brand-neon-color), 0 0 35px var(--brand-neon-color); }
|
||||||
|
100% { transform: scale(1); text-shadow: 0 0 10px var(--brand-neon-color), 0 0 20px var(--brand-neon-color); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-icon {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
margin: 0 auto 20px auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-icon svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
filter: drop-shadow(0 0 8px rgba(255, 77, 77, 0.7));
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-circle {
|
||||||
|
stroke-dasharray: 166;
|
||||||
|
stroke-dashoffset: 166;
|
||||||
|
stroke-width: 3;
|
||||||
|
stroke-miterlimit: 10;
|
||||||
|
stroke: var(--error-neon-color);
|
||||||
|
fill: none;
|
||||||
|
animation: stroke 0.6s cubic-bezier(0.65, 0, 0.45, 1) 0.5s forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-line {
|
||||||
|
transform-origin: 50% 50%;
|
||||||
|
stroke-dasharray: 48;
|
||||||
|
stroke-dashoffset: 48;
|
||||||
|
stroke-width: 4;
|
||||||
|
stroke: var(--error-neon-color);
|
||||||
|
fill: none;
|
||||||
|
animation: stroke 0.3s cubic-bezier(0.65, 0, 0.45, 1) 1s forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes stroke {
|
||||||
|
100% {
|
||||||
|
stroke-dashoffset: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-text, .instruction-text {
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 15px;
|
||||||
|
text-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instruction-text {
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-button {
|
||||||
|
display: inline-block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--bg-color);
|
||||||
|
background-color: var(--brand-neon-color);
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
box-sizing: border-box;
|
||||||
|
box-shadow: 0 0 15px rgba(0, 170, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-button:hover {
|
||||||
|
background-color: #00c0ff;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 0 20px rgba(0, 170, 255, 0.8), 0 0 30px rgba(0, 170, 255, 0.4);
|
||||||
|
}
|
||||||
|
.cta-button:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
box-shadow: 0 0 10px rgba(0, 170, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
|
||||||
|
<div class="logo-container">
|
||||||
|
<span class="logo-text">Bedolaga</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="error-icon">
|
||||||
|
<svg class="error-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 52 52">
|
||||||
|
<circle class="error-circle" cx="26" cy="26" r="25" fill="none"/>
|
||||||
|
<path class="error-line" fill="none" d="M16 16 36 36"/>
|
||||||
|
<path class="error-line" fill="none" d="M36 16 16 36"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1>Оплата не прошла</h1>
|
||||||
|
|
||||||
|
<p class="info-text">
|
||||||
|
К сожалению, не удалось обработать ваш платеж. Средства не были списаны.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="instruction-text">
|
||||||
|
Пожалуйста, вернитесь в бота, проверьте данные и попробуйте еще раз.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button id="close-btn" class="cta-button">
|
||||||
|
Вернуться в бота
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
try {
|
||||||
|
if (window.Telegram && window.Telegram.WebApp) {
|
||||||
|
Telegram.WebApp.ready();
|
||||||
|
var closeButton = document.getElementById('close-btn');
|
||||||
|
closeButton.addEventListener('click', function() {
|
||||||
|
Telegram.WebApp.close();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.warn('Telegram WebApp JS not loaded or not in TWA environment.');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Binary file not shown.
@@ -0,0 +1,295 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Оплата прошла успешно</title>
|
||||||
|
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||||
|
<style>
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap');
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@700&display=swap');
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--cyber-grid-color: rgba(0, 200, 255, 0.1);
|
||||||
|
--cyber-line-color: rgba(0, 255, 200, 0.1);
|
||||||
|
--brand-neon-color: #00aaff;
|
||||||
|
--success-neon-color: #00e676;
|
||||||
|
--bg-color: #1a1a2e;
|
||||||
|
--container-bg-color: rgba(20, 20, 35, 0.85);
|
||||||
|
--text-primary: #ffffff;
|
||||||
|
--text-secondary: #c0c0d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
margin: 0;
|
||||||
|
padding: 20px 15px; /* Исправлено для мобильных */
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 100vh; /* Исправлено для мобильных */
|
||||||
|
box-sizing: border-box;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
body::before,
|
||||||
|
body::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
body::before {
|
||||||
|
background:
|
||||||
|
linear-gradient(to right, var(--cyber-grid-color) 1px, transparent 1px),
|
||||||
|
linear-gradient(to bottom, var(--cyber-grid-color) 1px, transparent 1px);
|
||||||
|
background-size: 50px 50px;
|
||||||
|
animation: gridScroll 20s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes gridScroll {
|
||||||
|
from { background-position: 0 0; }
|
||||||
|
to { background-position: -50px -50px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
body::after {
|
||||||
|
background:
|
||||||
|
linear-gradient(45deg, transparent 49%, var(--cyber-line-color) 50%, transparent 51%),
|
||||||
|
linear-gradient(-45deg, transparent 49%, var(--cyber-line-color) 50%, transparent 51%);
|
||||||
|
background-size: 100% 100%;
|
||||||
|
animation: cyberScan 10s linear infinite alternate;
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cyberScan {
|
||||||
|
0% { background-position: 0% 0%; }
|
||||||
|
100% { background-position: 100% 100%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
background-color: var(--container-bg-color);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 30px 25px; /* Исправлено для мобильных */
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
|
||||||
|
max-width: 400px;
|
||||||
|
width: 100%;
|
||||||
|
transform: scale(0.95);
|
||||||
|
opacity: 0;
|
||||||
|
animation: fadeInScale 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) 0.2s forwards;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
border: 1px solid rgba(0, 200, 255, 0.2);
|
||||||
|
backdrop-filter: blur(5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInScale {
|
||||||
|
to {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-container {
|
||||||
|
margin: 0 auto 25px auto;
|
||||||
|
line-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
height: 60px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-text {
|
||||||
|
font-family: 'Orbitron', sans-serif;
|
||||||
|
font-size: 48px;
|
||||||
|
color: var(--brand-neon-color);
|
||||||
|
text-shadow: 0 0 10px var(--brand-neon-color), 0 0 20px var(--brand-neon-color);
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-20px);
|
||||||
|
animation: logoSlideIn 0.8s ease-out 0.4s forwards, logoPulse 2s infinite ease-in-out 1.5s forwards;
|
||||||
|
white-space: nowrap;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes logoSlideIn {
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes logoPulse {
|
||||||
|
0% { transform: scale(1); text-shadow: 0 0 10px var(--brand-neon-color), 0 0 20px var(--brand-neon-color); }
|
||||||
|
50% { transform: scale(1.02); text-shadow: 0 0 15px var(--brand-neon-color), 0 0 25px var(--brand-neon-color), 0 0 35px var(--brand-neon-color); }
|
||||||
|
100% { transform: scale(1); text-shadow: 0 0 10px var(--brand-neon-color), 0 0 20px var(--brand-neon-color); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-icon {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
margin: 0 auto 20px auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-icon svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
filter: drop-shadow(0 0 8px rgba(0, 230, 118, 0.7));
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkmark__circle {
|
||||||
|
stroke-dasharray: 166;
|
||||||
|
stroke-dashoffset: 166;
|
||||||
|
stroke-width: 3;
|
||||||
|
stroke-miterlimit: 10;
|
||||||
|
stroke: var(--success-neon-color);
|
||||||
|
fill: none;
|
||||||
|
/* [!] Анимация галочки (круга) начинается с задержкой 0.5с */
|
||||||
|
animation: stroke 0.6s cubic-bezier(0.65, 0, 0.45, 1) 0.5s forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkmark__check {
|
||||||
|
transform-origin: 50% 50%;
|
||||||
|
stroke-dasharray: 48;
|
||||||
|
stroke-dashoffset: 48;
|
||||||
|
stroke-width: 4;
|
||||||
|
stroke: var(--success-neon-color);
|
||||||
|
fill: none;
|
||||||
|
animation: stroke 0.3s cubic-bezier(0.65, 0, 0.45, 1) 1s forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes stroke {
|
||||||
|
100% {
|
||||||
|
stroke-dashoffset: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-text, .instruction-text {
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 15px;
|
||||||
|
text-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instruction-text {
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-button {
|
||||||
|
display: inline-block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--bg-color);
|
||||||
|
background-color: var(--brand-neon-color);
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
box-sizing: border-box;
|
||||||
|
box-shadow: 0 0 15px rgba(0, 170, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-button:hover {
|
||||||
|
background-color: #00c0ff;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 0 20px rgba(0, 170, 255, 0.8), 0 0 30px rgba(0, 170, 255, 0.4);
|
||||||
|
}
|
||||||
|
.cta-button:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
box-shadow: 0 0 10px rgba(0, 170, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<audio id="success-sound" src="/sound.mp3" preload="auto"></audio>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
|
||||||
|
<div class="logo-container">
|
||||||
|
<span class="logo-text">Bedolaga</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="success-icon">
|
||||||
|
<svg class="checkmark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 52 52">
|
||||||
|
<circle class="checkmark__circle" cx="26" cy="26" r="25" fill="none"/>
|
||||||
|
<path class="checkmark__check" fill="none" d="M14.1 27.2l7.1 7.2 16.7-16.8"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1>Оплата прошла успешно!</h1>
|
||||||
|
|
||||||
|
<p class="info-text">
|
||||||
|
Ваша подписка на VPN активирована. Наслаждайтесь безопасным и анонимным доступом в Интернет.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="instruction-text">
|
||||||
|
Теперь вы можете вернуться в бота, чтобы получить доступ к вашим настройкам и управлять подпиской.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button id="close-btn" class="cta-button">
|
||||||
|
Вернуться в бота
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
try {
|
||||||
|
if (window.Telegram && window.Telegram.WebApp) {
|
||||||
|
Telegram.WebApp.ready();
|
||||||
|
var closeButton = document.getElementById('close-btn');
|
||||||
|
closeButton.addEventListener('click', function() {
|
||||||
|
Telegram.WebApp.close();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.warn('Telegram WebApp JS not loaded or not in TWA environment.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- [!] 2. КОД ДЛЯ ВОСПРОИЗВЕДЕНИЯ ЗВУКА [!] ---
|
||||||
|
|
||||||
|
// Находим аудиофайл
|
||||||
|
const audio = document.getElementById('success-sound');
|
||||||
|
|
||||||
|
// Устанавливаем таймер. Анимация круга (.checkmark__circle)
|
||||||
|
// имеет задержку 0.5с (500мс). Мы запускаем звук в тот же момент.
|
||||||
|
setTimeout(() => {
|
||||||
|
// Пытаемся проиграть звук
|
||||||
|
var playPromise = audio.play();
|
||||||
|
|
||||||
|
if (playPromise !== undefined) {
|
||||||
|
playPromise.catch(error => {
|
||||||
|
// Блокировка авто-воспроизведения - это нормально.
|
||||||
|
console.warn('Audio autoplay blocked:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 500); // 500 миллисекунд = 0.5с
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+9
-9
@@ -134,9 +134,9 @@ async def test_handle_webhook_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||||||
status = response.status
|
status = response.status
|
||||||
text = await response.text()
|
text = await response.text()
|
||||||
|
|
||||||
assert status == 200
|
assert status == 400
|
||||||
assert text == "OK"
|
assert text == "No payment id"
|
||||||
process_mock.assert_awaited_once()
|
process_mock.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -160,9 +160,9 @@ async def test_handle_webhook_trusts_cf_connecting_ip(monkeypatch: pytest.Monkey
|
|||||||
status = response.status
|
status = response.status
|
||||||
text = await response.text()
|
text = await response.text()
|
||||||
|
|
||||||
assert status == 200
|
assert status == 400
|
||||||
assert text == "OK"
|
assert text == "No payment id"
|
||||||
process_mock.assert_awaited_once()
|
process_mock.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -184,9 +184,9 @@ async def test_handle_webhook_with_optional_signature(monkeypatch: pytest.Monkey
|
|||||||
status = response.status
|
status = response.status
|
||||||
text = await response.text()
|
text = await response.text()
|
||||||
|
|
||||||
assert status == 200
|
assert status == 400
|
||||||
assert text == "OK"
|
assert text == "No payment id"
|
||||||
process_mock.assert_awaited_once()
|
process_mock.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.services.platega_service import PlategaService
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_description_limits_utf8_bytes(caplog: pytest.LogCaptureFixture) -> None:
|
||||||
|
caplog.set_level(logging.DEBUG)
|
||||||
|
original = "Интернет-сервис - Пополнение баланса на 50 ₽ и ещё чуть-чуть"
|
||||||
|
|
||||||
|
trimmed = PlategaService._sanitize_description(original, 64)
|
||||||
|
|
||||||
|
assert len(trimmed.encode("utf-8")) <= 64
|
||||||
|
assert trimmed != original
|
||||||
|
assert any("trimmed" in record.message for record in caplog.records)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_description_returns_clean_value() -> None:
|
||||||
|
original = " Обычное описание "
|
||||||
|
|
||||||
|
trimmed = PlategaService._sanitize_description(original, 64)
|
||||||
|
|
||||||
|
assert trimmed == "Обычное описание"
|
||||||
|
assert len(trimmed.encode("utf-8")) <= 64
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
from datetime import datetime, timedelta
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from app.handlers.menu import _get_subscription_status
|
||||||
|
|
||||||
|
|
||||||
|
class DummyTexts:
|
||||||
|
def t(self, key: str, default: str): # pragma: no cover - simple stub
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _build_user_with_subscription(actual_status: str, is_trial: bool, days_left: int):
|
||||||
|
subscription = MagicMock()
|
||||||
|
subscription.actual_status = actual_status
|
||||||
|
subscription.is_trial = is_trial
|
||||||
|
subscription.end_date = datetime.utcnow() + timedelta(days=days_left, hours=1)
|
||||||
|
|
||||||
|
user = MagicMock()
|
||||||
|
user.subscription = subscription
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_subscription_status_marks_trial_as_trial():
|
||||||
|
texts = DummyTexts()
|
||||||
|
user = _build_user_with_subscription(actual_status="active", is_trial=True, days_left=5)
|
||||||
|
|
||||||
|
status_text = _get_subscription_status(user, texts)
|
||||||
|
|
||||||
|
assert "Тестовая подписка" in status_text
|
||||||
|
assert "Активна" not in status_text
|
||||||
@@ -19,10 +19,20 @@ os.environ.setdefault('BOT_TOKEN', 'test-token')
|
|||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.webapi.routes import miniapp
|
from app.webapi.routes import miniapp
|
||||||
from app.database.models import PaymentMethod
|
from app.database.models import PaymentMethod
|
||||||
|
from app.services.subscription_renewal_service import (
|
||||||
|
SubscriptionRenewalPricing,
|
||||||
|
SubscriptionRenewalResult,
|
||||||
|
build_payment_descriptor,
|
||||||
|
decode_payment_payload,
|
||||||
|
encode_payment_payload,
|
||||||
|
)
|
||||||
|
from app.services.payment.cryptobot import CryptoBotPaymentMixin
|
||||||
from app.webapi.schemas.miniapp import (
|
from app.webapi.schemas.miniapp import (
|
||||||
MiniAppPaymentCreateRequest,
|
MiniAppPaymentCreateRequest,
|
||||||
|
MiniAppPaymentIntegrationType,
|
||||||
MiniAppPaymentMethodsRequest,
|
MiniAppPaymentMethodsRequest,
|
||||||
MiniAppPaymentStatusQuery,
|
MiniAppPaymentStatusQuery,
|
||||||
|
MiniAppSubscriptionRenewalRequest,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -43,6 +53,572 @@ def test_compute_cryptobot_limits_scale_with_rate():
|
|||||||
assert high_rate_max > low_rate_max
|
assert high_rate_max > low_rate_max
|
||||||
|
|
||||||
|
|
||||||
|
def test_encode_decode_renewal_payload_preserves_snapshot():
|
||||||
|
pricing_model = SubscriptionRenewalPricing(
|
||||||
|
period_days=30,
|
||||||
|
period_id='days:30',
|
||||||
|
months=1,
|
||||||
|
base_original_total=12000,
|
||||||
|
discounted_total=10000,
|
||||||
|
final_total=9000,
|
||||||
|
promo_discount_value=1000,
|
||||||
|
promo_discount_percent=10,
|
||||||
|
overall_discount_percent=25,
|
||||||
|
per_month=9000,
|
||||||
|
server_ids=[1, 2],
|
||||||
|
details={'servers_price_per_month': 1000},
|
||||||
|
)
|
||||||
|
|
||||||
|
descriptor = build_payment_descriptor(
|
||||||
|
user_id=1,
|
||||||
|
subscription_id=42,
|
||||||
|
period_days=30,
|
||||||
|
total_amount_kopeks=pricing_model.final_total,
|
||||||
|
missing_amount_kopeks=1000,
|
||||||
|
pricing_snapshot=pricing_model.to_payload(),
|
||||||
|
)
|
||||||
|
|
||||||
|
payload_value = encode_payment_payload(descriptor)
|
||||||
|
decoded = decode_payment_payload(payload_value, expected_user_id=1)
|
||||||
|
|
||||||
|
assert decoded is not None
|
||||||
|
assert decoded.total_amount_kopeks == 9000
|
||||||
|
assert decoded.missing_amount_kopeks == 1000
|
||||||
|
assert decoded.pricing_snapshot is not None
|
||||||
|
assert decoded.pricing_snapshot.get('server_ids') == [1, 2]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio("asyncio")
|
||||||
|
async def test_submit_subscription_renewal_uses_balance_when_sufficient(monkeypatch):
|
||||||
|
monkeypatch.setattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False, raising=False)
|
||||||
|
monkeypatch.setattr(settings, 'BOT_TOKEN', 'token', raising=False)
|
||||||
|
monkeypatch.setattr(settings, 'RESET_TRAFFIC_ON_PAYMENT', False, raising=False)
|
||||||
|
monkeypatch.setattr(settings, 'DEFAULT_LANGUAGE', 'ru', raising=False)
|
||||||
|
monkeypatch.setattr(settings, 'CRYPTOBOT_ENABLED', False, raising=False)
|
||||||
|
monkeypatch.setattr(settings, 'CRYPTOBOT_API_TOKEN', None, raising=False)
|
||||||
|
monkeypatch.setattr(type(settings), 'get_available_renewal_periods', lambda self: [30], raising=False)
|
||||||
|
|
||||||
|
user = types.SimpleNamespace(id=10, balance_kopeks=10000, language='ru')
|
||||||
|
subscription = types.SimpleNamespace(
|
||||||
|
id=77,
|
||||||
|
connected_squads=[],
|
||||||
|
traffic_limit_gb=100,
|
||||||
|
device_limit=5,
|
||||||
|
end_date=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
|
||||||
|
pricing_model = SubscriptionRenewalPricing(
|
||||||
|
period_days=30,
|
||||||
|
period_id='days:30',
|
||||||
|
months=1,
|
||||||
|
base_original_total=10000,
|
||||||
|
discounted_total=10000,
|
||||||
|
final_total=10000,
|
||||||
|
promo_discount_value=0,
|
||||||
|
promo_discount_percent=0,
|
||||||
|
overall_discount_percent=0,
|
||||||
|
per_month=10000,
|
||||||
|
server_ids=[],
|
||||||
|
details={},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def fake_authorize(init_data, db): # noqa: ARG001
|
||||||
|
return user
|
||||||
|
|
||||||
|
def fake_ensure(subscription_user, allowed_statuses=None): # noqa: ARG001
|
||||||
|
return subscription
|
||||||
|
|
||||||
|
async def fake_calculate(db, u, sub, period): # noqa: ARG001
|
||||||
|
return pricing_model
|
||||||
|
|
||||||
|
captured: dict[str, Any] = {}
|
||||||
|
|
||||||
|
async def fake_finalize(db, u, sub, pricing, *, charge_balance_amount=None, description=None, payment_method=None): # noqa: ARG001
|
||||||
|
charge = charge_balance_amount if charge_balance_amount is not None else pricing.final_total
|
||||||
|
captured['charge'] = charge
|
||||||
|
captured['description'] = description
|
||||||
|
return SubscriptionRenewalResult(
|
||||||
|
subscription=types.SimpleNamespace(id=sub.id, end_date=datetime.utcnow()),
|
||||||
|
transaction=types.SimpleNamespace(id=501),
|
||||||
|
total_amount_kopeks=pricing.final_total,
|
||||||
|
charged_from_balance_kopeks=charge,
|
||||||
|
old_end_date=sub.end_date,
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(miniapp, '_authorize_miniapp_user', fake_authorize)
|
||||||
|
monkeypatch.setattr(miniapp, '_ensure_paid_subscription', fake_ensure)
|
||||||
|
monkeypatch.setattr(miniapp, '_validate_subscription_id', lambda *args, **kwargs: None)
|
||||||
|
monkeypatch.setattr(miniapp, '_calculate_subscription_renewal_pricing', fake_calculate)
|
||||||
|
monkeypatch.setattr(miniapp.renewal_service, 'finalize', fake_finalize)
|
||||||
|
|
||||||
|
payload = MiniAppSubscriptionRenewalRequest(
|
||||||
|
initData='init',
|
||||||
|
subscriptionId=77,
|
||||||
|
periodId='days:30',
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await miniapp.submit_subscription_renewal_endpoint(payload, db=types.SimpleNamespace())
|
||||||
|
|
||||||
|
assert response.success is True
|
||||||
|
assert response.requires_payment is False
|
||||||
|
assert response.subscription_id == 77
|
||||||
|
assert response.renewed_until is not None
|
||||||
|
assert 'Подписка' in (response.message or '')
|
||||||
|
assert captured['charge'] == 10000
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio("asyncio")
|
||||||
|
async def test_submit_subscription_renewal_returns_cryptobot_invoice(monkeypatch):
|
||||||
|
monkeypatch.setattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False, raising=False)
|
||||||
|
monkeypatch.setattr(settings, 'BOT_TOKEN', 'token', raising=False)
|
||||||
|
monkeypatch.setattr(settings, 'RESET_TRAFFIC_ON_PAYMENT', False, raising=False)
|
||||||
|
monkeypatch.setattr(settings, 'DEFAULT_LANGUAGE', 'ru', raising=False)
|
||||||
|
monkeypatch.setattr(settings, 'CRYPTOBOT_ENABLED', True, raising=False)
|
||||||
|
monkeypatch.setattr(settings, 'CRYPTOBOT_API_TOKEN', 'token', raising=False)
|
||||||
|
monkeypatch.setattr(settings, 'CRYPTOBOT_DEFAULT_ASSET', 'USDT', raising=False)
|
||||||
|
monkeypatch.setattr(type(settings), 'get_available_renewal_periods', lambda self: [30], raising=False)
|
||||||
|
|
||||||
|
user = types.SimpleNamespace(id=15, balance_kopeks=5000, language='ru')
|
||||||
|
subscription = types.SimpleNamespace(
|
||||||
|
id=88,
|
||||||
|
connected_squads=[],
|
||||||
|
traffic_limit_gb=100,
|
||||||
|
device_limit=5,
|
||||||
|
end_date=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
|
||||||
|
pricing_model = SubscriptionRenewalPricing(
|
||||||
|
period_days=30,
|
||||||
|
period_id='days:30',
|
||||||
|
months=1,
|
||||||
|
base_original_total=20000,
|
||||||
|
discounted_total=20000,
|
||||||
|
final_total=20000,
|
||||||
|
promo_discount_value=0,
|
||||||
|
promo_discount_percent=0,
|
||||||
|
overall_discount_percent=0,
|
||||||
|
per_month=20000,
|
||||||
|
server_ids=[],
|
||||||
|
details={},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def fake_authorize(init_data, db): # noqa: ARG001
|
||||||
|
return user
|
||||||
|
|
||||||
|
def fake_ensure(subscription_user, allowed_statuses=None): # noqa: ARG001
|
||||||
|
return subscription
|
||||||
|
|
||||||
|
async def fake_calculate(db, u, sub, period): # noqa: ARG001
|
||||||
|
return pricing_model
|
||||||
|
|
||||||
|
created_calls: dict[str, Any] = {}
|
||||||
|
|
||||||
|
class DummyPaymentService:
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def create_cryptobot_payment(self, db, **kwargs):
|
||||||
|
created_calls.update(kwargs)
|
||||||
|
return {
|
||||||
|
'local_payment_id': 321,
|
||||||
|
'invoice_id': 'inv_123',
|
||||||
|
'bot_invoice_url': 'https://t.me/invoice',
|
||||||
|
'mini_app_invoice_url': 'https://mini.app/pay',
|
||||||
|
'web_app_invoice_url': None,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def fake_rate():
|
||||||
|
return 100.0
|
||||||
|
|
||||||
|
monkeypatch.setattr(miniapp, '_authorize_miniapp_user', fake_authorize)
|
||||||
|
monkeypatch.setattr(miniapp, '_ensure_paid_subscription', fake_ensure)
|
||||||
|
monkeypatch.setattr(miniapp, '_validate_subscription_id', lambda *args, **kwargs: None)
|
||||||
|
monkeypatch.setattr(miniapp, '_calculate_subscription_renewal_pricing', fake_calculate)
|
||||||
|
monkeypatch.setattr(miniapp, 'PaymentService', lambda *args, **kwargs: DummyPaymentService())
|
||||||
|
monkeypatch.setattr(miniapp, '_get_usd_to_rub_rate', fake_rate)
|
||||||
|
|
||||||
|
payload = MiniAppSubscriptionRenewalRequest(
|
||||||
|
initData='init',
|
||||||
|
subscriptionId=88,
|
||||||
|
periodId='days:30',
|
||||||
|
method='cryptobot',
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await miniapp.submit_subscription_renewal_endpoint(payload, db=types.SimpleNamespace())
|
||||||
|
|
||||||
|
assert response.success is False
|
||||||
|
assert response.requires_payment is True
|
||||||
|
assert response.payment_method == 'cryptobot'
|
||||||
|
assert response.payment_amount_kopeks == 15000
|
||||||
|
assert response.payment_url == 'https://mini.app/pay'
|
||||||
|
assert response.invoice_id == 'inv_123'
|
||||||
|
assert response.payment_id == 321
|
||||||
|
assert response.payment_payload and response.payment_payload.startswith('subscription_renewal')
|
||||||
|
assert created_calls.get('amount_usd') == pytest.approx(1.5)
|
||||||
|
assert created_calls.get('description') == 'Продление подписки на 30 дней'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio("asyncio")
|
||||||
|
async def test_submit_subscription_renewal_rounds_up_cryptobot_amount(monkeypatch):
|
||||||
|
monkeypatch.setattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False, raising=False)
|
||||||
|
monkeypatch.setattr(settings, 'BOT_TOKEN', 'token', raising=False)
|
||||||
|
monkeypatch.setattr(settings, 'RESET_TRAFFIC_ON_PAYMENT', False, raising=False)
|
||||||
|
monkeypatch.setattr(settings, 'DEFAULT_LANGUAGE', 'ru', raising=False)
|
||||||
|
monkeypatch.setattr(settings, 'CRYPTOBOT_ENABLED', True, raising=False)
|
||||||
|
monkeypatch.setattr(settings, 'CRYPTOBOT_API_TOKEN', 'token', raising=False)
|
||||||
|
monkeypatch.setattr(settings, 'CRYPTOBOT_DEFAULT_ASSET', 'USDT', raising=False)
|
||||||
|
monkeypatch.setattr(type(settings), 'get_available_renewal_periods', lambda self: [30], raising=False)
|
||||||
|
|
||||||
|
user = types.SimpleNamespace(id=42, balance_kopeks=0, language='ru')
|
||||||
|
subscription = types.SimpleNamespace(
|
||||||
|
id=99,
|
||||||
|
connected_squads=[],
|
||||||
|
traffic_limit_gb=100,
|
||||||
|
device_limit=5,
|
||||||
|
end_date=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
|
||||||
|
pricing_model = SubscriptionRenewalPricing(
|
||||||
|
period_days=30,
|
||||||
|
period_id='days:30',
|
||||||
|
months=1,
|
||||||
|
base_original_total=9512,
|
||||||
|
discounted_total=9512,
|
||||||
|
final_total=9512,
|
||||||
|
promo_discount_value=0,
|
||||||
|
promo_discount_percent=0,
|
||||||
|
overall_discount_percent=0,
|
||||||
|
per_month=9512,
|
||||||
|
server_ids=[],
|
||||||
|
details={},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def fake_authorize(init_data, db): # noqa: ARG001
|
||||||
|
return user
|
||||||
|
|
||||||
|
def fake_ensure(subscription_user, allowed_statuses=None): # noqa: ARG001
|
||||||
|
return subscription
|
||||||
|
|
||||||
|
async def fake_calculate(db, u, sub, period): # noqa: ARG001
|
||||||
|
return pricing_model
|
||||||
|
|
||||||
|
captured: dict[str, Any] = {}
|
||||||
|
|
||||||
|
class DummyPaymentService:
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def create_cryptobot_payment(self, db, **kwargs):
|
||||||
|
captured.update(kwargs)
|
||||||
|
return {
|
||||||
|
'local_payment_id': 654,
|
||||||
|
'invoice_id': 'inv_round',
|
||||||
|
'bot_invoice_url': 'https://t.me/pay',
|
||||||
|
'mini_app_invoice_url': 'https://mini.app/pay-round',
|
||||||
|
'web_app_invoice_url': None,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def fake_rate():
|
||||||
|
return 95.0
|
||||||
|
|
||||||
|
monkeypatch.setattr(miniapp, '_authorize_miniapp_user', fake_authorize)
|
||||||
|
monkeypatch.setattr(miniapp, '_ensure_paid_subscription', fake_ensure)
|
||||||
|
monkeypatch.setattr(miniapp, '_validate_subscription_id', lambda *args, **kwargs: None)
|
||||||
|
monkeypatch.setattr(miniapp, '_calculate_subscription_renewal_pricing', fake_calculate)
|
||||||
|
monkeypatch.setattr(miniapp, 'PaymentService', lambda *args, **kwargs: DummyPaymentService())
|
||||||
|
monkeypatch.setattr(miniapp, '_get_usd_to_rub_rate', fake_rate)
|
||||||
|
|
||||||
|
payload = MiniAppSubscriptionRenewalRequest(
|
||||||
|
initData='init',
|
||||||
|
subscriptionId=99,
|
||||||
|
periodId='days:30',
|
||||||
|
method='cryptobot',
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await miniapp.submit_subscription_renewal_endpoint(payload, db=types.SimpleNamespace())
|
||||||
|
|
||||||
|
assert response.requires_payment is True
|
||||||
|
assert captured.get('amount_usd') == pytest.approx(1.01)
|
||||||
|
assert response.payment_amount_kopeks == 9512
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio("asyncio")
|
||||||
|
async def test_cryptobot_renewal_uses_pricing_snapshot(monkeypatch):
|
||||||
|
module = sys.modules['app.services.payment.cryptobot']
|
||||||
|
mixin = CryptoBotPaymentMixin()
|
||||||
|
|
||||||
|
subscription = types.SimpleNamespace(id=77, connected_squads=[], traffic_limit_gb=100, device_limit=5)
|
||||||
|
user = types.SimpleNamespace(id=5, balance_kopeks=7000, subscription=subscription)
|
||||||
|
|
||||||
|
pricing_model = SubscriptionRenewalPricing(
|
||||||
|
period_days=30,
|
||||||
|
period_id='days:30',
|
||||||
|
months=1,
|
||||||
|
base_original_total=12000,
|
||||||
|
discounted_total=10000,
|
||||||
|
final_total=10000,
|
||||||
|
promo_discount_value=0,
|
||||||
|
promo_discount_percent=0,
|
||||||
|
overall_discount_percent=0,
|
||||||
|
per_month=10000,
|
||||||
|
server_ids=[11, 22],
|
||||||
|
details={'servers_individual_prices': [500, 500]},
|
||||||
|
)
|
||||||
|
|
||||||
|
descriptor = build_payment_descriptor(
|
||||||
|
user_id=5,
|
||||||
|
subscription_id=77,
|
||||||
|
period_days=30,
|
||||||
|
total_amount_kopeks=10000,
|
||||||
|
missing_amount_kopeks=3000,
|
||||||
|
pricing_snapshot=pricing_model.to_payload(),
|
||||||
|
)
|
||||||
|
|
||||||
|
payment = types.SimpleNamespace(invoice_id='INV-1', user_id=5)
|
||||||
|
|
||||||
|
async def fake_get_user_by_id(db, user_id): # noqa: ARG001
|
||||||
|
return user if user_id == 5 else None
|
||||||
|
|
||||||
|
monkeypatch.setitem(sys.modules, 'app.services.payment_service', types.SimpleNamespace(get_user_by_id=fake_get_user_by_id))
|
||||||
|
|
||||||
|
async def fail_calculate(*args, **kwargs): # noqa: ARG001
|
||||||
|
raise AssertionError('calculate_pricing should not be called when snapshot is present')
|
||||||
|
|
||||||
|
monkeypatch.setattr(module.renewal_service, 'calculate_pricing', fail_calculate)
|
||||||
|
|
||||||
|
captured: dict[str, Any] = {}
|
||||||
|
|
||||||
|
async def fake_finalize(db, u, sub, pricing, *, charge_balance_amount=None, description=None, payment_method=None): # noqa: ARG001
|
||||||
|
captured['pricing'] = pricing
|
||||||
|
captured['charge'] = charge_balance_amount
|
||||||
|
captured['description'] = description
|
||||||
|
captured['payment_method'] = payment_method
|
||||||
|
return SubscriptionRenewalResult(
|
||||||
|
subscription=types.SimpleNamespace(id=sub.id, end_date=datetime.utcnow()),
|
||||||
|
transaction=types.SimpleNamespace(id=999),
|
||||||
|
total_amount_kopeks=pricing.final_total,
|
||||||
|
charged_from_balance_kopeks=charge_balance_amount or pricing.final_total,
|
||||||
|
old_end_date=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(module.renewal_service, 'finalize', fake_finalize)
|
||||||
|
|
||||||
|
async def fake_link(db, invoice_id, transaction_id): # noqa: ARG001
|
||||||
|
captured['linked'] = (invoice_id, transaction_id)
|
||||||
|
|
||||||
|
cryptobot_crud = types.SimpleNamespace(link_cryptobot_payment_to_transaction=fake_link)
|
||||||
|
|
||||||
|
result = await mixin._process_subscription_renewal_payment(
|
||||||
|
db=types.SimpleNamespace(),
|
||||||
|
payment=payment,
|
||||||
|
descriptor=descriptor,
|
||||||
|
cryptobot_crud=cryptobot_crud,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
assert captured['pricing'].server_ids == [11, 22]
|
||||||
|
assert captured['pricing'].final_total == 10000
|
||||||
|
assert captured['charge'] == 7000
|
||||||
|
assert captured['payment_method'] == PaymentMethod.CRYPTOBOT
|
||||||
|
assert captured['linked'] == ('INV-1', 999)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio("asyncio")
|
||||||
|
async def test_cryptobot_renewal_accepts_changed_pricing_without_snapshot(monkeypatch):
|
||||||
|
module = sys.modules['app.services.payment.cryptobot']
|
||||||
|
mixin = CryptoBotPaymentMixin()
|
||||||
|
|
||||||
|
subscription = types.SimpleNamespace(id=55, connected_squads=[], traffic_limit_gb=50, device_limit=3)
|
||||||
|
user = types.SimpleNamespace(id=8, balance_kopeks=4000, subscription=subscription)
|
||||||
|
|
||||||
|
descriptor = build_payment_descriptor(
|
||||||
|
user_id=8,
|
||||||
|
subscription_id=55,
|
||||||
|
period_days=30,
|
||||||
|
total_amount_kopeks=5000,
|
||||||
|
missing_amount_kopeks=1000,
|
||||||
|
)
|
||||||
|
|
||||||
|
payment = types.SimpleNamespace(invoice_id='INV-2', user_id=8)
|
||||||
|
|
||||||
|
async def fake_get_user_by_id(db, user_id): # noqa: ARG001
|
||||||
|
return user if user_id == 8 else None
|
||||||
|
|
||||||
|
monkeypatch.setitem(sys.modules, 'app.services.payment_service', types.SimpleNamespace(get_user_by_id=fake_get_user_by_id))
|
||||||
|
|
||||||
|
recalculated_pricing = SubscriptionRenewalPricing(
|
||||||
|
period_days=30,
|
||||||
|
period_id='days:30',
|
||||||
|
months=1,
|
||||||
|
base_original_total=5200,
|
||||||
|
discounted_total=5200,
|
||||||
|
final_total=5200,
|
||||||
|
promo_discount_value=0,
|
||||||
|
promo_discount_percent=0,
|
||||||
|
overall_discount_percent=0,
|
||||||
|
per_month=5200,
|
||||||
|
server_ids=[],
|
||||||
|
details={},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def fake_calculate(db, u, sub, period): # noqa: ARG001
|
||||||
|
return recalculated_pricing
|
||||||
|
|
||||||
|
monkeypatch.setattr(module.renewal_service, 'calculate_pricing', fake_calculate)
|
||||||
|
|
||||||
|
captured: dict[str, Any] = {}
|
||||||
|
|
||||||
|
async def fake_finalize(db, u, sub, pricing, *, charge_balance_amount=None, description=None, payment_method=None): # noqa: ARG001
|
||||||
|
captured['pricing'] = pricing
|
||||||
|
captured['charge'] = charge_balance_amount
|
||||||
|
return SubscriptionRenewalResult(
|
||||||
|
subscription=types.SimpleNamespace(id=sub.id, end_date=datetime.utcnow()),
|
||||||
|
transaction=None,
|
||||||
|
total_amount_kopeks=pricing.final_total,
|
||||||
|
charged_from_balance_kopeks=charge_balance_amount or pricing.final_total,
|
||||||
|
old_end_date=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(module.renewal_service, 'finalize', fake_finalize)
|
||||||
|
|
||||||
|
async def noop_link(*args, **kwargs):
|
||||||
|
return None
|
||||||
|
|
||||||
|
cryptobot_crud = types.SimpleNamespace(link_cryptobot_payment_to_transaction=noop_link)
|
||||||
|
|
||||||
|
result = await mixin._process_subscription_renewal_payment(
|
||||||
|
db=types.SimpleNamespace(),
|
||||||
|
payment=payment,
|
||||||
|
descriptor=descriptor,
|
||||||
|
cryptobot_crud=cryptobot_crud,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
assert captured['pricing'].final_total == 5000
|
||||||
|
assert captured['charge'] == 4000
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio("asyncio")
|
||||||
|
async def test_cryptobot_webhook_uses_inline_payload_when_db_missing(monkeypatch):
|
||||||
|
module = sys.modules['app.services.payment.cryptobot']
|
||||||
|
mixin = CryptoBotPaymentMixin()
|
||||||
|
|
||||||
|
subscription = types.SimpleNamespace(id=91, connected_squads=[], traffic_limit_gb=80, device_limit=4)
|
||||||
|
user = types.SimpleNamespace(id=21, balance_kopeks=6000, subscription=subscription)
|
||||||
|
|
||||||
|
pricing_model = SubscriptionRenewalPricing(
|
||||||
|
period_days=30,
|
||||||
|
period_id='days:30',
|
||||||
|
months=1,
|
||||||
|
base_original_total=9000,
|
||||||
|
discounted_total=9000,
|
||||||
|
final_total=9000,
|
||||||
|
promo_discount_value=0,
|
||||||
|
promo_discount_percent=0,
|
||||||
|
overall_discount_percent=0,
|
||||||
|
per_month=9000,
|
||||||
|
server_ids=[5, 6],
|
||||||
|
details={'servers_individual_prices': [300, 300]},
|
||||||
|
)
|
||||||
|
|
||||||
|
descriptor = build_payment_descriptor(
|
||||||
|
user_id=21,
|
||||||
|
subscription_id=91,
|
||||||
|
period_days=30,
|
||||||
|
total_amount_kopeks=9000,
|
||||||
|
missing_amount_kopeks=3000,
|
||||||
|
pricing_snapshot=pricing_model.to_payload(),
|
||||||
|
)
|
||||||
|
|
||||||
|
encoded_payload = encode_payment_payload(descriptor)
|
||||||
|
|
||||||
|
payment = types.SimpleNamespace(
|
||||||
|
invoice_id='INV-webhook',
|
||||||
|
user_id=21,
|
||||||
|
status='active',
|
||||||
|
payload=None,
|
||||||
|
amount='90.00',
|
||||||
|
asset='USDT',
|
||||||
|
bot_invoice_url=None,
|
||||||
|
mini_app_invoice_url=None,
|
||||||
|
web_app_invoice_url=None,
|
||||||
|
description='Продление подписки',
|
||||||
|
)
|
||||||
|
|
||||||
|
async def fake_get_user_by_id(db, user_id): # noqa: ARG001
|
||||||
|
return user if user_id == 21 else None
|
||||||
|
|
||||||
|
monkeypatch.setitem(
|
||||||
|
sys.modules,
|
||||||
|
'app.services.payment_service',
|
||||||
|
types.SimpleNamespace(get_user_by_id=fake_get_user_by_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def fail_calculate(*args, **kwargs): # noqa: ARG001
|
||||||
|
raise AssertionError('calculate_pricing should not be called')
|
||||||
|
|
||||||
|
monkeypatch.setattr(module.renewal_service, 'calculate_pricing', fail_calculate)
|
||||||
|
|
||||||
|
captured: dict[str, Any] = {}
|
||||||
|
|
||||||
|
async def fake_finalize(db, u, sub, pricing, *, charge_balance_amount=None, description=None, payment_method=None): # noqa: ARG001
|
||||||
|
captured['pricing'] = pricing
|
||||||
|
captured['charge'] = charge_balance_amount
|
||||||
|
captured['description'] = description
|
||||||
|
captured['payment_method'] = payment_method
|
||||||
|
return SubscriptionRenewalResult(
|
||||||
|
subscription=types.SimpleNamespace(id=sub.id, end_date=datetime.utcnow()),
|
||||||
|
transaction=types.SimpleNamespace(id=1234),
|
||||||
|
total_amount_kopeks=pricing.final_total,
|
||||||
|
charged_from_balance_kopeks=charge_balance_amount or pricing.final_total,
|
||||||
|
old_end_date=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(module.renewal_service, 'finalize', fake_finalize)
|
||||||
|
|
||||||
|
linked: dict[str, Any] = {}
|
||||||
|
|
||||||
|
async def fake_get(db, invoice_id): # noqa: ARG001
|
||||||
|
return payment if invoice_id == payment.invoice_id else None
|
||||||
|
|
||||||
|
async def fake_update(db, invoice_id, status, paid_at): # noqa: ARG001
|
||||||
|
if invoice_id == payment.invoice_id:
|
||||||
|
payment.status = status
|
||||||
|
payment.paid_at = paid_at
|
||||||
|
return payment
|
||||||
|
|
||||||
|
async def fake_link(db, invoice_id, transaction_id): # noqa: ARG001
|
||||||
|
linked['value'] = (invoice_id, transaction_id)
|
||||||
|
return payment
|
||||||
|
|
||||||
|
monkeypatch.setitem(
|
||||||
|
sys.modules,
|
||||||
|
'app.database.crud.cryptobot',
|
||||||
|
types.SimpleNamespace(
|
||||||
|
get_cryptobot_payment_by_invoice_id=fake_get,
|
||||||
|
update_cryptobot_payment_status=fake_update,
|
||||||
|
link_cryptobot_payment_to_transaction=fake_link,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
webhook_payload = {
|
||||||
|
'update_type': 'invoice_paid',
|
||||||
|
'payload': {
|
||||||
|
'invoice_id': payment.invoice_id,
|
||||||
|
'paid_at': '2024-05-01T12:00:00Z',
|
||||||
|
'payload': encoded_payload,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await mixin.process_cryptobot_webhook(types.SimpleNamespace(), webhook_payload)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
assert captured['pricing'].final_total == 9000
|
||||||
|
assert captured['charge'] == 6000
|
||||||
|
assert captured['payment_method'] == PaymentMethod.CRYPTOBOT
|
||||||
|
assert linked['value'] == (payment.invoice_id, 1234)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio("asyncio")
|
@pytest.mark.anyio("asyncio")
|
||||||
async def test_create_payment_link_pal24_uses_selected_option(monkeypatch):
|
async def test_create_payment_link_pal24_uses_selected_option(monkeypatch):
|
||||||
monkeypatch.setattr(settings, 'PAL24_ENABLED', True, raising=False)
|
monkeypatch.setattr(settings, 'PAL24_ENABLED', True, raising=False)
|
||||||
@@ -491,6 +1067,8 @@ async def test_get_payment_methods_exposes_stars_min_amount(monkeypatch):
|
|||||||
assert stars_method is not None
|
assert stars_method is not None
|
||||||
assert stars_method.min_amount_kopeks == 99999
|
assert stars_method.min_amount_kopeks == 99999
|
||||||
assert stars_method.amount_step_kopeks == 99999
|
assert stars_method.amount_step_kopeks == 99999
|
||||||
|
assert stars_method.integration_type == MiniAppPaymentIntegrationType.REDIRECT
|
||||||
|
assert stars_method.iframe_config is None
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio("asyncio")
|
@pytest.mark.anyio("asyncio")
|
||||||
@@ -515,6 +1093,32 @@ async def test_get_payment_methods_includes_wata(monkeypatch):
|
|||||||
assert wata_method.min_amount_kopeks == 5000
|
assert wata_method.min_amount_kopeks == 5000
|
||||||
assert wata_method.max_amount_kopeks == 7500000
|
assert wata_method.max_amount_kopeks == 7500000
|
||||||
assert wata_method.icon == '🌊'
|
assert wata_method.icon == '🌊'
|
||||||
|
assert wata_method.integration_type == MiniAppPaymentIntegrationType.REDIRECT
|
||||||
|
assert wata_method.iframe_config is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio("asyncio")
|
||||||
|
async def test_get_payment_methods_marks_mulenpay_iframe(monkeypatch):
|
||||||
|
monkeypatch.setattr(settings, 'MULENPAY_ENABLED', True, raising=False)
|
||||||
|
monkeypatch.setattr(settings, 'MULENPAY_API_KEY', 'api-key', raising=False)
|
||||||
|
monkeypatch.setattr(settings, 'MULENPAY_SECRET_KEY', 'secret', raising=False)
|
||||||
|
monkeypatch.setattr(settings, 'MULENPAY_SHOP_ID', 99, raising=False)
|
||||||
|
monkeypatch.setattr(settings, 'MULENPAY_BASE_URL', 'https://checkout.example/api', raising=False)
|
||||||
|
monkeypatch.setattr(settings, 'MULENPAY_IFRAME_EXPECTED_ORIGIN', None, raising=False)
|
||||||
|
|
||||||
|
async def fake_resolve_user(db, init_data):
|
||||||
|
return types.SimpleNamespace(id=1, language='ru'), {}
|
||||||
|
|
||||||
|
monkeypatch.setattr(miniapp, '_resolve_user_from_init_data', fake_resolve_user)
|
||||||
|
|
||||||
|
payload = MiniAppPaymentMethodsRequest(initData='abc')
|
||||||
|
response = await miniapp.get_payment_methods(payload, db=types.SimpleNamespace())
|
||||||
|
|
||||||
|
mulenpay_method = next((method for method in response.methods if method.id == 'mulenpay'), None)
|
||||||
|
assert mulenpay_method is not None
|
||||||
|
assert mulenpay_method.integration_type == MiniAppPaymentIntegrationType.IFRAME
|
||||||
|
assert mulenpay_method.iframe_config is not None
|
||||||
|
assert str(mulenpay_method.iframe_config.expected_origin) == 'https://checkout.example'
|
||||||
@pytest.mark.anyio("asyncio")
|
@pytest.mark.anyio("asyncio")
|
||||||
async def test_find_recent_deposit_ignores_transactions_before_attempt():
|
async def test_find_recent_deposit_ignores_transactions_before_attempt():
|
||||||
started_at = datetime(2024, 5, 1, 12, 0, 0)
|
started_at = datetime(2024, 5, 1, 12, 0, 0)
|
||||||
|
|||||||
Reference in New Issue
Block a user