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.
|
||||
DEFAULT_LANGUAGE=ru
|
||||
AVAILABLE_LANGUAGES=ru,en
|
||||
AVAILABLE_LANGUAGES=ru,en,ua,zh
|
||||
# Включить выбор языка при старте и отображение кнопки в меню
|
||||
LANGUAGE_SELECTION_ENABLED=true
|
||||
# Часовой пояс
|
||||
|
||||
@@ -36,15 +36,15 @@ jobs:
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🏷️ Собираем релизную версию: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v2.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}"
|
||||
echo "🚀 Собираем версию из main: $VERSION"
|
||||
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}"
|
||||
echo "🧪 Собираем dev версию: $VERSION"
|
||||
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)"
|
||||
echo "🔀 Собираем PR версию: $VERSION"
|
||||
fi
|
||||
|
||||
@@ -49,13 +49,13 @@ jobs:
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
echo "🏷️ Building release version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v2.6.2-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.7.0-$(git rev-parse --short HEAD)"
|
||||
echo "🚀 Building main version: $VERSION"
|
||||
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"
|
||||
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"
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v2.6.2"
|
||||
ARG VERSION="v2.7.0"
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
<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**
|
||||
|
||||
@@ -14,7 +13,7 @@
|
||||
[](LICENSE)
|
||||
[](https://github.com/Fr1ngg/remnawave-bedolaga-telegram-bot/stargazers)
|
||||
|
||||
[🚀 Быстрый старт](#-быстрый-старт) • [📖 Функционал](#-функционал) • [🐳 Docker](#-docker-развертывание) • [💻 Локальная разработка](#-локальная-разработка) • [💬 Поддержка](#-поддержка-и-сообщество)
|
||||
[🚀 Быстрый старт](#-быстрый-старт) • [📖 Функционал](#-функционал) • [Docker](#-docker-развертывание) • [💻 Локальная разработка](#-локальная-разработка) • [💬 Поддержка](#-поддержка-и-сообщество)
|
||||
|
||||
</div>
|
||||
|
||||
@@ -32,18 +31,18 @@
|
||||
Он просто делает вашу работу вместо вас, принимает оплату, выдаёт подписки, интегрируется с Remnawave и тихо ненавидит всех, кто ещё не подключил его.
|
||||
|
||||
Вы хотите продавать VPN — Бедолага позволит это делать.
|
||||
Вы хотите спать — он позволит и это.
|
||||
Вы хотите спать — он позволит и это...
|
||||
|
||||
### ⚡ **Полная автоматизация VPN бизнеса**
|
||||
- 🎯 **Готовое решение** - разверни за 5 минут, начни продавать сегодня
|
||||
- 💰 **Многоканальные платежи** - Telegram Stars + Tribute + CryptoBot + Heleket + YooKassa (СБП + карты) + MulenPay + PayPalych (СБП + карты) + Platega (карты + СБП) + WATA
|
||||
- 🔄 **Автоматизация 99%** - от регистрации до продления подписок
|
||||
- - 📱 **MiniApp лк** - личный кабинет с возможностью покупки/продления подписки
|
||||
- 📱 **MiniApp лк** - личный кабинет с возможностью покупки/продления подписки
|
||||
- 📊 **Детальная аналитика** - полная картина вашего бизнеса
|
||||
- 💬 **Уведомления в топики** об: Активация триала 💎 Покупка подписки 🔄 Конверсия из триала в платную ⏰ Продление подписки 💰 Пополнение баланса 🚧 Включении тех работ ♻️ Появлении новой версии бота
|
||||
|
||||
### 🎛️ **Гибкость конфигурации**
|
||||
- 🌍 **Умный выбор серверов** - автоматический пропуск при одном сервере, мультивыбор
|
||||
- 🌐 **Умный выбор серверов** - автоматический пропуск при одном сервере, мультивыбор
|
||||
- 📱 **Управление устройствами** - от 1 до неограниченного количества
|
||||
- 📊 **Режимы продажи трафика** - фиксированный лимит или выбор пакетов
|
||||
- 🎁 **Промо-система** - коды на деньги, дни подписки, триал-периоды
|
||||
@@ -51,13 +50,13 @@
|
||||
- 🛒 **Умная корзина** - сохранение параметров подписки при недостатке баланса
|
||||
|
||||
### 💪 **Enterprise готовность**
|
||||
- 🏗️ **Современная архитектура** - AsyncIO, PostgreSQL, Redis, модульная структура
|
||||
- 🗃️ **Современная архитектура** - AsyncIO, PostgreSQL, Redis, модульная структура
|
||||
- 🔒 **Безопасность** - интеграция с системой защиты панели через куки-аутентификацию
|
||||
- 📈 **Масштабируемость** - от стартапа до крупного бизнеса
|
||||
- 🔧 **Мониторинг** - автоматическое управление режимом тех. работ
|
||||
- 🛡️ **Защита панели** - поддержка [remnawave-reverse-proxy](https://github.com/eGamesAPI/remnawave-reverse-proxy)
|
||||
- 🗄️ **Бекапы/Восстановление** - автобекапы и восстановление бд прямо в боте с уведомлениями в топики
|
||||
- ✍️ **Проверка на подписку** - проверяет подписку на канал
|
||||
- ✔️ **Проверка на подписку** - проверяет подписку на канал
|
||||
- 🔄 **Автосинхронизация** - фоновая синхронизация подписок и серверов(сквадов) с Remnawave по расписанию
|
||||
|
||||
### 📚 Поддерживаемые методы авторизации
|
||||
@@ -83,19 +82,22 @@ mkdir -p ./logs ./data ./data/backups ./data/referral_qr
|
||||
chmod -R 755 ./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 down # Остановить контейнеры
|
||||
make reload # Перезапустить
|
||||
make reload-follow # Перезапустить с логами
|
||||
make test # Запустить тесты
|
||||
|
||||
|
||||
```
|
||||
|
||||
### 🐳 Ручной Docker запуск
|
||||
### Ручной Docker запуск
|
||||
|
||||
Если не хочется пользоваться мастером, можно настроить всё вручную:
|
||||
|
||||
@@ -122,21 +124,9 @@ docker compose logs
|
||||
|
||||
---
|
||||
|
||||
## 🌐 Настройка webhook-режима и обратного прокси
|
||||
## 🌐 Настройка веб-сервера и обратного проксирования
|
||||
|
||||
> Встроенный 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.
|
||||
|
||||
После миграции старые контейнеры/сервисы для отдельных вебхуков можно удалить.
|
||||
> Встроенный FastAPI-сервер обслуживает Telegram webhook, платежные webhooks, административное API и статические файлы миниапки на **одном порту 8080**. Снаружи вы публикуете только HTTPS-прокси, которое проксирует весь трафик на этот порт.
|
||||
|
||||
### 1. Выбор режима запуска
|
||||
|
||||
@@ -144,9 +134,41 @@ docker compose logs
|
||||
|----------------|------------|---------------------|
|
||||
| `polling` | Бот опрашивает Telegram через long polling. HTTP-сервер можно не поднимать. | Локальная отладка или отсутствие внешнего 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
|
||||
BOT_RUN_MODE=webhook
|
||||
@@ -173,47 +195,107 @@ MINIAPP_CUSTOM_URL=https://bot.example.com/miniapp
|
||||
|
||||
После изменения `.env` перезапустите сервис: `docker compose up -d remnawave_bot`.
|
||||
|
||||
### 3. Подготовка Docker-сети
|
||||
### 4. Настройка Docker-сетей
|
||||
|
||||
`docker-compose.yml` создаёт сеть `bot_network`. Прокси должен находиться в той же сети, чтобы обращаться к контейнеру по имени `remnawave_bot`.
|
||||
#### Вариант 1: Бот на отдельном сервере
|
||||
|
||||
```bash
|
||||
docker network ls | grep bot_network || docker network create bot_network
|
||||
docker network connect bot_network <proxy_container_name>
|
||||
```
|
||||
|
||||
Если прокси стартует отдельным compose-файлом, объявите сеть внешней:
|
||||
Если бот запускается на отдельном сервере (не там, где панель 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
|
||||
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:
|
||||
bot_network:
|
||||
external: true
|
||||
name: remnawave_bot_network
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.20.0.0/16
|
||||
gateway: 172.20.0.1
|
||||
```
|
||||
|
||||
### 4. Проверка здоровья
|
||||
|
||||
Статические файлы миниапки автоматически монтируются из каталога `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-конфига
|
||||
|
||||
**docker-compose.yml для Caddy (отдельный файл):**
|
||||
```yaml
|
||||
services:
|
||||
caddy:
|
||||
@@ -236,9 +318,143 @@ volumes:
|
||||
|
||||
networks:
|
||||
bot_network:
|
||||
name: remnawave_bot_network
|
||||
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`:
|
||||
|
||||
```caddy
|
||||
@@ -249,7 +465,7 @@ api.domain.com {
|
||||
@config path /app-config.json
|
||||
header @config Access-Control-Allow-Origin "*"
|
||||
|
||||
reverse_proxy localhost:8080 {
|
||||
reverse_proxy remnawave_bot:8080 {
|
||||
header_up Host {host}
|
||||
header_up X-Real-IP {remote_host}
|
||||
transport http {
|
||||
@@ -264,7 +480,7 @@ podpiska.domain.com {
|
||||
|
||||
# API эндпоинты /miniapp/* в приложение
|
||||
handle /miniapp/* {
|
||||
reverse_proxy localhost:8080 {
|
||||
reverse_proxy remnawave_bot:8080 {
|
||||
header_up Host {host}
|
||||
header_up X-Real-IP {remote_host}
|
||||
transport http {
|
||||
@@ -276,7 +492,7 @@ podpiska.domain.com {
|
||||
# app-config.json с CORS
|
||||
handle /app-config.json {
|
||||
header Access-Control-Allow-Origin "*"
|
||||
reverse_proxy localhost:8080 {
|
||||
reverse_proxy remnawave_bot:8080 {
|
||||
header_up Host {host}
|
||||
header_up X-Real-IP {remote_host}
|
||||
transport http {
|
||||
@@ -294,31 +510,9 @@ podpiska.domain.com {
|
||||
}
|
||||
```
|
||||
|
||||
####
|
||||
**Примечание:** Если прокси запускается на том же сервере, замените `remnawave_bot:8080` на `localhost:8080`
|
||||
|
||||
Либо вместо `remnawave_bot:8080` используйте `localhost:8080`
|
||||
|
||||
|
||||
### 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
|
||||
```
|
||||
### 8. Пример nginx-конфига
|
||||
|
||||
`nginx.conf`:
|
||||
|
||||
@@ -354,7 +548,7 @@ http {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Все остальное
|
||||
# Всё остальное
|
||||
location / {
|
||||
proxy_pass http://remnawave_bot_unified;
|
||||
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`.
|
||||
- После развёртывания перезапустите бот (`make reload`), чтобы он заново зарегистрировал webhook.
|
||||
|
||||
@@ -448,7 +640,7 @@ http {
|
||||
- `BOT_RUN_MODE` — определяет способ приёма обновлений: `polling`, `webhook` или `both`, чтобы одновременно использовать оба режима.
|
||||
- `WEBHOOK_SECRET_TOKEN` — секрет для проверки заголовка `X-Telegram-Bot-Api-Secret-Token` при работе через вебхуки.
|
||||
- `WEBHOOK_DROP_PENDING_UPDATES` — управляет очисткой очереди сообщений при установке вебхука.
|
||||
- `WEBHOOK_MAX_QUEUE_SIZE` — ограничивает длину очереди входящих обновлений, чтобы защищаться от перегрузок.
|
||||
- `WEBHOOK_MAX_QUEUE_SIZE` — ограничивает длину очереди входящих обновлений, чтобы защащаться от перегрузок.
|
||||
- `WEBHOOK_WORKERS` — количество фоновых воркеров, параллельно обрабатывающих обновления Telegram.
|
||||
- `WEBHOOK_ENQUEUE_TIMEOUT` — сколько секунд ждать свободного места в очереди перед отказом (0 — немедленный отказ).
|
||||
- `WEBHOOK_WORKER_SHUTDOWN_TIMEOUT` — таймаут корректного завершения воркеров при остановке приложения.
|
||||
@@ -464,7 +656,7 @@ http {
|
||||
| Переменная | Описание | Пример |
|
||||
|------------|----------|--------|
|
||||
| `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` |
|
||||
|
||||
### 🛡️ Защита панели Remnawave
|
||||
@@ -551,7 +743,7 @@ DEFAULT_DEVICE_LIMIT=3
|
||||
MAX_DEVICES_LIMIT=15
|
||||
```
|
||||
|
||||
### 👥 Реферальная система
|
||||
### 💥 Реферальная система
|
||||
|
||||
```env
|
||||
# Включение/выключение реферальной программы
|
||||
@@ -609,7 +801,7 @@ REDIS_URL=redis://redis:6379/0
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
### 👤 **Для пользователей**
|
||||
### 💤 **Для пользователей**
|
||||
|
||||
🧭 **Онбординг и доступ**
|
||||
- 🌐 Выбор языка интерфейса (RU/EN), динамическая локализация
|
||||
@@ -618,9 +810,9 @@ REDIS_URL=redis://redis:6379/0
|
||||
- 🔗 Deeplink-инвайты, UTM-кампании и реферальные коды
|
||||
|
||||
🛒 **Умная покупка подписок**
|
||||
- 📅 Гибкие периоды (14–360 дней) со скидками
|
||||
- 📅 Гибкие периоды (14—360 дней) со скидками
|
||||
- 📊 Выбор трафика: фиксированный лимит, пакеты или безлимит
|
||||
- 🌍 Автоматический выбор сервера или мультивыбор
|
||||
- 🌐 Автоматический выбор сервера или мультивыбор
|
||||
- 📱 Настройка количества устройств и серверов
|
||||
- 🧾 Динамический калькулятор стоимости
|
||||
- 💾 **Сквозная корзина** - сохранение параметров при недостатке баланса
|
||||
@@ -640,10 +832,10 @@ REDIS_URL=redis://redis:6379/0
|
||||
- 💰 CryptoBot (USDT, TON, BTC, ETH и др.)
|
||||
- 🪙 Heleket (криптовалюта с наценкой)
|
||||
- 💳 MulenPay (СБП)
|
||||
- 🏦 PayPalych/Pal24 (СБП + карты)
|
||||
- 🦐 PayPalych/Pal24 (СБП + карты)
|
||||
- 💳 Platega (СБП + банковские карты)
|
||||
- 💳 **WATA**
|
||||
- 📥 Автогенерация счетов и webhook-уведомления
|
||||
- 🔥 Автогенерация счетов и webhook-уведомления
|
||||
- 💼 История операций
|
||||
- 🔄 Автоплатёж с настройкой дня списания
|
||||
- 🎁 Реферальные и промо-бонусы
|
||||
@@ -651,7 +843,7 @@ REDIS_URL=redis://redis:6379/0
|
||||
|
||||
📱 **Управление подписками**
|
||||
- 📈 Реальный трафик, устройства и серверы
|
||||
- 🌍 Переключение серверов и стран
|
||||
- 🌐 Переключение серверов и стран
|
||||
- 📱 Сброс HWID
|
||||
- 🧩 Смена языка, промогруппы и параметров
|
||||
- 🧾 Просмотр активных услуг и статуса
|
||||
@@ -667,7 +859,7 @@ REDIS_URL=redis://redis:6379/0
|
||||
- 🎁 **Персональные промо-предложения** от админов
|
||||
- 💰 **Тестовый доступ к серверам** через промо-акции
|
||||
- 💸 **Автоматические скидки** при оплате и автопродлении
|
||||
- 👥 Реферальная программа с комиссиями и бонусами
|
||||
- 💥 Реферальная программа с комиссиями и бонусами
|
||||
- 📊 Аналитика доходов и конверсии рефералов
|
||||
- 🔗 Генерация реферальных ссылок и QR кодов
|
||||
|
||||
@@ -682,9 +874,9 @@ REDIS_URL=redis://redis:6379/0
|
||||
- 🖥️ **Полноценный личный кабинет** в Telegram WebApp
|
||||
- 📊 Управление подпиской и параметрами
|
||||
- 💳 Интегрированные платежи
|
||||
- 🎁 Активация промо-оферов и промокодов
|
||||
- 🎁 Активация промо-офферов и промокодов
|
||||
- 📱 Управление устройствами
|
||||
- 👥 Реферальная статистика
|
||||
- 💥 Реферальная статистика
|
||||
- 📋 FAQ и юридические документы
|
||||
- 📥 Библиотека загрузочных ссылок для клиентов
|
||||
- 🛰️ Web API для внешних интеграций
|
||||
@@ -702,7 +894,7 @@ REDIS_URL=redis://redis:6379/0
|
||||
- 🎯 **Статистика по промо-группам** и скидочным уровням
|
||||
- 📊 **Расширенная фильтрация** пользователей (баланс, траты, активность)
|
||||
|
||||
👥 **Управление пользователями**
|
||||
💥 **Управление пользователями**
|
||||
- 🔍 Поиск по ID, имени, юзернейму, Telegram ID и фильтры
|
||||
- 💰 Ручное изменение баланса
|
||||
- 📱 Изменение лимитов устройств, трафика, серверов
|
||||
@@ -722,7 +914,7 @@ REDIS_URL=redis://redis:6379/0
|
||||
- 📣 **Рекламные кампании** с deeplink и бонусами (Автовыдача подписки / баланса при переходе)
|
||||
- 📨 Рассылки по сегментам с медиа и кнопка
|
||||
- 🎨 **Кастомные кнопки** для рассылок (подключение, подписка, поддержка, партнертка и тд)
|
||||
- 🔘 Настройка главного меню и приветственных экранов
|
||||
- 📘 Настройка главного меню и приветственных экранов
|
||||
|
||||
🛟 **Поддержка и модерация**
|
||||
- 🎫 **Центр тикетов** с приоритетами и статусами
|
||||
@@ -749,18 +941,18 @@ REDIS_URL=redis://redis:6379/0
|
||||
- 🗓️ **Умные автобекапы** с гибким расписанием
|
||||
- 📦 Ручные бекапы с выбором содержимого
|
||||
- 📤 Отправка архивов в выделенный чат/топик
|
||||
- 🔁 Восстановление без остановки бота
|
||||
- 🔓 Восстановление без остановки бота
|
||||
- ✅ Автоматическая синхронизация sequences после восстановления
|
||||
|
||||
💳 **Биллинг и настройки**
|
||||
- ⚙️ **Управление ценами** без перезапуска бота
|
||||
- 🔘 **Управление пакетами трафика** (включение/отключение)
|
||||
- 📘 **Управление пакетами трафика** (включение/отключение)
|
||||
- 🧪 Тестовые платежи для каждого провайдера
|
||||
- 🪝 Управление вебхуками всех платёжных систем
|
||||
- 🪙 Управление вебхуками всех платёжных систем
|
||||
|
||||
🏗️ **REST API для интеграций**
|
||||
🗃️ **REST API для интеграций**
|
||||
- 🔌 **FastAPI Web API** с полной документацией
|
||||
- 🔑 Управление API-ключами и токенами
|
||||
- 🔒 Управление API-ключами и токенами
|
||||
- 📊 Эндпоинты для подписок, пользователей, транзакций
|
||||
- 🎁 API промо-системы и рассылок
|
||||
- 📋 API управления контентом и настройками
|
||||
@@ -775,22 +967,22 @@ REDIS_URL=redis://redis:6379/0
|
||||
- 🔄 **Автосинхронизация серверов** - фоновая синхронизация по расписанию
|
||||
- 🛒 **Умная корзина** - сохранение параметров подписки в Redis при недостатке баланса
|
||||
- 🛡️ **Антифрод** - валидация подписки на канал
|
||||
- 🚫 **Защита от блокировок** - автоблокировка подозрительных никнеймов и имитации фишинг аккаунтов
|
||||
- 🚫 **Защита от блокировок** - автоблокировка подозрительных никнеймов и имитация фишинг аккаунтов
|
||||
- 🧠 **Асинхронная архитектура** - aiogram 3, PostgreSQL/SQLite, Redis и очереди задач
|
||||
- 🌐 **Мультиязычность** - локализации RU/EN, быстрый выбор языка
|
||||
- 📦 **Интеграция с Remnawave API** - автоматическое создание пользователей и синхронизация
|
||||
- 🔄 **Миграция сквадов** - массовый перенос пользователей между сквадами
|
||||
- 🧾 **История операций** - хранение всех транзакций и действий для аудита
|
||||
- 💸 **Сервис автопрвоерки транзакций** - автоматическая проверка транзакций в статусе "В ожидании оплаты" за последние 24ч
|
||||
- 💸 **Сервис автопрворки транзакций** - автоматическая проверка транзакций в статусе "В ожидании оплаты" за последние 24ч
|
||||
|
||||
### 🌐 Веб-API и мини-приложение
|
||||
|
||||
- ⚙️ **FastAPI Web API** с эндпоинтами для управления всеми аспектами бота
|
||||
- 🔑 **Управление API-ключами** - выпуск, отзыв, реактивация токенов
|
||||
- 🔒 **Управление API-ключами** - выпуск, отзыв, реактивация токенов
|
||||
- 🛰️ **Mini App** - полноценный личный кабинет внутри Telegram
|
||||
- 💳 **Интегрированные платежи** в Mini App (Stars, Pal24, YooKassa, WATA)
|
||||
- 🧭 **Единый стандартный app-config.json** - централизованная раздача ссылок на клиенты
|
||||
- 🪝 **Платёжные вебхуки** - встроенные серверы для всех платёжных систем
|
||||
- 🪙 **Платёжные вебхуки** - встроенные серверы для всех платёжных систем
|
||||
- 📡 **Мониторинг серверов** - REST-эндпоинты для просмотра нод и статистики
|
||||
|
||||
## 🚀 Производительность
|
||||
@@ -804,14 +996,14 @@ REDIS_URL=redis://redis:6379/0
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Технологический стек
|
||||
## 🗃️ Технологический стек
|
||||
|
||||
### 💪 Современные технологии
|
||||
|
||||
- **🐍 Python 3.13+** с AsyncIO - максимальная производительность
|
||||
- **🗄️ PostgreSQL 15+** - надежное хранение данных
|
||||
- **⚡ Redis** - быстрое кеширование и сессии (для корзины)
|
||||
- **🐳 Docker** - простое развертывание в любой среде
|
||||
- **Docker** - простое развертывание в любой среде
|
||||
- **🔗 SQLAlchemy ORM** - безопасная работа с БД
|
||||
- **🚀 aiogram 3** - современная Telegram Bot API
|
||||
- **⚡ FastAPI** - высокопроизводительный REST API
|
||||
@@ -828,7 +1020,7 @@ REDIS_URL=redis://redis:6379/0
|
||||
- Нажми **Синхронизация** и дождись завершения
|
||||
- Без этого пользователи не смогут выбирать страны!
|
||||
|
||||
2. **👥 Синхронизация пользователей** (если есть база)
|
||||
2. **💥 Синхронизация пользователей** (если есть база)
|
||||
- **Админ панель** → **Remnawave** → **Синхронизация**
|
||||
- **Синхронизировать всех** → дождись импорта
|
||||
|
||||
@@ -880,9 +1072,9 @@ ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID=126 # ID топика для тикет
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Устранение неполадок
|
||||
## 🛠 Устранение неполадок
|
||||
|
||||
### 🏥 Health Checks
|
||||
### 🥼 Health Checks
|
||||
- **Unified сервер**: `http://localhost:8080/health/unified` (или `/health`, если административное API отключено)
|
||||
- **Telegram webhook**: `http://localhost:8080/health/telegram-webhook`
|
||||
- **Платёжные webhooks**: `http://localhost:8080/health/payment-webhooks`
|
||||
@@ -929,7 +1121,7 @@ docker system prune
|
||||
|
||||
## 💡 Использование
|
||||
|
||||
### 👤 **Для пользователей**
|
||||
### 💤 **Для пользователей**
|
||||
|
||||
1. **🚀 Старт** → Найди бота и нажми `/start`
|
||||
2. **🌐 Язык** → Выбери язык интерфейса (RU/EN)
|
||||
@@ -937,21 +1129,21 @@ docker system prune
|
||||
4. **💰 Баланс** → Пополни через любой удобный способ
|
||||
5. **🛒 Подписка** → Выбери тариф и параметры
|
||||
6. **📱 Подключение** → Получи ссылку или конфиг
|
||||
7. **👥 Партнерка** → Поделись ссылкой и получай бонусы
|
||||
7. **💥 Партнерка** → Поделись ссылкой и получай бонусы
|
||||
|
||||
### ⚙️ **Для администраторов**
|
||||
|
||||
Доступ через **"⚙️ Админ панель"**:
|
||||
|
||||
- **📦 Подписки** → настройка серверов, цен, синхронизация
|
||||
- **👥 Пользователи** → поиск, редактирование, блокировка
|
||||
- **💥 Пользователи** → поиск, редактирование, блокировка
|
||||
- **💎 Промо-группы** → управление скидочными группами и уровнями
|
||||
- **🎁 Промокоды** → создание и статистика
|
||||
- **🎯 Промо-предложения** → персональные акции и скидки
|
||||
- **📨 Рассылки** → уведомления по сегментам
|
||||
- **📣 Кампании** → управление рекламными кампаниями
|
||||
- **🎫 Тикеты** → система поддержки
|
||||
- **📄 Контент** → политика, оферта, FAQ
|
||||
- **🔄 Контент** → политика, оферта, FAQ
|
||||
- **🖥 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://t.me/tribute/app?startapp=duUO)** - помоги проекту расти
|
||||
- 👍 **[Поддержи разработку](https://t.me/tribute/app?startapp=duUO)** - помоги проекту расти
|
||||
- 🔧 **Отправляй 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)**
|
||||
|
||||
**Версия:** v2.5.2 | **Последнее обновление:** 2024
|
||||
|
||||
<div align="center">
|
||||
|
||||
### ⭐ Не забудь поставить звезду проекту!
|
||||
|
||||
@@ -59,6 +59,7 @@ from app.handlers.admin import (
|
||||
public_offer as admin_public_offer,
|
||||
faq as admin_faq,
|
||||
payments as admin_payments,
|
||||
trials as admin_trials,
|
||||
)
|
||||
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_faq.register_handlers(dp)
|
||||
admin_payments.register_handlers(dp)
|
||||
admin_trials.register_handlers(dp)
|
||||
common.register_handlers(dp)
|
||||
register_stars_handlers(dp)
|
||||
user_polls.register_handlers(dp)
|
||||
|
||||
+48
-7
@@ -6,7 +6,8 @@ import re
|
||||
import html
|
||||
from collections import defaultdict
|
||||
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 pydantic_settings import BaseSettings
|
||||
from pydantic import field_validator, Field
|
||||
@@ -254,6 +255,7 @@ class Settings(BaseSettings):
|
||||
MULENPAY_PAYMENT_MODE: int = 4
|
||||
MULENPAY_MIN_AMOUNT_KOPEKS: int = 10000
|
||||
MULENPAY_MAX_AMOUNT_KOPEKS: int = 10000000
|
||||
MULENPAY_IFRAME_EXPECTED_ORIGIN: Optional[str] = None
|
||||
|
||||
PAL24_ENABLED: bool = False
|
||||
PAL24_API_TOKEN: Optional[str] = None
|
||||
@@ -697,15 +699,40 @@ class Settings(BaseSettings):
|
||||
return bool(value)
|
||||
|
||||
def get_available_languages(self) -> List[str]:
|
||||
defaults = ["ru", "en", "ua", "zh"]
|
||||
|
||||
try:
|
||||
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:
|
||||
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:
|
||||
return bool(getattr(self, "LANGUAGE_SELECTION_ENABLED", True))
|
||||
@@ -943,6 +970,20 @@ class Settings(BaseSettings):
|
||||
def get_mulenpay_display_name_html(self) -> str:
|
||||
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:
|
||||
return (
|
||||
self.PAL24_ENABLED
|
||||
|
||||
@@ -3,6 +3,7 @@ from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from datetime import datetime
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
@@ -90,6 +91,7 @@ async def update_mulenpay_payment_status(
|
||||
paid_at: Optional[datetime] = None,
|
||||
callback_payload: Optional[dict] = None,
|
||||
mulen_payment_id: Optional[int] = None,
|
||||
metadata: Optional[dict] = None,
|
||||
) -> MulenPayPayment:
|
||||
payment.status = status
|
||||
if is_paid is not None:
|
||||
@@ -100,6 +102,8 @@ async def update_mulenpay_payment_status(
|
||||
payment.callback_payload = callback_payload
|
||||
if mulen_payment_id is not None and not payment.mulen_payment_id:
|
||||
payment.mulen_payment_id = mulen_payment_id
|
||||
if metadata is not None:
|
||||
payment.metadata_json = metadata
|
||||
|
||||
payment.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
@@ -107,6 +111,19 @@ async def update_mulenpay_payment_status(
|
||||
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(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
|
||||
@@ -96,6 +96,7 @@ async def update_pal24_payment_status(
|
||||
balance_currency: Optional[str] = None,
|
||||
payer_account: Optional[str] = None,
|
||||
callback_payload: Optional[Dict[str, Any]] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> Pal24Payment:
|
||||
update_values: Dict[str, Any] = {
|
||||
"status": status,
|
||||
@@ -121,6 +122,8 @@ async def update_pal24_payment_status(
|
||||
update_values["payer_account"] = payer_account
|
||||
if callback_payload is not None:
|
||||
update_values["callback_payload"] = callback_payload
|
||||
if metadata is not None:
|
||||
update_values["metadata_json"] = metadata
|
||||
|
||||
update_values["last_status"] = status
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
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.orm import selectinload
|
||||
|
||||
@@ -129,7 +129,7 @@ async def create_paid_subscription(
|
||||
connected_squads: List[str] = None,
|
||||
update_server_counters: bool = False,
|
||||
) -> Subscription:
|
||||
|
||||
|
||||
end_date = datetime.utcnow() + timedelta(days=duration_days)
|
||||
|
||||
if device_limit is None:
|
||||
@@ -186,6 +186,91 @@ async def create_paid_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(
|
||||
db: AsyncSession,
|
||||
subscription: Subscription,
|
||||
@@ -591,10 +676,113 @@ async def get_subscriptions_statistics(db: AsyncSession) -> dict:
|
||||
"purchased_today": purchased_today,
|
||||
"purchased_week": purchased_week,
|
||||
"purchased_month": purchased_month,
|
||||
"trial_to_paid_conversion": trial_to_paid_conversion,
|
||||
"renewals_count": renewals_count
|
||||
"trial_to_paid_conversion": trial_to_paid_conversion,
|
||||
"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(
|
||||
db: AsyncSession,
|
||||
subscription: Subscription,
|
||||
|
||||
@@ -379,11 +379,11 @@ async def subtract_user_balance(
|
||||
*,
|
||||
consume_promo_offer: bool = False,
|
||||
) -> bool:
|
||||
logger.error(f"💸 ОТЛАДКА subtract_user_balance:")
|
||||
logger.error(f" 👤 User ID: {user.id} (TG: {user.telegram_id})")
|
||||
logger.error(f" 💰 Баланс до списания: {user.balance_kopeks} копеек")
|
||||
logger.error(f" 💸 Сумма к списанию: {amount_kopeks} копеек")
|
||||
logger.error(f" 📝 Описание: {description}")
|
||||
logger.info(f"💸 ОТЛАДКА subtract_user_balance:")
|
||||
logger.info(f" 👤 User ID: {user.id} (TG: {user.telegram_id})")
|
||||
logger.info(f" 💰 Баланс до списания: {user.balance_kopeks} копеек")
|
||||
logger.info(f" 💸 Сумма к списанию: {amount_kopeks} копеек")
|
||||
logger.info(f" 📝 Описание: {description}")
|
||||
|
||||
log_context: Optional[Dict[str, object]] = None
|
||||
if consume_promo_offer:
|
||||
|
||||
Vendored
+24
-6
@@ -28,11 +28,28 @@ class HeleketService:
|
||||
def is_configured(self) -> bool:
|
||||
return bool(self.merchant_id and self.api_key)
|
||||
|
||||
def _prepare_body(self, payload: Dict[str, Any]) -> str:
|
||||
cleaned = {key: value for key, value in payload.items() if value is not None}
|
||||
serialized = json.dumps(cleaned, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
|
||||
def _prepare_body(
|
||||
self,
|
||||
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:
|
||||
serialized = serialized.replace("/", "\\/")
|
||||
|
||||
return serialized
|
||||
|
||||
def _generate_signature(self, body: str) -> str:
|
||||
@@ -52,7 +69,7 @@ class HeleketService:
|
||||
logger.error("Heleket сервис не настроен: merchant или api_key отсутствуют")
|
||||
return None
|
||||
|
||||
body = self._prepare_body(payload)
|
||||
body = self._prepare_body(payload, ignore_none=True, sort_keys=True)
|
||||
signature = self._generate_signature(body)
|
||||
|
||||
url = f"{self.base_url}/{endpoint.lstrip('/')}"
|
||||
@@ -145,8 +162,9 @@ class HeleketService:
|
||||
logger.error("Heleket webhook без подписи")
|
||||
return False
|
||||
|
||||
data = {key: value for key, value in payload.items() if key != "sign"}
|
||||
body = self._prepare_body(data)
|
||||
data = dict(payload)
|
||||
data.pop("sign", None)
|
||||
body = self._prepare_body(data, ignore_none=False, sort_keys=False)
|
||||
expected = self._generate_signature(body)
|
||||
|
||||
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.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")
|
||||
if not event_type:
|
||||
logger.warning("⚠️ Webhook YooKassa без типа события")
|
||||
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:
|
||||
logger.info(f"ℹ️ Игнорируем событие YooKassa: {event_type}")
|
||||
return web.Response(status=200, text="OK")
|
||||
@@ -274,8 +274,10 @@ class YooKassaWebhookHandler:
|
||||
# Проверяем, не обрабатывается ли этот платеж уже (защита от дублирования)
|
||||
from app.database.models import PaymentMethod
|
||||
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":
|
||||
logger.info(f"ℹ️ Платеж YooKassa {yookassa_payment_id} уже был обработан. Пропускаем дублирующий вебхук.")
|
||||
return web.Response(status=200, text="OK")
|
||||
|
||||
@@ -3,6 +3,7 @@ import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramRetryAfter
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
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)
|
||||
|
||||
# Ограничение на количество одновременных отправок
|
||||
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 with semaphore:
|
||||
try:
|
||||
if has_media and media_file_id:
|
||||
if media_type == "photo":
|
||||
await callback.bot.send_photo(
|
||||
for attempt in range(3):
|
||||
try:
|
||||
if has_media and media_file_id:
|
||||
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,
|
||||
photo=media_file_id,
|
||||
caption=message_text,
|
||||
text=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,
|
||||
text=message_text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=broadcast_keyboard
|
||||
|
||||
await asyncio.sleep(per_message_delay)
|
||||
return True, user.telegram_id
|
||||
except TelegramRetryAfter as e:
|
||||
retry_delay = min(e.retry_after + 1, 30)
|
||||
logger.warning(
|
||||
f"Превышен лимит Telegram для {user.telegram_id}, ожидание {retry_delay} сек."
|
||||
)
|
||||
return True, user.telegram_id
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки рассылки пользователю {user.telegram_id}: {e}")
|
||||
return False, user.telegram_id
|
||||
await asyncio.sleep(retry_delay)
|
||||
except TelegramForbiddenError:
|
||||
# Пользователь мог удалить бота или запретить сообщения
|
||||
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):
|
||||
batch = users[i:i + batch_size]
|
||||
tasks = [send_single_broadcast(user) for user in batch]
|
||||
@@ -826,7 +852,7 @@ async def confirm_broadcast(
|
||||
failed_count += 1
|
||||
|
||||
# Небольшая задержка между пакетами для снижения нагрузки на API
|
||||
await asyncio.sleep(0.1)
|
||||
await asyncio.sleep(0.25)
|
||||
|
||||
broadcast_history.sent_count = sent_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:
|
||||
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":
|
||||
return users
|
||||
|
||||
@@ -45,7 +45,7 @@ MIGRATION_PAGE_SIZE = 8
|
||||
|
||||
def _format_duration(seconds: float) -> str:
|
||||
if seconds < 1:
|
||||
return "<1с"
|
||||
return "менее 1с"
|
||||
|
||||
minutes, sec = divmod(int(seconds), 60)
|
||||
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.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()
|
||||
|
||||
|
||||
@@ -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=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"💰 Сумма к зачислению: {amount_rubles:.0f} ₽\n"
|
||||
f"💵 К оплате: {amount_usd:.2f} USD\n"
|
||||
@@ -177,7 +203,12 @@ async def process_cryptobot_payment_amount(
|
||||
reply_markup=keyboard,
|
||||
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()
|
||||
|
||||
logger.info(f"Создан CryptoBot платеж для пользователя {db_user.telegram_id}: "
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy import update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
@@ -66,7 +68,11 @@ async def start_heleket_payment(
|
||||
)
|
||||
|
||||
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()
|
||||
|
||||
|
||||
@@ -181,7 +187,52 @@ async def process_heleket_payment_amount(
|
||||
[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()
|
||||
|
||||
|
||||
|
||||
@@ -748,11 +748,19 @@ async def handle_topup_amount_callback(
|
||||
)
|
||||
elif method == "platega":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .platega import process_platega_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_platega_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
from .platega import process_platega_payment_amount, start_platega_payment
|
||||
|
||||
data = await state.get_data()
|
||||
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":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
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.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()
|
||||
|
||||
|
||||
@@ -93,6 +97,26 @@ async def process_mulenpay_payment_amount(
|
||||
|
||||
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:
|
||||
payment_service = PaymentService(message.bot)
|
||||
payment_result = await payment_service.create_mulenpay_payment(
|
||||
@@ -163,12 +187,39 @@ async def process_mulenpay_payment_amount(
|
||||
mulenpay_name_html=mulenpay_name_html,
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
invoice_message = await message.answer(
|
||||
message_text,
|
||||
reply_markup=keyboard,
|
||||
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()
|
||||
|
||||
logger.info(
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import html
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from aiogram import types
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy import update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
@@ -204,12 +206,36 @@ async def _send_pal24_payment_message(
|
||||
support=settings.get_support_contact_display_html(),
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
invoice_message = await message.answer(
|
||||
message_text,
|
||||
reply_markup=keyboard,
|
||||
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()
|
||||
|
||||
logger.info(
|
||||
@@ -277,7 +303,11 @@ async def start_pal24_payment(
|
||||
)
|
||||
|
||||
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()
|
||||
|
||||
|
||||
@@ -309,6 +339,24 @@ async def process_pal24_payment_amount(
|
||||
|
||||
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:
|
||||
await _send_pal24_payment_message(
|
||||
message,
|
||||
|
||||
@@ -32,6 +32,29 @@ async def _prompt_amount(
|
||||
texts = get_texts(db_user.language)
|
||||
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)
|
||||
max_amount_kopeks = settings.PLATEGA_MAX_AMOUNT_KOPEKS
|
||||
max_amount_label = (
|
||||
@@ -75,7 +98,10 @@ async def _prompt_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
|
||||
@@ -108,6 +134,8 @@ async def start_platega_payment(
|
||||
return
|
||||
|
||||
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:
|
||||
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),
|
||||
)
|
||||
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()
|
||||
|
||||
|
||||
@@ -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(
|
||||
method=method_title,
|
||||
amount=settings.format_price(amount_kopeks),
|
||||
@@ -286,6 +333,29 @@ async def process_platega_payment_amount(
|
||||
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()
|
||||
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import logging
|
||||
from aiogram import types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
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.services.payment_service import PaymentService
|
||||
from app.states import BalanceStates
|
||||
@@ -22,11 +21,11 @@ async def start_stars_payment(
|
||||
state: FSMContext
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
|
||||
if not settings.TELEGRAM_STARS_ENABLED:
|
||||
await callback.answer("❌ Пополнение через Stars временно недоступно", show_alert=True)
|
||||
return
|
||||
|
||||
|
||||
# Формируем текст сообщения в зависимости от настройки
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
|
||||
message_text = (
|
||||
@@ -35,10 +34,10 @@ async def start_stars_payment(
|
||||
)
|
||||
else:
|
||||
message_text = texts.TOP_UP_AMOUNT
|
||||
|
||||
|
||||
# Создаем клавиатуру
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
|
||||
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
|
||||
from .main import get_quick_amount_buttons
|
||||
@@ -46,12 +45,17 @@ async def start_stars_payment(
|
||||
if quick_amount_buttons:
|
||||
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
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.update_data(payment_method="stars")
|
||||
await callback.answer()
|
||||
@@ -65,29 +69,48 @@ async def process_stars_payment_amount(
|
||||
state: FSMContext
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
|
||||
if not settings.TELEGRAM_STARS_ENABLED:
|
||||
await message.answer("⚠️ Оплата Stars временно недоступна")
|
||||
return
|
||||
|
||||
|
||||
try:
|
||||
amount_rubles = amount_kopeks / 100
|
||||
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)
|
||||
invoice_link = await payment_service.create_stars_invoice(
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=f"Пополнение баланса на {texts.format_price(amount_kopeks)}",
|
||||
payload=f"balance_{db_user.id}_{amount_kopeks}"
|
||||
)
|
||||
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="⭐ Оплатить", url=invoice_link)],
|
||||
[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"💰 Сумма: {texts.format_price(amount_kopeks)}\n"
|
||||
f"⭐ К оплате: {stars_amount} звезд\n"
|
||||
@@ -96,9 +119,14 @@ async def process_stars_payment_amount(
|
||||
reply_markup=keyboard,
|
||||
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:
|
||||
logger.error(f"Ошибка создания Stars invoice: {e}")
|
||||
await message.answer("⚠️ Ошибка создания платежа")
|
||||
await message.answer("⚠️ Ошибка создания платежа")
|
||||
|
||||
@@ -13,47 +13,59 @@ logger = logging.getLogger(__name__)
|
||||
@error_handler
|
||||
async def start_tribute_payment(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User
|
||||
db_user: User,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
|
||||
if not settings.TRIBUTE_ENABLED:
|
||||
await callback.answer("❌ Оплата картой временно недоступна", show_alert=True)
|
||||
return
|
||||
|
||||
|
||||
try:
|
||||
from app.services.tribute_service import TributeService
|
||||
|
||||
|
||||
tribute_service = TributeService(callback.bot)
|
||||
payment_url = await tribute_service.create_payment_link(
|
||||
user_id=db_user.telegram_id,
|
||||
amount_kopeks=0,
|
||||
description="Пополнение баланса VPN"
|
||||
description="Пополнение баланса VPN",
|
||||
)
|
||||
|
||||
|
||||
if not payment_url:
|
||||
await callback.answer("❌ Ошибка создания платежа", show_alert=True)
|
||||
return
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[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"
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="💳 Перейти к оплате", url=payment_url)],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")],
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
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:
|
||||
logger.error(f"Ошибка создания Tribute платежа: {e}")
|
||||
await callback.answer("❌ Ошибка создания платежа", show_alert=True)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
await callback.answer()
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Dict
|
||||
|
||||
from aiogram import types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy import update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
@@ -56,7 +58,11 @@ async def start_wata_payment(
|
||||
)
|
||||
|
||||
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()
|
||||
|
||||
|
||||
@@ -159,12 +165,54 @@ async def process_wata_payment_amount(
|
||||
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,
|
||||
reply_markup=keyboard,
|
||||
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()
|
||||
|
||||
logger.info(
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from aiogram import types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy import update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
@@ -58,9 +61,13 @@ async def start_yookassa_payment(
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
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()
|
||||
|
||||
|
||||
@@ -108,9 +115,13 @@ async def start_yookassa_sbp_payment(
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
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()
|
||||
|
||||
|
||||
@@ -172,7 +183,25 @@ async def process_yookassa_payment_amount(
|
||||
[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"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
|
||||
f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...\n\n"
|
||||
@@ -187,9 +216,34 @@ async def process_yookassa_payment_amount(
|
||||
reply_markup=keyboard,
|
||||
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}: "
|
||||
f"{amount_kopeks//100}₽, ID: {payment_result['yookassa_payment_id']}")
|
||||
|
||||
@@ -310,27 +364,45 @@ async def process_yookassa_sbp_payment_amount(
|
||||
|
||||
# Создаем клавиатуру с кнопками для оплаты по ссылке и проверки статуса
|
||||
keyboard_buttons = []
|
||||
|
||||
|
||||
# Добавляем кнопку оплаты, если доступна ссылка
|
||||
if confirmation_url:
|
||||
keyboard_buttons.append([types.InlineKeyboardButton(text="🔗 Перейти к оплате", url=confirmation_url)])
|
||||
else:
|
||||
# Если ссылка недоступна, предлагаем оплатить через ID платежа в приложении банка
|
||||
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=texts.BACK, callback_data="balance_topup")])
|
||||
|
||||
|
||||
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 = (
|
||||
f"🔗 <b>Оплата через СБП</b>\n\n"
|
||||
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
|
||||
f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...\n\n"
|
||||
)
|
||||
|
||||
|
||||
# Добавляем инструкции в зависимости от доступных способов оплаты
|
||||
if not confirmation_url:
|
||||
message_text += (
|
||||
@@ -341,18 +413,18 @@ async def process_yookassa_sbp_payment_amount(
|
||||
f"4. Подтвердите платеж в приложении банка\n"
|
||||
f"5. Деньги поступят на баланс автоматически\n\n"
|
||||
)
|
||||
|
||||
|
||||
message_text += (
|
||||
f"🔒 Оплата происходит через защищенную систему YooKassa\n"
|
||||
f"✅ Принимаем СБП от всех банков-участников\n\n"
|
||||
f"❓ Если возникнут проблемы, обратитесь в {settings.get_support_contact_display_html()}"
|
||||
)
|
||||
|
||||
|
||||
# Отправляем сообщение с инструкциями и клавиатурой
|
||||
# Если есть QR-код, отправляем его как медиа-сообщение
|
||||
if qr_photo:
|
||||
# Используем метод отправки медиа-группы или фото с описанием
|
||||
await message.answer_photo(
|
||||
invoice_message = await message.answer_photo(
|
||||
photo=qr_photo,
|
||||
caption=message_text,
|
||||
reply_markup=keyboard,
|
||||
@@ -360,12 +432,39 @@ async def process_yookassa_sbp_payment_amount(
|
||||
)
|
||||
else:
|
||||
# Если QR-код недоступен, отправляем обычное текстовое сообщение
|
||||
await message.answer(
|
||||
invoice_message = await message.answer(
|
||||
message_text,
|
||||
reply_markup=keyboard,
|
||||
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}: "
|
||||
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}",
|
||||
).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:
|
||||
return texts.t(
|
||||
"SUB_STATUS_TRIAL_ACTIVE",
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import logging
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.external.telegram_stars import TelegramStarsService
|
||||
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)
|
||||
|
||||
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_")
|
||||
|
||||
@@ -35,6 +37,7 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
|
||||
|
||||
try:
|
||||
from app.database.database import get_db
|
||||
|
||||
async for db in get_db():
|
||||
user = await get_user_by_telegram_id(db, query.from_user.id)
|
||||
if not user:
|
||||
@@ -77,6 +80,7 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
|
||||
async def handle_successful_payment(
|
||||
message: types.Message,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
**kwargs
|
||||
):
|
||||
texts = get_texts(DEFAULT_LANGUAGE)
|
||||
@@ -106,6 +110,27 @@ async def handle_successful_payment(
|
||||
return
|
||||
|
||||
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(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
@@ -113,7 +138,14 @@ async def handle_successful_payment(
|
||||
payload=payment.invoice_payload,
|
||||
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:
|
||||
rubles_amount = TelegramStarsService.calculate_rubles_from_stars(payment.total_amount)
|
||||
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):
|
||||
|
||||
|
||||
dp.pre_checkout_query.register(
|
||||
handle_pre_checkout_query,
|
||||
F.currency == "XTR"
|
||||
F.currency == "XTR"
|
||||
)
|
||||
|
||||
|
||||
dp.message.register(
|
||||
handle_successful_payment,
|
||||
F.successful_payment
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
|
||||
previous_devices = data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
|
||||
|
||||
data['devices'] = devices
|
||||
data['total_price'] = base_price + countries_price + devices_price
|
||||
await state.set_data(data)
|
||||
|
||||
await callback.message.edit_reply_markup(
|
||||
reply_markup=get_devices_keyboard(devices, db_user.language)
|
||||
)
|
||||
if devices != previous_devices:
|
||||
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()
|
||||
|
||||
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(
|
||||
text=_t(texts, "ADMIN_MAIN_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:
|
||||
texts = get_texts(language)
|
||||
|
||||
|
||||
+50
-10
@@ -82,7 +82,40 @@ def _build_additional_buttons(additional_section, language: str) -> List[InlineK
|
||||
|
||||
_LANGUAGE_DISPLAY_NAMES = {
|
||||
"ru": "🇷🇺 Русский",
|
||||
"ru-ru": "🇷🇺 Русский",
|
||||
"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:
|
||||
@@ -95,27 +128,34 @@ def get_rules_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup
|
||||
])
|
||||
|
||||
def get_channel_sub_keyboard(
|
||||
channel_link: str,
|
||||
channel_link: Optional[str],
|
||||
language: str = DEFAULT_LANGUAGE,
|
||||
) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
|
||||
buttons: List[List[InlineKeyboardButton]] = []
|
||||
|
||||
if channel_link:
|
||||
buttons.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("CHANNEL_SUBSCRIBE_BUTTON", "🔗 Подписаться"),
|
||||
url=channel_link,
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("CHANNEL_CHECK_BUTTON", "✅ Я подписался"),
|
||||
callback_data="sub_channel_check",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
buttons.append(
|
||||
[
|
||||
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:
|
||||
texts = get_texts(language)
|
||||
|
||||
@@ -130,6 +130,7 @@
|
||||
"ADMIN_MAIN_SETTINGS": "⚙️ Settings",
|
||||
"ADMIN_MAIN_SUPPORT": "🛟 Support",
|
||||
"ADMIN_MAIN_SYSTEM": "🛠️ System",
|
||||
"ADMIN_MAIN_TRIALS": "🧪 Trials",
|
||||
"ADMIN_MAIN_PAYMENTS": "💳 Top-ups",
|
||||
"ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Users / Subscriptions",
|
||||
"ADMIN_MESSAGES": "📨 Broadcasts",
|
||||
@@ -168,6 +169,11 @@
|
||||
"ADMIN_PAYMENTS_TITLE": "💳 <b>Top-up verification</b>",
|
||||
"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_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_ITEM_DETAILS": "📄 #{number}",
|
||||
"ADMIN_PAYMENT_STATUS_PENDING": "Pending",
|
||||
@@ -1017,7 +1023,6 @@
|
||||
"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_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_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.",
|
||||
@@ -1055,12 +1060,8 @@
|
||||
"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_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_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_NAME": "💳 <b>Bank card (Platega)</b>",
|
||||
"PAYMENT_METHOD_STARS_DESCRIPTION": "fast and convenient",
|
||||
@@ -1069,8 +1070,6 @@
|
||||
"PAYMENT_METHOD_SUPPORT_NAME": "🛠️ <b>Support team</b>",
|
||||
"PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "via Tribute",
|
||||
"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_NAME": "💳 <b>Bank card</b>",
|
||||
"PAYMENT_METHOD_YOOKASSA_SBP_DESCRIPTION": "via YooKassa Fast Payment System",
|
||||
|
||||
@@ -130,6 +130,7 @@
|
||||
"ADMIN_MAIN_SETTINGS": "⚙️ Настройки",
|
||||
"ADMIN_MAIN_SUPPORT": "🛟 Поддержка",
|
||||
"ADMIN_MAIN_SYSTEM": "🛠️ Система",
|
||||
"ADMIN_MAIN_TRIALS": "🧪 Триалы",
|
||||
"ADMIN_MAIN_PAYMENTS": "💳 Пополнения",
|
||||
"ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Юзеры/Подписки",
|
||||
"ADMIN_MESSAGES": "📨 Рассылки",
|
||||
@@ -168,6 +169,11 @@
|
||||
"ADMIN_PAYMENTS_TITLE": "💳 <b>Проверка пополнений</b>",
|
||||
"ADMIN_PAYMENTS_DESCRIPTION": "Список счетов на пополнение, созданных за последние 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_ITEM_DETAILS": "📄 №{number}",
|
||||
"ADMIN_PAYMENT_STATUS_PENDING": "Ожидает оплаты",
|
||||
@@ -1037,7 +1043,6 @@
|
||||
"PAL24_INSTRUCTION_FOLLOW": "{step}. Следуйте подсказкам платёжной системы",
|
||||
"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_PAY_BUTTON": "🏦 Оплатить через PayPalych (СБП)",
|
||||
"PAL24_SBP_PAY_BUTTON": "🏦 Оплатить через PayPalych (СБП)",
|
||||
"PAL24_SELECT_PAYMENT_METHOD": "Выберите способ оплаты PayPalych:",
|
||||
"PAL24_TOPUP_PROMPT": "🏦 <b>Оплата через PayPalych (СБП)</b>\n\nВведите сумму для пополнения от 100 до 1 000 000 ₽.\nОплата проходит через систему быстрых платежей PayPalych.",
|
||||
@@ -1075,12 +1080,8 @@
|
||||
"PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку.",
|
||||
"PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "через CryptoBot",
|
||||
"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_NAME": "💳 <b>Банковская карта ({mulenpay_name})</b>",
|
||||
"PAYMENT_METHOD_PAL24_DESCRIPTION": "через систему быстрых платежей",
|
||||
"PAYMENT_METHOD_PAL24_NAME": "🏦 <b>СБП (PayPalych)</b>",
|
||||
"PAYMENT_METHOD_PLATEGA_DESCRIPTION": "через Platega (карты + СБП)",
|
||||
"PAYMENT_METHOD_PLATEGA_NAME": "💳 <b>Банковская карта (Platega)</b>",
|
||||
"PAYMENT_METHOD_STARS_DESCRIPTION": "быстро и удобно",
|
||||
@@ -1089,8 +1090,6 @@
|
||||
"PAYMENT_METHOD_SUPPORT_NAME": "🛠️ <b>Через поддержку</b>",
|
||||
"PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "через Tribute",
|
||||
"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_NAME": "💳 <b>Банковская карта</b>",
|
||||
"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] = {}
|
||||
|
||||
|
||||
_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:
|
||||
if language in _cached_rules:
|
||||
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]:
|
||||
language_code = (language or DEFAULT_LANGUAGE).split("-")[0].lower()
|
||||
|
||||
if language_code == "ru":
|
||||
return {
|
||||
"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"
|
||||
),
|
||||
}
|
||||
language_code = _LANGUAGE_ALIASES.get(language_code, language_code)
|
||||
config = _DYNAMIC_LANGUAGE_CONFIGS.get(language_code)
|
||||
|
||||
if language_code == "en":
|
||||
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"
|
||||
),
|
||||
}
|
||||
if not config:
|
||||
return {}
|
||||
|
||||
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:
|
||||
|
||||
@@ -82,7 +82,7 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
bot: Bot = data["bot"]
|
||||
|
||||
channel_id = settings.CHANNEL_SUB_ID
|
||||
|
||||
|
||||
if not channel_id:
|
||||
logger.warning("⚠️ CHANNEL_SUB_ID не установлен, пропускаем проверку")
|
||||
return await handler(event, data)
|
||||
@@ -93,7 +93,12 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
logger.debug("⚠️ Обязательная подписка отключена, пропускаем проверку")
|
||||
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:
|
||||
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)
|
||||
return
|
||||
|
||||
return await self._deny_message(event, bot, channel_link)
|
||||
return await self._deny_message(event, bot, channel_link, channel_id)
|
||||
else:
|
||||
logger.warning(f"⚠️ Неожиданный статус пользователя {telegram_id}: {member.status}")
|
||||
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:
|
||||
logger.error(f"❌ Бот заблокирован в канале {channel_id}: {e}")
|
||||
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:
|
||||
if "chat not found" in str(e).lower():
|
||||
logger.error(f"❌ Канал {channel_id} не найден: {e}")
|
||||
@@ -130,11 +135,29 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
else:
|
||||
logger.error(f"❌ Ошибка запроса к каналу {channel_id}: {e}")
|
||||
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:
|
||||
logger.error(f"❌ Неожиданная ошибка при проверке подписки: {e}")
|
||||
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(
|
||||
self,
|
||||
state: Optional[FSMContext],
|
||||
@@ -278,7 +301,12 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
break
|
||||
|
||||
@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("🚫 Отправляем сообщение о необходимости подписки")
|
||||
|
||||
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:
|
||||
if isinstance(event, Message):
|
||||
return await event.answer(text, reply_markup=channel_sub_kb)
|
||||
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:
|
||||
return await bot.send_message(event.message.chat.id, text, reply_markup=channel_sub_kb)
|
||||
except Exception as e:
|
||||
|
||||
@@ -912,12 +912,17 @@ class MonitoringService:
|
||||
|
||||
result = await db.execute(
|
||||
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(
|
||||
and_(
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
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 (
|
||||
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.user_utils import format_referrer_info
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
renewal_service = SubscriptionRenewalService()
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _AdminNotificationContext:
|
||||
user_id: int
|
||||
@@ -173,6 +185,36 @@ class CryptoBotPaymentMixin:
|
||||
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:
|
||||
amount_usd = updated_payment.amount_float
|
||||
|
||||
@@ -394,6 +436,161 @@ class CryptoBotPaymentMixin:
|
||||
)
|
||||
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(
|
||||
self, context: _AdminNotificationContext
|
||||
) -> None:
|
||||
|
||||
@@ -255,6 +255,42 @@ class HeleketPaymentMixin:
|
||||
if updated_payment is 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:
|
||||
logger.info(
|
||||
"Heleket платеж %s уже связан с транзакцией %s",
|
||||
|
||||
@@ -182,7 +182,43 @@ class MulenPayPaymentMixin:
|
||||
)
|
||||
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 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(
|
||||
"%s платеж %s уже обработан, игнорируем повторный callback",
|
||||
display_name,
|
||||
@@ -197,6 +233,7 @@ class MulenPayPaymentMixin:
|
||||
status="success",
|
||||
callback_payload=callback_data,
|
||||
mulen_payment_id=mulen_payment_id_int,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
if payment.transaction_id:
|
||||
|
||||
@@ -333,6 +333,41 @@ class Pal24PaymentMixin:
|
||||
|
||||
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:
|
||||
logger.info(
|
||||
"Pal24 платеж %s уже привязан к транзакции (trigger=%s)",
|
||||
|
||||
@@ -306,6 +306,22 @@ class PlategaPaymentMixin:
|
||||
metadata = dict(getattr(payment, "metadata_json", {}) or {})
|
||||
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:
|
||||
logger.info(
|
||||
"Platega платеж %s уже связан с транзакцией %s",
|
||||
|
||||
@@ -515,36 +515,6 @@ class TelegramStarsMixin:
|
||||
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:
|
||||
from aiogram import types
|
||||
|
||||
@@ -415,6 +415,25 @@ class WataPaymentMixin:
|
||||
if not paid_at and getattr(payment, "paid_at", None):
|
||||
paid_at = payment.paid_at
|
||||
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
|
||||
|
||||
await payment_module.update_wata_payment_status(
|
||||
|
||||
@@ -383,11 +383,19 @@ class YooKassaPaymentMixin:
|
||||
payment_module = import_module("app.services.payment_service")
|
||||
|
||||
# Проверяем, не обрабатывается ли уже этот платеж (защита от дублирования)
|
||||
existing_transaction = await payment_module.get_transaction_by_external_id( # type: ignore[attr-defined]
|
||||
db,
|
||||
payment.yookassa_payment_id,
|
||||
PaymentMethod.YOOKASSA,
|
||||
get_transaction_by_external_id = getattr(
|
||||
payment_module, "get_transaction_by_external_id", None
|
||||
)
|
||||
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:
|
||||
# Если транзакция уже существует, просто завершаем обработку
|
||||
@@ -437,6 +445,22 @@ class YooKassaPaymentMixin:
|
||||
except Exception as 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"))
|
||||
|
||||
transaction = None
|
||||
@@ -472,11 +496,20 @@ class YooKassaPaymentMixin:
|
||||
)
|
||||
|
||||
if transaction is None:
|
||||
existing_transaction = await payment_module.get_transaction_by_external_id( # type: ignore[attr-defined]
|
||||
db,
|
||||
payment.yookassa_payment_id,
|
||||
PaymentMethod.YOOKASSA,
|
||||
get_transaction_by_external_id = getattr(
|
||||
payment_module, "get_transaction_by_external_id", None
|
||||
)
|
||||
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:
|
||||
# Если транзакция уже существует, пропускаем обработку
|
||||
|
||||
@@ -112,6 +112,11 @@ async def 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):
|
||||
mulenpay_crud = import_module("app.database.crud.mulenpay")
|
||||
return await mulenpay_crud.link_mulenpay_payment_to_transaction(*args, **kwargs)
|
||||
|
||||
@@ -26,6 +26,7 @@ class PlategaService:
|
||||
self._max_retries = 3
|
||||
self._retry_delay = 0.5
|
||||
self._retryable_statuses = {500, 502, 503, 504}
|
||||
self._description_max_length = 64
|
||||
|
||||
@property
|
||||
def is_configured(self) -> bool:
|
||||
@@ -51,7 +52,10 @@ class PlategaService:
|
||||
}
|
||||
|
||||
if description:
|
||||
body["description"] = description
|
||||
sanitized_description = self._sanitize_description(
|
||||
description, self._description_max_length
|
||||
)
|
||||
body["description"] = sanitized_description
|
||||
if return_url:
|
||||
body["return"] = return_url
|
||||
if failed_url:
|
||||
@@ -176,6 +180,32 @@ class PlategaService:
|
||||
|
||||
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
|
||||
def parse_expires_at(expires_in: Optional[str]) -> Optional[datetime]:
|
||||
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:
|
||||
_invoice_messages: Dict[int, Dict[str, int]] = {}
|
||||
|
||||
def __init__(self, bot: Bot):
|
||||
self.bot = bot
|
||||
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(
|
||||
self,
|
||||
@@ -174,7 +194,8 @@ class TributeService:
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о Tribute пополнении: {e}")
|
||||
|
||||
|
||||
await self._cleanup_invoice_message(user_telegram_id)
|
||||
await self._send_success_notification(user_telegram_id, amount_kopeks)
|
||||
|
||||
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 (
|
||||
User, UserStatus, Subscription, Transaction, PromoCode, PromoCodeUse,
|
||||
ReferralEarning, SubscriptionServer, YooKassaPayment, BroadcastHistory,
|
||||
CryptoBotPayment, SubscriptionConversion, UserMessage, WelcomeText,
|
||||
CryptoBotPayment, PlategaPayment, SubscriptionConversion, UserMessage, WelcomeText,
|
||||
SentNotification, PromoGroup, MulenPayPayment, Pal24Payment, HeleketPayment,
|
||||
AdvertisingCampaign, AdvertisingCampaignRegistration, PaymentMethod,
|
||||
TransactionType
|
||||
@@ -732,6 +732,27 @@ class UserService:
|
||||
except Exception as 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:
|
||||
mulenpay_result = await db.execute(
|
||||
select(MulenPayPayment).where(MulenPayPayment.user_id == user_id)
|
||||
|
||||
@@ -24,6 +24,7 @@ from ..schemas.logs import (
|
||||
SupportAuditLogEntry,
|
||||
SupportAuditLogListResponse,
|
||||
SystemLogPreviewResponse,
|
||||
SystemLogFullResponse,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
@@ -141,6 +142,33 @@ async def download_system_log(
|
||||
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)
|
||||
async def list_monitoring_logs(
|
||||
_: Any = Security(require_api_token),
|
||||
|
||||
+360
-267
@@ -3,13 +3,14 @@ from __future__ import annotations
|
||||
import logging
|
||||
import re
|
||||
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 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 fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
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 (
|
||||
add_user_to_servers,
|
||||
get_available_server_squads,
|
||||
get_server_ids_by_uuids,
|
||||
get_server_squad_by_uuid,
|
||||
remove_user_from_servers,
|
||||
)
|
||||
from app.database.crud.subscription import (
|
||||
add_subscription_servers,
|
||||
calculate_subscription_total_cost,
|
||||
create_trial_subscription,
|
||||
extend_subscription,
|
||||
remove_subscription_servers,
|
||||
@@ -54,7 +53,6 @@ from app.database.models import (
|
||||
PaymentMethod,
|
||||
User,
|
||||
)
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.services.faq_service import FaqService
|
||||
from app.services.privacy_policy_service import PrivacyPolicyService
|
||||
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.promocode_service import PromoCodeService
|
||||
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 (
|
||||
TrialPaymentChargeFailed,
|
||||
TrialPaymentInsufficientFunds,
|
||||
@@ -93,11 +101,9 @@ from app.utils.user_utils import (
|
||||
)
|
||||
from app.utils.pricing_utils import (
|
||||
apply_percentage_discount,
|
||||
calculate_months_from_days,
|
||||
calculate_prorated_price,
|
||||
format_period_description,
|
||||
get_remaining_months,
|
||||
validate_pricing_calculation,
|
||||
)
|
||||
from app.utils.promo_offer import get_user_active_promo_discount_percent
|
||||
|
||||
@@ -113,6 +119,8 @@ from ..schemas.miniapp import (
|
||||
MiniAppLegalDocuments,
|
||||
MiniAppPaymentCreateRequest,
|
||||
MiniAppPaymentCreateResponse,
|
||||
MiniAppPaymentIframeConfig,
|
||||
MiniAppPaymentIntegrationType,
|
||||
MiniAppPaymentMethod,
|
||||
MiniAppPaymentMethodsRequest,
|
||||
MiniAppPaymentMethodsResponse,
|
||||
@@ -177,27 +185,7 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
promo_code_service = PromoCodeService()
|
||||
|
||||
|
||||
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()
|
||||
renewal_service = SubscriptionRenewalService()
|
||||
|
||||
|
||||
_CRYPTOBOT_MIN_USD = 1.0
|
||||
@@ -624,6 +612,18 @@ def _normalize_amount_kopeks(
|
||||
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(
|
||||
"/payments/methods",
|
||||
response_model=MiniAppPaymentMethodsResponse,
|
||||
@@ -646,6 +646,7 @@ async def get_payment_methods(
|
||||
currency="RUB",
|
||||
min_amount_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",
|
||||
min_amount_kopeks=settings.YOOKASSA_MIN_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",
|
||||
min_amount_kopeks=settings.YOOKASSA_MIN_AMOUNT_KOPEKS,
|
||||
max_amount_kopeks=settings.YOOKASSA_MAX_AMOUNT_KOPEKS,
|
||||
integration_type=MiniAppPaymentIntegrationType.REDIRECT,
|
||||
)
|
||||
)
|
||||
|
||||
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(
|
||||
MiniAppPaymentMethod(
|
||||
id="mulenpay",
|
||||
@@ -683,6 +692,8 @@ async def get_payment_methods(
|
||||
currency="RUB",
|
||||
min_amount_kopeks=settings.MULENPAY_MIN_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",
|
||||
min_amount_kopeks=settings.PAL24_MIN_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",
|
||||
min_amount_kopeks=settings.WATA_MIN_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",
|
||||
min_amount_kopeks=min_amount_kopeks,
|
||||
max_amount_kopeks=max_amount_kopeks,
|
||||
integration_type=MiniAppPaymentIntegrationType.REDIRECT,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -733,6 +747,7 @@ async def get_payment_methods(
|
||||
currency="RUB",
|
||||
min_amount_kopeks=100 * 100,
|
||||
max_amount_kopeks=100_000 * 100,
|
||||
integration_type=MiniAppPaymentIntegrationType.REDIRECT,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -743,6 +758,7 @@ async def get_payment_methods(
|
||||
icon="💎",
|
||||
requires_amount=False,
|
||||
currency="RUB",
|
||||
integration_type=MiniAppPaymentIntegrationType.REDIRECT,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1613,6 +1629,9 @@ async def _resolve_cryptobot_payment_status(
|
||||
except (InvalidOperation, TypeError):
|
||||
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(
|
||||
method="cryptobot",
|
||||
status=status,
|
||||
@@ -1629,6 +1648,9 @@ async def _resolve_cryptobot_payment_status(
|
||||
"invoice_id": payment.invoice_id,
|
||||
"payload": query.payload,
|
||||
"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:
|
||||
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(
|
||||
db,
|
||||
user,
|
||||
@@ -3772,10 +3794,93 @@ def _build_promo_offer_payload(user: Optional[User]) -> Optional[Dict[str, Any]]
|
||||
return payload
|
||||
|
||||
|
||||
def _build_renewal_period_id(period_days: int) -> str:
|
||||
return f"days:{period_days}"
|
||||
def _format_payment_method_title(method: str) -> str:
|
||||
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]:
|
||||
if not identifier:
|
||||
return None
|
||||
@@ -3795,95 +3900,14 @@ async def _calculate_subscription_renewal_pricing(
|
||||
user: User,
|
||||
subscription: Subscription,
|
||||
period_days: int,
|
||||
) -> Dict[str, Any]:
|
||||
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(
|
||||
):
|
||||
return await renewal_service.calculate_pricing(
|
||||
db,
|
||||
user,
|
||||
subscription,
|
||||
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(
|
||||
db: AsyncSession,
|
||||
@@ -3898,12 +3922,13 @@ async def _prepare_subscription_renewal_options(
|
||||
|
||||
for period_days in available_periods:
|
||||
try:
|
||||
pricing = await _calculate_subscription_renewal_pricing(
|
||||
pricing_model = await _calculate_subscription_renewal_pricing(
|
||||
db,
|
||||
user,
|
||||
subscription,
|
||||
period_days,
|
||||
)
|
||||
pricing = pricing_model.to_payload()
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
"Failed to calculate renewal pricing for subscription %s (period %s): %s",
|
||||
@@ -4077,7 +4102,11 @@ async def _authorize_miniapp_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)
|
||||
if not subscription:
|
||||
raise HTTPException(
|
||||
@@ -4085,7 +4114,9 @@ def _ensure_paid_subscription(user: User) -> Subscription:
|
||||
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(
|
||||
status.HTTP_403_FORBIDDEN,
|
||||
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(
|
||||
status.HTTP_403_FORBIDDEN,
|
||||
detail={
|
||||
@@ -4367,7 +4419,10 @@ async def get_subscription_renewal_options_endpoint(
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> MiniAppSubscriptionRenewalOptionsResponse:
|
||||
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)
|
||||
|
||||
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),
|
||||
) -> MiniAppSubscriptionRenewalResponse:
|
||||
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)
|
||||
|
||||
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"},
|
||||
)
|
||||
|
||||
method = (payload.method or "").strip().lower()
|
||||
|
||||
try:
|
||||
pricing = await _calculate_subscription_renewal_pricing(
|
||||
pricing_model = await _calculate_subscription_renewal_pricing(
|
||||
db,
|
||||
user,
|
||||
subscription,
|
||||
@@ -4498,156 +4558,177 @@ async def submit_subscription_renewal_endpoint(
|
||||
detail={"code": "pricing_failed", "message": "Failed to calculate renewal pricing"},
|
||||
) 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)
|
||||
|
||||
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"))
|
||||
missing_amount = calculate_missing_amount(balance_kopeks, final_total)
|
||||
description = f"Продление подписки на {period_days} дней"
|
||||
old_end_date = subscription.end_date
|
||||
|
||||
if final_total > 0 or consume_promo_offer:
|
||||
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:
|
||||
if missing_amount <= 0:
|
||||
try:
|
||||
await add_subscription_servers(
|
||||
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(
|
||||
result = await renewal_service.finalize(
|
||||
db,
|
||||
user,
|
||||
subscription,
|
||||
transaction,
|
||||
period_days,
|
||||
old_end_date,
|
||||
new_end_date=subscription.end_date,
|
||||
balance_after=user.balance_kopeks,
|
||||
pricing_model,
|
||||
description=description,
|
||||
)
|
||||
except SubscriptionRenewalChargeError as error:
|
||||
logger.error(
|
||||
"Failed to charge balance for subscription renewal %s: %s",
|
||||
subscription.id,
|
||||
error,
|
||||
)
|
||||
raise HTTPException(
|
||||
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail={"code": "charge_failed", "message": "Failed to charge balance"},
|
||||
) from error
|
||||
|
||||
updated_subscription = result.subscription
|
||||
message = _build_renewal_success_message(
|
||||
user,
|
||||
updated_subscription,
|
||||
result.total_amount_kopeks,
|
||||
pricing_model.promo_discount_value,
|
||||
)
|
||||
|
||||
language_code = _normalize_language_code(user)
|
||||
amount_label = settings.format_price(final_total)
|
||||
date_label = (
|
||||
format_local_datetime(subscription.end_date, "%d.%m.%Y %H:%M")
|
||||
if subscription.end_date
|
||||
else ""
|
||||
)
|
||||
return MiniAppSubscriptionRenewalResponse(
|
||||
message=message,
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
balance_label=settings.format_price(user.balance_kopeks),
|
||||
subscription_id=updated_subscription.id,
|
||||
renewed_until=updated_subscription.end_date,
|
||||
)
|
||||
|
||||
if language_code == "ru":
|
||||
if final_total > 0:
|
||||
message = (
|
||||
f"Подписка продлена до {date_label}. " if date_label else "Подписка продлена. "
|
||||
) + f"Списано {amount_label}."
|
||||
else:
|
||||
message = (
|
||||
f"Подписка продлена до {date_label}."
|
||||
if date_label
|
||||
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."
|
||||
if not method:
|
||||
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,
|
||||
},
|
||||
)
|
||||
|
||||
promo_discount_value = pricing.get("promo_discount_value") or 0
|
||||
if consume_promo_offer and 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}."
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
detail={
|
||||
"code": "payment_method_required",
|
||||
"message": "Payment method is required when balance is insufficient",
|
||||
},
|
||||
)
|
||||
|
||||
return MiniAppSubscriptionRenewalResponse(
|
||||
message=message,
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
balance_label=settings.format_price(user.balance_kopeks),
|
||||
subscription_id=subscription.id,
|
||||
renewed_until=subscription.end_date,
|
||||
supported_methods = {"cryptobot"}
|
||||
if method not in supported_methods:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
detail={"code": "unsupported_method", "message": "Payment method is not supported for renewal"},
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
if subscription and transaction and period_days:
|
||||
await _with_admin_notification_service(
|
||||
await with_admin_notification_service(
|
||||
lambda service: service.send_subscription_purchase_notification(
|
||||
db,
|
||||
user,
|
||||
@@ -4790,7 +4871,10 @@ async def get_subscription_settings_endpoint(
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> MiniAppSubscriptionSettingsResponse:
|
||||
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)
|
||||
|
||||
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),
|
||||
) -> MiniAppSubscriptionUpdateResponse:
|
||||
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)
|
||||
old_servers = list(getattr(subscription, "connected_squads", []) or [])
|
||||
|
||||
@@ -4989,7 +5076,7 @@ async def update_subscription_servers_endpoint(
|
||||
service = SubscriptionService()
|
||||
await service.update_remnawave_user(db, subscription)
|
||||
|
||||
await _with_admin_notification_service(
|
||||
await with_admin_notification_service(
|
||||
lambda service: service.send_subscription_update_notification(
|
||||
db,
|
||||
user,
|
||||
@@ -5013,7 +5100,10 @@ async def update_subscription_traffic_endpoint(
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> MiniAppSubscriptionUpdateResponse:
|
||||
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)
|
||||
old_traffic = subscription.traffic_limit_gb
|
||||
|
||||
@@ -5153,7 +5243,7 @@ async def update_subscription_traffic_endpoint(
|
||||
service = SubscriptionService()
|
||||
await service.update_remnawave_user(db, subscription)
|
||||
|
||||
await _with_admin_notification_service(
|
||||
await with_admin_notification_service(
|
||||
lambda service: service.send_subscription_update_notification(
|
||||
db,
|
||||
user,
|
||||
@@ -5177,7 +5267,10 @@ async def update_subscription_devices_endpoint(
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> MiniAppSubscriptionUpdateResponse:
|
||||
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)
|
||||
|
||||
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()
|
||||
await service.update_remnawave_user(db, subscription)
|
||||
|
||||
await _with_admin_notification_service(
|
||||
await with_admin_notification_service(
|
||||
lambda service: service.send_subscription_update_notification(
|
||||
db,
|
||||
user,
|
||||
|
||||
@@ -2,6 +2,9 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Depends,
|
||||
@@ -23,6 +26,8 @@ from app.database.crud.poll import (
|
||||
get_poll_statistics,
|
||||
)
|
||||
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 ..schemas.polls import (
|
||||
@@ -38,6 +43,8 @@ from ..schemas.polls import (
|
||||
PollStatisticsResponse,
|
||||
PollSummaryResponse,
|
||||
PollUserResponse,
|
||||
PollSendRequest,
|
||||
PollSendResponse,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
@@ -306,3 +313,55 @@ async def get_poll_responses(
|
||||
limit=limit,
|
||||
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
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
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 app.config import settings
|
||||
from app.database.crud.server_squad import get_random_trial_squad_uuid
|
||||
from app.database.crud.subscription import (
|
||||
add_subscription_devices,
|
||||
add_subscription_squad,
|
||||
@@ -16,6 +18,7 @@ from app.database.crud.subscription import (
|
||||
create_trial_subscription,
|
||||
extend_subscription,
|
||||
get_subscription_by_user_id,
|
||||
replace_subscription,
|
||||
remove_subscription_squad,
|
||||
)
|
||||
from app.database.models import Subscription, SubscriptionStatus
|
||||
@@ -30,6 +33,8 @@ from ..schemas.subscriptions import (
|
||||
SubscriptionTrafficRequest,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
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:
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
@@ -109,7 +136,7 @@ async def create_subscription(
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> SubscriptionResponse:
|
||||
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")
|
||||
|
||||
forced_devices = None
|
||||
@@ -120,15 +147,36 @@ async def create_subscription(
|
||||
trial_device_limit = payload.device_limit
|
||||
if trial_device_limit is None:
|
||||
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(
|
||||
db,
|
||||
user_id=payload.user_id,
|
||||
duration_days=payload.duration_days,
|
||||
traffic_limit_gb=payload.traffic_limit_gb,
|
||||
device_limit=trial_device_limit,
|
||||
squad_uuid=payload.squad_uuid,
|
||||
)
|
||||
if existing:
|
||||
connected_squads = await _choose_trial_squads(
|
||||
db, payload.squad_uuid, list(existing.connected_squads or [])
|
||||
)
|
||||
subscription = await replace_subscription(
|
||||
db,
|
||||
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:
|
||||
if payload.duration_days is None:
|
||||
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
|
||||
else:
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
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,
|
||||
)
|
||||
if existing:
|
||||
subscription = await replace_subscription(
|
||||
db,
|
||||
existing,
|
||||
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 [],
|
||||
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)
|
||||
return _serialize_subscription(subscription)
|
||||
|
||||
@@ -6,7 +6,12 @@ from typing import Any, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Security, status
|
||||
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 ..dependencies import get_db_session, require_api_token
|
||||
@@ -14,7 +19,10 @@ from ..schemas.tickets import (
|
||||
TicketMessageResponse,
|
||||
TicketPriorityUpdateRequest,
|
||||
TicketReplyBlockRequest,
|
||||
TicketReplyRequest,
|
||||
TicketReplyResponse,
|
||||
TicketResponse,
|
||||
TicketMediaResponse,
|
||||
TicketStatusUpdateRequest,
|
||||
)
|
||||
|
||||
@@ -29,6 +37,7 @@ def _serialize_message(message: TicketMessage) -> TicketMessageResponse:
|
||||
is_from_admin=message.is_from_admin,
|
||||
has_media=message.has_media,
|
||||
media_type=message.media_type,
|
||||
media_file_id=message.media_file_id,
|
||||
media_caption=message.media_caption,
|
||||
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)
|
||||
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)
|
||||
|
||||
|
||||
@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)
|
||||
async def create_user_endpoint(
|
||||
payload: UserCreateRequest,
|
||||
|
||||
@@ -88,3 +88,14 @@ class SystemLogPreviewResponse(BaseModel):
|
||||
default=None,
|
||||
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 datetime import datetime
|
||||
from enum import Enum
|
||||
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):
|
||||
@@ -200,6 +202,7 @@ class MiniAppSubscriptionRenewalRequest(BaseModel):
|
||||
subscription_id: Optional[int] = Field(default=None, alias="subscriptionId")
|
||||
period_id: Optional[str] = Field(default=None, alias="periodId")
|
||||
period_days: Optional[int] = Field(default=None, alias="periodDays")
|
||||
method: Optional[str] = None
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
@@ -211,6 +214,14 @@ class MiniAppSubscriptionRenewalResponse(BaseModel):
|
||||
balance_label: Optional[str] = Field(default=None, alias="balanceLabel")
|
||||
subscription_id: Optional[int] = Field(default=None, alias="subscriptionId")
|
||||
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)
|
||||
|
||||
@@ -357,6 +368,30 @@ class MiniAppPaymentMethodsRequest(BaseModel):
|
||||
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):
|
||||
id: str
|
||||
name: Optional[str] = None
|
||||
@@ -366,6 +401,17 @@ class MiniAppPaymentMethod(BaseModel):
|
||||
min_amount_kopeks: Optional[int] = None
|
||||
max_amount_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):
|
||||
|
||||
@@ -169,3 +169,23 @@ class PollResponsesListResponse(BaseModel):
|
||||
total: int
|
||||
limit: 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
|
||||
squad_uuid: Optional[str] = None
|
||||
connected_squads: Optional[List[str]] = None
|
||||
replace_existing: bool = False
|
||||
|
||||
|
||||
class SubscriptionExtendRequest(BaseModel):
|
||||
|
||||
@@ -13,6 +13,7 @@ class TicketMessageResponse(BaseModel):
|
||||
is_from_admin: bool
|
||||
has_media: bool
|
||||
media_type: Optional[str] = None
|
||||
media_file_id: Optional[str] = None
|
||||
media_caption: Optional[str] = None
|
||||
created_at: datetime
|
||||
|
||||
@@ -42,3 +43,27 @@ class TicketPriorityUpdateRequest(BaseModel):
|
||||
class TicketReplyBlockRequest(BaseModel):
|
||||
permanent: bool = False
|
||||
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. |
|
||||
| Нет ссылок подписки | Не настроена интеграция с 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 = {}) {
|
||||
const { force = false } = options;
|
||||
|
||||
if (!hasPaidSubscription()) {
|
||||
if (!hasActiveSubscription()) {
|
||||
resetSubscriptionRenewalState(null);
|
||||
renderSubscriptionRenewalCard();
|
||||
return Promise.resolve(null);
|
||||
@@ -14484,7 +14484,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldShow = hasPaidSubscription();
|
||||
const shouldShow = hasActiveSubscription();
|
||||
card.classList.toggle('hidden', !shouldShow);
|
||||
if (!shouldShow) {
|
||||
return;
|
||||
@@ -14601,7 +14601,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasPaidSubscription()) {
|
||||
if (!hasActiveSubscription()) {
|
||||
handleSubscriptionRenewalError(createError('Subscription renewal', t('subscription_renewal.error.validation')));
|
||||
return;
|
||||
}
|
||||
@@ -14784,7 +14784,7 @@
|
||||
|
||||
function ensureSubscriptionSettingsLoaded(options = {}) {
|
||||
const { force = false } = options;
|
||||
if (!hasPaidSubscription()) {
|
||||
if (!hasActiveSubscription()) {
|
||||
subscriptionSettingsData = null;
|
||||
subscriptionSettingsPromise = null;
|
||||
subscriptionSettingsError = null;
|
||||
@@ -14912,7 +14912,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldShow = hasPaidSubscription();
|
||||
const shouldShow = hasActiveSubscription();
|
||||
card.classList.toggle('hidden', !shouldShow);
|
||||
if (!shouldShow) {
|
||||
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
|
||||
text = await response.text()
|
||||
|
||||
assert status == 200
|
||||
assert text == "OK"
|
||||
process_mock.assert_awaited_once()
|
||||
assert status == 400
|
||||
assert text == "No payment id"
|
||||
process_mock.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -160,9 +160,9 @@ async def test_handle_webhook_trusts_cf_connecting_ip(monkeypatch: pytest.Monkey
|
||||
status = response.status
|
||||
text = await response.text()
|
||||
|
||||
assert status == 200
|
||||
assert text == "OK"
|
||||
process_mock.assert_awaited_once()
|
||||
assert status == 400
|
||||
assert text == "No payment id"
|
||||
process_mock.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -184,9 +184,9 @@ async def test_handle_webhook_with_optional_signature(monkeypatch: pytest.Monkey
|
||||
status = response.status
|
||||
text = await response.text()
|
||||
|
||||
assert status == 200
|
||||
assert text == "OK"
|
||||
process_mock.assert_awaited_once()
|
||||
assert status == 400
|
||||
assert text == "No payment id"
|
||||
process_mock.assert_not_awaited()
|
||||
|
||||
|
||||
@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.webapi.routes import miniapp
|
||||
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 (
|
||||
MiniAppPaymentCreateRequest,
|
||||
MiniAppPaymentIntegrationType,
|
||||
MiniAppPaymentMethodsRequest,
|
||||
MiniAppPaymentStatusQuery,
|
||||
MiniAppSubscriptionRenewalRequest,
|
||||
)
|
||||
|
||||
|
||||
@@ -43,6 +53,572 @@ def test_compute_cryptobot_limits_scale_with_rate():
|
||||
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")
|
||||
async def test_create_payment_link_pal24_uses_selected_option(monkeypatch):
|
||||
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.min_amount_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")
|
||||
@@ -515,6 +1093,32 @@ async def test_get_payment_methods_includes_wata(monkeypatch):
|
||||
assert wata_method.min_amount_kopeks == 5000
|
||||
assert wata_method.max_amount_kopeks == 7500000
|
||||
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")
|
||||
async def test_find_recent_deposit_ignores_transactions_before_attempt():
|
||||
started_at = datetime(2024, 5, 1, 12, 0, 0)
|
||||
|
||||
Reference in New Issue
Block a user