diff --git a/.env.example b/.env.example index c5071757..0d40f8dc 100644 --- a/.env.example +++ b/.env.example @@ -181,10 +181,17 @@ REMNAWAVE_AUTO_SYNC_TIMES=03:00 # ========= ПОДПИСКИ ========= # ===== РЕЖИМ ПРОДАЖ ===== -# Режим продаж подписок: -# "classic" - классический режим (выбор серверов, трафика, устройств, периода отдельно) -# "tariffs" - режим тарифов (готовые пакеты с фиксированными параметрами) -SALES_MODE=classic +# Режим продаж подписок (можно переключить в кабинете: Настройки → Подписки): +# "classic" - классический режим: +# - Пользователь выбирает период, серверы, трафик, устройства отдельно +# - Цены периодов берутся из PERIOD_PRICES ниже +# - Подходит для гибкой настройки под каждого пользователя +# "tariffs" - режим тарифов: +# - Пользователь выбирает готовый тариф (Premium, Basic и т.д.) +# - Тарифы создаются в кабинете: Админ → Тарифы +# - Каждый тариф имеет свои серверы, трафик, устройства и цены за периоды +# - Подходит для продажи готовых пакетов услуг +SALES_MODE=tariffs # ===== ТРИАЛ ПОДПИСКА ===== TRIAL_DURATION_DAYS=3 diff --git a/.github/workflows/docker-hub.yml b/.github/workflows/docker-hub.yml index 06ebd817..b0387631 100644 --- a/.github/workflows/docker-hub.yml +++ b/.github/workflows/docker-hub.yml @@ -36,15 +36,15 @@ jobs: TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}" echo "🏷️ Собираем релизную версию: $VERSION" elif [[ $GITHUB_REF == refs/heads/main ]]; then - VERSION="v2.9.5-$(git rev-parse --short HEAD)" + VERSION="v3.0.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.9.5-dev-$(git rev-parse --short HEAD)" + VERSION="v3.0.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.9.5-pr-$(git rev-parse --short HEAD)" + VERSION="v3.0.0-pr-$(git rev-parse --short HEAD)" TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)" echo "🔀 Собираем PR версию: $VERSION" fi diff --git a/.github/workflows/docker-registry.yml b/.github/workflows/docker-registry.yml index d23c7a8c..22b7e48e 100644 --- a/.github/workflows/docker-registry.yml +++ b/.github/workflows/docker-registry.yml @@ -49,13 +49,13 @@ jobs: VERSION=${GITHUB_REF#refs/tags/} echo "🏷️ Building release version: $VERSION" elif [[ $GITHUB_REF == refs/heads/main ]]; then - VERSION="v2.9.5-$(git rev-parse --short HEAD)" + VERSION="v3.0.0-$(git rev-parse --short HEAD)" echo "🚀 Building main version: $VERSION" elif [[ $GITHUB_REF == refs/heads/dev ]]; then - VERSION="v2.9.5-dev-$(git rev-parse --short HEAD)" + VERSION="v3.0.0-dev-$(git rev-parse --short HEAD)" echo "🧪 Building dev version: $VERSION" else - VERSION="v2.9.5-pr-$(git rev-parse --short HEAD)" + VERSION="v3.0.0-pr-$(git rev-parse --short HEAD)" echo "🔀 Building PR version: $VERSION" fi echo "version=$VERSION" >> $GITHUB_OUTPUT diff --git a/Dockerfile b/Dockerfile index b11675f7..7b563c2f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \ FROM python:3.13-slim -ARG VERSION="v2.9.5" +ARG VERSION="v3.0.0" ARG BUILD_DATE ARG VCS_REF diff --git a/README.md b/README.md index 9699a6e2..43d2bf45 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ **🤖 Современный Telegram-бот для управления VPN подписками через Remnawave API** -*Полнофункциональное решение с управлением пользователями, платежами и администрированием* +_Полнофункциональное решение с управлением пользователями, платежами и администрированием_ [![Python](https://img.shields.io/badge/Python-3.13+-blue?logo=python&logoColor=white)](https://python.org) [![PostgreSQL](https://img.shields.io/badge/PostgreSQL-15+-blue?logo=postgresql&logoColor=white)](https://postgresql.org) @@ -34,14 +34,16 @@ Вы хотите спать — он позволит и это... ### ⚡ **Полная автоматизация VPN бизнеса** + - 🎯 **Готовое решение** - разверни за 5 минут, начни продавать сегодня - 💰 **Многоканальные платежи** - Telegram Stars + Tribute + CryptoBot + Heleket + YooKassa (СБП + карты) + MulenPay + PayPalych (СБП + карты) + Platega (карты + СБП) + WATA + Freekassa (NSPK СБП + карты) + CloudPayments (карты + СБП) - 🔄 **Автоматизация 99%** - от регистрации до продления подписок - 📱 **MiniApp лк** - личный кабинет с возможностью покупки/продления подписки - 📊 **Детальная аналитика** - полная картина вашего бизнеса - 💬 **Уведомления в топики** об: Активация триала 💎 Покупка подписки 🔄 Конверсия из триала в платную ⏰ Продление подписки 💰 Пополнение баланса 🚧 Включении тех работ ♻️ Появлении новой версии бота - + ### 🎛️ **Гибкость конфигурации** + - 🌐 **Умный выбор серверов** - автоматический пропуск при одном сервере, мультивыбор - 📱 **Управление устройствами** - от 1 до неограниченного количества - 📊 **Режимы продажи трафика** - фиксированный лимит или выбор пакетов @@ -51,6 +53,7 @@ - 🛒 **Умная корзина** - сохранение параметров подписки при недостатке баланса ### 💪 **Enterprise готовность** + - 🗃️ **Современная архитектура** - AsyncIO, PostgreSQL, Redis, модульная структура - 🔒 **Безопасность** - интеграция с системой защиты панели через куки-аутентификацию - 📈 **Масштабируемость** - от стартапа до крупного бизнеса @@ -66,12 +69,12 @@ ### 📚 Поддерживаемые методы авторизации - | Конфигурация | Authorization | X-Api-Key | - |------------------|---------------------|---------------------| - | Только API Key | Bearer | | - | Basic Auth | Bearer | Basic | - | Caddy + API Key | Basic | | - | Cookies (eGames) | Bearer | + cookies | +| Конфигурация | Authorization | X-Api-Key | +| ---------------- | ------------------- | ------------------- | +| Только API Key | Bearer | | +| Basic Auth | Bearer | Basic | +| Caddy + API Key | Basic | | +| Cookies (eGames) | Bearer | + cookies | --- @@ -135,11 +138,11 @@ docker compose logs ### 1. Выбор режима запуска -| `BOT_RUN_MODE` | Что делает | Когда использовать | -|----------------|------------|---------------------| -| `polling` | Бот опрашивает Telegram через long polling. HTTP-сервер можно не поднимать. | Локальная отладка или отсутствие внешнего HTTPS. | -| `webhook` | Aiogram получает апдейты только через вебхук. | Продакшн и серверы за HTTPS-прокси. | -| `both` | Одновременно работают polling и webhook. | Тестирование или повышенная отказоустойчивость. | +| `BOT_RUN_MODE` | Что делает | Когда использовать | +| -------------- | --------------------------------------------------------------------------- | ------------------------------------------------ | +| `polling` | Бот опрашивает Telegram через long polling. HTTP-сервер можно не поднимать. | Локальная отладка или отсутствие внешнего HTTPS. | +| `webhook` | Aiogram получает апдейты только через вебхук. | Продакшн и серверы за HTTPS-прокси. | +| `both` | Одновременно работают polling и webhook. | Тестирование или повышенная отказоустойчивость. | ### 2. Минимальные настройки для webhook @@ -168,6 +171,7 @@ REMNAWAVE_API_KEY=your_api_key ``` **Важно:** + - `WEBHOOK_SECRET_TOKEN` - сгенерируйте командой `openssl rand -hex 32` - `WEB_API_DEFAULT_TOKEN` - сгенерируйте командой `openssl rand -hex 32` - `WEBHOOK_URL` должен быть доступен извне по HTTPS @@ -192,10 +196,10 @@ WEB_API_PORT=8080 WEB_API_ALLOWED_ORIGINS=https://miniapp.domain.com ``` -* `WEBHOOK_URL` — публичный HTTPS-домен прокси. К нему автоматически добавится путь из `WEBHOOK_PATH`. -* `WEBHOOK_SECRET_TOKEN` — защитный токен Telegram, обязательно задайте своё значение. -* Очередь можно тюнить через `WEBHOOK_MAX_QUEUE_SIZE`, `WEBHOOK_WORKERS`, `WEBHOOK_ENQUEUE_TIMEOUT` и `WEBHOOK_WORKER_SHUTDOWN_TIMEOUT`. -* Если миниапка или админка доступны по другим доменам, перечислите их через запятую в `WEB_API_ALLOWED_ORIGINS`. +- `WEBHOOK_URL` — публичный HTTPS-домен прокси. К нему автоматически добавится путь из `WEBHOOK_PATH`. +- `WEBHOOK_SECRET_TOKEN` — защитный токен Telegram, обязательно задайте своё значение. +- Очередь можно тюнить через `WEBHOOK_MAX_QUEUE_SIZE`, `WEBHOOK_WORKERS`, `WEBHOOK_ENQUEUE_TIMEOUT` и `WEBHOOK_WORKER_SHUTDOWN_TIMEOUT`. +- Если миниапка или админка доступны по другим доменам, перечислите их через запятую в `WEB_API_ALLOWED_ORIGINS`. После изменения `.env` перезапустите сервис: `docker compose up -d remnawave_bot`. @@ -206,6 +210,7 @@ WEB_API_ALLOWED_ORIGINS=https://miniapp.domain.com Если бот запускается на отдельном сервере (не там, где панель Remnawave), используйте стандартную конфигурацию: **docker-compose.yml бота:** + ```yaml services: postgres: @@ -216,13 +221,17 @@ services: POSTGRES_DB: ${POSTGRES_DB:-remnawave_bot} POSTGRES_USER: ${POSTGRES_USER:-remnawave_user} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-secure_password_123} - POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C" + POSTGRES_INITDB_ARGS: '--encoding=UTF8 --locale=C' volumes: - postgres_data:/var/lib/postgresql/data networks: - bot_network healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-remnawave_user} -d ${POSTGRES_DB:-remnawave_bot}"] + test: + [ + 'CMD-SHELL', + 'pg_isready -U ${POSTGRES_USER:-remnawave_user} -d ${POSTGRES_DB:-remnawave_bot}', + ] interval: 30s timeout: 5s retries: 5 @@ -238,7 +247,7 @@ services: networks: - bot_network healthcheck: - test: ["CMD", "redis-cli", "ping"] + test: ['CMD', 'redis-cli', 'ping'] interval: 30s timeout: 10s retries: 3 @@ -255,16 +264,16 @@ services: env_file: - .env environment: - DOCKER_ENV: "true" - DATABASE_MODE: "auto" - POSTGRES_HOST: "postgres" - POSTGRES_PORT: "5432" - POSTGRES_DB: "${POSTGRES_DB:-remnawave_bot}" - POSTGRES_USER: "${POSTGRES_USER:-remnawave_user}" - POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:-secure_password_123}" - REDIS_URL: "redis://redis:6379/0" - TZ: "Europe/Moscow" - LOCALES_PATH: "${LOCALES_PATH:-/app/locales}" + DOCKER_ENV: 'true' + DATABASE_MODE: 'auto' + POSTGRES_HOST: 'postgres' + POSTGRES_PORT: '5432' + POSTGRES_DB: '${POSTGRES_DB:-remnawave_bot}' + POSTGRES_USER: '${POSTGRES_USER:-remnawave_user}' + POSTGRES_PASSWORD: '${POSTGRES_PASSWORD:-secure_password_123}' + REDIS_URL: 'redis://redis:6379/0' + TZ: 'Europe/Moscow' + LOCALES_PATH: '${LOCALES_PATH:-/app/locales}' volumes: - ./logs:/app/logs:rw - ./data:/app/data:rw @@ -273,11 +282,15 @@ services: - /etc/localtime:/etc/localtime:ro - ./vpn_logo.png:/app/vpn_logo.png:ro ports: - - "${WEB_API_PORT:-8080}:8080" + - '${WEB_API_PORT:-8080}:8080' networks: - bot_network healthcheck: - test: ["CMD-SHELL", "python -c \"import requests, os; requests.get('http://localhost:8080/health', headers={'X-API-Key': os.environ.get('WEB_API_DEFAULT_TOKEN')}, timeout=5) or exit(1)\""] + test: + [ + 'CMD-SHELL', + 'python -c "import requests, os; requests.get(''http://localhost:8080/health'', headers={''X-API-Key'': os.environ.get(''WEB_API_DEFAULT_TOKEN'')}, timeout=5) or exit(1)"', + ] interval: 60s timeout: 10s retries: 3 @@ -297,9 +310,12 @@ networks: config: - subnet: 172.20.0.0/16 gateway: 172.20.0.1 + driver_opts: + com.docker.network.driver.mtu: 1350 ``` **docker-compose.yml для Caddy (отдельный файл):** + ```yaml services: caddy: @@ -307,8 +323,8 @@ services: container_name: remnawave_caddy restart: unless-stopped ports: - - "80:80" - - "443:443" + - '80:80' + - '443:443' volumes: - ./Caddyfile:/etc/caddy/Caddyfile - caddy_data:/data @@ -331,6 +347,7 @@ networks: Если бот и панель Remnawave запускаются на одном сервере, подключите бота к сети панели: **docker-compose.local.yml бота(есть в репо):** + ```yaml services: postgres: @@ -341,14 +358,18 @@ services: POSTGRES_DB: ${POSTGRES_DB:-remnawave_bot} POSTGRES_USER: ${POSTGRES_USER:-remnawave_user} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-secure_password_123} - POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C" + POSTGRES_INITDB_ARGS: '--encoding=UTF8 --locale=C' volumes: - postgres_data:/var/lib/postgresql/data networks: - bot_network - - remnawave-network # Подключаем к сети панели + - remnawave-network # Подключаем к сети панели healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-remnawave_user} -d ${POSTGRES_DB:-remnawave_bot}"] + test: + [ + 'CMD-SHELL', + 'pg_isready -U ${POSTGRES_USER:-remnawave_user} -d ${POSTGRES_DB:-remnawave_bot}', + ] interval: 30s timeout: 5s retries: 5 @@ -363,9 +384,9 @@ services: - redis_data:/data networks: - bot_network - - remnawave-network # Подключаем к сети панели + - remnawave-network # Подключаем к сети панели healthcheck: - test: ["CMD", "redis-cli", "ping"] + test: ['CMD', 'redis-cli', 'ping'] interval: 30s timeout: 10s retries: 3 @@ -382,16 +403,16 @@ services: env_file: - .env environment: - DOCKER_ENV: "true" - DATABASE_MODE: "auto" - POSTGRES_HOST: "postgres" - POSTGRES_PORT: "5432" - POSTGRES_DB: "${POSTGRES_DB:-remnawave_bot}" - POSTGRES_USER: "${POSTGRES_USER:-remnawave_user}" - POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:-secure_password_123}" - REDIS_URL: "redis://redis:6379/0" - TZ: "Europe/Moscow" - LOCALES_PATH: "${LOCALES_PATH:-/app/locales}" + DOCKER_ENV: 'true' + DATABASE_MODE: 'auto' + POSTGRES_HOST: 'postgres' + POSTGRES_PORT: '5432' + POSTGRES_DB: '${POSTGRES_DB:-remnawave_bot}' + POSTGRES_USER: '${POSTGRES_USER:-remnawave_user}' + POSTGRES_PASSWORD: '${POSTGRES_PASSWORD:-secure_password_123}' + REDIS_URL: 'redis://redis:6379/0' + TZ: 'Europe/Moscow' + LOCALES_PATH: '${LOCALES_PATH:-/app/locales}' volumes: - ./logs:/app/logs:rw - ./data:/app/data:rw @@ -400,12 +421,16 @@ services: - /etc/localtime:/etc/localtime:ro - ./vpn_logo.png:/app/vpn_logo.png:ro ports: - - "${WEB_API_PORT:-8080}:8080" + - '${WEB_API_PORT:-8080}:8080' networks: - bot_network - - remnawave-network # Подключаем к сети панели + - remnawave-network # Подключаем к сети панели healthcheck: - test: ["CMD-SHELL", "python -c \"import requests, os; requests.get('http://localhost:8080/health', headers={'X-API-Key': os.environ.get('WEB_API_DEFAULT_TOKEN')}, timeout=5) or exit(1)\""] + test: + [ + 'CMD-SHELL', + 'python -c "import requests, os; requests.get(''http://localhost:8080/health'', headers={''X-API-Key'': os.environ.get(''WEB_API_DEFAULT_TOKEN'')}, timeout=5) or exit(1)"', + ] interval: 60s timeout: 10s retries: 3 @@ -424,13 +449,16 @@ networks: config: - subnet: 172.20.0.0/16 gateway: 172.20.0.1 - + driver_opts: + com.docker.network.driver.mtu: 1350 + remnawave-network: name: remnawave-network - external: true # Используем существующую сеть панели + external: true # Используем существующую сеть панели ``` **Важно:** + - Сеть `remnawave-network` должна быть создана панелью Remnawave - В `.env` укажите `REMNAWAVE_API_URL=http://remnawave:3000` (обращение внутри Docker-сети) - Бот сможет напрямую общаться с панелью без внешних запросов @@ -460,10 +488,10 @@ curl -s https://bot.example.com/health/unified | jq `Caddyfile`: ```caddy -# Hooks + API +# Hooks + API hooks.domain.com { encode gzip zstd - + # Webhook пути для платежных систем handle /yookassa-webhook { reverse_proxy remnawave_bot:8080 { @@ -474,7 +502,7 @@ hooks.domain.com { } } } - + handle /platega-webhook { reverse_proxy remnawave_bot:8080 { header_up Host {host} @@ -484,7 +512,7 @@ hooks.domain.com { } } } - + handle /cryptobot-webhook { reverse_proxy remnawave_bot:8080 { header_up Host {host} @@ -494,7 +522,7 @@ hooks.domain.com { } } } - + handle /wata-webhook { reverse_proxy remnawave_bot:8080 { header_up Host {host} @@ -504,7 +532,7 @@ hooks.domain.com { } } } - + handle /heleket-webhook { reverse_proxy remnawave_bot:8080 { header_up Host {host} @@ -514,7 +542,7 @@ hooks.domain.com { } } } - + handle /tribute-webhook { reverse_proxy remnawave_bot:8080 { header_up Host {host} @@ -524,7 +552,7 @@ hooks.domain.com { } } } - + handle /pal24-webhook { reverse_proxy remnawave_bot:8080 { header_up Host {host} @@ -534,7 +562,7 @@ hooks.domain.com { } } } - + handle /mulenpay-webhook { reverse_proxy remnawave_bot:8080 { header_up Host {host} @@ -576,7 +604,7 @@ hooks.domain.com { } } } - + # Все остальные запросы handle { reverse_proxy remnawave_bot:8080 { @@ -592,7 +620,7 @@ hooks.domain.com { # Статика для miniapp miniapp.domain.com { encode gzip zstd - + # API эндпоинты /miniapp/* в приложение handle /miniapp/* { reverse_proxy remnawave_bot:8080 { @@ -603,7 +631,7 @@ miniapp.domain.com { } } } - + # app-config.json с CORS handle /app-config.json { header Access-Control-Allow-Origin "*" @@ -615,7 +643,7 @@ miniapp.domain.com { } } } - + # Статические файлы (корень и всё остальное) handle { root * /var/www/remnawave-miniapp @@ -636,22 +664,22 @@ events {} http { include /etc/nginx/mime.types; sendfile on; - + upstream remnawave_bot_unified { server remnawave_bot:8080; } - + # Hooks + API домен server { listen 80; listen 443 ssl http2; server_name hooks.domain.com; - + ssl_certificate /etc/ssl/private/hooks.fullchain.pem; ssl_certificate_key /etc/ssl/private/hooks.privkey.pem; - + client_max_body_size 32m; - + # Webhook пути для платежных систем location = /yookassa-webhook { proxy_pass http://remnawave_bot_unified; @@ -664,7 +692,7 @@ http { proxy_buffering off; proxy_request_buffering off; } - + location = /platega-webhook { proxy_pass http://remnawave_bot_unified; proxy_set_header Host $host; @@ -676,7 +704,7 @@ http { proxy_buffering off; proxy_request_buffering off; } - + location = /cryptobot-webhook { proxy_pass http://remnawave_bot_unified; proxy_set_header Host $host; @@ -688,7 +716,7 @@ http { proxy_buffering off; proxy_request_buffering off; } - + location = /wata-webhook { proxy_pass http://remnawave_bot_unified; proxy_set_header Host $host; @@ -700,7 +728,7 @@ http { proxy_buffering off; proxy_request_buffering off; } - + location = /heleket-webhook { proxy_pass http://remnawave_bot_unified; proxy_set_header Host $host; @@ -712,7 +740,7 @@ http { proxy_buffering off; proxy_request_buffering off; } - + location = /tribute-webhook { proxy_pass http://remnawave_bot_unified; proxy_set_header Host $host; @@ -724,7 +752,7 @@ http { proxy_buffering off; proxy_request_buffering off; } - + location = /pal24-webhook { proxy_pass http://remnawave_bot_unified; proxy_set_header Host $host; @@ -736,7 +764,7 @@ http { proxy_buffering off; proxy_request_buffering off; } - + location = /mulenpay-webhook { proxy_pass http://remnawave_bot_unified; proxy_set_header Host $host; @@ -782,7 +810,7 @@ http { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } - + # Всё остальное location / { proxy_pass http://remnawave_bot_unified; @@ -796,18 +824,18 @@ http { proxy_request_buffering off; } } - + # Miniapp домен (статика + API) server { listen 80; listen 443 ssl http2; server_name miniapp.domain.com; - + ssl_certificate /etc/ssl/private/miniapp.fullchain.pem; ssl_certificate_key /etc/ssl/private/miniapp.privkey.pem; - + client_max_body_size 32m; - + # API эндпоинты /miniapp/* location /miniapp/ { proxy_pass http://remnawave_bot_unified; @@ -820,7 +848,7 @@ http { proxy_buffering off; proxy_request_buffering off; } - + # app-config.json с CORS location = /app-config.json { add_header Access-Control-Allow-Origin "*"; @@ -830,12 +858,12 @@ http { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } - + # Статические файлы location / { root /var/www/remnawave-miniapp; try_files $uri $uri/ /index.html; - + # Кэширование expires 1h; add_header Cache-Control "public, immutable"; @@ -855,11 +883,13 @@ http { ### Быстрая настройка SSL для Nginx в Docker #### 1. Установка Certbot + ```bash sudo apt update && sudo apt install certbot -y ``` #### 2. Генерация сертификатов + ```bash # Остановите Nginx Docker контейнер docker compose down @@ -870,6 +900,7 @@ sudo certbot certonly --standalone -d miniapp.domain.com --agree-tos --email you ``` #### 3. Копирование сертификатов + ```bash sudo mkdir -p /etc/ssl/private @@ -882,14 +913,15 @@ sudo chmod 600 /etc/ssl/private/*.pem ``` #### 4. Обновите docker-compose.yml + ```yaml services: nginx: image: nginx:latest container_name: nginx ports: - - "80:80" - - "443:443" + - '80:80' + - '443:443' volumes: - ./nginx.conf:/etc/nginx/nginx.conf:ro - /etc/ssl/private:/etc/ssl/private:ro @@ -904,11 +936,13 @@ networks: ``` #### 5. Запуск Nginx + ```bash docker compose up -d ``` #### 6. Автообновление сертификатов + ```bash # Создайте скрипт обновления sudo tee /opt/renew-certs.sh > /dev/null <<'EOF' @@ -938,12 +972,14 @@ echo "0 3 * * * /opt/renew-certs.sh" | sudo crontab - ``` #### Проверка работы SSL + ```bash curl -I https://hooks.domain.com curl -I https://miniapp.domain.com ``` -**Важно:** +**Важно:** + - Замените `domain.com` и `your-email@example.com` на свои данные - Замените `/путь/к/docker-compose.yml` на реальный путь к вашему docker-compose файлу - Убедитесь, что DNS записи для доменов указывают на IP вашего сервера @@ -954,11 +990,11 @@ curl -I https://miniapp.domain.com ### 🔧 Основные параметры -| Настройка | Где взять | Пример | -|-----------|-----------|---------| -| 🤖 **BOT_TOKEN** | [@BotFather](https://t.me/BotFather) | `1234567890:AABBCCdd...` | -| 👑 **ADMIN_IDS** | Твой Telegram ID | `123456789,987654321` | -| **BOT_RUN_MODE** | определяет способ приёма обновлений: `polling`, `webhook` или `both`, чтобы одновременно использовать оба режима. +| Настройка | Где взять | Пример | +| ---------------- | ----------------------------------------------------------------------------------------------------------------- | ------------------------ | +| 🤖 **BOT_TOKEN** | [@BotFather](https://t.me/BotFather) | `1234567890:AABBCCdd...` | +| 👑 **ADMIN_IDS** | Твой Telegram ID | `123456789,987654321` | +| **BOT_RUN_MODE** | определяет способ приёма обновлений: `polling`, `webhook` или `both`, чтобы одновременно использовать оба режима. | [Полный список доступных параметров:](.env.example) @@ -984,11 +1020,11 @@ curl -I https://miniapp.domain.com ### 📊 Статус серверов в главном меню -| Переменная | Описание | Пример | -|------------|----------|--------| -| `SERVER_STATUS_MODE` | Режим работы кнопки: `disabled`, `external_link`, `external_link_miniapp` или `xray` | `xray` | -| `SERVER_STATUS_EXTERNAL_URL` | Прямаяссылка на внешний мониторинг | `https://status.example.com` | -| `SERVER_STATUS_METRICS_URL` | URL страницы метрик XrayChecker | `https://sub.example.com/metrics` | +| Переменная | Описание | Пример | +| ---------------------------- | ------------------------------------------------------------------------------------ | --------------------------------- | +| `SERVER_STATUS_MODE` | Режим работы кнопки: `disabled`, `external_link`, `external_link_miniapp` или `xray` | `xray` | +| `SERVER_STATUS_EXTERNAL_URL` | Прямаяссылка на внешний мониторинг | `https://status.example.com` | +| `SERVER_STATUS_METRICS_URL` | URL страницы метрик XrayChecker | `https://sub.example.com/metrics` | ### 🛡️ Защита панели Remnawave @@ -1017,6 +1053,7 @@ FREEKASSA_PAYMENT_SYSTEM_ID=44 # 44 = NSPK СБП, 42 = обычный СБП ``` **Важно:** + - `FREEKASSA_USE_API=true` - использовать API для создания заказов (рекомендуется для NSPK) - `FREEKASSA_USE_API=false` - редирект на платежную форму - `FREEKASSA_PAYMENT_SYSTEM_ID=44` - для NSPK СБП (быстрее и надежнее) @@ -1034,6 +1071,7 @@ CLOUDPAYMENTS_CURRENCY=RUB ``` **Важно:** + - `CLOUDPAYMENTS_PUBLIC_ID` - Public ID из личного кабинета CloudPayments - `CLOUDPAYMENTS_API_SECRET` - API Secret для подписи запросов - Настройте webhook (Pay/Fail уведомления) на `https://your-domain.com/cloudpayments-webhook` @@ -1068,12 +1106,14 @@ PLATEGA_WEBHOOK_PATH=/platega-webhook ### 📊 Режимы продажи трафика #### **Выбираемые пакеты** (по умолчанию) + ```env TRAFFIC_SELECTION_MODE=selectable TRAFFIC_PACKAGES_CONFIG="5:2000:false,10:3500:false,25:7000:false,50:11000:true,100:15000:true,250:17000:false,500:19000:false,1000:19500:true,0:20000:true" ``` #### **Фиксированный лимит** + ```env TRAFFIC_SELECTION_MODE=fixed FIXED_TRAFFIC_LIMIT_GB=100 # 0 = безлимит @@ -1093,6 +1133,7 @@ SALES_MODE=tariffs # classic | tariffs ``` **Преимущества режима тарифов:** + - Готовые пакеты без сложного выбора параметров - Упрощенный UX для пользователей - Возможность создавать акционные тарифы @@ -1104,6 +1145,7 @@ SALES_MODE=tariffs # classic | tariffs **Базовая цена + Стоимость трафика + Доп. устройства + Доп. серверы** **Пример расчета для подписки на 180 дней:** + - Базовый период: 400₽ - Трафик безлимит: 200₽/мес × 6 мес = 1200₽ - 4 устройства: 50₽/мес × 6 мес = 300₽ @@ -1190,6 +1232,7 @@ CONTEST_BUTTON_VISIBLE=true ``` **Возможности системы конкурсов:** + - Реферальные конкурсы с рейтингами - Ежедневные игры с гарантированными призами - Детальная статистика участников @@ -1237,12 +1280,14 @@ CONTEST_BUTTON_VISIBLE=true ### 💤 **Для пользователей** 🧭 **Онбординг и доступ** + - 🌐 Выбор языка интерфейса (RU/EN), динамическая локализация - 📜 Принятие правил, оферты и политики конфиденциальности - 📡 Проверка подписки на обязательный канал - 🔗 Deeplink-инвайты, UTM-кампании и реферальные коды 🛒 **Умная покупка подписок** + - 📅 Гибкие периоды (14—360 дней) со скидками - 📊 Выбор трафика: фиксированный лимит, пакеты или безлимит - 🌐 Автоматический выбор сервера или мультивыбор @@ -1254,6 +1299,7 @@ CONTEST_BUTTON_VISIBLE=true - 🛒 **Простая покупка** - упрощенный режим для быстрого оформления 🧪 **Тестовая подписка** + - 🎁 Гибко настраиваемый триал и welcome-цепочка - 🔔 Уведомления об истечении и автоконверсия - 💎 Автовыдача бонусов за кампании и инвайты @@ -1262,6 +1308,7 @@ CONTEST_BUTTON_VISIBLE=true - 💳 **Платный триал** с выбором способа оплаты 💰 **Платежи и баланс** + - ⭐ Telegram Stars - 💳 Tribute - 💳 YooKassa (СБП + банковские карты) @@ -1281,6 +1328,7 @@ CONTEST_BUTTON_VISIBLE=true - 🔄 **Умная автоактивация** подписки после пополнения баланса 📱 **Управление подписками** + - 📈 Реальный трафик, устройства и серверы - 🌐 Переключение серверов и стран - 📱 Сброс HWID @@ -1290,11 +1338,13 @@ CONTEST_BUTTON_VISIBLE=true - 📡 **Модем-режим** - дополнительные устройства для модемов 🛟 **Поддержка и самообслуживание** + - 🎫 **Система тикетов** с вложениями - 📚 FAQ, правила, оферта и политика - 💬 Быстрые ссылки на поддержку 🧩 **Бонусы и промо** + - 🎫 Промокоды на деньги, дни, триал подписку, промогруппу - 🎁 **Персональные промо-предложения** от админов - 💰 **Тестовый доступ к серверам** через промо-акции @@ -1304,12 +1354,14 @@ CONTEST_BUTTON_VISIBLE=true - 🔗 Генерация реферальных ссылок и QR кодов 🎮 **Конкурсы и игры** + - 🎲 **Ежедневные игры** с призами (баланс, подписка, промогруппа) - 🏆 **Реферальные конкурсы** с рейтингами и наградами - 📊 **Детальная статистика** участия в конкурсах - 🎁 **Разнообразные призы** - баланс, дни подписки, промогруппы 💎 **Промо-группы и скидки** + - 🏷️ **Система промогрупп** с индивидуальными скидками с приоритетами - 💰 Скидки на серверы, трафик и устройства - 📊 **Скидочные уровни за траты** - прозрачная система лояльности @@ -1317,6 +1369,7 @@ CONTEST_BUTTON_VISIBLE=true - 🎯 **Скидки за длительные периоды** подписки для базовых юзеров 📱 **Mini App и гайды** + - 🖥️ **Полноценный личный кабинет** в Telegram WebApp - 📊 Управление подпиской и параметрами - 💳 Интегрированные платежи @@ -1334,6 +1387,7 @@ CONTEST_BUTTON_VISIBLE=true ### ⚙️ **Для администраторов** 📊 **Аналитика и отчётность** + - 📈 Дашборды по пользователям, подпискам и трафику - 💰 Детализация платежей по всем источникам - 🧮 Продажи по тарифам, устройствам и странам @@ -1344,6 +1398,7 @@ CONTEST_BUTTON_VISIBLE=true - 📡 **Мониторинг трафика** и выявление злоупотреблений 💥 **Управление пользователями** + - 🔍 Поиск по ID, имени, юзернейму, Telegram ID и фильтры - 💰 Ручное изменение баланса - 📱 Изменение лимитов устройств, трафика, серверов @@ -1361,6 +1416,7 @@ CONTEST_BUTTON_VISIBLE=true - 🗑️ **Черный список** пользователей с управлением 🎯 **Продажи, маркетинг и удержание** + - 🎫 Промокоды - 💳 Промо-группы со скидками - 🎁 **Персональные промо-предложения** с поиском получателей @@ -1376,6 +1432,7 @@ CONTEST_BUTTON_VISIBLE=true - 👁️ **Видимость кнопок** - управление отображением конкурсов и реферальной программы 🛟 **Поддержка и модерация** + - 🎫 **Центр тикетов** с приоритетами и статусами - ⏱️ **SLA таймеры** и автоуведомления - 🧑‍⚖️ Роли модераторов с ограниченным доступом @@ -1385,12 +1442,14 @@ CONTEST_BUTTON_VISIBLE=true - 🔔 **Уведомления об ответах** на тикеты для админов 🔔 **Уведомления и коммуникации** + - 📢 **Топики для событий** (покупки, триалы, техработы) - 🔔 Настройка уведомлений и расписаний - 📨 **Управление контентом** - политика, оферта, FAQ - 💬 Автоматические сообщения о задолженностях 🧰 **Обслуживание и DevOps** + - 🚧 Ручной и авто-режим техработ - 🗒️ Просмотр системных логов и health-check - 🔄 **Автосинхронизация Remnawave** по расписанию и при старте бота @@ -1400,6 +1459,7 @@ CONTEST_BUTTON_VISIBLE=true - 🔄 **Массовая синхронизация** пользователей с Remnawave 🗄️ **Бекапы и восстановление** + - 🗓️ **Умные автобекапы** с гибким расписанием - 📦 Ручные бекапы с выбором содержимого - 📤 Отправка архивов в выделенный чат/топик @@ -1407,6 +1467,7 @@ CONTEST_BUTTON_VISIBLE=true - ✅ Автоматическая синхронизация sequences после восстановления 💳 **Биллинг и настройки** + - ⚙️ **Управление ценами** без перезапуска бота - 📘 **Управление пакетами трафика** (включение/отключение) - 🧪 Тестовые платежи для каждого провайдера @@ -1418,6 +1479,7 @@ CONTEST_BUTTON_VISIBLE=true - 👁️ **Скрытие реферальной** программы ⚙️ **Remnawave** + - Синхронизация юзеров из панели в бота (Ручная/автоматическая по таймеру) - Синхронизация юзеров из бота в панель - Синхронизация сквадов(серверов) из панели в бота @@ -1426,6 +1488,7 @@ CONTEST_BUTTON_VISIBLE=true - Создание/Редактивание сквадов в боте 🗃️ **REST API для интеграций** + - 🔌 **FastAPI Web API** с полной документацией - 🔒 Управление API-ключами и токенами - 📊 Эндпоинты для подписок, пользователей, транзакций @@ -1465,12 +1528,12 @@ CONTEST_BUTTON_VISIBLE=true ## 🚀 Производительность -| Пользователей | Память | CPU | Диск | Описание | -|---------------|--------|-----|------|----------| -| **1,000** | 512MB | 1 vCPU | 10GB | ✅ Стартап | -| **10,000** | 2GB | 2 vCPU | 50GB | ✅ Малый бизнес | -| **50,000** | 4GB | 4 vCPU | 100GB | ✅ Средний бизнес | -| **100,000+** | 8GB+ | 8+ vCPU | 200GB+ | 🚀 Enterprise | +| Пользователей | Память | CPU | Диск | Описание | +| ------------- | ------ | ------- | ------ | ----------------- | +| **1,000** | 512MB | 1 vCPU | 10GB | ✅ Стартап | +| **10,000** | 2GB | 2 vCPU | 50GB | ✅ Малый бизнес | +| **50,000** | 4GB | 4 vCPU | 100GB | ✅ Средний бизнес | +| **100,000+** | 8GB+ | 8+ vCPU | 200GB+ | 🚀 Enterprise | --- @@ -1494,15 +1557,18 @@ CONTEST_BUTTON_VISIBLE=true После запуска необходимо: 1. **📡 Синхронизация серверов** (обязательно!) + - Зайди в бот → **Админ панель** → **Подписки** → **Управление серверами** - Нажми **Синхронизация** и дождись завершения - Без этого пользователи не смогут выбирать страны! 2. **💥 Синхронизация пользователей** (если есть база) + - **Админ панель** → **Remnawave** → **Синхронизация** - **Синхронизировать всех** → дождись импорта 3. **💳 Настройка платежных систем** + - **Telegram Stars**: Работает автоматически - **Tribute**: Настрой webhook на `https://your-domain.com/tribute-webhook` - **YooKassa**: Настрой webhook на `https://your-domain.com/yookassa-webhook` @@ -1555,11 +1621,13 @@ ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID=126 # ID топика для тикет ## 🛠 Устранение неполадок ### 🥼 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` ### 🔧 Полезные команды + ```bash # Просмотр логов в реальном времени docker compose logs -f bot @@ -1588,14 +1656,14 @@ docker system prune ### 🚨 Частые проблемы и решения -| Проблема | Диагностика | Решение | -|----------|-------------|---------| -| **Бот не отвечает** | `docker logs remnawave_bot` | Проверь `BOT_TOKEN` и интернет | -| **Ошибки БД** | `docker compose ps postgres` | Проверь статус PostgreSQL | -| **Webhook не работает** | `curl http://localhost:8080/health/telegram-webhook` | Проверь `WEBHOOK_URL`, прокси и секрет | -| **API недоступен** | Проверь логи бота | Проверь `REMNAWAVE_API_URL` | -| **Корзина не сохраняется** | `docker compose ps redis` | Проверь статус Redis | -| **Платежи не проходят** | Проверь webhook'и | Настрой URL в платежных системах | +| Проблема | Диагностика | Решение | +| -------------------------- | ---------------------------------------------------- | -------------------------------------- | +| **Бот не отвечает** | `docker logs remnawave_bot` | Проверь `BOT_TOKEN` и интернет | +| **Ошибки БД** | `docker compose ps postgres` | Проверь статус PostgreSQL | +| **Webhook не работает** | `curl http://localhost:8080/health/telegram-webhook` | Проверь `WEBHOOK_URL`, прокси и секрет | +| **API недоступен** | Проверь логи бота | Проверь `REMNAWAVE_API_URL` | +| **Корзина не сохраняется** | `docker compose ps redis` | Проверь статус Redis | +| **Платежи не проходят** | Проверь webhook'и | Настрой URL в платежных системах | --- @@ -1639,14 +1707,14 @@ docker system prune # Для защищенных панелей REMNAWAVE_SECRET_KEY=secret_name:secret_value -# Для панелей eGames скрипта +# Для панелей eGames скрипта REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD ``` ### 🔒 Дополнительные меры безопасности - **Валидация всех входящих данных** -- **Rate limiting для защиты от спама** +- **Rate limiting для защиты от спама** - **Шифрование чувствительных данных** - **Автоматическое управление сессиями** - **Мониторинг подозрительной активности** @@ -1677,7 +1745,7 @@ REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD ### 📚 **Полезные ресурсы** - **📖 [https://docs.remna.st](https://docs.rw)))** - документация панели -- **🤖 [Telegram Bot API](https://core.telegram.org/bots/api)** - API ботов +- **🤖 [Telegram Bot API](https://core.telegram.org/bots/api)** - API ботов - **🐳 [Docker Guide](https://docs.docker.com/get-started/)** - обучение Docker - **🛡️ [Reverse Proxy](https://github.com/eGamesAPI/remnawave-reverse-proxy)** - защита панели @@ -1764,16 +1832,16 @@ REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD ## 🎯 Ключевые особенности в цифрах -| Метрика | Значение | -|---------|----------| -| 💳 **Платёжных систем** | 11 (Stars, YooKassa, Tribute, CryptoBot, Heleket, MulenPay, Pal24, Platega, WATA, Freekassa, CloudPayments) | -| 🌍 **Языков интерфейса** | 2 (RU, EN) с возможностью расширения | -| 📊 **Периодов подписки** | 6 (от 14 дней до года) | -| 🎁 **Типов промо-акций** | 5 (коды, группы, предложения, скидки, кампании) | -| 🔌 **REST API эндпоинтов** | 50+ для полного управления | -| 📱 **Режимов работы** | 3 (классический + тарифы + MiniApp focus) | -| 🛡️ **Методов авторизации** | 4 (API Key, Bearer, Basic Auth, eGames) | -| 🗄️ **Способов хранения** | 2 (PostgreSQL, SQLite) с автовыбором | +| Метрика | Значение | +| -------------------------- | ----------------------------------------------------------------------------------------------------------- | +| 💳 **Платёжных систем** | 11 (Stars, YooKassa, Tribute, CryptoBot, Heleket, MulenPay, Pal24, Platega, WATA, Freekassa, CloudPayments) | +| 🌍 **Языков интерфейса** | 2 (RU, EN) с возможностью расширения | +| 📊 **Периодов подписки** | 6 (от 14 дней до года) | +| 🎁 **Типов промо-акций** | 5 (коды, группы, предложения, скидки, кампании) | +| 🔌 **REST API эндпоинтов** | 50+ для полного управления | +| 📱 **Режимов работы** | 3 (классический + тарифы + MiniApp focus) | +| 🛡️ **Методов авторизации** | 4 (API Key, Bearer, Basic Auth, eGames) | +| 🗄️ **Способов хранения** | 2 (PostgreSQL, SQLite) с автовыбором | --- @@ -1786,7 +1854,7 @@ REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD ✅ **Прозрачная аналитика** - всегда знаете, сколько зарабатываете ✅ **Гибкие тарифы** - настройте цены под свою аудиторию ✅ **Система лояльности** - удерживайте клиентов промо-группами и скидками -✅ **Масштабируемость** - от 10 до 100,000+ пользователей +✅ **Масштабируемость** - от 10 до 100,000+ пользователей ### 🛠️ **Для разработчиков** @@ -1795,7 +1863,7 @@ REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD ✅ **Полное API** - интегрируйте с любыми сервисами ✅ **Docker-ready** - разворачивается за минуты ✅ **Подробная документация** - все описано и понятно -✅ **Активное сообщество** - помощь в Telegram чате +✅ **Активное сообщество** - помощь в Telegram чате ### 👥 **Для пользователей** @@ -1804,7 +1872,7 @@ REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD ✅ **Быстрая поддержка** - система тикетов с приоритетами ✅ **Прозрачность** - всегда видите, за что платите ✅ **Бонусы и скидки** - реферальная программа и промо-акции -✅ **Удобное управление** - все в одном месте, в Telegram +✅ **Удобное управление** - все в одном месте, в Telegram --- @@ -1866,6 +1934,7 @@ REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD Какие требования к серверу? Минимальные: + - 1 vCPU - 512 MB RAM - 10 GB диск @@ -1873,6 +1942,7 @@ REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD - Docker и Docker Compose Рекомендуемые: + - 2+ vCPU - 2+ GB RAM - 50+ GB SSD @@ -1897,6 +1967,7 @@ REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD Как обновить бота? **Вручную:** + ```bash cd /root/remnawave-bedolaga-telegram-bot @@ -1926,10 +1997,12 @@ make reload-follow Как сделать бэкап? **Автоматически:** + - Настройте в `.env`: `BACKUP_AUTO_ENABLED=true` - Бэкапы создаются по расписанию **Через админ-панель:** + - Админ панель → Настройки → Бэкапы → Создать diff --git a/app/cabinet/routes/__init__.py b/app/cabinet/routes/__init__.py index 612976c1..6f99721a 100644 --- a/app/cabinet/routes/__init__.py +++ b/app/cabinet/routes/__init__.py @@ -17,6 +17,12 @@ from .promo import router as promo_router from .notifications import router as notifications_router from .info import router as info_router from .branding import router as branding_router +from .wheel import router as wheel_router +from .admin_wheel import router as admin_wheel_router +from .admin_tariffs import router as admin_tariffs_router +from .admin_servers import router as admin_servers_router +from .admin_stats import router as admin_stats_router +from .media import router as media_router # Main cabinet router router = APIRouter(prefix="/cabinet", tags=["Cabinet"]) @@ -34,10 +40,18 @@ router.include_router(promo_router) router.include_router(notifications_router) router.include_router(info_router) router.include_router(branding_router) +router.include_router(media_router) + +# Wheel routes +router.include_router(wheel_router) # Admin routes router.include_router(admin_tickets_router) router.include_router(admin_settings_router) router.include_router(admin_apps_router) +router.include_router(admin_wheel_router) +router.include_router(admin_tariffs_router) +router.include_router(admin_servers_router) +router.include_router(admin_stats_router) __all__ = ["router"] diff --git a/app/cabinet/routes/admin_apps.py b/app/cabinet/routes/admin_apps.py index 61eca0c4..a67dea91 100644 --- a/app/cabinet/routes/admin_apps.py +++ b/app/cabinet/routes/admin_apps.py @@ -11,6 +11,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.database.models import User from app.config import settings +from app.services.remnawave_service import RemnaWaveService +from app.services.system_settings_service import bot_configuration_service from ..dependencies import get_cabinet_db, get_current_admin_user @@ -419,3 +421,141 @@ async def copy_app_to_platform( logger.info(f"Admin {admin.id} copied app '{app_id}' from '{platform}' to '{target_platform}' as '{new_id}'") return {"status": "copied", "new_id": new_id, "target_platform": target_platform} + + +# ============ RemnaWave Config Routes ============ + +class RemnaWaveConfigStatus(BaseModel): + """Status of RemnaWave config integration.""" + enabled: bool + config_uuid: Optional[str] = None + + +class UpdateRemnaWaveUuidRequest(BaseModel): + """Request to update RemnaWave config UUID.""" + uuid: Optional[str] = None + + +def _get_remnawave_config_uuid() -> Optional[str]: + """Get RemnaWave config UUID from system settings or env.""" + try: + return bot_configuration_service.get_current_value("CABINET_REMNA_SUB_CONFIG") + except Exception: + return settings.CABINET_REMNA_SUB_CONFIG + + +@router.get("/remnawave/status", response_model=RemnaWaveConfigStatus) +async def get_remnawave_config_status( + admin: User = Depends(get_current_admin_user), +): + """Get RemnaWave config integration status.""" + config_uuid = _get_remnawave_config_uuid() + return RemnaWaveConfigStatus( + enabled=bool(config_uuid), + config_uuid=config_uuid, + ) + + +@router.put("/remnawave/uuid", response_model=RemnaWaveConfigStatus) +async def set_remnawave_config_uuid( + request: UpdateRemnaWaveUuidRequest, + admin: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Set RemnaWave subscription config UUID.""" + uuid_value = request.uuid.strip() if request.uuid else None + + # Validate UUID format if provided + if uuid_value: + import re + uuid_pattern = re.compile( + r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' + ) + if not uuid_pattern.match(uuid_value): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid UUID format", + ) + + try: + await bot_configuration_service.set_value(db, "CABINET_REMNA_SUB_CONFIG", uuid_value) + await db.commit() + logger.info(f"Admin {admin.id} updated CABINET_REMNA_SUB_CONFIG to '{uuid_value}'") + except Exception as e: + logger.error(f"Error saving RemnaWave config UUID: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to save configuration", + ) + + return RemnaWaveConfigStatus( + enabled=bool(uuid_value), + config_uuid=uuid_value, + ) + + +@router.get("/remnawave/config") +async def get_remnawave_subscription_config( + admin: User = Depends(get_current_admin_user), +): + """ + Fetch subscription page config from RemnaWave panel. + Uses CABINET_REMNA_SUB_CONFIG setting for the config UUID. + """ + config_uuid = _get_remnawave_config_uuid() + if not config_uuid: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="CABINET_REMNA_SUB_CONFIG is not configured", + ) + + try: + service = RemnaWaveService() + async with service.get_api_client() as api: + config = await api.get_subscription_page_config(config_uuid) + if not config: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Subscription config '{config_uuid}' not found in RemnaWave", + ) + + # Return the raw config data from RemnaWave + return { + "uuid": config.uuid, + "name": config.name, + "view_position": config.view_position, + "config": config.config, + } + except HTTPException: + raise + except Exception as e: + logger.error(f"Error fetching RemnaWave config: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to fetch config from RemnaWave: {str(e)}", + ) + + +@router.get("/remnawave/configs") +async def list_remnawave_subscription_configs( + admin: User = Depends(get_current_admin_user), +): + """List available subscription page configs from RemnaWave panel.""" + try: + service = RemnaWaveService() + async with service.get_api_client() as api: + configs = await api.get_subscription_page_configs() + return [ + { + "uuid": c.uuid, + "name": c.name, + "view_position": c.view_position, + } + for c in configs + ] + except Exception as e: + logger.error(f"Error listing RemnaWave configs: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to fetch configs from RemnaWave: {str(e)}", + ) diff --git a/app/cabinet/routes/admin_servers.py b/app/cabinet/routes/admin_servers.py new file mode 100644 index 00000000..642eedf2 --- /dev/null +++ b/app/cabinet/routes/admin_servers.py @@ -0,0 +1,330 @@ +"""Admin routes for managing servers in cabinet.""" + +import logging +from typing import List + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, String +from sqlalchemy.orm import selectinload + +from app.database.models import User, ServerSquad, Subscription, Tariff, PromoGroup +from app.database.crud.server_squad import ( + get_all_server_squads, + get_server_squad_by_id, + update_server_squad, + update_server_squad_promo_groups, + sync_with_remnawave, + count_active_users_for_squad, +) +from app.services.subscription_service import SubscriptionService + +from ..dependencies import get_cabinet_db, get_current_admin_user +from ..schemas.servers import ( + ServerListResponse, + ServerListItem, + ServerDetailResponse, + ServerUpdateRequest, + ServerToggleResponse, + ServerTrialToggleResponse, + ServerStatsResponse, + ServerSyncResponse, + PromoGroupInfo, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/admin/servers", tags=["Cabinet Admin Servers"]) + + +async def _get_server_promo_groups(db: AsyncSession, server: ServerSquad) -> List[PromoGroupInfo]: + """Get promo group info for server.""" + result = await db.execute(select(PromoGroup).order_by(PromoGroup.name)) + all_groups = result.scalars().all() + + selected_ids = {pg.id for pg in server.allowed_promo_groups} if server.allowed_promo_groups else set() + + return [ + PromoGroupInfo( + id=pg.id, + name=pg.name, + is_selected=pg.id in selected_ids, + ) + for pg in all_groups + ] + + +async def _get_tariffs_using_server(db: AsyncSession, squad_uuid: str) -> List[str]: + """Get list of tariff names using this server.""" + # Get all tariffs and filter in Python since JSON array queries are DB-specific + result = await db.execute(select(Tariff.name, Tariff.allowed_squads)) + tariff_names = [] + for name, allowed_squads in result.fetchall(): + if allowed_squads and squad_uuid in allowed_squads: + tariff_names.append(name) + return tariff_names + + +@router.get("", response_model=ServerListResponse) +async def list_servers( + include_unavailable: bool = True, + admin: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Get list of all servers.""" + servers, total = await get_all_server_squads( + db, + available_only=not include_unavailable, + ) + + items = [] + for server in servers: + items.append(ServerListItem( + id=server.id, + squad_uuid=server.squad_uuid, + display_name=server.display_name, + original_name=server.original_name, + country_code=server.country_code, + is_available=server.is_available, + is_trial_eligible=server.is_trial_eligible, + price_kopeks=server.price_kopeks, + price_rubles=server.price_kopeks / 100, + max_users=server.max_users, + current_users=server.current_users or 0, + sort_order=server.sort_order, + is_full=server.is_full, + availability_status=server.availability_status, + created_at=server.created_at, + )) + + return ServerListResponse(servers=items, total=total) + + +@router.get("/{server_id}", response_model=ServerDetailResponse) +async def get_server( + server_id: int, + admin: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Get detailed server info.""" + server = await get_server_squad_by_id(db, server_id) + if not server: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Server not found", + ) + + promo_groups = await _get_server_promo_groups(db, server) + tariffs_using = await _get_tariffs_using_server(db, server.squad_uuid) + active_subs = await count_active_users_for_squad(db, server.squad_uuid) + + return ServerDetailResponse( + id=server.id, + squad_uuid=server.squad_uuid, + display_name=server.display_name, + original_name=server.original_name, + country_code=server.country_code, + description=server.description, + is_available=server.is_available, + is_trial_eligible=server.is_trial_eligible, + price_kopeks=server.price_kopeks, + price_rubles=server.price_kopeks / 100, + max_users=server.max_users, + current_users=server.current_users or 0, + sort_order=server.sort_order, + is_full=server.is_full, + availability_status=server.availability_status, + promo_groups=promo_groups, + active_subscriptions=active_subs, + tariffs_using=tariffs_using, + created_at=server.created_at, + updated_at=server.updated_at, + ) + + +@router.put("/{server_id}", response_model=ServerDetailResponse) +async def update_existing_server( + server_id: int, + request: ServerUpdateRequest, + admin: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Update an existing server.""" + server = await get_server_squad_by_id(db, server_id) + if not server: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Server not found", + ) + + # Build updates dict + updates = {} + if request.display_name is not None: + updates["display_name"] = request.display_name + if request.description is not None: + updates["description"] = request.description + if request.country_code is not None: + updates["country_code"] = request.country_code + if request.is_available is not None: + updates["is_available"] = request.is_available + if request.is_trial_eligible is not None: + updates["is_trial_eligible"] = request.is_trial_eligible + if request.price_kopeks is not None: + updates["price_kopeks"] = request.price_kopeks + if request.max_users is not None: + updates["max_users"] = request.max_users if request.max_users > 0 else None + if request.sort_order is not None: + updates["sort_order"] = request.sort_order + + if updates: + await update_server_squad(db, server_id, **updates) + + # Update promo groups separately + if request.promo_group_ids is not None: + await update_server_squad_promo_groups(db, server_id, request.promo_group_ids) + + logger.info(f"Admin {admin.id} updated server {server_id}") + + return await get_server(server_id, admin, db) + + +@router.post("/{server_id}/toggle", response_model=ServerToggleResponse) +async def toggle_server( + server_id: int, + admin: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Toggle server availability.""" + server = await get_server_squad_by_id(db, server_id) + if not server: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Server not found", + ) + + new_status = not server.is_available + await update_server_squad(db, server_id, is_available=new_status) + + status_text = "enabled" if new_status else "disabled" + logger.info(f"Admin {admin.id} {status_text} server {server_id}") + + return ServerToggleResponse( + id=server_id, + is_available=new_status, + message=f"Server {status_text}", + ) + + +@router.post("/{server_id}/trial", response_model=ServerTrialToggleResponse) +async def toggle_server_trial( + server_id: int, + admin: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Toggle server trial eligibility.""" + server = await get_server_squad_by_id(db, server_id) + if not server: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Server not found", + ) + + new_status = not server.is_trial_eligible + await update_server_squad(db, server_id, is_trial_eligible=new_status) + + status_text = "enabled for trial" if new_status else "disabled for trial" + logger.info(f"Admin {admin.id} {status_text} server {server_id}") + + return ServerTrialToggleResponse( + id=server_id, + is_trial_eligible=new_status, + message=f"Server {status_text}", + ) + + +@router.get("/{server_id}/stats", response_model=ServerStatsResponse) +async def get_server_stats( + server_id: int, + admin: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Get server statistics.""" + server = await get_server_squad_by_id(db, server_id) + if not server: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Server not found", + ) + + active_subs = await count_active_users_for_squad(db, server.squad_uuid) + + # Count trial subscriptions on this server + # Use LIKE query for JSON array since .contains() is DB-specific + trial_result = await db.execute( + select(func.count(Subscription.id)) + .where( + Subscription.is_trial == True, + Subscription.status == "active", + func.cast(Subscription.connected_squads, String).like(f'%"{server.squad_uuid}"%'), + ) + ) + trial_count = trial_result.scalar() or 0 + + usage_percent = None + if server.max_users and server.max_users > 0: + usage_percent = round((server.current_users or 0) / server.max_users * 100, 1) + + return ServerStatsResponse( + id=server_id, + display_name=server.display_name, + squad_uuid=server.squad_uuid, + current_users=server.current_users or 0, + max_users=server.max_users, + active_subscriptions=active_subs, + trial_subscriptions=trial_count, + usage_percent=usage_percent, + ) + + +@router.post("/sync", response_model=ServerSyncResponse) +async def sync_servers( + admin: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Sync servers with RemnaWave.""" + try: + subscription_service = SubscriptionService() + if not subscription_service.is_configured: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="RemnaWave is not configured", + ) + + # Get squads from RemnaWave + squads = await subscription_service.get_remnawave_squads() + if squads is None: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to fetch squads from RemnaWave", + ) + + # Sync with database + created, updated, removed = await sync_with_remnawave(db, squads) + + logger.info(f"Admin {admin.id} synced servers: +{created} ~{updated} -{removed}") + + return ServerSyncResponse( + created=created, + updated=updated, + removed=removed, + message=f"Synced: {created} created, {updated} updated, {removed} removed", + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to sync servers: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Sync failed: {str(e)}", + ) diff --git a/app/cabinet/routes/admin_stats.py b/app/cabinet/routes/admin_stats.py new file mode 100644 index 00000000..b13cb5ef --- /dev/null +++ b/app/cabinet/routes/admin_stats.py @@ -0,0 +1,428 @@ +"""Admin routes for statistics dashboard in cabinet.""" + +import logging +from datetime import datetime, timedelta +from typing import List, Dict, Any, Optional + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from pydantic import BaseModel + +from sqlalchemy import select, func, and_ + +from app.database.crud.subscription import get_subscriptions_statistics +from app.database.crud.transaction import get_transactions_statistics, get_revenue_by_period +from app.database.crud.server_squad import get_server_statistics +from app.services.remnawave_service import RemnaWaveService +from app.config import settings + +from ..dependencies import get_cabinet_db, get_current_admin_user +from app.database.models import User, Subscription, Tariff, SubscriptionStatus + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/admin/stats", tags=["Cabinet Admin Stats"]) + + +# ============ Schemas ============ + +class NodeStatus(BaseModel): + """Node status info.""" + uuid: str + name: str + address: str + is_connected: bool + is_disabled: bool + users_online: int + traffic_used_bytes: Optional[int] = None + uptime: Optional[str] = None + + +class NodesOverview(BaseModel): + """Overview of all nodes.""" + total: int + online: int + offline: int + disabled: int + total_users_online: int + nodes: List[NodeStatus] + + +class RevenueData(BaseModel): + """Revenue data point.""" + date: str + amount_kopeks: int + amount_rubles: float + + +class SubscriptionStats(BaseModel): + """Subscription statistics.""" + total: int + active: int + trial: int + paid: int + expired: int + purchased_today: int + purchased_week: int + purchased_month: int + trial_to_paid_conversion: float + + +class FinancialStats(BaseModel): + """Financial statistics.""" + income_today_kopeks: int + income_today_rubles: float + income_month_kopeks: int + income_month_rubles: float + income_total_kopeks: int + income_total_rubles: float + subscription_income_kopeks: int + subscription_income_rubles: float + + +class ServerStats(BaseModel): + """Server statistics.""" + total_servers: int + available_servers: int + servers_with_connections: int + total_revenue_kopeks: int + total_revenue_rubles: float + + +class TariffStatItem(BaseModel): + """Statistics for a single tariff.""" + tariff_id: int + tariff_name: str + active_subscriptions: int + trial_subscriptions: int + purchased_today: int + purchased_week: int + purchased_month: int + + +class TariffStats(BaseModel): + """Tariff statistics.""" + tariffs: List[TariffStatItem] + total_tariff_subscriptions: int + + +class DashboardStats(BaseModel): + """Complete dashboard statistics.""" + nodes: NodesOverview + subscriptions: SubscriptionStats + financial: FinancialStats + servers: ServerStats + revenue_chart: List[RevenueData] + tariff_stats: Optional[TariffStats] = None + + +# ============ Routes ============ + +@router.get("/dashboard", response_model=DashboardStats) +async def get_dashboard_stats( + admin: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Get complete dashboard statistics for admin panel.""" + try: + # Get nodes status from RemnaWave + nodes_data = await _get_nodes_overview() + + # Get subscription statistics + sub_stats = await get_subscriptions_statistics(db) + + # Get financial statistics + now = datetime.utcnow() + month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + + trans_stats = await get_transactions_statistics(db, month_start, now) + + # Get revenue chart data (last 30 days) + revenue_data = await get_revenue_by_period(db, days=30) + + # Get server statistics + server_stats = await get_server_statistics(db) + + # Get tariff statistics + tariff_stats = await _get_tariff_stats(db) + + # Build response + return DashboardStats( + nodes=nodes_data, + subscriptions=SubscriptionStats( + total=sub_stats.get("total_subscriptions", 0), + active=sub_stats.get("active_subscriptions", 0), + trial=sub_stats.get("trial_subscriptions", 0), + paid=sub_stats.get("paid_subscriptions", 0), + expired=sub_stats.get("total_subscriptions", 0) - sub_stats.get("active_subscriptions", 0), + purchased_today=sub_stats.get("purchased_today", 0), + purchased_week=sub_stats.get("purchased_week", 0), + purchased_month=sub_stats.get("purchased_month", 0), + trial_to_paid_conversion=sub_stats.get("trial_to_paid_conversion", 0.0), + ), + financial=FinancialStats( + income_today_kopeks=trans_stats.get("today", {}).get("income_kopeks", 0), + income_today_rubles=trans_stats.get("today", {}).get("income_kopeks", 0) / 100, + income_month_kopeks=trans_stats.get("totals", {}).get("income_kopeks", 0), + income_month_rubles=trans_stats.get("totals", {}).get("income_kopeks", 0) / 100, + income_total_kopeks=trans_stats.get("totals", {}).get("income_kopeks", 0), + income_total_rubles=trans_stats.get("totals", {}).get("income_kopeks", 0) / 100, + subscription_income_kopeks=trans_stats.get("totals", {}).get("subscription_income_kopeks", 0), + subscription_income_rubles=trans_stats.get("totals", {}).get("subscription_income_kopeks", 0) / 100, + ), + servers=ServerStats( + total_servers=server_stats.get("total_servers", 0), + available_servers=server_stats.get("available_servers", 0), + servers_with_connections=server_stats.get("servers_with_connections", 0), + total_revenue_kopeks=server_stats.get("total_revenue_kopeks", 0), + total_revenue_rubles=server_stats.get("total_revenue_rubles", 0.0), + ), + revenue_chart=[ + RevenueData( + date=item.get("date", "").isoformat() if hasattr(item.get("date", ""), "isoformat") else str(item.get("date", "")), + amount_kopeks=item.get("amount_kopeks", 0), + amount_rubles=item.get("amount_kopeks", 0) / 100, + ) + for item in revenue_data + ], + tariff_stats=tariff_stats, + ) + + except Exception as e: + logger.error(f"Failed to get dashboard stats: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to load dashboard statistics", + ) + + +@router.get("/nodes", response_model=NodesOverview) +async def get_nodes_status( + admin: User = Depends(get_current_admin_user), +): + """Get status of all nodes.""" + try: + return await _get_nodes_overview() + except Exception as e: + logger.error(f"Failed to get nodes status: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to load nodes status", + ) + + +@router.post("/nodes/{node_uuid}/restart") +async def restart_node( + node_uuid: str, + admin: User = Depends(get_current_admin_user), +): + """Restart a node.""" + try: + service = RemnaWaveService() + success = await service.manage_node(node_uuid, "restart") + + if success: + logger.info(f"Admin {admin.id} restarted node {node_uuid}") + return {"success": True, "message": "Node restart initiated"} + else: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Failed to restart node", + ) + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to restart node {node_uuid}: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to restart node", + ) + + +@router.post("/nodes/{node_uuid}/toggle") +async def toggle_node( + node_uuid: str, + admin: User = Depends(get_current_admin_user), +): + """Enable or disable a node.""" + try: + service = RemnaWaveService() + nodes = await service.get_all_nodes() + + node = next((n for n in nodes if n.get("uuid") == node_uuid), None) + if not node: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Node not found", + ) + + is_disabled = node.get("is_disabled", False) + action = "enable" if is_disabled else "disable" + success = await service.manage_node(node_uuid, action) + + if success: + logger.info(f"Admin {admin.id} {action}d node {node_uuid}") + return {"success": True, "message": f"Node {action}d", "is_disabled": not is_disabled} + else: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Failed to {action} node", + ) + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to toggle node {node_uuid}: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to toggle node", + ) + + +async def _get_nodes_overview() -> NodesOverview: + """Get overview of all nodes.""" + try: + service = RemnaWaveService() + nodes = await service.get_all_nodes() + + total = len(nodes) + online = sum(1 for n in nodes if n.get("is_connected") and not n.get("is_disabled")) + disabled = sum(1 for n in nodes if n.get("is_disabled")) + offline = total - online - disabled + total_users_online = sum(n.get("users_online", 0) or 0 for n in nodes) + + node_statuses = [ + NodeStatus( + uuid=n.get("uuid", ""), + name=n.get("name", "Unknown"), + address=n.get("address", ""), + is_connected=n.get("is_connected", False), + is_disabled=n.get("is_disabled", False), + users_online=n.get("users_online", 0) or 0, + traffic_used_bytes=n.get("traffic_used_bytes"), + uptime=n.get("uptime"), + ) + for n in nodes + ] + + return NodesOverview( + total=total, + online=online, + offline=offline, + disabled=disabled, + total_users_online=total_users_online, + nodes=node_statuses, + ) + except Exception as e: + logger.warning(f"Failed to get nodes from RemnaWave: {e}") + # Return empty data if RemnaWave is unavailable + return NodesOverview( + total=0, + online=0, + offline=0, + disabled=0, + total_users_online=0, + nodes=[], + ) + + +async def _get_tariff_stats(db: AsyncSession) -> Optional[TariffStats]: + """Get statistics for all tariffs.""" + try: + # Получаем ВСЕ тарифы (включая неактивные) для статистики + tariffs_result = await db.execute( + select(Tariff) + .order_by(Tariff.display_order) + ) + tariffs = tariffs_result.scalars().all() + + if not tariffs: + logger.info("📊 Нет тарифов в системе, пропускаем статистику") + return None + + now = datetime.utcnow() + today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + week_ago = now - timedelta(days=7) + month_ago = now - timedelta(days=30) + + tariff_items = [] + total_tariff_subscriptions = 0 + + for tariff in tariffs: + # Активные подписки на этом тарифе + active_result = await db.execute( + select(func.count(Subscription.id)) + .where( + Subscription.tariff_id == tariff.id, + Subscription.status == SubscriptionStatus.ACTIVE.value + ) + ) + active_count = active_result.scalar() or 0 + + # Триальные подписки на этом тарифе + trial_result = await db.execute( + select(func.count(Subscription.id)) + .where( + Subscription.tariff_id == tariff.id, + Subscription.status == SubscriptionStatus.ACTIVE.value, + Subscription.is_trial == True + ) + ) + trial_count = trial_result.scalar() or 0 + + # Куплено сегодня (не триальные) + today_result = await db.execute( + select(func.count(Subscription.id)) + .where( + Subscription.tariff_id == tariff.id, + Subscription.created_at >= today_start, + Subscription.is_trial == False + ) + ) + purchased_today = today_result.scalar() or 0 + + # Куплено за неделю + week_result = await db.execute( + select(func.count(Subscription.id)) + .where( + Subscription.tariff_id == tariff.id, + Subscription.created_at >= week_ago, + Subscription.is_trial == False + ) + ) + purchased_week = week_result.scalar() or 0 + + # Куплено за месяц + month_result = await db.execute( + select(func.count(Subscription.id)) + .where( + Subscription.tariff_id == tariff.id, + Subscription.created_at >= month_ago, + Subscription.is_trial == False + ) + ) + purchased_month = month_result.scalar() or 0 + + logger.info(f"📊 Тариф '{tariff.name}': активных={active_count}, триал={trial_count}") + + tariff_items.append(TariffStatItem( + tariff_id=tariff.id, + tariff_name=tariff.name, + active_subscriptions=active_count, + trial_subscriptions=trial_count, + purchased_today=purchased_today, + purchased_week=purchased_week, + purchased_month=purchased_month, + )) + + total_tariff_subscriptions += active_count + + logger.info(f"📊 Всего подписок по тарифам: {total_tariff_subscriptions}") + + return TariffStats( + tariffs=tariff_items, + total_tariff_subscriptions=total_tariff_subscriptions, + ) + + except Exception as e: + logger.error(f"Failed to get tariff stats: {e}", exc_info=True) + return None diff --git a/app/cabinet/routes/admin_tariffs.py b/app/cabinet/routes/admin_tariffs.py new file mode 100644 index 00000000..a03e39ba --- /dev/null +++ b/app/cabinet/routes/admin_tariffs.py @@ -0,0 +1,520 @@ +"""Admin routes for managing tariffs in cabinet.""" + +import logging +from typing import List, Optional + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func + +from app.database.models import User, Tariff, Subscription, ServerSquad, PromoGroup +from app.database.crud.tariff import ( + get_all_tariffs, + get_tariff_by_id, + create_tariff, + update_tariff, + delete_tariff, + get_tariff_subscriptions_count, + set_tariff_promo_groups, + load_period_prices_from_db, +) +from app.database.crud.server_squad import get_all_server_squads + +from ..dependencies import get_cabinet_db, get_current_admin_user +from ..schemas.tariffs import ( + TariffListResponse, + TariffListItem, + TariffDetailResponse, + TariffCreateRequest, + TariffUpdateRequest, + TariffToggleResponse, + TariffTrialResponse, + TariffStatsResponse, + PeriodPrice, + ServerInfo, + PromoGroupInfo, + ServerTrafficLimit, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/admin/tariffs", tags=["Cabinet Admin Tariffs"]) + + +async def _get_tariff_servers( + db: AsyncSession, + allowed_squads: List[str], + server_traffic_limits: dict = None +) -> List[ServerInfo]: + """Get server info for tariff.""" + servers, _ = await get_all_server_squads(db, available_only=False) + limits = server_traffic_limits or {} + result = [] + for server in servers: + # Получаем индивидуальный лимит трафика для сервера + server_limit = None + if server.squad_uuid in limits: + limit_data = limits[server.squad_uuid] + if isinstance(limit_data, dict) and 'traffic_limit_gb' in limit_data: + server_limit = limit_data['traffic_limit_gb'] + elif isinstance(limit_data, int): + server_limit = limit_data + + result.append(ServerInfo( + id=server.id, + squad_uuid=server.squad_uuid, + display_name=server.display_name, + country_code=server.country_code, + is_selected=server.squad_uuid in allowed_squads, + traffic_limit_gb=server_limit, + )) + return result + + +async def _get_tariff_promo_groups(db: AsyncSession, tariff: Tariff) -> List[PromoGroupInfo]: + """Get promo group info for tariff.""" + result = await db.execute(select(PromoGroup).order_by(PromoGroup.name)) + all_groups = result.scalars().all() + + selected_ids = {pg.id for pg in tariff.allowed_promo_groups} if tariff.allowed_promo_groups else set() + + return [ + PromoGroupInfo( + id=pg.id, + name=pg.name, + is_selected=pg.id in selected_ids, + ) + for pg in all_groups + ] + + +def _period_prices_to_list(period_prices: dict) -> List[PeriodPrice]: + """Convert period_prices dict to list.""" + if not period_prices: + return [] + return [ + PeriodPrice(days=int(days), price_kopeks=price) + for days, price in sorted(period_prices.items(), key=lambda x: int(x[0])) + ] + + +def _period_prices_to_dict(period_prices: List[PeriodPrice]) -> dict: + """Convert period_prices list to dict.""" + return {str(pp.days): pp.price_kopeks for pp in period_prices} + + +@router.get("", response_model=TariffListResponse) +async def list_tariffs( + include_inactive: bool = True, + admin: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Get list of all tariffs.""" + tariffs = await get_all_tariffs(db, include_inactive=include_inactive) + + items = [] + for tariff in tariffs: + subs_count = await get_tariff_subscriptions_count(db, tariff.id) + items.append(TariffListItem( + id=tariff.id, + name=tariff.name, + description=tariff.description, + is_active=tariff.is_active, + is_trial_available=tariff.is_trial_available, + is_daily=tariff.is_daily, + daily_price_kopeks=tariff.daily_price_kopeks, + allow_traffic_topup=tariff.allow_traffic_topup, + traffic_limit_gb=tariff.traffic_limit_gb, + device_limit=tariff.device_limit, + tier_level=tariff.tier_level, + display_order=tariff.display_order, + servers_count=len(tariff.allowed_squads or []), + subscriptions_count=subs_count, + created_at=tariff.created_at, + )) + + return TariffListResponse(tariffs=items, total=len(items)) + + +@router.get("/available-servers", response_model=List[ServerInfo]) +async def get_available_servers( + admin: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Get list of all servers for tariff selection.""" + servers, _ = await get_all_server_squads(db, available_only=False) + return [ + ServerInfo( + id=server.id, + squad_uuid=server.squad_uuid, + display_name=server.display_name, + country_code=server.country_code, + is_selected=False, + ) + for server in servers + ] + + +@router.get("/{tariff_id}", response_model=TariffDetailResponse) +async def get_tariff( + tariff_id: int, + admin: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Get detailed tariff info.""" + tariff = await get_tariff_by_id(db, tariff_id) + if not tariff: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Tariff not found", + ) + + allowed_squads = tariff.allowed_squads or [] + server_traffic_limits = tariff.server_traffic_limits or {} + servers = await _get_tariff_servers(db, allowed_squads, server_traffic_limits) + promo_groups = await _get_tariff_promo_groups(db, tariff) + subs_count = await get_tariff_subscriptions_count(db, tariff.id) + + # Преобразуем server_traffic_limits в формат для схемы + server_limits_response = {} + for uuid, limit_data in server_traffic_limits.items(): + if isinstance(limit_data, dict): + server_limits_response[uuid] = ServerTrafficLimit(**limit_data) + elif isinstance(limit_data, int): + server_limits_response[uuid] = ServerTrafficLimit(traffic_limit_gb=limit_data) + + return TariffDetailResponse( + id=tariff.id, + name=tariff.name, + description=tariff.description, + is_active=tariff.is_active, + is_trial_available=tariff.is_trial_available, + allow_traffic_topup=tariff.allow_traffic_topup, + traffic_topup_enabled=tariff.traffic_topup_enabled, + traffic_topup_packages=tariff.traffic_topup_packages or {}, + max_topup_traffic_gb=tariff.max_topup_traffic_gb, + traffic_limit_gb=tariff.traffic_limit_gb, + device_limit=tariff.device_limit, + device_price_kopeks=tariff.device_price_kopeks, + max_device_limit=tariff.max_device_limit, + tier_level=tariff.tier_level, + display_order=tariff.display_order, + period_prices=_period_prices_to_list(tariff.period_prices), + allowed_squads=allowed_squads, + server_traffic_limits=server_limits_response, + servers=servers, + promo_groups=promo_groups, + subscriptions_count=subs_count, + # Произвольное количество дней + custom_days_enabled=tariff.custom_days_enabled, + price_per_day_kopeks=tariff.price_per_day_kopeks, + min_days=tariff.min_days, + max_days=tariff.max_days, + # Произвольный трафик при покупке + custom_traffic_enabled=tariff.custom_traffic_enabled, + traffic_price_per_gb_kopeks=tariff.traffic_price_per_gb_kopeks, + min_traffic_gb=tariff.min_traffic_gb, + max_traffic_gb=tariff.max_traffic_gb, + # Дневной тариф + is_daily=tariff.is_daily, + daily_price_kopeks=tariff.daily_price_kopeks, + # Режим сброса трафика + traffic_reset_mode=tariff.traffic_reset_mode, + created_at=tariff.created_at, + updated_at=tariff.updated_at, + ) + + +@router.post("", response_model=TariffDetailResponse) +async def create_new_tariff( + request: TariffCreateRequest, + admin: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Create a new tariff.""" + period_prices_dict = _period_prices_to_dict(request.period_prices) + + # Преобразуем ServerTrafficLimit в dict для хранения + server_limits_dict = { + uuid: limit.model_dump() for uuid, limit in request.server_traffic_limits.items() + } if request.server_traffic_limits else {} + + tariff = await create_tariff( + db=db, + name=request.name, + description=request.description, + is_active=request.is_active, + allow_traffic_topup=request.allow_traffic_topup, + traffic_topup_enabled=request.traffic_topup_enabled, + traffic_topup_packages=request.traffic_topup_packages, + max_topup_traffic_gb=request.max_topup_traffic_gb, + traffic_limit_gb=request.traffic_limit_gb, + device_limit=request.device_limit, + device_price_kopeks=request.device_price_kopeks, + max_device_limit=request.max_device_limit, + tier_level=request.tier_level, + period_prices=period_prices_dict, + allowed_squads=request.allowed_squads, + server_traffic_limits=server_limits_dict, + promo_group_ids=request.promo_group_ids if request.promo_group_ids else None, + # Произвольное количество дней + custom_days_enabled=request.custom_days_enabled, + price_per_day_kopeks=request.price_per_day_kopeks, + min_days=request.min_days, + max_days=request.max_days, + # Произвольный трафик при покупке + custom_traffic_enabled=request.custom_traffic_enabled, + traffic_price_per_gb_kopeks=request.traffic_price_per_gb_kopeks, + min_traffic_gb=request.min_traffic_gb, + max_traffic_gb=request.max_traffic_gb, + # Дневной тариф + is_daily=request.is_daily, + daily_price_kopeks=request.daily_price_kopeks, + # Режим сброса трафика + traffic_reset_mode=request.traffic_reset_mode, + ) + + logger.info(f"Admin {admin.id} created tariff {tariff.id}: {tariff.name}") + + # Перезагружаем периоды из БД для синхронизации с ботом + await load_period_prices_from_db(db) + + # Return full detail + return await get_tariff(tariff.id, admin, db) + + +@router.put("/{tariff_id}", response_model=TariffDetailResponse) +async def update_existing_tariff( + tariff_id: int, + request: TariffUpdateRequest, + admin: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Update an existing tariff.""" + tariff = await get_tariff_by_id(db, tariff_id) + if not tariff: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Tariff not found", + ) + + # Build updates dict + updates = {} + if request.name is not None: + updates["name"] = request.name + if request.description is not None: + updates["description"] = request.description + if request.is_active is not None: + updates["is_active"] = request.is_active + if request.allow_traffic_topup is not None: + updates["allow_traffic_topup"] = request.allow_traffic_topup + if request.traffic_topup_enabled is not None: + updates["traffic_topup_enabled"] = request.traffic_topup_enabled + if request.traffic_topup_packages is not None: + updates["traffic_topup_packages"] = request.traffic_topup_packages + if request.max_topup_traffic_gb is not None: + updates["max_topup_traffic_gb"] = request.max_topup_traffic_gb + if request.traffic_limit_gb is not None: + updates["traffic_limit_gb"] = request.traffic_limit_gb + if request.device_limit is not None: + updates["device_limit"] = request.device_limit + if request.device_price_kopeks is not None: + updates["device_price_kopeks"] = request.device_price_kopeks + if request.max_device_limit is not None: + updates["max_device_limit"] = request.max_device_limit + if request.tier_level is not None: + updates["tier_level"] = request.tier_level + if request.display_order is not None: + updates["display_order"] = request.display_order + if request.period_prices is not None: + updates["period_prices"] = _period_prices_to_dict(request.period_prices) + if request.allowed_squads is not None: + updates["allowed_squads"] = request.allowed_squads + if request.server_traffic_limits is not None: + # Преобразуем ServerTrafficLimit в dict для хранения + updates["server_traffic_limits"] = { + uuid: limit.model_dump() for uuid, limit in request.server_traffic_limits.items() + } + # Произвольное количество дней + if request.custom_days_enabled is not None: + updates["custom_days_enabled"] = request.custom_days_enabled + if request.price_per_day_kopeks is not None: + updates["price_per_day_kopeks"] = request.price_per_day_kopeks + if request.min_days is not None: + updates["min_days"] = request.min_days + if request.max_days is not None: + updates["max_days"] = request.max_days + # Произвольный трафик при покупке + if request.custom_traffic_enabled is not None: + updates["custom_traffic_enabled"] = request.custom_traffic_enabled + if request.traffic_price_per_gb_kopeks is not None: + updates["traffic_price_per_gb_kopeks"] = request.traffic_price_per_gb_kopeks + if request.min_traffic_gb is not None: + updates["min_traffic_gb"] = request.min_traffic_gb + if request.max_traffic_gb is not None: + updates["max_traffic_gb"] = request.max_traffic_gb + # Дневной тариф + if request.is_daily is not None: + updates["is_daily"] = request.is_daily + if request.daily_price_kopeks is not None: + updates["daily_price_kopeks"] = request.daily_price_kopeks + # Режим сброса трафика (None допускается как значение для сброса к глобальной настройке) + if 'traffic_reset_mode' in request.model_fields_set: + updates["traffic_reset_mode"] = request.traffic_reset_mode + + if updates: + await update_tariff(db, tariff, **updates) + + # Update promo groups separately + if request.promo_group_ids is not None: + await set_tariff_promo_groups(db, tariff_id, request.promo_group_ids) + + logger.info(f"Admin {admin.id} updated tariff {tariff_id}") + + # Перезагружаем периоды из БД для синхронизации с ботом + await load_period_prices_from_db(db) + + return await get_tariff(tariff_id, admin, db) + + +@router.delete("/{tariff_id}") +async def delete_existing_tariff( + tariff_id: int, + admin: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Delete a tariff.""" + tariff = await get_tariff_by_id(db, tariff_id) + if not tariff: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Tariff not found", + ) + + # Check if tariff has subscriptions + subs_count = await get_tariff_subscriptions_count(db, tariff_id) + if subs_count > 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Cannot delete tariff with {subs_count} active subscriptions", + ) + + await delete_tariff(db, tariff) + logger.info(f"Admin {admin.id} deleted tariff {tariff_id}: {tariff.name}") + + # Перезагружаем периоды из БД для синхронизации с ботом + await load_period_prices_from_db(db) + + return {"message": "Tariff deleted successfully"} + + +@router.post("/{tariff_id}/toggle", response_model=TariffToggleResponse) +async def toggle_tariff( + tariff_id: int, + admin: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Toggle tariff active status.""" + tariff = await get_tariff_by_id(db, tariff_id) + if not tariff: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Tariff not found", + ) + + new_status = not tariff.is_active + await update_tariff(db, tariff, is_active=new_status) + + status_text = "activated" if new_status else "deactivated" + logger.info(f"Admin {admin.id} {status_text} tariff {tariff_id}") + + # Перезагружаем периоды из БД для синхронизации с ботом + await load_period_prices_from_db(db) + + return TariffToggleResponse( + id=tariff_id, + is_active=new_status, + message=f"Tariff {status_text}", + ) + + +@router.post("/{tariff_id}/trial", response_model=TariffTrialResponse) +async def toggle_trial_tariff( + tariff_id: int, + admin: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Toggle tariff trial availability.""" + tariff = await get_tariff_by_id(db, tariff_id) + if not tariff: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Tariff not found", + ) + + new_status = not tariff.is_trial_available + await update_tariff(db, tariff, is_trial_available=new_status) + + status_text = "set as trial" if new_status else "removed from trial" + logger.info(f"Admin {admin.id} {status_text} tariff {tariff_id}") + + return TariffTrialResponse( + id=tariff_id, + is_trial_available=new_status, + message=f"Tariff {status_text}", + ) + + +@router.get("/{tariff_id}/stats", response_model=TariffStatsResponse) +async def get_tariff_stats( + tariff_id: int, + admin: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Get tariff statistics.""" + tariff = await get_tariff_by_id(db, tariff_id) + if not tariff: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Tariff not found", + ) + + # Count subscriptions + total_result = await db.execute( + select(func.count(Subscription.id)) + .where(Subscription.tariff_id == tariff_id) + ) + total_count = total_result.scalar() or 0 + + # Count active subscriptions + active_result = await db.execute( + select(func.count(Subscription.id)) + .where( + Subscription.tariff_id == tariff_id, + Subscription.status == "active", + ) + ) + active_count = active_result.scalar() or 0 + + # Count trial subscriptions + trial_result = await db.execute( + select(func.count(Subscription.id)) + .where( + Subscription.tariff_id == tariff_id, + Subscription.is_trial == True, + ) + ) + trial_count = trial_result.scalar() or 0 + + # TODO: Calculate revenue from transactions + revenue_kopeks = 0 + + return TariffStatsResponse( + id=tariff_id, + name=tariff.name, + subscriptions_count=total_count, + active_subscriptions=active_count, + trial_subscriptions=trial_count, + revenue_kopeks=revenue_kopeks, + revenue_rubles=revenue_kopeks / 100, + ) diff --git a/app/cabinet/routes/admin_tickets.py b/app/cabinet/routes/admin_tickets.py index 9f3acc87..cfef4544 100644 --- a/app/cabinet/routes/admin_tickets.py +++ b/app/cabinet/routes/admin_tickets.py @@ -103,6 +103,24 @@ class AdminStatsResponse(BaseModel): closed: int +class TicketSettingsResponse(BaseModel): + """Ticket system settings.""" + sla_enabled: bool + sla_minutes: int + sla_check_interval_seconds: int + sla_reminder_cooldown_minutes: int + support_system_mode: str # tickets, contact, both + + +class TicketSettingsUpdateRequest(BaseModel): + """Update ticket settings.""" + sla_enabled: Optional[bool] = None + sla_minutes: Optional[int] = Field(None, ge=1, le=1440, description="SLA time in minutes (1-1440)") + sla_check_interval_seconds: Optional[int] = Field(None, ge=30, le=600, description="Check interval (30-600 seconds)") + sla_reminder_cooldown_minutes: Optional[int] = Field(None, ge=1, le=120, description="Reminder cooldown (1-120 minutes)") + support_system_mode: Optional[str] = Field(None, description="Support mode: tickets, contact, both") + + def _message_to_response(message: TicketMessage) -> TicketMessageResponse: """Convert TicketMessage to response.""" return TicketMessageResponse( @@ -111,6 +129,7 @@ def _message_to_response(message: TicketMessage) -> TicketMessageResponse: is_from_admin=message.is_from_admin, has_media=bool(message.media_file_id), media_type=message.media_type, + media_file_id=message.media_file_id, media_caption=message.media_caption, created_at=message.created_at, ) @@ -448,3 +467,100 @@ async def update_ticket_priority( user=user_info, messages=messages_response, ) + + +@router.get("/settings", response_model=TicketSettingsResponse) +async def get_ticket_settings( + admin: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Get ticket system settings.""" + return TicketSettingsResponse( + sla_enabled=settings.SUPPORT_TICKET_SLA_ENABLED, + sla_minutes=settings.SUPPORT_TICKET_SLA_MINUTES, + sla_check_interval_seconds=settings.SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS, + sla_reminder_cooldown_minutes=settings.SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES, + support_system_mode=settings.get_support_system_mode(), + ) + + +@router.patch("/settings", response_model=TicketSettingsResponse) +async def update_ticket_settings( + request: TicketSettingsUpdateRequest, + admin: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Update ticket system settings.""" + import os + from pathlib import Path + + # Validate support_system_mode + if request.support_system_mode is not None: + mode = request.support_system_mode.strip().lower() + if mode not in {"tickets", "contact", "both"}: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid support_system_mode. Must be: tickets, contact, or both", + ) + + # Update in-memory settings + if request.sla_enabled is not None: + settings.SUPPORT_TICKET_SLA_ENABLED = request.sla_enabled + if request.sla_minutes is not None: + settings.SUPPORT_TICKET_SLA_MINUTES = request.sla_minutes + if request.sla_check_interval_seconds is not None: + settings.SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS = request.sla_check_interval_seconds + if request.sla_reminder_cooldown_minutes is not None: + settings.SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES = request.sla_reminder_cooldown_minutes + if request.support_system_mode is not None: + settings.SUPPORT_SYSTEM_MODE = request.support_system_mode.strip().lower() + + # Try to persist to .env file + try: + env_file = Path(".env") + if env_file.exists(): + lines = env_file.read_text().splitlines() + updates = {} + + if request.sla_enabled is not None: + updates["SUPPORT_TICKET_SLA_ENABLED"] = str(request.sla_enabled).lower() + if request.sla_minutes is not None: + updates["SUPPORT_TICKET_SLA_MINUTES"] = str(request.sla_minutes) + if request.sla_check_interval_seconds is not None: + updates["SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS"] = str(request.sla_check_interval_seconds) + if request.sla_reminder_cooldown_minutes is not None: + updates["SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES"] = str(request.sla_reminder_cooldown_minutes) + if request.support_system_mode is not None: + updates["SUPPORT_SYSTEM_MODE"] = request.support_system_mode.strip().lower() + + new_lines = [] + updated_keys = set() + + for line in lines: + updated = False + for key, value in updates.items(): + if line.startswith(f"{key}="): + new_lines.append(f"{key}={value}") + updated_keys.add(key) + updated = True + break + if not updated: + new_lines.append(line) + + # Add any keys that weren't found + for key, value in updates.items(): + if key not in updated_keys: + new_lines.append(f"{key}={value}") + + env_file.write_text("\n".join(new_lines) + "\n") + logger.info(f"Updated ticket settings in .env file") + except Exception as e: + logger.warning(f"Failed to update .env file: {e}") + + return TicketSettingsResponse( + sla_enabled=settings.SUPPORT_TICKET_SLA_ENABLED, + sla_minutes=settings.SUPPORT_TICKET_SLA_MINUTES, + sla_check_interval_seconds=settings.SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS, + sla_reminder_cooldown_minutes=settings.SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES, + support_system_mode=settings.get_support_system_mode(), + ) diff --git a/app/cabinet/routes/admin_wheel.py b/app/cabinet/routes/admin_wheel.py new file mode 100644 index 00000000..ff3770d4 --- /dev/null +++ b/app/cabinet/routes/admin_wheel.py @@ -0,0 +1,387 @@ +""" +API роуты колеса удачи для администраторов. +""" +import logging +import math +from datetime import datetime +from typing import Optional, List +from fastapi import APIRouter, Depends, HTTPException, status, Query +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.models import User +from app.database.crud.wheel import ( + get_or_create_wheel_config, + update_wheel_config, + get_wheel_prizes, + get_wheel_prize_by_id, + create_wheel_prize, + update_wheel_prize, + delete_wheel_prize, + reorder_wheel_prizes, + get_all_spins, + get_wheel_statistics, +) +from app.services.wheel_service import wheel_service +from app.cabinet.dependencies import get_cabinet_db, get_current_admin_user +from app.cabinet.schemas.wheel import ( + AdminWheelConfigResponse, + WheelPrizeAdminResponse, + UpdateWheelConfigRequest, + CreatePrizeRequest, + UpdatePrizeRequest, + ReorderPrizesRequest, + AdminSpinsResponse, + AdminSpinItem, + WheelStatisticsResponse, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/admin/wheel", tags=["Admin Fortune Wheel"]) + + +@router.get("/config", response_model=AdminWheelConfigResponse) +async def get_admin_wheel_config( + admin: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Получить полную конфигурацию колеса.""" + config = await get_or_create_wheel_config(db) + prizes = await get_wheel_prizes(db, config.id, active_only=False) + + prizes_response = [ + WheelPrizeAdminResponse( + id=p.id, + config_id=p.config_id, + prize_type=p.prize_type, + prize_value=p.prize_value, + display_name=p.display_name, + emoji=p.emoji, + color=p.color, + prize_value_kopeks=p.prize_value_kopeks, + sort_order=p.sort_order, + manual_probability=p.manual_probability, + is_active=p.is_active, + promo_balance_bonus_kopeks=p.promo_balance_bonus_kopeks or 0, + promo_subscription_days=p.promo_subscription_days or 0, + promo_traffic_gb=p.promo_traffic_gb or 0, + created_at=p.created_at, + updated_at=p.updated_at, + ) + for p in prizes + ] + + return AdminWheelConfigResponse( + id=config.id, + is_enabled=config.is_enabled, + name=config.name, + spin_cost_stars=config.spin_cost_stars, + spin_cost_days=config.spin_cost_days, + spin_cost_stars_enabled=config.spin_cost_stars_enabled, + spin_cost_days_enabled=config.spin_cost_days_enabled, + rtp_percent=config.rtp_percent, + daily_spin_limit=config.daily_spin_limit, + min_subscription_days_for_day_payment=config.min_subscription_days_for_day_payment, + promo_prefix=config.promo_prefix, + promo_validity_days=config.promo_validity_days, + prizes=prizes_response, + created_at=config.created_at, + updated_at=config.updated_at, + ) + + +@router.put("/config", response_model=AdminWheelConfigResponse) +async def update_admin_wheel_config( + request: UpdateWheelConfigRequest, + admin: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Обновить конфигурацию колеса.""" + update_data = request.model_dump(exclude_unset=True) + + if not update_data: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="No fields to update", + ) + + config = await update_wheel_config(db, **update_data) + + logger.info(f"🎡 Admin {admin.telegram_id} updated wheel config: {update_data}") + + # Возвращаем полную конфигурацию + prizes = await get_wheel_prizes(db, config.id, active_only=False) + + prizes_response = [ + WheelPrizeAdminResponse( + id=p.id, + config_id=p.config_id, + prize_type=p.prize_type, + prize_value=p.prize_value, + display_name=p.display_name, + emoji=p.emoji, + color=p.color, + prize_value_kopeks=p.prize_value_kopeks, + sort_order=p.sort_order, + manual_probability=p.manual_probability, + is_active=p.is_active, + promo_balance_bonus_kopeks=p.promo_balance_bonus_kopeks or 0, + promo_subscription_days=p.promo_subscription_days or 0, + promo_traffic_gb=p.promo_traffic_gb or 0, + created_at=p.created_at, + updated_at=p.updated_at, + ) + for p in prizes + ] + + return AdminWheelConfigResponse( + id=config.id, + is_enabled=config.is_enabled, + name=config.name, + spin_cost_stars=config.spin_cost_stars, + spin_cost_days=config.spin_cost_days, + spin_cost_stars_enabled=config.spin_cost_stars_enabled, + spin_cost_days_enabled=config.spin_cost_days_enabled, + rtp_percent=config.rtp_percent, + daily_spin_limit=config.daily_spin_limit, + min_subscription_days_for_day_payment=config.min_subscription_days_for_day_payment, + promo_prefix=config.promo_prefix, + promo_validity_days=config.promo_validity_days, + prizes=prizes_response, + created_at=config.created_at, + updated_at=config.updated_at, + ) + + +@router.get("/prizes", response_model=List[WheelPrizeAdminResponse]) +async def get_prizes( + admin: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Получить список призов.""" + config = await get_or_create_wheel_config(db) + prizes = await get_wheel_prizes(db, config.id, active_only=False) + + return [ + WheelPrizeAdminResponse( + id=p.id, + config_id=p.config_id, + prize_type=p.prize_type, + prize_value=p.prize_value, + display_name=p.display_name, + emoji=p.emoji, + color=p.color, + prize_value_kopeks=p.prize_value_kopeks, + sort_order=p.sort_order, + manual_probability=p.manual_probability, + is_active=p.is_active, + promo_balance_bonus_kopeks=p.promo_balance_bonus_kopeks or 0, + promo_subscription_days=p.promo_subscription_days or 0, + promo_traffic_gb=p.promo_traffic_gb or 0, + created_at=p.created_at, + updated_at=p.updated_at, + ) + for p in prizes + ] + + +@router.post("/prizes", response_model=WheelPrizeAdminResponse, status_code=status.HTTP_201_CREATED) +async def create_prize( + request: CreatePrizeRequest, + admin: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Создать новый приз.""" + config = await get_or_create_wheel_config(db) + + prize = await create_wheel_prize( + db=db, + config_id=config.id, + prize_type=request.prize_type.value, + prize_value=request.prize_value, + display_name=request.display_name, + prize_value_kopeks=request.prize_value_kopeks, + emoji=request.emoji, + color=request.color, + sort_order=request.sort_order, + manual_probability=request.manual_probability, + is_active=request.is_active, + promo_balance_bonus_kopeks=request.promo_balance_bonus_kopeks, + promo_subscription_days=request.promo_subscription_days, + promo_traffic_gb=request.promo_traffic_gb, + ) + + logger.info(f"🎁 Admin {admin.telegram_id} created prize: {prize.display_name}") + + return WheelPrizeAdminResponse( + id=prize.id, + config_id=prize.config_id, + prize_type=prize.prize_type, + prize_value=prize.prize_value, + display_name=prize.display_name, + emoji=prize.emoji, + color=prize.color, + prize_value_kopeks=prize.prize_value_kopeks, + sort_order=prize.sort_order, + manual_probability=prize.manual_probability, + is_active=prize.is_active, + promo_balance_bonus_kopeks=prize.promo_balance_bonus_kopeks or 0, + promo_subscription_days=prize.promo_subscription_days or 0, + promo_traffic_gb=prize.promo_traffic_gb or 0, + created_at=prize.created_at, + updated_at=prize.updated_at, + ) + + +@router.put("/prizes/{prize_id}", response_model=WheelPrizeAdminResponse) +async def update_prize( + prize_id: int, + request: UpdatePrizeRequest, + admin: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Обновить приз.""" + update_data = request.model_dump(exclude_unset=True) + + # Конвертируем enum в строку если есть + if 'prize_type' in update_data and update_data['prize_type']: + update_data['prize_type'] = update_data['prize_type'].value + + if not update_data: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="No fields to update", + ) + + prize = await update_wheel_prize(db, prize_id, **update_data) + + if not prize: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Prize not found", + ) + + logger.info(f"🎁 Admin {admin.telegram_id} updated prize {prize_id}: {update_data}") + + return WheelPrizeAdminResponse( + id=prize.id, + config_id=prize.config_id, + prize_type=prize.prize_type, + prize_value=prize.prize_value, + display_name=prize.display_name, + emoji=prize.emoji, + color=prize.color, + prize_value_kopeks=prize.prize_value_kopeks, + sort_order=prize.sort_order, + manual_probability=prize.manual_probability, + is_active=prize.is_active, + promo_balance_bonus_kopeks=prize.promo_balance_bonus_kopeks or 0, + promo_subscription_days=prize.promo_subscription_days or 0, + promo_traffic_gb=prize.promo_traffic_gb or 0, + created_at=prize.created_at, + updated_at=prize.updated_at, + ) + + +@router.delete("/prizes/{prize_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_prize_endpoint( + prize_id: int, + admin: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Удалить приз.""" + success = await delete_wheel_prize(db, prize_id) + + if not success: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Prize not found", + ) + + logger.info(f"🗑️ Admin {admin.telegram_id} deleted prize {prize_id}") + + +@router.post("/prizes/reorder", status_code=status.HTTP_200_OK) +async def reorder_prizes( + request: ReorderPrizesRequest, + admin: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Переупорядочить призы.""" + await reorder_wheel_prizes(db, request.prize_ids) + logger.info(f"🔄 Admin {admin.telegram_id} reordered prizes: {request.prize_ids}") + return {"success": True} + + +@router.get("/statistics", response_model=WheelStatisticsResponse) +async def get_statistics( + date_from: Optional[datetime] = Query(None), + date_to: Optional[datetime] = Query(None), + admin: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Получить статистику колеса.""" + stats = await wheel_service.get_statistics(db, date_from, date_to) + + return WheelStatisticsResponse( + total_spins=stats["total_spins"], + total_revenue_kopeks=stats["total_revenue_kopeks"], + total_payout_kopeks=stats["total_payout_kopeks"], + actual_rtp_percent=stats["actual_rtp_percent"], + configured_rtp_percent=stats["configured_rtp_percent"], + spins_by_payment_type=stats["spins_by_payment_type"], + prizes_distribution=stats["prizes_distribution"], + top_wins=stats["top_wins"], + period_from=stats["period_from"], + period_to=stats["period_to"], + ) + + +@router.get("/spins", response_model=AdminSpinsResponse) +async def get_all_spins_endpoint( + user_id: Optional[int] = Query(None), + date_from: Optional[datetime] = Query(None), + date_to: Optional[datetime] = Query(None), + page: int = Query(1, ge=1), + per_page: int = Query(50, ge=1, le=100), + admin: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Получить все спины с фильтрами.""" + offset = (page - 1) * per_page + spins, total = await get_all_spins( + db, + user_id=user_id, + date_from=date_from, + date_to=date_to, + limit=per_page, + offset=offset, + ) + + items = [ + AdminSpinItem( + id=spin.id, + user_id=spin.user_id, + username=spin.user.username if spin.user else None, + payment_type=spin.payment_type, + payment_amount=spin.payment_amount, + payment_value_kopeks=spin.payment_value_kopeks, + prize_type=spin.prize_type, + prize_value=spin.prize_value, + prize_display_name=spin.prize_display_name, + prize_value_kopeks=spin.prize_value_kopeks, + is_applied=spin.is_applied, + created_at=spin.created_at, + ) + for spin in spins + ] + + pages = math.ceil(total / per_page) if total > 0 else 1 + + return AdminSpinsResponse( + items=items, + total=total, + page=page, + per_page=per_page, + pages=pages, + ) diff --git a/app/cabinet/routes/balance.py b/app/cabinet/routes/balance.py index ff0558ad..41acebbe 100644 --- a/app/cabinet/routes/balance.py +++ b/app/cabinet/routes/balance.py @@ -2,8 +2,10 @@ import logging import math +import time from typing import List, Optional +import httpx from fastapi import APIRouter, Depends, HTTPException, status, Query, Request from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func, desc @@ -23,6 +25,8 @@ from ..schemas.balance import ( PaymentMethodResponse, TopUpRequest, TopUpResponse, + StarsInvoiceRequest, + StarsInvoiceResponse, ) logger = logging.getLogger(__name__) @@ -79,20 +83,25 @@ async def get_transactions( result = await db.execute(query) transactions = result.scalars().all() - items = [ - TransactionResponse( + items = [] + for t in transactions: + # Determine sign based on transaction type + # Credits (positive): DEPOSIT, REFERRAL_REWARD, REFUND, POLL_REWARD + # Debits (negative): SUBSCRIPTION_PAYMENT, WITHDRAWAL + is_debit = t.type in ['subscription_payment', 'withdrawal'] + amount_kopeks = -abs(t.amount_kopeks) if is_debit else abs(t.amount_kopeks) + + items.append(TransactionResponse( id=t.id, type=t.type, - amount_kopeks=t.amount_kopeks, - amount_rubles=t.amount_kopeks / 100, + amount_kopeks=amount_kopeks, + amount_rubles=amount_kopeks / 100, description=t.description, payment_method=t.payment_method, is_completed=t.is_completed, created_at=t.created_at, completed_at=t.completed_at, - ) - for t in transactions - ] + )) pages = math.ceil(total / per_page) if total > 0 else 1 @@ -165,7 +174,7 @@ async def get_payment_methods(): is_available=True, )) - # PAL24 + # PAL24 - add options for card/sbp if settings.is_pal24_enabled(): methods.append(PaymentMethodResponse( id="pal24", @@ -174,17 +183,33 @@ async def get_payment_methods(): min_amount_kopeks=settings.PAL24_MIN_AMOUNT_KOPEKS, max_amount_kopeks=settings.PAL24_MAX_AMOUNT_KOPEKS, is_available=True, + options=[ + {"id": "sbp", "name": "🏦 СБП", "description": "Система быстрых платежей"}, + {"id": "card", "name": "💳 Карта", "description": "Банковская карта"}, + ], )) - # Platega + # Platega - add options for different payment methods if settings.is_platega_enabled(): + platega_methods = settings.get_platega_active_methods() + definitions = settings.get_platega_method_definitions() + platega_options = [] + for method_code in platega_methods: + info = definitions.get(method_code, {}) + platega_options.append({ + "id": str(method_code), + "name": info.get("title") or info.get("name") or f"Platega {method_code}", + "description": info.get("description") or info.get("name") or "", + }) + methods.append(PaymentMethodResponse( id="platega", - name="Platega", + name=settings.get_platega_display_name(), description="Pay via Platega", min_amount_kopeks=settings.PLATEGA_MIN_AMOUNT_KOPEKS, max_amount_kopeks=settings.PLATEGA_MAX_AMOUNT_KOPEKS, is_available=True, + options=platega_options if platega_options else None, )) # Wata @@ -198,9 +223,124 @@ async def get_payment_methods(): is_available=True, )) + # CloudPayments + if settings.is_cloudpayments_enabled(): + methods.append(PaymentMethodResponse( + id="cloudpayments", + name="CloudPayments", + description="Pay with bank card via CloudPayments", + min_amount_kopeks=settings.CLOUDPAYMENTS_MIN_AMOUNT_KOPEKS, + max_amount_kopeks=settings.CLOUDPAYMENTS_MAX_AMOUNT_KOPEKS, + is_available=True, + )) + + # FreeKassa + if settings.is_freekassa_enabled(): + methods.append(PaymentMethodResponse( + id="freekassa", + name=settings.get_freekassa_display_name(), + description="Pay via FreeKassa", + min_amount_kopeks=settings.FREEKASSA_MIN_AMOUNT_KOPEKS, + max_amount_kopeks=settings.FREEKASSA_MAX_AMOUNT_KOPEKS, + is_available=True, + )) + return methods +@router.post("/stars-invoice", response_model=StarsInvoiceResponse) +async def create_stars_invoice( + request: StarsInvoiceRequest, + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """ + Создать Telegram Stars invoice для пополнения баланса. + Используется в Telegram Mini App для прямой оплаты Stars. + """ + if not settings.TELEGRAM_STARS_ENABLED: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Telegram Stars payments are not enabled", + ) + + # Validate amount + if request.amount_kopeks < 100: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Minimum amount is 1.00 RUB", + ) + + if request.amount_kopeks > 1000000: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Maximum amount is 10,000.00 RUB", + ) + + # Calculate Stars amount + try: + amount_rubles = request.amount_kopeks / 100 + stars_amount = settings.rubles_to_stars(amount_rubles) + + if stars_amount <= 0: + stars_amount = 1 + except Exception as e: + logger.error(f"Error calculating Stars amount: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to calculate Stars amount", + ) + + # Create payload for tracking payment + payload = f"balance_topup_{user.id}_{request.amount_kopeks}_{int(time.time())}" + + # Create invoice through Telegram Bot API + try: + bot_token = settings.BOT_TOKEN + api_url = f"https://api.telegram.org/bot{bot_token}/createInvoiceLink" + + async with httpx.AsyncClient() as client: + response = await client.post( + api_url, + json={ + "title": "Пополнение баланса VPN", + "description": f"Пополнение баланса на {amount_rubles:.2f} ₽ ({stars_amount} ⭐)", + "payload": payload, + "provider_token": "", # Empty for Stars + "currency": "XTR", + "prices": [{"label": "Пополнение баланса", "amount": stars_amount}], + }, + ) + + result = response.json() + + if not result.get("ok"): + logger.error(f"Telegram API error: {result}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to create Stars invoice", + ) + + invoice_url = result["result"] + logger.info( + f"Created Stars invoice for balance top-up: user={user.id}, " + f"amount={request.amount_kopeks} kopeks, stars={stars_amount}" + ) + + return StarsInvoiceResponse( + invoice_url=invoice_url, + stars_amount=stars_amount, + amount_kopeks=request.amount_kopeks, + ) + + except httpx.HTTPError as e: + logger.error(f"HTTP error creating Stars invoice: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to connect to Telegram API", + ) + + @router.post("/topup", response_model=TopUpResponse) async def create_topup( request: TopUpRequest, @@ -244,6 +384,8 @@ async def create_topup( description=f"Пополнение баланса на {amount_rubles:.2f} ₽", metadata={ "user_id": str(user.id), + "user_telegram_id": str(user.telegram_id) if user.telegram_id else "", + "user_username": user.username or "", "amount_kopeks": str(request.amount_kopeks), "type": "balance_topup", "source": "cabinet", @@ -269,7 +411,13 @@ async def create_topup( payload=f"cabinet_topup_{user.id}_{request.amount_kopeks}", ) if result: - payment_url = result.get("pay_url") or result.get("bot_invoice_url") + # Priority: web_app for desktop/browser, mini_app for mobile, bot as fallback + payment_url = ( + result.get("web_app_invoice_url") + or result.get("mini_app_invoice_url") + or result.get("bot_invoice_url") + or result.get("pay_url") + ) payment_id = str(result.get("invoice_id")) else: raise HTTPException( @@ -320,7 +468,7 @@ async def create_topup( db=db, user_id=user.id, amount_kopeks=request.amount_kopeks, - description=settings.get_balance_payment_description(request.amount_kopeks), + description=settings.get_balance_payment_description(request.amount_kopeks, telegram_user_id=user.telegram_id), language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE, payment_method_code=method_code, ) @@ -334,6 +482,176 @@ async def create_topup( detail="Failed to create Platega payment", ) + elif request.payment_method == "heleket": + if not settings.is_heleket_enabled(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Heleket payment method is unavailable", + ) + + payment_service = PaymentService() + result = await payment_service.create_heleket_payment( + db=db, + user_id=user.id, + amount_kopeks=request.amount_kopeks, + description=settings.get_balance_payment_description(request.amount_kopeks), + language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE, + ) + + if result and result.get("payment_url"): + payment_url = result.get("payment_url") + payment_id = str(result.get("local_payment_id") or result.get("uuid") or "pending") + else: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to create Heleket payment", + ) + + elif request.payment_method == "mulenpay": + if not settings.is_mulenpay_enabled(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="MulenPay payment method is unavailable", + ) + + payment_service = PaymentService() + result = await payment_service.create_mulenpay_payment( + db=db, + user_id=user.id, + amount_kopeks=request.amount_kopeks, + description=settings.get_balance_payment_description(request.amount_kopeks), + language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE, + ) + + if result and result.get("payment_url"): + payment_url = result.get("payment_url") + payment_id = str(result.get("local_payment_id") or result.get("mulen_payment_id") or "pending") + else: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to create MulenPay payment", + ) + + elif request.payment_method == "pal24": + if not settings.is_pal24_enabled(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="PAL24 payment method is unavailable", + ) + + # Use payment_option to select card or sbp (default: sbp) + option = (request.payment_option or "").strip().lower() + if option not in {"card", "sbp"}: + option = "sbp" + provider_method = "card" if option == "card" else "sbp" + + payment_service = PaymentService() + result = await payment_service.create_pal24_payment( + db=db, + user_id=user.id, + amount_kopeks=request.amount_kopeks, + description=settings.get_balance_payment_description(request.amount_kopeks), + language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE, + payment_method=provider_method, + ) + + if result: + # Select appropriate URL based on payment option + preferred_urls = [] + if option == "sbp": + preferred_urls.append(result.get("sbp_url") or result.get("transfer_url")) + elif option == "card": + preferred_urls.append(result.get("card_url")) + preferred_urls.extend([ + result.get("link_url"), + result.get("link_page_url"), + result.get("payment_url"), + result.get("transfer_url"), + ]) + payment_url = next((url for url in preferred_urls if url), None) + payment_id = str(result.get("local_payment_id") or result.get("bill_id") or "pending") + + if not payment_url: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to create PAL24 payment", + ) + + elif request.payment_method == "wata": + if not settings.is_wata_enabled(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Wata payment method is unavailable", + ) + + payment_service = PaymentService() + result = await payment_service.create_wata_payment( + db=db, + user_id=user.id, + amount_kopeks=request.amount_kopeks, + description=settings.get_balance_payment_description(request.amount_kopeks), + language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE, + ) + + if result and result.get("payment_url"): + payment_url = result.get("payment_url") + payment_id = str(result.get("local_payment_id") or result.get("payment_link_id") or "pending") + else: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to create Wata payment", + ) + + elif request.payment_method == "cloudpayments": + if not settings.is_cloudpayments_enabled(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="CloudPayments payment method is unavailable", + ) + + payment_service = PaymentService() + result = await payment_service.create_cloudpayments_payment( + db=db, + user_id=user.id, + amount_kopeks=request.amount_kopeks, + description=settings.get_balance_payment_description(request.amount_kopeks), + language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE, + ) + + if result and result.get("payment_url"): + payment_url = result.get("payment_url") + payment_id = str(result.get("local_payment_id") or result.get("invoice_id") or "pending") + else: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to create CloudPayments payment", + ) + + elif request.payment_method == "freekassa": + if not settings.is_freekassa_enabled(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="FreeKassa payment method is unavailable", + ) + + payment_service = PaymentService() + result = await payment_service.create_freekassa_payment( + db=db, + user_id=user.id, + amount_kopeks=request.amount_kopeks, + description=settings.get_balance_payment_description(request.amount_kopeks), + language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE, + ) + + if result and result.get("payment_url"): + payment_url = result.get("payment_url") + payment_id = str(result.get("local_payment_id") or result.get("order_id") or "pending") + else: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to create FreeKassa payment", + ) + else: # For other payment methods, redirect to bot raise HTTPException( diff --git a/app/cabinet/routes/branding.py b/app/cabinet/routes/branding.py index 5c363e39..71000c82 100644 --- a/app/cabinet/routes/branding.py +++ b/app/cabinet/routes/branding.py @@ -1,8 +1,8 @@ -"""Branding routes for cabinet - logo and project name management.""" +"""Branding routes for cabinet - logo, project name, and theme colors management.""" import logging import os -import base64 +import json from pathlib import Path from typing import Optional @@ -23,15 +23,17 @@ router = APIRouter(prefix="/branding", tags=["Branding"]) # Directory for storing branding assets BRANDING_DIR = Path("data/branding") -LOGO_FILENAME = "logo.png" +LOGO_EXTENSIONS = [".png", ".jpg", ".jpeg", ".webp", ".svg"] # Settings keys BRANDING_NAME_KEY = "CABINET_BRANDING_NAME" BRANDING_LOGO_KEY = "CABINET_BRANDING_LOGO" # Stores "custom" or "default" +THEME_COLORS_KEY = "CABINET_THEME_COLORS" # Stores JSON with theme colors +ENABLED_THEMES_KEY = "CABINET_ENABLED_THEMES" # Stores JSON with enabled themes {"dark": true, "light": false} # Allowed image types ALLOWED_CONTENT_TYPES = {"image/png", "image/jpeg", "image/jpg", "image/webp", "image/svg+xml"} -MAX_FILE_SIZE = 2 * 1024 * 1024 # 2MB +MAX_FILE_SIZE = 5 * 1024 * 1024 # 5MB for larger logos # ============ Schemas ============ @@ -49,6 +51,67 @@ class BrandingNameUpdate(BaseModel): name: str +class ThemeColorsResponse(BaseModel): + """Theme colors settings.""" + accent: str = "#3b82f6" + darkBackground: str = "#0a0f1a" + darkSurface: str = "#0f172a" + darkText: str = "#f1f5f9" + darkTextSecondary: str = "#94a3b8" + lightBackground: str = "#F7E7CE" + lightSurface: str = "#FEF9F0" + lightText: str = "#1F1A12" + lightTextSecondary: str = "#7D6B48" + success: str = "#22c55e" + warning: str = "#f59e0b" + error: str = "#ef4444" + + +class ThemeColorsUpdate(BaseModel): + """Request to update theme colors (partial update allowed).""" + accent: Optional[str] = None + darkBackground: Optional[str] = None + darkSurface: Optional[str] = None + darkText: Optional[str] = None + darkTextSecondary: Optional[str] = None + lightBackground: Optional[str] = None + lightSurface: Optional[str] = None + lightText: Optional[str] = None + lightTextSecondary: Optional[str] = None + success: Optional[str] = None + warning: Optional[str] = None + error: Optional[str] = None + + +class EnabledThemesResponse(BaseModel): + """Enabled themes settings.""" + dark: bool = True + light: bool = True + + +class EnabledThemesUpdate(BaseModel): + """Request to update enabled themes.""" + dark: Optional[bool] = None + light: Optional[bool] = None + + +# Default theme colors +DEFAULT_THEME_COLORS = { + "accent": "#3b82f6", + "darkBackground": "#0a0f1a", + "darkSurface": "#0f172a", + "darkText": "#f1f5f9", + "darkTextSecondary": "#94a3b8", + "lightBackground": "#F7E7CE", + "lightSurface": "#FEF9F0", + "lightText": "#1F1A12", + "lightTextSecondary": "#7D6B48", + "success": "#22c55e", + "warning": "#f59e0b", + "error": "#ef4444", +} + + # ============ Helper Functions ============ def ensure_branding_dir(): @@ -81,14 +144,23 @@ async def set_setting_value(db: AsyncSession, key: str, value: str): await db.commit() -def get_logo_path() -> Path: - """Get the path to the custom logo file.""" - return BRANDING_DIR / LOGO_FILENAME +def get_logo_path() -> Optional[Path]: + """Get the path to the custom logo file (any supported format).""" + if not BRANDING_DIR.exists(): + return None + + # Search for logo file with any supported extension + for ext in LOGO_EXTENSIONS: + logo_path = BRANDING_DIR / f"logo{ext}" + if logo_path.exists(): + return logo_path + + return None def has_custom_logo() -> bool: """Check if a custom logo exists.""" - return get_logo_path().exists() + return get_logo_path() is not None # ============ Routes ============ @@ -129,7 +201,7 @@ async def get_logo(): """ logo_path = get_logo_path() - if not logo_path.exists(): + if logo_path is None or not logo_path.exists(): raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="No custom logo set" @@ -279,3 +351,156 @@ async def delete_logo( logo_letter=logo_letter, has_custom_logo=False, ) + + +# ============ Theme Colors Routes ============ + +def validate_hex_color(color: str) -> bool: + """Validate hex color format.""" + if not color or not isinstance(color, str): + return False + if not color.startswith("#"): + return False + hex_part = color[1:] + if len(hex_part) not in (3, 6): + return False + try: + int(hex_part, 16) + return True + except ValueError: + return False + + +@router.get("/colors", response_model=ThemeColorsResponse) +async def get_theme_colors( + db: AsyncSession = Depends(get_cabinet_db), +): + """ + Get current theme colors. + This is a public endpoint - no authentication required. + """ + colors_json = await get_setting_value(db, THEME_COLORS_KEY) + + if colors_json: + try: + colors = json.loads(colors_json) + # Merge with defaults to ensure all fields exist + merged = {**DEFAULT_THEME_COLORS, **colors} + return ThemeColorsResponse(**merged) + except (json.JSONDecodeError, TypeError): + pass + + return ThemeColorsResponse(**DEFAULT_THEME_COLORS) + + +@router.patch("/colors", response_model=ThemeColorsResponse) +async def update_theme_colors( + payload: ThemeColorsUpdate, + admin: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Update theme colors. Admin only. Partial update supported.""" + # Get current colors + colors_json = await get_setting_value(db, THEME_COLORS_KEY) + current_colors = DEFAULT_THEME_COLORS.copy() + + if colors_json: + try: + current_colors.update(json.loads(colors_json)) + except (json.JSONDecodeError, TypeError): + pass + + # Update with new values (only non-None fields) + update_data = payload.model_dump(exclude_none=True) + + # Validate hex colors + for key, value in update_data.items(): + if not validate_hex_color(value): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid hex color for {key}: {value}" + ) + + current_colors.update(update_data) + + # Save to database + await set_setting_value(db, THEME_COLORS_KEY, json.dumps(current_colors)) + + logger.info(f"Admin {admin.telegram_id} updated theme colors: {list(update_data.keys())}") + + return ThemeColorsResponse(**current_colors) + + +@router.post("/colors/reset", response_model=ThemeColorsResponse) +async def reset_theme_colors( + admin: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Reset theme colors to defaults. Admin only.""" + # Save default colors + await set_setting_value(db, THEME_COLORS_KEY, json.dumps(DEFAULT_THEME_COLORS)) + + logger.info(f"Admin {admin.telegram_id} reset theme colors to defaults") + + return ThemeColorsResponse(**DEFAULT_THEME_COLORS) + + +# ============ Enabled Themes Routes ============ + +DEFAULT_ENABLED_THEMES = {"dark": True, "light": True} + + +@router.get("/themes", response_model=EnabledThemesResponse) +async def get_enabled_themes( + db: AsyncSession = Depends(get_cabinet_db), +): + """ + Get which themes are enabled. + This is a public endpoint - no authentication required. + """ + themes_json = await get_setting_value(db, ENABLED_THEMES_KEY) + + if themes_json: + try: + themes = json.loads(themes_json) + return EnabledThemesResponse(**themes) + except (json.JSONDecodeError, TypeError): + pass + + return EnabledThemesResponse(**DEFAULT_ENABLED_THEMES) + + +@router.patch("/themes", response_model=EnabledThemesResponse) +async def update_enabled_themes( + payload: EnabledThemesUpdate, + admin: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Update which themes are enabled. Admin only. At least one theme must be enabled.""" + # Get current settings + themes_json = await get_setting_value(db, ENABLED_THEMES_KEY) + current_themes = DEFAULT_ENABLED_THEMES.copy() + + if themes_json: + try: + current_themes.update(json.loads(themes_json)) + except (json.JSONDecodeError, TypeError): + pass + + # Update with new values + update_data = payload.model_dump(exclude_none=True) + current_themes.update(update_data) + + # Ensure at least one theme is enabled + if not current_themes.get("dark") and not current_themes.get("light"): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="At least one theme must be enabled" + ) + + # Save to database + await set_setting_value(db, ENABLED_THEMES_KEY, json.dumps(current_themes)) + + logger.info(f"Admin {admin.telegram_id} updated enabled themes: {current_themes}") + + return EnabledThemesResponse(**current_themes) diff --git a/app/cabinet/routes/info.py b/app/cabinet/routes/info.py index 8554e634..ad0c8393 100644 --- a/app/cabinet/routes/info.py +++ b/app/cabinet/routes/info.py @@ -59,6 +59,14 @@ class ServiceInfoResponse(BaseModel): website: Optional[str] = None +class SupportConfigResponse(BaseModel): + """Support/tickets configuration for miniapp.""" + tickets_enabled: bool + support_type: str # "tickets", "profile", "url" + support_url: Optional[str] = None + support_username: Optional[str] = None + + # ============ Routes ============ @router.get("/faq", response_model=List[FaqPageResponse]) @@ -235,3 +243,31 @@ async def update_user_language( await db.refresh(user) return {"language": user.language} + + +@router.get("/support-config", response_model=SupportConfigResponse) +async def get_support_config(): + """Get support/tickets configuration for cabinet.""" + # Use SUPPORT_SYSTEM_MODE setting (configurable from admin panel) + support_mode = settings.get_support_system_mode() # returns: tickets, contact, or both + + # Map support mode to support type for frontend + # - "tickets" mode -> tickets only, no contact + # - "contact" mode -> contact only (profile), no tickets + # - "both" mode -> tickets enabled, contact available as fallback + if support_mode == "tickets": + tickets_enabled = True + support_type = "tickets" + elif support_mode == "contact": + tickets_enabled = False + support_type = "profile" + else: # both + tickets_enabled = True + support_type = "tickets" + + return SupportConfigResponse( + tickets_enabled=tickets_enabled, + support_type=support_type, + support_url=None, # Cabinet doesn't use custom URLs + support_username=settings.SUPPORT_USERNAME, # Always return for fallback + ) diff --git a/app/cabinet/routes/media.py b/app/cabinet/routes/media.py new file mode 100644 index 00000000..ec9ac2c9 --- /dev/null +++ b/app/cabinet/routes/media.py @@ -0,0 +1,196 @@ +"""Media upload/download routes for cabinet tickets.""" + +import logging +import mimetypes +from typing import Optional + +from aiogram import Bot +from aiogram.client.default import DefaultBotProperties +from aiogram.enums import ParseMode +from aiogram.types import BufferedInputFile +from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, Response, UploadFile, status +from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.models import User + +from ..dependencies import get_cabinet_db, get_current_cabinet_user + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/media", tags=["Cabinet Media"]) + +ALLOWED_MEDIA_TYPES = {"photo", "video", "document"} +MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB + + +class MediaUploadResponse(BaseModel): + """Response after successful media upload.""" + media_type: str + file_id: str + file_unique_id: Optional[str] = None + media_url: str + + +def _resolve_target_chat_id() -> int: + """Get chat ID for uploading files (notification channel or first admin).""" + chat_id = settings.get_admin_notifications_chat_id() + if chat_id is not None: + return chat_id + + admin_ids = settings.get_admin_ids() + if admin_ids: + return admin_ids[0] + + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="No chat configured for file uploads", + ) + + +def _build_media_url(request: Request, file_id: str) -> str: + """Build URL for downloading media.""" + return str(request.url_for("cabinet_download_media", file_id=file_id)) + + +@router.post("/upload", response_model=MediaUploadResponse, status_code=status.HTTP_201_CREATED) +async def upload_media( + request: Request, + user: User = Depends(get_current_cabinet_user), + file: UploadFile = File(...), + media_type: str = Form("photo", description="File type: photo, video, or document"), +): + """ + Upload media file for use in ticket messages. + Returns file_id that can be used when creating ticket or adding message. + """ + media_type_normalized = (media_type or "").strip().lower() + if media_type_normalized not in ALLOWED_MEDIA_TYPES: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Unsupported media type. Allowed: {', '.join(ALLOWED_MEDIA_TYPES)}", + ) + + # Read and validate file + file_bytes = await file.read() + if not file_bytes: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="File is empty", + ) + + if len(file_bytes) > MAX_FILE_SIZE: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"File too large. Maximum size: {MAX_FILE_SIZE // 1024 // 1024}MB", + ) + + # Validate content type for photos + if media_type_normalized == "photo": + allowed_image_types = {"image/jpeg", "image/png", "image/gif", "image/webp"} + if file.content_type and file.content_type not in allowed_image_types: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid image type. Allowed: JPEG, PNG, GIF, WebP", + ) + + target_chat_id = _resolve_target_chat_id() + upload = BufferedInputFile(file_bytes, filename=file.filename or "upload") + + bot = Bot( + token=settings.BOT_TOKEN, + default=DefaultBotProperties(parse_mode=ParseMode.HTML), + ) + + try: + if media_type_normalized == "photo": + message = await bot.send_photo( + chat_id=target_chat_id, + photo=upload, + ) + media = message.photo[-1] + elif media_type_normalized == "video": + message = await bot.send_video( + chat_id=target_chat_id, + video=upload, + ) + media = message.video + else: + message = await bot.send_document( + chat_id=target_chat_id, + document=upload, + ) + media = message.document + + media_url = _build_media_url(request, media.file_id) + + logger.info(f"User {user.telegram_id} uploaded {media_type_normalized}: {media.file_id}") + + return MediaUploadResponse( + media_type=media_type_normalized, + file_id=media.file_id, + file_unique_id=getattr(media, "file_unique_id", None), + media_url=media_url, + ) + except HTTPException: + raise + except Exception as error: + logger.error(f"Failed to upload media for user {user.telegram_id}: {error}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to upload media", + ) from error + finally: + await bot.session.close() + + +@router.get("/{file_id}", name="cabinet_download_media") +async def download_media( + file_id: str, +) -> Response: + """ + Download media file by file_id. + Used to display images/documents in ticket messages. + """ + bot = Bot( + token=settings.BOT_TOKEN, + default=DefaultBotProperties(parse_mode=ParseMode.HTML), + ) + + try: + file = await bot.get_file(file_id) + if not file.file_path: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Media file not found", + ) + + buffer = await bot.download_file(file.file_path) + + if hasattr(buffer, "seek"): + buffer.seek(0) + + content = buffer.read() if hasattr(buffer, "read") else bytes(buffer) + filename = file.file_path.split("/")[-1] + + media_type = mimetypes.guess_type(filename)[0] or "application/octet-stream" + + return Response( + content=content, + media_type=media_type, + headers={ + "Content-Disposition": f"inline; filename={filename}", + "Cache-Control": "public, max-age=86400", # Cache for 24 hours + }, + ) + except HTTPException: + raise + except Exception as error: + logger.error(f"Failed to download media {file_id}: {error}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to download media", + ) from error + finally: + await bot.session.close() diff --git a/app/cabinet/routes/referral.py b/app/cabinet/routes/referral.py index 02570d62..872932a7 100644 --- a/app/cabinet/routes/referral.py +++ b/app/cabinet/routes/referral.py @@ -7,6 +7,7 @@ from typing import Optional from fastapi import APIRouter, Depends, HTTPException, status, Query from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func, desc +from sqlalchemy.orm import selectinload from app.database.models import User, ReferralEarning from app.config import settings @@ -83,8 +84,8 @@ async def get_referral_list( db: AsyncSession = Depends(get_cabinet_db), ): """Get list of invited users.""" - # Base query - query = select(User).where(User.referred_by_id == user.id) + # Base query with eager loading of subscription relationship + query = select(User).options(selectinload(User.subscription)).where(User.referred_by_id == user.id) # Get total count count_query = select(func.count()).select_from(User).where(User.referred_by_id == user.id) diff --git a/app/cabinet/routes/subscription.py b/app/cabinet/routes/subscription.py index b941986c..3a102fc1 100644 --- a/app/cabinet/routes/subscription.py +++ b/app/cabinet/routes/subscription.py @@ -3,17 +3,29 @@ import base64 import json import logging -from datetime import datetime +from datetime import datetime, timedelta from typing import List, Optional, Dict, Any from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession -from app.database.models import User, Subscription, ServerSquad -from app.database.crud.subscription import create_trial_subscription, get_subscription_by_user_id +from app.database.models import User, Subscription, ServerSquad, Tariff, TransactionType +from app.database.crud.subscription import ( + create_trial_subscription, + get_subscription_by_user_id, + create_paid_subscription, + extend_subscription, +) +from app.database.crud.tariff import get_tariffs_for_user, get_tariff_by_id +from app.database.crud.server_squad import get_server_squad_by_uuid +from app.database.crud.user import subtract_user_balance +from app.database.crud.transaction import create_transaction from sqlalchemy import select from app.config import settings, PERIOD_PRICES +from app.utils.pricing_utils import format_period_description from app.services.subscription_service import SubscriptionService +from app.services.system_settings_service import bot_configuration_service +from app.services.remnawave_service import RemnaWaveService from app.services.subscription_purchase_service import ( MiniAppSubscriptionPurchaseService, PurchaseValidationError, @@ -24,6 +36,7 @@ from ..dependencies import get_cabinet_db, get_current_cabinet_user from ..schemas.subscription import ( SubscriptionResponse, ServerInfo, + TrafficPurchaseInfo, RenewalOptionResponse, RenewalRequest, TrafficPackageResponse, @@ -33,6 +46,7 @@ from ..schemas.subscription import ( TrialInfoResponse, PurchaseSelectionRequest, PurchasePreviewRequest, + TariffPurchaseRequest, ) logger = logging.getLogger(__name__) @@ -42,7 +56,9 @@ router = APIRouter(prefix="/subscription", tags=["Cabinet Subscription"]) def _subscription_to_response( subscription: Subscription, - servers: Optional[List[ServerInfo]] = None + servers: Optional[List[ServerInfo]] = None, + tariff_name: Optional[str] = None, + traffic_purchases: Optional[List[Dict[str, Any]]] = None, ) -> SubscriptionResponse: """Convert Subscription model to response.""" now = datetime.utcnow() @@ -87,6 +103,32 @@ def _subscription_to_response( else: traffic_used_percent = 0 + # Check if this is a daily tariff + is_daily_paused = getattr(subscription, 'is_daily_paused', False) or False + tariff_id = getattr(subscription, 'tariff_id', None) + + # Use subscription's is_daily_tariff property if available + is_daily = False + daily_price_kopeks = None + + if hasattr(subscription, 'is_daily_tariff'): + is_daily = subscription.is_daily_tariff + elif tariff_id and hasattr(subscription, 'tariff') and subscription.tariff: + is_daily = getattr(subscription.tariff, 'is_daily', False) + + # Get daily_price_kopeks and tariff_name from tariff (separate from is_daily check) + if tariff_id and hasattr(subscription, 'tariff') and subscription.tariff: + daily_price_kopeks = getattr(subscription.tariff, 'daily_price_kopeks', None) + if not tariff_name: # Only set if not passed as parameter + tariff_name = getattr(subscription.tariff, 'name', None) + + # Calculate next daily charge time (24 hours after last charge) + next_daily_charge_at = None + if is_daily and not is_daily_paused: + last_charge = getattr(subscription, 'last_daily_charge_at', None) + if last_charge: + next_daily_charge_at = last_charge + timedelta(days=1) + return SubscriptionResponse( id=subscription.id, status=actual_status, # Use actual_status instead of raw status @@ -108,6 +150,13 @@ def _subscription_to_response( subscription_url=subscription.subscription_url, is_active=is_active, is_expired=is_expired, + traffic_purchases=traffic_purchases or [], + is_daily=is_daily, + is_daily_paused=is_daily_paused, + daily_price_kopeks=daily_price_kopeks, + next_daily_charge_at=next_daily_charge_at, + tariff_id=tariff_id, + tariff_name=tariff_name, ) @@ -128,6 +177,14 @@ async def get_subscription( detail="No subscription found", ) + # Load tariff for daily subscription check and tariff name + tariff_name = None + if fresh_user.subscription.tariff_id: + tariff = await get_tariff_by_id(db, fresh_user.subscription.tariff_id) + if tariff: + fresh_user.subscription.tariff = tariff + tariff_name = tariff.name + # Fetch server names for connected squads servers: List[ServerInfo] = [] connected_squads = fresh_user.subscription.connected_squads or [] @@ -145,19 +202,71 @@ async def get_subscription( for sq in server_squads ] - return _subscription_to_response(fresh_user.subscription, servers) + # Fetch traffic purchases (monthly packages) + traffic_purchases_data = [] + from app.database.models import TrafficPurchase + + now = datetime.utcnow() + purchases_query = ( + select(TrafficPurchase) + .where(TrafficPurchase.subscription_id == fresh_user.subscription.id) + .where(TrafficPurchase.expires_at > now) + .order_by(TrafficPurchase.expires_at.asc()) + ) + purchases_result = await db.execute(purchases_query) + purchases = purchases_result.scalars().all() + + for purchase in purchases: + time_remaining = purchase.expires_at - now + days_remaining = max(0, int(time_remaining.total_seconds() / 86400)) + total_duration_seconds = (purchase.expires_at - purchase.created_at).total_seconds() + elapsed_seconds = (now - purchase.created_at).total_seconds() + progress_percent = min(100.0, max(0.0, (elapsed_seconds / total_duration_seconds * 100) if total_duration_seconds > 0 else 0)) + + traffic_purchases_data.append({ + "id": purchase.id, + "traffic_gb": purchase.traffic_gb, + "expires_at": purchase.expires_at, + "created_at": purchase.created_at, + "days_remaining": days_remaining, + "progress_percent": round(progress_percent, 1) + }) + + return _subscription_to_response(fresh_user.subscription, servers, tariff_name, traffic_purchases_data) @router.get("/renewal-options", response_model=List[RenewalOptionResponse]) async def get_renewal_options( user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), ): """Get available subscription renewal options with prices.""" - periods = settings.get_available_renewal_periods() options = [] + # В режиме тарифов берём цены из тарифа пользователя + tariff_prices = None + tariff_periods = None + if settings.is_tariffs_mode(): + subscription = await get_subscription_by_user_id(db, user.id) + if subscription and subscription.tariff_id: + tariff = await get_tariff_by_id(db, subscription.tariff_id) + if tariff and tariff.period_prices: + tariff_prices = {int(k): v for k, v in tariff.period_prices.items()} + tariff_periods = sorted(tariff_prices.keys()) + + # Используем периоды тарифа или стандартные + if tariff_periods: + periods = tariff_periods + else: + periods = settings.get_available_renewal_periods() + for period in periods: - price_kopeks = PERIOD_PRICES.get(period, 0) + # Получаем цену из тарифа или из PERIOD_PRICES + if tariff_prices and period in tariff_prices: + price_kopeks = tariff_prices[period] + else: + price_kopeks = PERIOD_PRICES.get(period, 0) + if price_kopeks <= 0: continue @@ -198,8 +307,17 @@ async def renew_subscription( detail="No subscription found", ) - # Get price for requested period - price_kopeks = PERIOD_PRICES.get(request.period_days, 0) + # В режиме тарифов берём цену из тарифа пользователя + price_kopeks = 0 + if settings.is_tariffs_mode() and user.subscription.tariff_id: + tariff = await get_tariff_by_id(db, user.subscription.tariff_id) + if tariff and tariff.period_prices: + price_kopeks = tariff.period_prices.get(str(request.period_days), 0) + + # Fallback на PERIOD_PRICES + if price_kopeks <= 0: + price_kopeks = PERIOD_PRICES.get(request.period_days, 0) + if price_kopeks <= 0: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -247,8 +365,55 @@ async def renew_subscription( @router.get("/traffic-packages", response_model=List[TrafficPackageResponse]) -async def get_traffic_packages(): +async def get_traffic_packages( + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +): """Get available traffic packages.""" + from app.database.crud.user import get_user_by_id + from app.database.crud.tariff import get_tariff_by_id + + fresh_user = await get_user_by_id(db, user.id) + if not fresh_user or not fresh_user.subscription: + return [] + + # Режим тарифов - берём пакеты из тарифа + if settings.is_tariffs_mode() and fresh_user.subscription.tariff_id: + tariff = await get_tariff_by_id(db, fresh_user.subscription.tariff_id) + if not tariff: + return [] + + # Проверяем, разрешена ли докупка для этого тарифа + if not getattr(tariff, 'traffic_topup_enabled', False): + return [] + + # Проверяем безлимит + if tariff.traffic_limit_gb == 0: + return [] + + packages = tariff.get_traffic_topup_packages() if hasattr(tariff, 'get_traffic_topup_packages') else {} + result = [] + + for gb, price in packages.items(): + result.append(TrafficPackageResponse( + gb=gb, + price_kopeks=price, + price_rubles=price / 100, + is_unlimited=False, + )) + + return sorted(result, key=lambda x: x.gb) + + # Classic режим - глобальные настройки + if not settings.is_traffic_topup_enabled(): + return [] + + # Проверяем настройку тарифа пользователя (allow_traffic_topup) + if fresh_user.subscription.tariff_id: + tariff = await get_tariff_by_id(db, fresh_user.subscription.tariff_id) + if tariff and not tariff.allow_traffic_topup: + return [] + packages = settings.get_traffic_packages() result = [] @@ -273,6 +438,10 @@ async def purchase_traffic( db: AsyncSession = Depends(get_cabinet_db), ): """Purchase additional traffic.""" + from app.database.crud.subscription import add_subscription_traffic + from app.database.crud.tariff import get_tariff_by_id + from app.utils.pricing_utils import calculate_prorated_price + await db.refresh(user, ["subscription"]) if not user.subscription: @@ -281,46 +450,166 @@ async def purchase_traffic( detail="No subscription found", ) - # Find matching package - packages = settings.get_traffic_packages() - matching_pkg = next( - (pkg for pkg in packages if pkg["gb"] == request.gb and pkg.get("enabled", True)), - None + subscription = user.subscription + tariff = None + base_price_kopeks = 0 + is_tariff_mode = settings.is_tariffs_mode() and subscription.tariff_id + + # Режим тарифов + if is_tariff_mode: + tariff = await get_tariff_by_id(db, subscription.tariff_id) + if not tariff: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Tariff not found", + ) + + # Проверяем, разрешена ли докупка + if not getattr(tariff, 'traffic_topup_enabled', False): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Traffic top-up is disabled for this tariff", + ) + + # Проверяем безлимит + if tariff.traffic_limit_gb == 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Cannot add traffic to unlimited subscription", + ) + + # Проверяем лимит докупки + max_topup_limit = getattr(tariff, 'max_topup_traffic_gb', 0) or 0 + if max_topup_limit > 0: + current_traffic = subscription.traffic_limit_gb or 0 + new_traffic = current_traffic + request.gb + if new_traffic > max_topup_limit: + available_gb = max(0, max_topup_limit - current_traffic) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Traffic limit exceeded. Max: {max_topup_limit} GB, available: {available_gb} GB", + ) + + # Получаем цену из тарифа + packages = tariff.get_traffic_topup_packages() if hasattr(tariff, 'get_traffic_topup_packages') else {} + if request.gb not in packages: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Traffic package {request.gb}GB is not available", + ) + base_price_kopeks = packages[request.gb] + + else: + # Classic режим + if not settings.is_traffic_topup_enabled(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Traffic top-up feature is disabled", + ) + + # Проверяем настройку тарифа (allow_traffic_topup) + if subscription.tariff_id: + tariff = await get_tariff_by_id(db, subscription.tariff_id) + if tariff and not tariff.allow_traffic_topup: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Traffic top-up is not available for your tariff", + ) + + # Получаем цену из глобальных настроек + packages = settings.get_traffic_packages() + matching_pkg = next( + (pkg for pkg in packages if pkg["gb"] == request.gb and pkg.get("enabled", True)), + None + ) + if not matching_pkg: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid traffic package", + ) + base_price_kopeks = matching_pkg["price"] + + # Применяем скидку промогруппы + traffic_discount_percent = 0 + promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else getattr(user, "promo_group", None) + if promo_group: + apply_to_addons = getattr(promo_group, 'apply_discounts_to_addons', True) + if apply_to_addons: + traffic_discount_percent = max(0, min(100, int(getattr(promo_group, 'traffic_discount_percent', 0) or 0))) + + if traffic_discount_percent > 0: + base_price_kopeks = int(base_price_kopeks * (100 - traffic_discount_percent) / 100) + + # Пропорциональный расчёт цены + final_price, months_charged = calculate_prorated_price( + base_price_kopeks, + subscription.end_date, ) - if not matching_pkg: + # Проверяем баланс + if user.balance_kopeks < final_price: raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid traffic package", + status_code=status.HTTP_402_PAYMENT_REQUIRED, + detail=f"Insufficient balance. Need {final_price / 100:.2f} RUB, have {user.balance_kopeks / 100:.2f} RUB", ) - price_kopeks = matching_pkg["price"] - - # Check balance - if user.balance_kopeks < price_kopeks: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Insufficient balance", - ) - - # Deduct balance and add traffic - user.balance_kopeks -= price_kopeks - - if request.gb == 0: - # Unlimited traffic - user.subscription.traffic_limit = 0 # 0 means unlimited + # Формируем описание + if traffic_discount_percent > 0: + traffic_description = f"Докупка {request.gb} ГБ трафика (скидка {traffic_discount_percent}%)" else: - # Add GB to current limit - current_limit = user.subscription.traffic_limit or 0 - additional_bytes = request.gb * (1024 ** 3) - user.subscription.traffic_limit = current_limit + additional_bytes + traffic_description = f"Докупка {request.gb} ГБ трафика" + + # Списываем баланс + success = await subtract_user_balance(db, user, final_price, traffic_description) + if not success: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to charge balance", + ) + + # Добавляем трафик + await add_subscription_traffic(db, subscription, request.gb) + + # Обновляем purchased_traffic_gb + current_purchased = getattr(subscription, 'purchased_traffic_gb', 0) or 0 + subscription.purchased_traffic_gb = current_purchased + request.gb + + # Устанавливаем дату сброса трафика (только при первой докупке) + # При повторной докупке дата НЕ продлевается + if not subscription.traffic_reset_at: + from datetime import timedelta + subscription.traffic_reset_at = datetime.utcnow() + timedelta(days=30) + logger.info(f"Set traffic_reset_at for subscription {subscription.id}: {subscription.traffic_reset_at}") await db.commit() + # Синхронизируем с RemnaWave + try: + subscription_service = SubscriptionService() + await subscription_service.update_remnawave_user(db, subscription) + except Exception as e: + logger.error(f"Failed to sync traffic with RemnaWave: {e}") + + # Создаём транзакцию + await create_transaction( + db=db, + user_id=user.id, + type=TransactionType.SUBSCRIPTION_PAYMENT, + amount_kopeks=final_price, + description=traffic_description, + ) + + await db.refresh(user) + await db.refresh(subscription) + return { + "success": True, "message": "Traffic purchased successfully", "gb_added": request.gb, - "amount_paid_kopeks": price_kopeks, + "new_traffic_limit_gb": subscription.traffic_limit_gb, + "amount_paid_kopeks": final_price, + "discount_percent": traffic_discount_percent, + "new_balance_kopeks": user.balance_kopeks, } @@ -502,13 +791,39 @@ async def activate_trial( user.balance_kopeks -= price_kopeks logger.info(f"User {user.id} paid {price_kopeks} kopeks for trial activation") + # Get trial parameters from tariff if configured (same logic as bot handler) + trial_duration = settings.TRIAL_DURATION_DAYS + trial_traffic_limit = settings.TRIAL_TRAFFIC_LIMIT_GB + trial_device_limit = settings.TRIAL_DEVICE_LIMIT + trial_squads = [] + tariff_id_for_trial = None + + trial_tariff_id = settings.get_trial_tariff_id() + if trial_tariff_id: + try: + from app.database.crud.tariff import get_tariff_by_id + trial_tariff = await get_tariff_by_id(db, trial_tariff_id) + if trial_tariff: + trial_traffic_limit = trial_tariff.traffic_limit_gb + trial_device_limit = trial_tariff.device_limit + trial_squads = trial_tariff.allowed_squads or [] + tariff_id_for_trial = trial_tariff.id + tariff_trial_days = getattr(trial_tariff, 'trial_duration_days', None) + if tariff_trial_days: + trial_duration = tariff_trial_days + logger.info(f"Using trial tariff {trial_tariff.name} (ID: {trial_tariff.id}) with squads: {trial_squads}") + except Exception as e: + logger.error(f"Error getting trial tariff: {e}") + # Create trial subscription subscription = await create_trial_subscription( db=db, user_id=user.id, - duration_days=settings.TRIAL_DURATION_DAYS, - traffic_limit_gb=settings.TRIAL_TRAFFIC_LIMIT_GB, - device_limit=settings.TRIAL_DEVICE_LIMIT, + duration_days=trial_duration, + traffic_limit_gb=trial_traffic_limit, + device_limit=trial_device_limit, + connected_squads=trial_squads if trial_squads else None, + tariff_id=tariff_id_for_trial, ) logger.info(f"Trial subscription activated for user {user.id}") @@ -522,6 +837,24 @@ async def activate_trial( except Exception as e: logger.error(f"Failed to create RemnaWave user for trial: {e}") + # Send admin notification about trial activation + try: + from aiogram import Bot + from app.services.admin_notification_service import AdminNotificationService + + if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN: + bot = Bot(token=settings.BOT_TOKEN) + try: + notification_service = AdminNotificationService(bot) + charged_amount = settings.TRIAL_ACTIVATION_PRICE if requires_payment else None + await notification_service.send_trial_activation_notification( + db, user, subscription, charged_amount_kopeks=charged_amount + ) + finally: + await bot.session.close() + except Exception as e: + logger.error(f"Failed to send trial activation notification: {e}") + return _subscription_to_response(subscription) @@ -530,6 +863,163 @@ async def activate_trial( purchase_service = MiniAppSubscriptionPurchaseService() +async def _build_tariff_response( + db: AsyncSession, + tariff: Tariff, + current_tariff_id: Optional[int] = None, + language: str = "ru", + user: Optional[User] = None, +) -> Dict[str, Any]: + """Build tariff model for API response with promo group discounts applied.""" + servers = [] + servers_count = 0 + + if tariff.allowed_squads: + servers_count = len(tariff.allowed_squads) + for squad_uuid in tariff.allowed_squads[:5]: # Limit for preview + server = await get_server_squad_by_uuid(db, squad_uuid) + if server: + servers.append({ + "uuid": squad_uuid, + "name": server.display_name or squad_uuid[:8], + }) + + # Get promo group for discount calculation + promo_group = user.get_primary_promo_group() if user and hasattr(user, 'get_primary_promo_group') else None + promo_group_name = promo_group.name if promo_group else None + + periods = [] + if tariff.period_prices: + for period_str, price_kopeks in sorted(tariff.period_prices.items(), key=lambda x: int(x[0])): + if int(price_kopeks) <= 0: + continue # Skip disabled periods + period_days = int(period_str) + + # Apply promo group discount for this period + original_price = int(price_kopeks) + discount_percent = 0 + discount_amount = 0 + final_price = original_price + + if promo_group: + discount_percent = promo_group.get_discount_percent("period", period_days) + if discount_percent > 0: + discount_amount = original_price * discount_percent // 100 + final_price = original_price - discount_amount + + months = max(1, period_days // 30) + per_month = final_price // months if months > 0 else final_price + original_per_month = original_price // months if months > 0 else original_price + + period_data = { + "days": period_days, + "months": months, + "label": format_period_description(period_days, language), + "price_kopeks": final_price, + "price_label": settings.format_price(final_price), + "price_per_month_kopeks": per_month, + "price_per_month_label": settings.format_price(per_month), + } + + # Add discount info if discount is applied + if discount_percent > 0: + period_data["original_price_kopeks"] = original_price + period_data["original_price_label"] = settings.format_price(original_price) + period_data["original_per_month_kopeks"] = original_per_month + period_data["original_per_month_label"] = settings.format_price(original_per_month) + period_data["discount_percent"] = discount_percent + period_data["discount_amount_kopeks"] = discount_amount + period_data["discount_label"] = f"-{discount_percent}%" + + periods.append(period_data) + + traffic_label = "♾️ Безлимит" if tariff.traffic_limit_gb == 0 else f"{tariff.traffic_limit_gb} ГБ" + + # Apply discount to daily price if applicable + daily_price = getattr(tariff, 'daily_price_kopeks', 0) + original_daily_price = daily_price + daily_discount_percent = 0 + if promo_group and daily_price > 0: + # For daily tariffs, use period discount with period_days=1 + daily_discount_percent = promo_group.get_discount_percent("period", 1) + if daily_discount_percent > 0: + discount_amount = daily_price * daily_discount_percent // 100 + daily_price = daily_price - discount_amount + + # Apply discount to custom price_per_day if applicable + price_per_day = tariff.price_per_day_kopeks + original_price_per_day = price_per_day + custom_days_discount_percent = 0 + if promo_group and price_per_day > 0: + custom_days_discount_percent = promo_group.get_discount_percent("period", 30) # Use 30-day rate as base + if custom_days_discount_percent > 0: + discount_amount = price_per_day * custom_days_discount_percent // 100 + price_per_day = price_per_day - discount_amount + + # Apply discount to device price if applicable + device_price = tariff.device_price_kopeks or 0 + original_device_price = device_price + device_discount_percent = 0 + if promo_group and device_price > 0: + device_discount_percent = promo_group.get_discount_percent("devices") + if device_discount_percent > 0: + discount_amount = device_price * device_discount_percent // 100 + device_price = device_price - discount_amount + + response = { + "id": tariff.id, + "name": tariff.name, + "description": tariff.description, + "tier_level": tariff.tier_level, + "traffic_limit_gb": tariff.traffic_limit_gb, + "traffic_limit_label": traffic_label, + "is_unlimited_traffic": tariff.traffic_limit_gb == 0, + "device_limit": tariff.device_limit, + "device_price_kopeks": device_price, + "servers_count": servers_count, + "servers": servers, + "periods": periods, + "is_current": current_tariff_id == tariff.id if current_tariff_id else False, + "is_available": tariff.is_active, + # Произвольное количество дней + "custom_days_enabled": tariff.custom_days_enabled, + "price_per_day_kopeks": price_per_day, + "min_days": tariff.min_days, + "max_days": tariff.max_days, + # Произвольный трафик при покупке + "custom_traffic_enabled": tariff.custom_traffic_enabled, + "traffic_price_per_gb_kopeks": tariff.traffic_price_per_gb_kopeks, + "min_traffic_gb": tariff.min_traffic_gb, + "max_traffic_gb": tariff.max_traffic_gb, + # Докупка трафика + "traffic_topup_enabled": tariff.traffic_topup_enabled, + "traffic_topup_packages": tariff.get_traffic_topup_packages() if hasattr(tariff, 'get_traffic_topup_packages') else {}, + "max_topup_traffic_gb": tariff.max_topup_traffic_gb, + # Дневной тариф + "is_daily": getattr(tariff, 'is_daily', False), + "daily_price_kopeks": daily_price, + } + + # Add promo group info if user has discounts + if promo_group_name: + response["promo_group_name"] = promo_group_name + + # Add original prices if discounts were applied + if device_discount_percent > 0: + response["original_device_price_kopeks"] = original_device_price + response["device_discount_percent"] = device_discount_percent + + if daily_discount_percent > 0 and original_daily_price > 0: + response["original_daily_price_kopeks"] = original_daily_price + response["daily_discount_percent"] = daily_discount_percent + + if custom_days_discount_percent > 0 and original_price_per_day > 0: + response["original_price_per_day_kopeks"] = original_price_per_day + response["custom_days_discount_percent"] = custom_days_discount_percent + + return response + + @router.get("/purchase-options") async def get_purchase_options( user: User = Depends(get_current_cabinet_user), @@ -537,8 +1027,37 @@ async def get_purchase_options( ) -> Dict[str, Any]: """Get all subscription purchase options (periods, servers, traffic, devices).""" try: + sales_mode = settings.get_sales_mode() + + # Tariffs mode - return list of tariffs + if settings.is_tariffs_mode(): + promo_group = getattr(user, "promo_group", None) + promo_group_id = promo_group.id if promo_group else None + tariffs = await get_tariffs_for_user(db, promo_group_id) + + subscription = await get_subscription_by_user_id(db, user.id) + current_tariff_id = subscription.tariff_id if subscription else None + language = getattr(user, "language", "ru") or "ru" + + tariff_responses = [] + for tariff in tariffs: + tariff_data = await _build_tariff_response(db, tariff, current_tariff_id, language, user) + tariff_responses.append(tariff_data) + + return { + "sales_mode": "tariffs", + "tariffs": tariff_responses, + "current_tariff_id": current_tariff_id, + "balance_kopeks": user.balance_kopeks, + "balance_label": settings.format_price(user.balance_kopeks), + } + + # Classic mode - return periods context = await purchase_service.build_options(db, user) - return context.payload + payload = context.payload + payload["sales_mode"] = "classic" + return payload + except PurchaseValidationError as e: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -640,9 +1159,441 @@ async def submit_purchase( ) +# ============ Tariff Purchase (for tariffs mode) ============ + +@router.post("/purchase-tariff") +async def purchase_tariff( + request: TariffPurchaseRequest, + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +) -> Dict[str, Any]: + """Purchase a tariff (for tariffs mode).""" + try: + # Check tariffs mode + if not settings.is_tariffs_mode(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Tariffs mode is not enabled", + ) + + # Get tariff + tariff = await get_tariff_by_id(db, request.tariff_id) + if not tariff or not tariff.is_active: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Tariff not found or inactive", + ) + + # Check tariff availability for user's promo group and get promo group for discounts + promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None + promo_group_id = promo_group.id if promo_group else None + if not tariff.is_available_for_promo_group(promo_group_id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="This tariff is not available for your promo group", + ) + + # Handle daily tariffs specially + is_daily_tariff = getattr(tariff, 'is_daily', False) + discount_percent = 0 + original_price = 0 + + if is_daily_tariff: + daily_price = getattr(tariff, 'daily_price_kopeks', 0) + if daily_price <= 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Daily tariff has invalid price", + ) + original_price = daily_price + # Apply promo group discount for daily tariff + if promo_group: + discount_percent = promo_group.get_discount_percent("period", 1) + if discount_percent > 0: + discount_amount = daily_price * discount_percent // 100 + daily_price = daily_price - discount_amount + # For daily tariffs, charge first day and set period to 1 day + price_kopeks = daily_price + period_days = 1 + else: + period_days = request.period_days + # Get price for period (support custom days) + price_kopeks = tariff.get_price_for_period(period_days) + if price_kopeks is None: + # Check for custom days + if tariff.can_purchase_custom_days(): + price_kopeks = tariff.get_price_for_custom_days(period_days) + if price_kopeks is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Period must be between {tariff.min_days} and {tariff.max_days} days", + ) + else: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid period for this tariff", + ) + + original_price = price_kopeks + # Apply promo group discount for period + if promo_group and price_kopeks > 0: + discount_percent = promo_group.get_discount_percent("period", period_days) + if discount_percent > 0: + discount_amount = price_kopeks * discount_percent // 100 + price_kopeks = price_kopeks - discount_amount + + # Calculate traffic limit and price + traffic_limit_gb = tariff.traffic_limit_gb + traffic_price_kopeks = 0 + if request.traffic_gb is not None and tariff.can_purchase_custom_traffic(): + # Custom traffic requested + traffic_price_kopeks = tariff.get_price_for_custom_traffic(request.traffic_gb) + if traffic_price_kopeks is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Traffic must be between {tariff.min_traffic_gb} and {tariff.max_traffic_gb} GB", + ) + # Apply traffic discount if promo group has it + if promo_group and traffic_price_kopeks > 0: + traffic_discount_percent = promo_group.get_discount_percent("traffic", period_days) + if traffic_discount_percent > 0: + traffic_discount = traffic_price_kopeks * traffic_discount_percent // 100 + traffic_price_kopeks = traffic_price_kopeks - traffic_discount + traffic_limit_gb = request.traffic_gb + price_kopeks += traffic_price_kopeks + + # Check balance + if user.balance_kopeks < price_kopeks: + missing = price_kopeks - user.balance_kopeks + raise HTTPException( + status_code=status.HTTP_402_PAYMENT_REQUIRED, + detail={ + "code": "insufficient_funds", + "message": f"Недостаточно средств. Не хватает {settings.format_price(missing)}", + "missing_amount": missing, + }, + ) + + subscription = await get_subscription_by_user_id(db, user.id) + + # Get server squads from tariff + squads = tariff.allowed_squads or [] + + # If allowed_squads is empty, it means "all servers" + if not squads: + from app.database.crud.server_squad import get_all_server_squads + all_servers, _ = await get_all_server_squads(db, available_only=True) + squads = [s.squad_uuid for s in all_servers if s.squad_uuid] + + # Charge balance + if is_daily_tariff: + description = f"Активация суточного тарифа '{tariff.name}'" + else: + description = f"Покупка тарифа '{tariff.name}' на {period_days} дней" + if discount_percent > 0: + description += f" (скидка {discount_percent}%)" + success = await subtract_user_balance(db, user, price_kopeks, description) + if not success: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="Failed to charge balance", + ) + + # Create transaction + await create_transaction( + db=db, + user_id=user.id, + type=TransactionType.SUBSCRIPTION_PAYMENT, + amount_kopeks=price_kopeks, + description=description, + ) + + if subscription: + # Extend/change tariff + subscription = await extend_subscription( + db=db, + subscription=subscription, + days=period_days, + tariff_id=tariff.id, + traffic_limit_gb=traffic_limit_gb, + device_limit=tariff.device_limit, + connected_squads=squads, + ) + else: + # Create new subscription + subscription = await create_paid_subscription( + db=db, + user_id=user.id, + duration_days=period_days, + traffic_limit_gb=traffic_limit_gb, + device_limit=tariff.device_limit, + connected_squads=squads, + tariff_id=tariff.id, + ) + + # For daily tariffs, set last_daily_charge_at + if is_daily_tariff: + subscription.last_daily_charge_at = datetime.utcnow() + subscription.is_daily_paused = False + await db.commit() + await db.refresh(subscription) + + # Sync with RemnaWave + service = SubscriptionService() + await service.update_remnawave_user(db, subscription) + + # Save cart for auto-renewal (not for daily tariffs - they have their own charging) + if not is_daily_tariff: + try: + from app.services.user_cart_service import user_cart_service + cart_data = { + "cart_mode": "extend", + "subscription_id": subscription.id, + "period_days": period_days, + "total_price": price_kopeks, + "tariff_id": tariff.id, + "description": f"Продление тарифа {tariff.name} на {period_days} дней", + } + await user_cart_service.save_user_cart(user.id, cart_data) + logger.info(f"Tariff cart saved for auto-renewal (cabinet) user {user.telegram_id}") + except Exception as e: + logger.error(f"Error saving tariff cart (cabinet): {e}") + + await db.refresh(user) + + response = { + "success": True, + "message": f"Тариф '{tariff.name}' успешно активирован", + "subscription": _subscription_to_response(subscription), + "tariff_id": tariff.id, + "tariff_name": tariff.name, + "charged_amount": price_kopeks, + "charged_label": settings.format_price(price_kopeks), + "balance_kopeks": user.balance_kopeks, + "balance_label": settings.format_price(user.balance_kopeks), + } + + # Add discount info if discount was applied + if discount_percent > 0: + response["discount_percent"] = discount_percent + response["original_price_kopeks"] = original_price + response["original_price_label"] = settings.format_price(original_price) + response["discount_amount_kopeks"] = original_price - price_kopeks + response["discount_label"] = settings.format_price(original_price - price_kopeks) + if promo_group: + response["promo_group_name"] = promo_group.name + + return response + + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to purchase tariff for user {user.id}: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to process tariff purchase", + ) + + +# ============ Device Purchase ============ + +@router.post("/devices/purchase") +async def purchase_devices( + request: DevicePurchaseRequest, + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Purchase additional device slots for subscription.""" + try: + await db.refresh(user, ["subscription"]) + subscription = user.subscription + + if not subscription: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="У вас нет активной подписки", + ) + + if subscription.status not in ['active', 'trial']: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ваша подписка неактивна", + ) + + # Get tariff for device price + tariff = None + if subscription.tariff_id: + from app.database.crud.tariff import get_tariff_by_id + tariff = await get_tariff_by_id(db, subscription.tariff_id) + + if not tariff or not tariff.device_price_kopeks: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Докупка устройств недоступна для вашего тарифа", + ) + + # Check max device limit + current_devices = subscription.device_limit or 1 + new_device_count = current_devices + request.devices + if tariff.max_device_limit and new_device_count > tariff.max_device_limit: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Максимальное количество устройств для вашего тарифа: {tariff.max_device_limit}", + ) + + # Calculate prorated price based on remaining days + from datetime import datetime, timezone + now = datetime.now(timezone.utc) + end_date = subscription.end_date + if end_date.tzinfo is None: + end_date = end_date.replace(tzinfo=timezone.utc) + + days_left = max(1, (end_date - now).days) + total_days = 30 # Base period for device price calculation + + # Price = device_price * devices * (days_left / 30) + price_kopeks = int(tariff.device_price_kopeks * request.devices * days_left / total_days) + price_kopeks = max(100, price_kopeks) # Minimum 1 ruble + + # Check balance + if user.balance_kopeks < price_kopeks: + missing = price_kopeks - user.balance_kopeks + raise HTTPException( + status_code=status.HTTP_402_PAYMENT_REQUIRED, + detail={ + "error": "Insufficient balance", + "required_kopeks": price_kopeks, + "current_kopeks": user.balance_kopeks, + "missing_kopeks": missing, + }, + ) + + # Deduct balance + from app.database.crud.user import subtract_user_balance + await subtract_user_balance( + db=db, + user=user, + amount_kopeks=price_kopeks, + description=f"Покупка {request.devices} доп. устройств", + ) + + # Increase device limit + subscription.device_limit += request.devices + await db.commit() + await db.refresh(subscription) + + # Sync with RemnaWave + service = SubscriptionService() + await service.update_remnawave_user(db, subscription) + + await db.refresh(user) + + logger.info( + f"User {user.telegram_id} purchased {request.devices} devices for {price_kopeks} kopeks" + ) + + return { + "success": True, + "message": f"Добавлено {request.devices} устройств", + "devices_added": request.devices, + "new_device_limit": subscription.device_limit, + "price_kopeks": price_kopeks, + "price_label": settings.format_price(price_kopeks), + "balance_kopeks": user.balance_kopeks, + "balance_label": settings.format_price(user.balance_kopeks), + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to purchase devices for user {user.id}: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Не удалось обработать покупку устройств", + ) + + +@router.get("/devices/price") +async def get_device_price( + devices: int = 1, + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Get price for additional devices.""" + await db.refresh(user, ["subscription"]) + subscription = user.subscription + + if not subscription or subscription.status not in ['active', 'trial']: + return { + "available": False, + "reason": "Нет активной подписки", + } + + tariff = None + if subscription.tariff_id: + from app.database.crud.tariff import get_tariff_by_id + tariff = await get_tariff_by_id(db, subscription.tariff_id) + + if not tariff or not tariff.device_price_kopeks: + return { + "available": False, + "reason": "Докупка устройств недоступна для вашего тарифа", + } + + # Check max device limit + current_devices = subscription.device_limit or 1 + max_device_limit = tariff.max_device_limit + can_add = max_device_limit - current_devices if max_device_limit else None + + if max_device_limit and current_devices >= max_device_limit: + return { + "available": False, + "reason": f"Достигнут максимум устройств ({max_device_limit})", + "current_device_limit": current_devices, + "max_device_limit": max_device_limit, + } + + if max_device_limit and current_devices + devices > max_device_limit: + return { + "available": False, + "reason": f"Можно добавить максимум {can_add} устройств", + "current_device_limit": current_devices, + "max_device_limit": max_device_limit, + "can_add": can_add, + } + + # Calculate prorated price + from datetime import datetime, timezone + now = datetime.now(timezone.utc) + end_date = subscription.end_date + if end_date.tzinfo is None: + end_date = end_date.replace(tzinfo=timezone.utc) + + days_left = max(1, (end_date - now).days) + total_days = 30 + + price_per_device_kopeks = int(tariff.device_price_kopeks * days_left / total_days) + price_per_device_kopeks = max(100, price_per_device_kopeks) + total_price_kopeks = price_per_device_kopeks * devices + + return { + "available": True, + "devices": devices, + "price_per_device_kopeks": price_per_device_kopeks, + "price_per_device_label": settings.format_price(price_per_device_kopeks), + "total_price_kopeks": total_price_kopeks, + "total_price_label": settings.format_price(total_price_kopeks), + "current_device_limit": current_devices, + "max_device_limit": max_device_limit, + "can_add": can_add, + "days_left": days_left, + "base_device_price_kopeks": tariff.device_price_kopeks, + } + + # ============ App Config for Connection ============ -def _load_app_config() -> Dict[str, Any]: +def _load_app_config_from_file() -> Dict[str, Any]: """Load app-config.json file.""" try: config_path = settings.get_app_config_path() @@ -655,13 +1606,215 @@ def _load_app_config() -> Dict[str, Any]: return {} +def _get_remnawave_config_uuid() -> Optional[str]: + """Get RemnaWave config UUID from system settings or env.""" + try: + return bot_configuration_service.get_current_value("CABINET_REMNA_SUB_CONFIG") + except Exception: + return settings.CABINET_REMNA_SUB_CONFIG + + +def _is_subscription_link_template(url: str) -> bool: + """Check if URL is a RemnaWave subscription link template.""" + if not url: + return False + # RemnaWave uses templates like {{HAPP_CRYPT4_LINK}}, {{V2RAY_LINK}}, etc. + if url.startswith("{{") and url.endswith("}}"): + return True + # Also check for button type "subscriptionLink" indicator + return False + + +def _convert_remnawave_block_to_step(block: Dict[str, Any], url_scheme: str = "") -> Dict[str, Any]: + """Convert RemnaWave block format to cabinet step format.""" + step = { + "description": block.get("description", {}), + } + if block.get("title"): + step["title"] = block["title"] + if block.get("buttons"): + buttons = [] + for btn in block["buttons"]: + btn_url = btn.get("url", "") or btn.get("link", "") + btn_type = btn.get("type", "") + + # Replace subscription link templates with {{deepLink}} placeholder + # RemnaWave uses templates like {{HAPP_CRYPT4_LINK}} or type="subscriptionLink" + if _is_subscription_link_template(btn_url) or btn_type == "subscriptionLink": + btn_url = "{{deepLink}}" + # Also check for urlScheme-based URLs + elif url_scheme and btn_url and ( + btn_url.startswith(url_scheme) or + btn_url.endswith("://") or + btn_url.endswith("://add/") or + ("://" in btn_url and not btn_url.startswith("http")) + ): + btn_url = "{{deepLink}}" + + buttons.append({ + "buttonLink": btn_url, + "buttonText": btn.get("text", {}), + }) + step["buttons"] = buttons + return step + + +# Known app URL schemes (fallback if RemnaWave doesn't provide urlScheme) +KNOWN_APP_URL_SCHEMES = { + "happ": "happ://add/", + "streisand": "streisand://import/", + "shadowrocket": "sub://", + "v2rayn": "v2rayng://install-config?url=", + "v2rayng": "v2rayng://install-config?url=", + "clash": "clash://install-config?url=", + "clash meta": "clash://install-config?url=", + "clash verge": "clash://install-config?url=", + "hiddify": "hiddify://import/", + "nekoray": "sn://subscription?url=", + "nekobox": "sn://subscription?url=", + "karing": "karing://add/", +} + + +def _convert_remnawave_app_to_cabinet(app: Dict[str, Any]) -> Dict[str, Any]: + """Convert RemnaWave app format to cabinet app format.""" + blocks = app.get("blocks", []) + url_scheme = app.get("urlScheme", "") + + # If urlScheme is missing, try to determine from app name + if not url_scheme: + app_name = app.get("name", "").lower().strip() + url_scheme = KNOWN_APP_URL_SCHEMES.get(app_name, "") + + # Map blocks to steps based on position + installation_step = _convert_remnawave_block_to_step(blocks[0], url_scheme) if len(blocks) > 0 else {"description": {}} + subscription_step = _convert_remnawave_block_to_step(blocks[1], url_scheme) if len(blocks) > 1 else {"description": {}} + connect_step = _convert_remnawave_block_to_step(blocks[2], url_scheme) if len(blocks) > 2 else {"description": {}} + + # Ensure subscription step has a deepLink button if urlScheme exists + if url_scheme: + has_deeplink_button = False + if "buttons" in subscription_step: + for btn in subscription_step["buttons"]: + if btn.get("buttonLink") == "{{deepLink}}": + has_deeplink_button = True + break + + if not has_deeplink_button: + # Add deepLink button at the beginning + deeplink_button = { + "buttonLink": "{{deepLink}}", + "buttonText": { + "en": "Open app", + "ru": "Открыть приложение", + "zh": "打开应用", + "fa": "باز کردن برنامه", + }, + } + if "buttons" not in subscription_step: + subscription_step["buttons"] = [] + subscription_step["buttons"].insert(0, deeplink_button) + + return { + "id": app.get("name", "").lower().replace(" ", "-"), + "name": app.get("name", ""), + "isFeatured": app.get("featured", False), + "urlScheme": app.get("urlScheme", ""), + "isNeedBase64Encoding": app.get("isNeedBase64Encoding", False), + "installationStep": installation_step, + "addSubscriptionStep": subscription_step, + "connectAndUseStep": connect_step, + } + + +def _convert_remnawave_config_to_cabinet(config: Dict[str, Any]) -> Dict[str, Any]: + """Convert RemnaWave config format to cabinet format.""" + platforms = {} + remnawave_platforms = config.get("platforms", {}) + + for platform_key, platform_data in remnawave_platforms.items(): + if not isinstance(platform_data, dict): + continue + apps = platform_data.get("apps", []) + if not isinstance(apps, list): + continue + + cabinet_apps = [] + for app in apps: + if isinstance(app, dict): + cabinet_apps.append(_convert_remnawave_app_to_cabinet(app)) + + if cabinet_apps: + platforms[platform_key] = cabinet_apps + + # Convert branding + branding = {} + if config.get("brandingSettings"): + branding = { + "name": config["brandingSettings"].get("name", ""), + "logoUrl": config["brandingSettings"].get("logoUrl", ""), + "supportUrl": config["brandingSettings"].get("supportUrl", ""), + } + + return { + "config": { + "additionalLocales": ["zh", "fa"], + "branding": branding, + }, + "platforms": platforms, + } + + +async def _load_app_config_async() -> Dict[str, Any]: + """Load app config from RemnaWave (if configured) or local file.""" + remnawave_uuid = _get_remnawave_config_uuid() + + if remnawave_uuid: + try: + service = RemnaWaveService() + async with service.get_api_client() as api: + config = await api.get_subscription_page_config(remnawave_uuid) + if config and config.config: + logger.info(f"Loaded app config from RemnaWave: {remnawave_uuid}") + # Debug: log raw RemnaWave config structure + import json + logger.info(f"RemnaWave raw config: {json.dumps(config.config, ensure_ascii=False, indent=2)[:2000]}") + converted = _convert_remnawave_config_to_cabinet(config.config) + logger.info(f"Converted config platforms: {list(converted.get('platforms', {}).keys())}") + # Log first app from each platform + for platform, apps in converted.get('platforms', {}).items(): + if apps: + first_app = apps[0] + logger.info(f"Platform {platform} first app: name={first_app.get('name')}, urlScheme={first_app.get('urlScheme')}") + return converted + except Exception as e: + logger.warning(f"Failed to load RemnaWave config, falling back to file: {e}") + + # Fallback to local file + return _load_app_config_from_file() + + +def _load_app_config() -> Dict[str, Any]: + """Load app-config.json file (sync version for compatibility).""" + return _load_app_config_from_file() + + def _create_deep_link(app: Dict[str, Any], subscription_url: str) -> Optional[str]: """Create deep link for app with subscription URL.""" if not subscription_url or not isinstance(app, dict): + logger.debug(f"_create_deep_link: no subscription_url or invalid app") return None scheme = str(app.get("urlScheme", "")).strip() if not scheme: + # Try fallback from app name + app_name = app.get("name", "").lower().strip() + scheme = KNOWN_APP_URL_SCHEMES.get(app_name, "") + if scheme: + logger.info(f"_create_deep_link: used fallback urlScheme for '{app_name}': {scheme}") + + if not scheme: + logger.warning(f"_create_deep_link: no urlScheme for app '{app.get('name', 'unknown')}'") return None payload = subscription_url @@ -689,7 +1842,10 @@ async def get_available_countries( await db.refresh(user, ["subscription"]) promo_group_id = user.promo_group_id - available_servers = await get_available_server_squads(db, promo_group_id=promo_group_id) + # Exclude trial-only servers from available servers for purchase + available_servers = await get_available_server_squads( + db, promo_group_id=promo_group_id, exclude_trial_only=True + ) connected_squads = [] if user.subscription: @@ -705,7 +1861,6 @@ async def get_available_countries( "price_rubles": server.price_kopeks / 100, "is_available": server.is_available and not server.is_full, "is_connected": server.squad_uuid in connected_squads, - "is_trial_eligible": server.is_trial_eligible, }) return { @@ -753,7 +1908,10 @@ async def update_countries( current_countries = user.subscription.connected_squads or [] promo_group_id = user.promo_group_id - available_servers = await get_available_server_squads(db, promo_group_id=promo_group_id) + # Exclude trial-only servers from available servers for purchase + available_servers = await get_available_server_squads( + db, promo_group_id=promo_group_id, exclude_trial_only=True + ) allowed_country_ids = {server.squad_uuid for server in available_servers} # Validate selected countries @@ -972,7 +2130,8 @@ async def get_app_config( if user.subscription: subscription_url = user.subscription.subscription_url - config = _load_app_config() + # Load config from RemnaWave (if configured) or local file + config = await _load_app_config_async() platforms_raw = config.get("platforms", {}) if not isinstance(platforms_raw, dict): @@ -1027,3 +2186,720 @@ async def get_app_config( "subscriptionUrl": subscription_url, "branding": config.get("config", {}).get("branding", {}), } + + +# ============ Device Management ============ + +@router.get("/devices") +async def get_devices( + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +) -> Dict[str, Any]: + """Get list of connected devices.""" + from app.services.remnawave_service import RemnaWaveService + + await db.refresh(user, ["subscription"]) + + if not user.subscription: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="No subscription found", + ) + + if not user.remnawave_uuid: + return { + "devices": [], + "total": 0, + "device_limit": user.subscription.device_limit or 1, + } + + try: + service = RemnaWaveService() + async with service.get_api_client() as api: + response = await api.get_user_devices(user.remnawave_uuid) + + devices_list = response.get('devices', []) + formatted_devices = [] + for device in devices_list: + hwid = device.get("hwid") or device.get("deviceId") or device.get("id") + platform = device.get("platform") or device.get("platformType") or "Unknown" + model = device.get("deviceModel") or device.get("model") or device.get("name") or "Unknown" + created_at = device.get("updatedAt") or device.get("lastSeen") or device.get("createdAt") + + formatted_devices.append({ + "hwid": hwid, + "platform": platform, + "device_model": model, + "created_at": created_at, + }) + + return { + "devices": formatted_devices, + "total": response.get('total', len(formatted_devices)), + "device_limit": user.subscription.device_limit or 1, + } + + except Exception as e: + logger.error(f"Error fetching devices: {e}") + return { + "devices": [], + "total": 0, + "device_limit": user.subscription.device_limit or 1, + } + + +@router.delete("/devices/{hwid}") +async def delete_device( + hwid: str, + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +) -> Dict[str, Any]: + """Delete a specific device by HWID.""" + from app.services.remnawave_service import RemnaWaveService + + await db.refresh(user, ["subscription"]) + + if not user.subscription: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="No subscription found", + ) + + if not user.remnawave_uuid: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="User UUID not found", + ) + + try: + service = RemnaWaveService() + async with service.get_api_client() as api: + delete_data = { + "userUuid": user.remnawave_uuid, + "hwid": hwid + } + await api._make_request('POST', '/api/hwid/devices/delete', data=delete_data) + + return { + "success": True, + "message": "Device deleted successfully", + "deleted_hwid": hwid, + } + + except Exception as e: + logger.error(f"Error deleting device: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to delete device", + ) + + +@router.delete("/devices") +async def delete_all_devices( + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +) -> Dict[str, Any]: + """Delete all connected devices.""" + from app.services.remnawave_service import RemnaWaveService + + await db.refresh(user, ["subscription"]) + + if not user.subscription: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="No subscription found", + ) + + if not user.remnawave_uuid: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="User UUID not found", + ) + + try: + service = RemnaWaveService() + async with service.get_api_client() as api: + # Get all devices first + response = await api._make_request('GET', f'/api/hwid/devices/{user.remnawave_uuid}') + + if not response or 'response' not in response: + return { + "success": True, + "message": "No devices to delete", + "deleted_count": 0, + } + + devices_list = response['response'].get('devices', []) + if not devices_list: + return { + "success": True, + "message": "No devices to delete", + "deleted_count": 0, + } + + deleted_count = 0 + for device in devices_list: + device_hwid = device.get('hwid') + if device_hwid: + try: + delete_data = { + "userUuid": user.remnawave_uuid, + "hwid": device_hwid + } + await api._make_request('POST', '/api/hwid/devices/delete', data=delete_data) + deleted_count += 1 + except Exception as device_error: + logger.error(f"Error deleting device {device_hwid}: {device_error}") + + return { + "success": True, + "message": f"Deleted {deleted_count} devices", + "deleted_count": deleted_count, + } + + except Exception as e: + logger.error(f"Error deleting all devices: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to delete devices", + ) + + +# ============ Tariff Switch ============ + +@router.post("/tariff/switch/preview") +async def preview_tariff_switch( + request: TariffPurchaseRequest, + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +) -> Dict[str, Any]: + """Preview tariff switch - shows cost calculation.""" + if not settings.is_tariffs_mode(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Tariffs mode is not enabled", + ) + + await db.refresh(user, ["subscription"]) + + if not user.subscription or not user.subscription.tariff_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="No active subscription with tariff", + ) + + if user.subscription.status not in ("active", "trial"): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Subscription is not active", + ) + + current_tariff = await get_tariff_by_id(db, user.subscription.tariff_id) + new_tariff = await get_tariff_by_id(db, request.tariff_id) + + if not new_tariff or not new_tariff.is_active: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Tariff not found or inactive", + ) + + if user.subscription.tariff_id == request.tariff_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Already on this tariff", + ) + + # Check tariff availability for user's promo group + promo_group = getattr(user, "promo_group", None) + promo_group_id = promo_group.id if promo_group else None + if not new_tariff.is_available_for_promo_group(promo_group_id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Tariff not available for your promo group", + ) + + # Calculate remaining days + remaining_days = 0 + if user.subscription.end_date and user.subscription.end_date > datetime.utcnow(): + delta = user.subscription.end_date - datetime.utcnow() + remaining_days = max(0, delta.days) + + # Calculate switch cost + current_is_daily = getattr(current_tariff, 'is_daily', False) if current_tariff else False + new_is_daily = getattr(new_tariff, 'is_daily', False) + switching_to_daily = not current_is_daily and new_is_daily + switching_from_daily = current_is_daily and not new_is_daily + + def get_monthly_price(tariff) -> int: + """Get 30-day price from tariff, or calculate from closest period.""" + if not tariff or not tariff.period_prices: + return 0 + # Try to get 30-day price directly + if '30' in tariff.period_prices: + return tariff.period_prices['30'] + # Find closest period and calculate monthly equivalent + min_period = None + min_price = 0 + for period_str, price in tariff.period_prices.items(): + period_days = int(period_str) + if min_period is None or period_days < min_period: + min_period = period_days + min_price = price + if min_period and min_period > 0: + return int(min_price * 30 / min_period) + return 0 + + if switching_to_daily: + # Switching TO daily - pay first day price + daily_price = getattr(new_tariff, 'daily_price_kopeks', 0) + upgrade_cost = daily_price + is_upgrade = daily_price > 0 + elif switching_from_daily: + # Switching FROM daily TO periodic - full payment for new tariff + min_period_price = 0 + if new_tariff.period_prices: + min_period_price = min(new_tariff.period_prices.values()) + upgrade_cost = min_period_price + is_upgrade = min_period_price > 0 + else: + # Calculate proportional cost difference using monthly prices + current_monthly = get_monthly_price(current_tariff) + new_monthly = get_monthly_price(new_tariff) + + price_diff = new_monthly - current_monthly + + if price_diff > 0: + # Upgrade - pay proportional difference + upgrade_cost = int(price_diff * remaining_days / 30) + is_upgrade = True + else: + # Downgrade or same - free + upgrade_cost = 0 + is_upgrade = False + + balance = user.balance_kopeks or 0 + has_enough = balance >= upgrade_cost + missing = max(0, upgrade_cost - balance) if not has_enough else 0 + + return { + "can_switch": has_enough, + "current_tariff_id": current_tariff.id if current_tariff else None, + "current_tariff_name": current_tariff.name if current_tariff else None, + "new_tariff_id": new_tariff.id, + "new_tariff_name": new_tariff.name, + "remaining_days": remaining_days, + "upgrade_cost_kopeks": upgrade_cost, + "upgrade_cost_label": settings.format_price(upgrade_cost) if upgrade_cost > 0 else "Бесплатно", + "balance_kopeks": balance, + "balance_label": settings.format_price(balance), + "has_enough_balance": has_enough, + "missing_amount_kopeks": missing, + "missing_amount_label": settings.format_price(missing) if missing > 0 else "", + "is_upgrade": is_upgrade, + } + + +@router.post("/tariff/switch") +async def switch_tariff( + request: TariffPurchaseRequest, + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +) -> Dict[str, Any]: + """Switch to a different tariff without changing end date.""" + from datetime import timedelta + + if not settings.is_tariffs_mode(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Tariffs mode is not enabled", + ) + + await db.refresh(user, ["subscription"]) + + if not user.subscription or not user.subscription.tariff_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="No active subscription with tariff", + ) + + if user.subscription.status not in ("active", "trial"): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Subscription is not active", + ) + + current_tariff = await get_tariff_by_id(db, user.subscription.tariff_id) + new_tariff = await get_tariff_by_id(db, request.tariff_id) + + if not new_tariff or not new_tariff.is_active: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Tariff not found or inactive", + ) + + if user.subscription.tariff_id == request.tariff_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Already on this tariff", + ) + + # Check tariff availability + promo_group = getattr(user, "promo_group", None) + promo_group_id = promo_group.id if promo_group else None + if not new_tariff.is_available_for_promo_group(promo_group_id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Tariff not available", + ) + + # Calculate remaining days + remaining_days = 0 + if user.subscription.end_date and user.subscription.end_date > datetime.utcnow(): + delta = user.subscription.end_date - datetime.utcnow() + remaining_days = max(0, delta.days) + + # Calculate cost + current_is_daily = getattr(current_tariff, 'is_daily', False) if current_tariff else False + new_is_daily = getattr(new_tariff, 'is_daily', False) + switching_from_daily = current_is_daily and not new_is_daily + switching_to_daily = not current_is_daily and new_is_daily + + if switching_to_daily: + # Switching TO daily tariff - charge first day price + daily_price = getattr(new_tariff, 'daily_price_kopeks', 0) + if daily_price <= 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Daily tariff has invalid price", + ) + upgrade_cost = daily_price + new_period_days = 1 # Daily tariff starts with 1 day + elif switching_from_daily: + # Switch FROM daily to regular tariff - pay for minimum period + min_period_days = 30 + min_period_price = 0 + if new_tariff.period_prices: + min_period_days = min(int(k) for k in new_tariff.period_prices.keys()) + min_period_price = new_tariff.period_prices.get(str(min_period_days), 0) + upgrade_cost = min_period_price + new_period_days = min_period_days + else: + # Regular tariff switch - calculate proportional cost difference using monthly prices + def get_monthly_price(tariff) -> int: + if not tariff or not tariff.period_prices: + return 0 + if '30' in tariff.period_prices: + return tariff.period_prices['30'] + min_period = None + min_price = 0 + for period_str, price in tariff.period_prices.items(): + period_days = int(period_str) + if min_period is None or period_days < min_period: + min_period = period_days + min_price = price + if min_period and min_period > 0: + return int(min_price * 30 / min_period) + return 0 + + current_monthly = get_monthly_price(current_tariff) + new_monthly = get_monthly_price(new_tariff) + price_diff = new_monthly - current_monthly + + if price_diff > 0: + upgrade_cost = int(price_diff * remaining_days / 30) + else: + upgrade_cost = 0 + new_period_days = 0 + + # Charge if upgrade + if upgrade_cost > 0: + if user.balance_kopeks < upgrade_cost: + missing = upgrade_cost - user.balance_kopeks + raise HTTPException( + status_code=status.HTTP_402_PAYMENT_REQUIRED, + detail={ + "code": "insufficient_funds", + "message": f"Insufficient funds. Missing {settings.format_price(missing)}", + "missing_amount": missing, + }, + ) + + if switching_to_daily: + description = f"Переход на суточный тариф '{new_tariff.name}'" + elif switching_from_daily: + description = f"Переход с суточного на тариф '{new_tariff.name}' ({new_period_days} дней)" + else: + description = f"Переход на тариф '{new_tariff.name}' (доплата за {remaining_days} дней)" + + success = await subtract_user_balance(db, user, upgrade_cost, description) + if not success: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to charge balance", + ) + + # Create transaction + await create_transaction( + db=db, + user_id=user.id, + type=TransactionType.SUBSCRIPTION_PAYMENT, + amount_kopeks=upgrade_cost, + description=description, + ) + + # Update subscription + old_tariff_name = current_tariff.name if current_tariff else "Unknown" + user.subscription.tariff_id = new_tariff.id + user.subscription.traffic_limit_gb = new_tariff.traffic_limit_gb + user.subscription.device_limit = new_tariff.device_limit + user.subscription.connected_squads = new_tariff.allowed_squads or [] + + # Reset purchased traffic and delete TrafficPurchase records on tariff switch + from app.database.models import TrafficPurchase + from sqlalchemy import delete as sql_delete + await db.execute(sql_delete(TrafficPurchase).where(TrafficPurchase.subscription_id == user.subscription.id)) + user.subscription.purchased_traffic_gb = 0 + user.subscription.traffic_reset_at = None + + if switching_to_daily: + # Switching TO daily - reset end_date to 1 day, set last_daily_charge_at + user.subscription.end_date = datetime.utcnow() + timedelta(days=1) + user.subscription.last_daily_charge_at = datetime.utcnow() + user.subscription.is_daily_paused = False + elif switching_from_daily: + user.subscription.end_date = datetime.utcnow() + timedelta(days=new_period_days) + user.subscription.is_daily_paused = False + + user.subscription.updated_at = datetime.utcnow() + await db.commit() + + # Sync with RemnaWave + try: + subscription_service = SubscriptionService() + await subscription_service.update_remnawave_user(db, user.subscription) + except Exception as e: + logger.error(f"Failed to sync tariff switch with RemnaWave: {e}") + + await db.refresh(user) + await db.refresh(user.subscription) + + return { + "success": True, + "message": f"Switched from '{old_tariff_name}' to '{new_tariff.name}'", + "subscription": _subscription_to_response(user.subscription), + "old_tariff_name": old_tariff_name, + "new_tariff_id": new_tariff.id, + "new_tariff_name": new_tariff.name, + "charged_kopeks": upgrade_cost, + "balance_kopeks": user.balance_kopeks, + "balance_label": settings.format_price(user.balance_kopeks), + } + + +# ============ Daily Subscription Pause ============ + +@router.post("/pause") +async def toggle_subscription_pause( + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +) -> Dict[str, Any]: + """Toggle pause/resume for daily subscription.""" + from datetime import timedelta + + await db.refresh(user, ["subscription"]) + + if not user.subscription: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="No subscription found", + ) + + tariff_id = getattr(user.subscription, 'tariff_id', None) + if not tariff_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Subscription has no tariff", + ) + + tariff = await get_tariff_by_id(db, tariff_id) + if not tariff or not getattr(tariff, 'is_daily', False): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Pause is only available for daily tariffs", + ) + + # Toggle pause state + is_currently_paused = getattr(user.subscription, 'is_daily_paused', False) + new_paused_state = not is_currently_paused + user.subscription.is_daily_paused = new_paused_state + + # If resuming, check balance + if not new_paused_state: + daily_price = getattr(tariff, 'daily_price_kopeks', 0) + if daily_price > 0 and user.balance_kopeks < daily_price: + raise HTTPException( + status_code=status.HTTP_402_PAYMENT_REQUIRED, + detail={ + "code": "insufficient_balance", + "message": "Insufficient balance to resume daily subscription", + "required": daily_price, + "balance": user.balance_kopeks, + }, + ) + + # Restore ACTIVE status if was DISABLED + from app.database.models import SubscriptionStatus + if user.subscription.status == SubscriptionStatus.DISABLED.value: + user.subscription.status = SubscriptionStatus.ACTIVE.value + user.subscription.last_daily_charge_at = datetime.utcnow() + user.subscription.end_date = datetime.utcnow() + timedelta(days=1) + + await db.commit() + await db.refresh(user.subscription) + await db.refresh(user) + + # Sync with RemnaWave when resuming + if not new_paused_state: + try: + subscription_service = SubscriptionService() + if user.remnawave_uuid: + await subscription_service.enable_remnawave_user(user.remnawave_uuid) + except Exception as e: + logger.error(f"Error syncing with RemnaWave on resume: {e}") + + if new_paused_state: + message = "Daily subscription paused" + else: + message = "Daily subscription resumed" + + return { + "success": True, + "message": message, + "is_paused": new_paused_state, + "balance_kopeks": user.balance_kopeks, + "balance_label": settings.format_price(user.balance_kopeks), + } + + +# ============ Traffic Switch (Change Traffic Package) ============ + +@router.put("/traffic") +async def switch_traffic_package( + request: TrafficPurchaseRequest, + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +) -> Dict[str, Any]: + """Switch to a different traffic package (change limit).""" + from app.utils.pricing_utils import calculate_prorated_price, apply_percentage_discount + + await db.refresh(user, ["subscription"]) + + if not user.subscription: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="No subscription found", + ) + + if user.subscription.is_trial: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Traffic management is only available for paid subscriptions", + ) + + current_traffic = user.subscription.traffic_limit_gb or 0 + new_traffic = request.gb + + if current_traffic == new_traffic: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Already on this traffic package", + ) + + # Get available packages + packages = settings.get_traffic_packages() + current_pkg = next((p for p in packages if p["gb"] == current_traffic and p.get("enabled", True)), None) + new_pkg = next((p for p in packages if p["gb"] == new_traffic and p.get("enabled", True)), None) + + if not new_pkg: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid traffic package", + ) + + # Calculate price difference (only charge for upgrade) + current_price = current_pkg["price"] if current_pkg else 0 + new_price = new_pkg["price"] + + if new_price > current_price: + # Upgrade - charge difference + price_diff = new_price - current_price + + # Apply promo discount + traffic_discount_percent = 0 + promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else getattr(user, "promo_group", None) + if promo_group: + apply_to_addons = getattr(promo_group, 'apply_discounts_to_addons', True) + if apply_to_addons: + traffic_discount_percent = max(0, min(100, int(getattr(promo_group, 'traffic_discount_percent', 0) or 0))) + + if traffic_discount_percent > 0: + price_diff = int(price_diff * (100 - traffic_discount_percent) / 100) + + # Prorated calculation + final_price, months_charged = calculate_prorated_price(price_diff, user.subscription.end_date) + + if user.balance_kopeks < final_price: + raise HTTPException( + status_code=status.HTTP_402_PAYMENT_REQUIRED, + detail=f"Insufficient balance. Need {final_price / 100:.2f} RUB", + ) + + # Charge balance + description = f"Traffic upgrade from {current_traffic}GB to {new_traffic}GB" + success = await subtract_user_balance(db, user, final_price, description) + if not success: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to charge balance", + ) + + # Create transaction + await create_transaction( + db=db, + user_id=user.id, + type=TransactionType.SUBSCRIPTION_PAYMENT, + amount_kopeks=final_price, + description=description, + ) + + charged = final_price + else: + # Downgrade - no charge, no refund + charged = 0 + + # Update subscription + user.subscription.traffic_limit_gb = new_traffic + user.subscription.purchased_traffic_gb = 0 # Reset purchased traffic on switch + user.subscription.traffic_reset_at = None # Reset traffic reset date + user.subscription.updated_at = datetime.utcnow() + await db.commit() + + # Sync with RemnaWave + try: + subscription_service = SubscriptionService() + await subscription_service.update_remnawave_user(db, user.subscription) + except Exception as e: + logger.error(f"Failed to sync traffic switch with RemnaWave: {e}") + + await db.refresh(user) + await db.refresh(user.subscription) + + return { + "success": True, + "message": f"Traffic changed from {current_traffic}GB to {new_traffic}GB", + "old_traffic_gb": current_traffic, + "new_traffic_gb": new_traffic, + "charged_kopeks": charged, + "balance_kopeks": user.balance_kopeks, + "balance_label": settings.format_price(user.balance_kopeks), + } diff --git a/app/cabinet/routes/tickets.py b/app/cabinet/routes/tickets.py index 2b2ebe86..9547b8cf 100644 --- a/app/cabinet/routes/tickets.py +++ b/app/cabinet/routes/tickets.py @@ -36,6 +36,7 @@ def _message_to_response(message: TicketMessage) -> TicketMessageResponse: is_from_admin=message.is_from_admin, has_media=bool(message.media_file_id), media_type=message.media_type, + media_file_id=message.media_file_id, media_caption=message.media_caption, created_at=message.created_at, ) @@ -143,12 +144,15 @@ async def create_ticket( db.add(ticket) await db.flush() - # Create initial message + # Create initial message with optional media message = TicketMessage( ticket_id=ticket.id, user_id=user.id, message_text=request.message, is_from_admin=False, + media_type=request.media_type, + media_file_id=request.media_file_id, + media_caption=request.media_caption, created_at=datetime.utcnow(), ) db.add(message) @@ -243,12 +247,15 @@ async def add_ticket_message( detail="Replies to this ticket are blocked", ) - # Create message + # Create message with optional media message = TicketMessage( ticket_id=ticket.id, user_id=user.id, message_text=request.message, is_from_admin=False, + media_type=request.media_type, + media_file_id=request.media_file_id, + media_caption=request.media_caption, created_at=datetime.utcnow(), ) db.add(message) diff --git a/app/cabinet/routes/wheel.py b/app/cabinet/routes/wheel.py new file mode 100644 index 00000000..7d9320c7 --- /dev/null +++ b/app/cabinet/routes/wheel.py @@ -0,0 +1,271 @@ +""" +API роуты колеса удачи для пользователей. +""" +import logging +import math +import time +import httpx +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from pydantic import BaseModel +from typing import Optional + +from app.config import settings +from app.database.models import User +from app.database.crud.wheel import ( + get_or_create_wheel_config, + get_wheel_prizes, + get_user_spins_today, + get_user_spin_history, +) +from app.services.wheel_service import wheel_service +from app.cabinet.dependencies import get_cabinet_db, get_current_cabinet_user +from app.cabinet.schemas.wheel import ( + WheelConfigResponse, + WheelPrizeDisplay, + SpinAvailabilityResponse, + SpinRequest, + SpinResultResponse, + SpinHistoryResponse, + SpinHistoryItem, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/wheel", tags=["Fortune Wheel"]) + + +@router.get("/config", response_model=WheelConfigResponse) +async def get_wheel_config( + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Получить конфигурацию колеса удачи.""" + config = await get_or_create_wheel_config(db) + prizes = await get_wheel_prizes(db, config.id, active_only=True) + spins_today = await get_user_spins_today(db, user.id) + + # Проверяем доступность + availability = await wheel_service.check_availability(db, user) + + prizes_display = [ + WheelPrizeDisplay( + id=p.id, + display_name=p.display_name, + emoji=p.emoji, + color=p.color, + prize_type=p.prize_type, + ) + for p in prizes + ] + + return WheelConfigResponse( + is_enabled=config.is_enabled, + name=config.name, + spin_cost_stars=config.spin_cost_stars if config.spin_cost_stars_enabled else None, + spin_cost_days=config.spin_cost_days if config.spin_cost_days_enabled else None, + spin_cost_stars_enabled=config.spin_cost_stars_enabled, + spin_cost_days_enabled=config.spin_cost_days_enabled, + prizes=prizes_display, + daily_limit=config.daily_spin_limit, + user_spins_today=spins_today, + can_spin=availability.can_spin, + can_spin_reason=availability.reason, + can_pay_stars=availability.can_pay_stars, + can_pay_days=availability.can_pay_days, + user_balance_kopeks=availability.user_balance_kopeks, + required_balance_kopeks=availability.required_balance_kopeks, + ) + + +@router.get("/availability", response_model=SpinAvailabilityResponse) +async def check_spin_availability( + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Проверить доступность спина.""" + availability = await wheel_service.check_availability(db, user) + + return SpinAvailabilityResponse( + can_spin=availability.can_spin, + reason=availability.reason, + spins_remaining_today=availability.spins_remaining_today, + can_pay_stars=availability.can_pay_stars, + can_pay_days=availability.can_pay_days, + min_subscription_days=availability.min_subscription_days, + user_subscription_days=availability.user_subscription_days, + user_balance_kopeks=availability.user_balance_kopeks, + required_balance_kopeks=availability.required_balance_kopeks, + ) + + +@router.post("/spin", response_model=SpinResultResponse) +async def spin_wheel( + request: SpinRequest, + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Крутить колесо удачи.""" + result = await wheel_service.spin(db, user, request.payment_type.value) + + if not result.success: + # Возвращаем ошибку в теле ответа, а не HTTP exception + return SpinResultResponse( + success=False, + error=result.error, + message=result.message, + ) + + return SpinResultResponse( + success=True, + prize_id=result.prize_id, + prize_type=result.prize_type, + prize_value=result.prize_value, + prize_display_name=result.prize_display_name, + emoji=result.emoji, + color=result.color, + rotation_degrees=result.rotation_degrees, + message=result.message, + promocode=result.promocode, + ) + + +@router.get("/history", response_model=SpinHistoryResponse) +async def get_spin_history( + page: int = 1, + per_page: int = 20, + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Получить историю спинов пользователя.""" + if page < 1: + page = 1 + if per_page < 1 or per_page > 100: + per_page = 20 + + offset = (page - 1) * per_page + + spins, total = await get_user_spin_history(db, user.id, limit=per_page, offset=offset) + + items = [] + for spin in spins: + # Получаем emoji и color из приза, если он есть + emoji = "🎁" + color = "#3B82F6" + if spin.prize: + emoji = spin.prize.emoji + color = spin.prize.color + + items.append(SpinHistoryItem( + id=spin.id, + payment_type=spin.payment_type, + payment_amount=spin.payment_amount, + prize_type=spin.prize_type, + prize_value=spin.prize_value, + prize_display_name=spin.prize_display_name, + emoji=emoji, + color=color, + prize_value_kopeks=spin.prize_value_kopeks, + created_at=spin.created_at, + )) + + pages = math.ceil(total / per_page) if total > 0 else 1 + + return SpinHistoryResponse( + items=items, + total=total, + page=page, + per_page=per_page, + pages=pages, + ) + + +class StarsInvoiceResponse(BaseModel): + """Ответ с ссылкой на Stars invoice.""" + invoice_url: str + stars_amount: int + + +@router.post("/stars-invoice", response_model=StarsInvoiceResponse) +async def create_stars_invoice( + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """ + Создать Telegram Stars invoice для оплаты спина колеса. + Используется в Telegram Mini App для прямой оплаты Stars. + """ + config = await get_or_create_wheel_config(db) + + if not config.is_enabled: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Колесо удачи недоступно", + ) + + if not config.spin_cost_stars_enabled or not config.spin_cost_stars: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Оплата Stars не включена", + ) + + # Проверяем лимит спинов + spins_today = await get_user_spins_today(db, user.id) + if config.daily_spin_limit > 0 and spins_today >= config.daily_spin_limit: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Достигнут дневной лимит спинов", + ) + + # Проверяем наличие призов + prizes = await get_wheel_prizes(db, config.id, active_only=True) + if not prizes: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Призы не настроены", + ) + + stars_amount = config.spin_cost_stars + payload = f"wheel_spin_{user.id}_{int(time.time())}" + + # Создаем invoice через Telegram Bot API + try: + bot_token = settings.BOT_TOKEN + api_url = f"https://api.telegram.org/bot{bot_token}/createInvoiceLink" + + async with httpx.AsyncClient() as client: + response = await client.post( + api_url, + json={ + "title": "Колесо удачи", + "description": f"Спин колеса удачи ({stars_amount} ⭐)", + "payload": payload, + "provider_token": "", # Пустой для Stars + "currency": "XTR", + "prices": [{"label": "Спин колеса", "amount": stars_amount}], + }, + ) + + result = response.json() + + if not result.get("ok"): + logger.error(f"Telegram API error: {result}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Ошибка создания инвойса", + ) + + invoice_url = result["result"] + logger.info(f"Created Stars invoice for wheel spin: user={user.id}, stars={stars_amount}") + + return StarsInvoiceResponse( + invoice_url=invoice_url, + stars_amount=stars_amount, + ) + + except httpx.HTTPError as e: + logger.error(f"HTTP error creating invoice: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Ошибка соединения с Telegram", + ) diff --git a/app/cabinet/schemas/__init__.py b/app/cabinet/schemas/__init__.py index 8bde9f9c..8bca5b54 100644 --- a/app/cabinet/schemas/__init__.py +++ b/app/cabinet/schemas/__init__.py @@ -15,6 +15,7 @@ from .auth import ( ) from .subscription import ( SubscriptionResponse, + TrafficPurchaseInfo, RenewalOptionResponse, RenewalRequest, TrafficPackageResponse, @@ -59,6 +60,7 @@ __all__ = [ "AuthResponse", # Subscription "SubscriptionResponse", + "TrafficPurchaseInfo", "RenewalOptionResponse", "RenewalRequest", "TrafficPackageResponse", diff --git a/app/cabinet/schemas/balance.py b/app/cabinet/schemas/balance.py index fbb5498d..b150cfac 100644 --- a/app/cabinet/schemas/balance.py +++ b/app/cabinet/schemas/balance.py @@ -1,7 +1,7 @@ """Balance and payment schemas for cabinet.""" from datetime import datetime -from typing import Optional, List +from typing import Optional, List, Dict, Any from pydantic import BaseModel, Field @@ -36,6 +36,13 @@ class TransactionListResponse(BaseModel): pages: int +class PaymentOptionResponse(BaseModel): + """Payment method option (e.g. Platega sub-methods).""" + id: str + name: str + description: Optional[str] = None + + class PaymentMethodResponse(BaseModel): """Available payment method.""" id: str @@ -44,6 +51,7 @@ class PaymentMethodResponse(BaseModel): min_amount_kopeks: int max_amount_kopeks: int is_available: bool = True + options: Optional[List[Dict[str, Any]]] = None class TopUpRequest(BaseModel): @@ -61,3 +69,15 @@ class TopUpResponse(BaseModel): amount_rubles: float status: str expires_at: Optional[datetime] = None + + +class StarsInvoiceRequest(BaseModel): + """Request to create Telegram Stars invoice for balance top-up.""" + amount_kopeks: int = Field(..., ge=100, description="Amount in kopeks (min 1 ruble)") + + +class StarsInvoiceResponse(BaseModel): + """Response with Telegram Stars invoice link.""" + invoice_url: str + stars_amount: int + amount_kopeks: int diff --git a/app/cabinet/schemas/servers.py b/app/cabinet/schemas/servers.py new file mode 100644 index 00000000..19819fb4 --- /dev/null +++ b/app/cabinet/schemas/servers.py @@ -0,0 +1,119 @@ +"""Schemas for server management in cabinet.""" + +from datetime import datetime +from typing import List, Optional +from pydantic import BaseModel, Field + + +class PromoGroupInfo(BaseModel): + """Promo group info for server.""" + id: int + name: str + is_selected: bool = False + + +class ServerListItem(BaseModel): + """Server item for list view.""" + id: int + squad_uuid: str + display_name: str + original_name: Optional[str] = None + country_code: Optional[str] = None + is_available: bool + is_trial_eligible: bool + price_kopeks: int + price_rubles: float + max_users: Optional[int] = None + current_users: int + sort_order: int + is_full: bool + availability_status: str + created_at: datetime + + class Config: + from_attributes = True + + +class ServerListResponse(BaseModel): + """Response with list of servers.""" + servers: List[ServerListItem] + total: int + + +class ServerDetailResponse(BaseModel): + """Detailed server response.""" + id: int + squad_uuid: str + display_name: str + original_name: Optional[str] = None + country_code: Optional[str] = None + description: Optional[str] = None + is_available: bool + is_trial_eligible: bool + price_kopeks: int + price_rubles: float + max_users: Optional[int] = None + current_users: int + sort_order: int + is_full: bool + availability_status: str + promo_groups: List[PromoGroupInfo] + active_subscriptions: int + tariffs_using: List[str] # Names of tariffs using this server + created_at: datetime + updated_at: Optional[datetime] = None + + class Config: + from_attributes = True + + +class ServerUpdateRequest(BaseModel): + """Request to update a server.""" + display_name: Optional[str] = Field(None, min_length=1, max_length=255) + description: Optional[str] = None + country_code: Optional[str] = Field(None, max_length=5) + is_available: Optional[bool] = None + is_trial_eligible: Optional[bool] = None + price_kopeks: Optional[int] = Field(None, ge=0) + max_users: Optional[int] = Field(None, ge=0) + sort_order: Optional[int] = Field(None, ge=0) + promo_group_ids: Optional[List[int]] = None + + +class ServerToggleResponse(BaseModel): + """Response after toggling server.""" + id: int + is_available: bool + message: str + + +class ServerTrialToggleResponse(BaseModel): + """Response after toggling trial eligibility.""" + id: int + is_trial_eligible: bool + message: str + + +class ServerStatsResponse(BaseModel): + """Server statistics.""" + id: int + display_name: str + squad_uuid: str + current_users: int + max_users: Optional[int] + active_subscriptions: int + trial_subscriptions: int + usage_percent: Optional[float] = None + + +class ServerSyncResponse(BaseModel): + """Response after syncing with RemnaWave.""" + created: int + updated: int + removed: int + message: str + + +class ServerSyncRequest(BaseModel): + """Request to sync servers.""" + force: bool = False # Force sync even if recently synced diff --git a/app/cabinet/schemas/subscription.py b/app/cabinet/schemas/subscription.py index 4ede8761..d0a5681c 100644 --- a/app/cabinet/schemas/subscription.py +++ b/app/cabinet/schemas/subscription.py @@ -12,6 +12,16 @@ class ServerInfo(BaseModel): country_code: Optional[str] = None +class TrafficPurchaseInfo(BaseModel): + """Purchased traffic package info.""" + id: int + traffic_gb: int + expires_at: datetime + created_at: datetime + days_remaining: int + progress_percent: float + + class SubscriptionResponse(BaseModel): """User subscription data.""" id: int @@ -34,6 +44,14 @@ class SubscriptionResponse(BaseModel): subscription_url: Optional[str] = None is_active: bool is_expired: bool + traffic_purchases: List[TrafficPurchaseInfo] = [] + # Daily tariff fields + is_daily: bool = False + is_daily_paused: bool = False + daily_price_kopeks: Optional[int] = None + next_daily_charge_at: Optional[datetime] = None # When next daily charge will happen + tariff_id: Optional[int] = None + tariff_name: Optional[str] = None class Config: from_attributes = True @@ -103,3 +121,12 @@ class PurchaseSelectionRequest(BaseModel): class PurchasePreviewRequest(BaseModel): """Request to preview purchase pricing.""" selection: PurchaseSelectionRequest + + +# ============ Tariff Purchase Schemas ============ + +class TariffPurchaseRequest(BaseModel): + """Request to purchase a tariff.""" + tariff_id: int = Field(..., description="Tariff ID to purchase") + period_days: int = Field(..., description="Period in days") + traffic_gb: Optional[int] = Field(None, ge=0, description="Custom traffic in GB (for custom_traffic_enabled tariffs)") diff --git a/app/cabinet/schemas/tariffs.py b/app/cabinet/schemas/tariffs.py new file mode 100644 index 00000000..69bb052e --- /dev/null +++ b/app/cabinet/schemas/tariffs.py @@ -0,0 +1,208 @@ +"""Schemas for tariff management in cabinet.""" + +from datetime import datetime +from typing import List, Optional, Dict +from pydantic import BaseModel, Field + + +class PeriodPrice(BaseModel): + """Price for a specific period.""" + days: int = Field(..., ge=1, description="Period in days") + price_kopeks: int = Field(..., ge=0, description="Price in kopeks") + price_rubles: Optional[float] = None + + def __init__(self, **data): + super().__init__(**data) + if self.price_rubles is None: + self.price_rubles = self.price_kopeks / 100 + + +class ServerTrafficLimit(BaseModel): + """Traffic limit for a specific server.""" + traffic_limit_gb: int = Field(0, ge=0, description="0 = use default tariff limit") + + +class ServerInfo(BaseModel): + """Server info for tariff.""" + id: int + squad_uuid: str + display_name: str + country_code: Optional[str] = None + is_selected: bool = False + traffic_limit_gb: Optional[int] = None # Индивидуальный лимит для сервера + + +class PromoGroupInfo(BaseModel): + """Promo group info for tariff.""" + id: int + name: str + is_selected: bool = False + + +class TariffListItem(BaseModel): + """Tariff item for list view.""" + id: int + name: str + description: Optional[str] = None + is_active: bool + is_trial_available: bool + is_daily: bool = False + daily_price_kopeks: int = 0 + allow_traffic_topup: bool = True + traffic_limit_gb: int + device_limit: int + tier_level: int + display_order: int + servers_count: int + subscriptions_count: int + created_at: datetime + + class Config: + from_attributes = True + + +class TariffListResponse(BaseModel): + """Response with list of tariffs.""" + tariffs: List[TariffListItem] + total: int + + +class TariffDetailResponse(BaseModel): + """Detailed tariff response.""" + id: int + name: str + description: Optional[str] = None + is_active: bool + is_trial_available: bool + allow_traffic_topup: bool = True + traffic_topup_enabled: bool = False + traffic_topup_packages: Dict[str, int] = Field(default_factory=dict) + max_topup_traffic_gb: int = 0 + traffic_limit_gb: int + device_limit: int + device_price_kopeks: Optional[int] = None + max_device_limit: Optional[int] = None + tier_level: int + display_order: int + period_prices: List[PeriodPrice] + allowed_squads: List[str] # UUIDs + server_traffic_limits: Dict[str, ServerTrafficLimit] = Field(default_factory=dict) # {uuid: {traffic_limit_gb}} + servers: List[ServerInfo] + promo_groups: List[PromoGroupInfo] + subscriptions_count: int + # Произвольное количество дней + custom_days_enabled: bool = False + price_per_day_kopeks: int = 0 + min_days: int = 1 + max_days: int = 365 + # Произвольный трафик при покупке + custom_traffic_enabled: bool = False + traffic_price_per_gb_kopeks: int = 0 + min_traffic_gb: int = 1 + max_traffic_gb: int = 1000 + # Дневной тариф + is_daily: bool = False + daily_price_kopeks: int = 0 + # Режим сброса трафика + traffic_reset_mode: Optional[str] = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка + created_at: datetime + updated_at: Optional[datetime] = None + + class Config: + from_attributes = True + + +class TariffCreateRequest(BaseModel): + """Request to create a tariff.""" + name: str = Field(..., min_length=1, max_length=255) + description: Optional[str] = None + is_active: bool = True + allow_traffic_topup: bool = True + traffic_topup_enabled: bool = False + traffic_topup_packages: Dict[str, int] = Field(default_factory=dict) + max_topup_traffic_gb: int = Field(0, ge=0) + traffic_limit_gb: int = Field(0, ge=0, description="0 = unlimited") + device_limit: int = Field(1, ge=1) + device_price_kopeks: Optional[int] = Field(None, ge=0) + max_device_limit: Optional[int] = Field(None, ge=1) + tier_level: int = Field(1, ge=1, le=10) + period_prices: List[PeriodPrice] = Field(default_factory=list) + allowed_squads: List[str] = Field(default_factory=list, description="Server UUIDs") + server_traffic_limits: Dict[str, ServerTrafficLimit] = Field(default_factory=dict, description="Per-server traffic limits") + promo_group_ids: List[int] = Field(default_factory=list) + # Произвольное количество дней + custom_days_enabled: bool = False + price_per_day_kopeks: int = Field(0, ge=0) + min_days: int = Field(1, ge=1) + max_days: int = Field(365, ge=1) + # Произвольный трафик при покупке + custom_traffic_enabled: bool = False + traffic_price_per_gb_kopeks: int = Field(0, ge=0) + min_traffic_gb: int = Field(1, ge=1) + max_traffic_gb: int = Field(1000, ge=1) + # Дневной тариф + is_daily: bool = False + daily_price_kopeks: int = Field(0, ge=0) + # Режим сброса трафика + traffic_reset_mode: Optional[str] = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка + + +class TariffUpdateRequest(BaseModel): + """Request to update a tariff.""" + name: Optional[str] = Field(None, min_length=1, max_length=255) + description: Optional[str] = None + is_active: Optional[bool] = None + allow_traffic_topup: Optional[bool] = None + traffic_topup_enabled: Optional[bool] = None + traffic_topup_packages: Optional[Dict[str, int]] = None + max_topup_traffic_gb: Optional[int] = Field(None, ge=0) + traffic_limit_gb: Optional[int] = Field(None, ge=0) + device_limit: Optional[int] = Field(None, ge=1) + device_price_kopeks: Optional[int] = Field(None, ge=0) + max_device_limit: Optional[int] = Field(None, ge=1) + tier_level: Optional[int] = Field(None, ge=1, le=10) + display_order: Optional[int] = Field(None, ge=0) + period_prices: Optional[List[PeriodPrice]] = None + allowed_squads: Optional[List[str]] = None + server_traffic_limits: Optional[Dict[str, ServerTrafficLimit]] = None + promo_group_ids: Optional[List[int]] = None + # Произвольное количество дней + custom_days_enabled: Optional[bool] = None + price_per_day_kopeks: Optional[int] = Field(None, ge=0) + min_days: Optional[int] = Field(None, ge=1) + max_days: Optional[int] = Field(None, ge=1) + # Произвольный трафик при покупке + custom_traffic_enabled: Optional[bool] = None + traffic_price_per_gb_kopeks: Optional[int] = Field(None, ge=0) + min_traffic_gb: Optional[int] = Field(None, ge=1) + max_traffic_gb: Optional[int] = Field(None, ge=1) + # Дневной тариф + is_daily: Optional[bool] = None + daily_price_kopeks: Optional[int] = Field(None, ge=0) + # Режим сброса трафика + traffic_reset_mode: Optional[str] = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка + + +class TariffToggleResponse(BaseModel): + """Response after toggling tariff.""" + id: int + is_active: bool + message: str + + +class TariffTrialResponse(BaseModel): + """Response after setting trial tariff.""" + id: int + is_trial_available: bool + message: str + + +class TariffStatsResponse(BaseModel): + """Tariff statistics.""" + id: int + name: str + subscriptions_count: int + active_subscriptions: int + trial_subscriptions: int + revenue_kopeks: int + revenue_rubles: float diff --git a/app/cabinet/schemas/tickets.py b/app/cabinet/schemas/tickets.py index a0388935..90ede395 100644 --- a/app/cabinet/schemas/tickets.py +++ b/app/cabinet/schemas/tickets.py @@ -12,6 +12,7 @@ class TicketMessageResponse(BaseModel): is_from_admin: bool has_media: bool = False media_type: Optional[str] = None + media_file_id: Optional[str] = None media_caption: Optional[str] = None created_at: datetime @@ -64,8 +65,14 @@ class TicketCreateRequest(BaseModel): """Request to create a new ticket.""" title: str = Field(..., min_length=3, max_length=255, description="Ticket title") message: str = Field(..., min_length=10, max_length=4000, description="Initial message") + media_type: Optional[str] = Field(None, description="Media type: photo, video, document") + media_file_id: Optional[str] = Field(None, description="Telegram file_id of uploaded media") + media_caption: Optional[str] = Field(None, max_length=1000, description="Media caption") class TicketMessageCreateRequest(BaseModel): """Request to add message to ticket.""" message: str = Field(..., min_length=1, max_length=4000, description="Message text") + media_type: Optional[str] = Field(None, description="Media type: photo, video, document") + media_file_id: Optional[str] = Field(None, description="Telegram file_id of uploaded media") + media_caption: Optional[str] = Field(None, max_length=1000, description="Media caption") diff --git a/app/cabinet/schemas/wheel.py b/app/cabinet/schemas/wheel.py new file mode 100644 index 00000000..612da3e5 --- /dev/null +++ b/app/cabinet/schemas/wheel.py @@ -0,0 +1,259 @@ +"""Схемы для колеса удачи (Fortune Wheel).""" + +from datetime import datetime +from typing import Optional, List +from pydantic import BaseModel, Field +from enum import Enum + + +# ==================== ENUMS ==================== + + +class WheelPaymentType(str, Enum): + """Способы оплаты спина.""" + TELEGRAM_STARS = "telegram_stars" + SUBSCRIPTION_DAYS = "subscription_days" + + +class WheelPrizeType(str, Enum): + """Типы призов.""" + SUBSCRIPTION_DAYS = "subscription_days" + BALANCE_BONUS = "balance_bonus" + TRAFFIC_GB = "traffic_gb" + PROMOCODE = "promocode" + NOTHING = "nothing" + + +# ==================== USER SCHEMAS ==================== + + +class WheelPrizeDisplay(BaseModel): + """Отображение приза для пользователя.""" + id: int + display_name: str + emoji: str + color: str + prize_type: str + + class Config: + from_attributes = True + + +class WheelConfigResponse(BaseModel): + """Конфигурация колеса для пользователя.""" + is_enabled: bool + name: str + spin_cost_stars: Optional[int] = None + spin_cost_days: Optional[int] = None + spin_cost_stars_enabled: bool + spin_cost_days_enabled: bool + prizes: List[WheelPrizeDisplay] + daily_limit: int + user_spins_today: int + can_spin: bool + can_spin_reason: Optional[str] = None + can_pay_stars: bool = False + can_pay_days: bool = False + user_balance_kopeks: int = 0 + required_balance_kopeks: int = 0 + + +class SpinAvailabilityResponse(BaseModel): + """Доступность спина.""" + can_spin: bool + reason: Optional[str] = None + spins_remaining_today: int + can_pay_stars: bool + can_pay_days: bool + min_subscription_days: int + user_subscription_days: int + user_balance_kopeks: int = 0 + required_balance_kopeks: int = 0 + + +class SpinRequest(BaseModel): + """Запрос на спин.""" + payment_type: WheelPaymentType + + +class SpinResultResponse(BaseModel): + """Результат спина.""" + success: bool + prize_id: Optional[int] = None + prize_type: Optional[str] = None + prize_value: int = 0 + prize_display_name: str = "" + emoji: str = "🎁" + color: str = "#3B82F6" + rotation_degrees: float = 0.0 + message: str = "" + promocode: Optional[str] = None + error: Optional[str] = None + + +class SpinHistoryItem(BaseModel): + """Элемент истории спинов.""" + id: int + payment_type: str + payment_amount: int + prize_type: str + prize_value: int + prize_display_name: str + emoji: str = "🎁" + color: str = "#3B82F6" + prize_value_kopeks: int + created_at: datetime + + class Config: + from_attributes = True + + +class SpinHistoryResponse(BaseModel): + """История спинов с пагинацией.""" + items: List[SpinHistoryItem] + total: int + page: int + per_page: int + pages: int + + +# ==================== ADMIN SCHEMAS ==================== + + +class WheelPrizeAdminResponse(BaseModel): + """Полная информация о призе для админа.""" + id: int + config_id: int + prize_type: str + prize_value: int + display_name: str + emoji: str + color: str + prize_value_kopeks: int + sort_order: int + manual_probability: Optional[float] = None + is_active: bool + promo_balance_bonus_kopeks: int = 0 + promo_subscription_days: int = 0 + promo_traffic_gb: int = 0 + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + class Config: + from_attributes = True + + +class AdminWheelConfigResponse(BaseModel): + """Полная конфигурация колеса для админа.""" + id: int + is_enabled: bool + name: str + spin_cost_stars: int + spin_cost_days: int + spin_cost_stars_enabled: bool + spin_cost_days_enabled: bool + rtp_percent: int + daily_spin_limit: int + min_subscription_days_for_day_payment: int + promo_prefix: str + promo_validity_days: int + prizes: List[WheelPrizeAdminResponse] + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + class Config: + from_attributes = True + + +class UpdateWheelConfigRequest(BaseModel): + """Запрос на обновление конфига колеса.""" + is_enabled: Optional[bool] = None + name: Optional[str] = Field(None, min_length=1, max_length=255) + spin_cost_stars: Optional[int] = Field(None, ge=1, le=1000) + spin_cost_days: Optional[int] = Field(None, ge=1, le=30) + spin_cost_stars_enabled: Optional[bool] = None + spin_cost_days_enabled: Optional[bool] = None + rtp_percent: Optional[int] = Field(None, ge=0, le=100) + daily_spin_limit: Optional[int] = Field(None, ge=0, le=100) + min_subscription_days_for_day_payment: Optional[int] = Field(None, ge=1, le=30) + promo_prefix: Optional[str] = Field(None, min_length=1, max_length=20) + promo_validity_days: Optional[int] = Field(None, ge=1, le=365) + + +class CreatePrizeRequest(BaseModel): + """Запрос на создание приза.""" + prize_type: WheelPrizeType + prize_value: int = Field(..., ge=0) + display_name: str = Field(..., min_length=1, max_length=100) + emoji: str = Field(default="🎁", max_length=10) + color: str = Field(default="#3B82F6", pattern=r'^#[0-9A-Fa-f]{6}$') + prize_value_kopeks: int = Field(..., ge=0) + sort_order: int = Field(default=0, ge=0) + manual_probability: Optional[float] = Field(None, ge=0, le=1) + is_active: bool = True + promo_balance_bonus_kopeks: int = Field(default=0, ge=0) + promo_subscription_days: int = Field(default=0, ge=0) + promo_traffic_gb: int = Field(default=0, ge=0) + + +class UpdatePrizeRequest(BaseModel): + """Запрос на обновление приза.""" + prize_type: Optional[WheelPrizeType] = None + prize_value: Optional[int] = Field(None, ge=0) + display_name: Optional[str] = Field(None, min_length=1, max_length=100) + emoji: Optional[str] = Field(None, max_length=10) + color: Optional[str] = Field(None, pattern=r'^#[0-9A-Fa-f]{6}$') + prize_value_kopeks: Optional[int] = Field(None, ge=0) + sort_order: Optional[int] = Field(None, ge=0) + manual_probability: Optional[float] = Field(None, ge=0, le=1) + is_active: Optional[bool] = None + promo_balance_bonus_kopeks: Optional[int] = Field(None, ge=0) + promo_subscription_days: Optional[int] = Field(None, ge=0) + promo_traffic_gb: Optional[int] = Field(None, ge=0) + + +class ReorderPrizesRequest(BaseModel): + """Запрос на переупорядочивание призов.""" + prize_ids: List[int] + + +class AdminSpinItem(BaseModel): + """Спин для админки.""" + id: int + user_id: int + username: Optional[str] = None + payment_type: str + payment_amount: int + payment_value_kopeks: int + prize_type: str + prize_value: int + prize_display_name: str + prize_value_kopeks: int + is_applied: bool + created_at: datetime + + class Config: + from_attributes = True + + +class AdminSpinsResponse(BaseModel): + """Список спинов для админки с пагинацией.""" + items: List[AdminSpinItem] + total: int + page: int + per_page: int + pages: int + + +class WheelStatisticsResponse(BaseModel): + """Статистика колеса.""" + total_spins: int + total_revenue_kopeks: int + total_payout_kopeks: int + actual_rtp_percent: float + configured_rtp_percent: int + spins_by_payment_type: dict + prizes_distribution: List[dict] + top_wins: List[dict] + period_from: Optional[str] = None + period_to: Optional[str] = None diff --git a/app/config.py b/app/config.py index 2489c969..100b16bd 100644 --- a/app/config.py +++ b/app/config.py @@ -15,9 +15,9 @@ from pydantic import field_validator, Field from pathlib import Path -DEFAULT_DISPLAY_NAME_BANNED_KEYWORDS = [ - "tme", - "joingroup", +DEFAULT_DISPLAY_NAME_BANNED_KEYWORDS: list[str] = [ + # Пустой по умолчанию - администратор может добавить ключевые слова через DISPLAY_NAME_BANNED_KEYWORDS + # Примеры: "tme", "joingroup", "support", "admin" ] USER_TAG_PATTERN = re.compile(r"^[A-Z0-9_]{1,16}$") @@ -41,6 +41,11 @@ class Settings(BaseSettings): SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS: int = 60 SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES: int = 15 + # MiniApp tickets settings + MINIAPP_TICKETS_ENABLED: bool = True # Enable/disable tickets section in miniapp + MINIAPP_SUPPORT_TYPE: str = "tickets" # one of: tickets, profile, url + MINIAPP_SUPPORT_URL: str = "" # Custom URL to redirect when tickets disabled (only for url type) + ADMIN_NOTIFICATIONS_ENABLED: bool = False ADMIN_NOTIFICATIONS_CHAT_ID: Optional[str] = None ADMIN_NOTIFICATIONS_TOPIC_ID: Optional[int] = None @@ -94,6 +99,7 @@ class Settings(BaseSettings): REMNAWAVE_USER_DELETE_MODE: str = "delete" # "delete" или "disable" REMNAWAVE_AUTO_SYNC_ENABLED: bool = False REMNAWAVE_AUTO_SYNC_TIMES: str = "03:00" + CABINET_REMNA_SUB_CONFIG: Optional[str] = None # UUID конфига страницы подписки из RemnaWave TRIAL_DURATION_DAYS: int = 3 TRIAL_TRAFFIC_LIMIT_GB: int = 10 @@ -227,7 +233,7 @@ class Settings(BaseSettings): BLACKLIST_IGNORE_ADMINS: bool = True # Настройки простой покупки - SIMPLE_SUBSCRIPTION_ENABLED: bool = True + SIMPLE_SUBSCRIPTION_ENABLED: bool = False SIMPLE_SUBSCRIPTION_PERIOD_DAYS: int = 30 SIMPLE_SUBSCRIPTION_DEVICE_LIMIT: int = 1 SIMPLE_SUBSCRIPTION_TRAFFIC_GB: int = 0 # 0 означает безлимит @@ -262,6 +268,9 @@ class Settings(BaseSettings): TRAFFIC_CHECK_CONCURRENCY: int = 10 # Параллельных запросов TRAFFIC_NOTIFICATION_COOLDOWN_MINUTES: int = 60 # Кулдаун уведомлений (минуты) TRAFFIC_SNAPSHOT_TTL_HOURS: int = 24 # TTL для snapshot трафика в Redis (часы) + # Настройки суточных подписок + DAILY_SUBSCRIPTIONS_ENABLED: bool = True # Включить автоматическое списание для суточных тарифов + DAILY_SUBSCRIPTIONS_CHECK_INTERVAL_MINUTES: int = 30 # Интервал проверки в минутах AUTOPAY_WARNING_DAYS: str = "3,1" @@ -468,6 +477,8 @@ class Settings(BaseSettings): FREEKASSA_PAYMENT_SYSTEM_ID: Optional[int] = None # Использовать API для создания заказов (нужно для NSPK СБП) FREEKASSA_USE_API: bool = False + # Публичный IP сервера для Freekassa API (если не задан - определяется автоматически) + SERVER_PUBLIC_IP: Optional[str] = None MAIN_MENU_MODE: str = "default" CONNECT_BUTTON_MODE: str = "guide" @@ -517,6 +528,88 @@ class Settings(BaseSettings): LOG_ERROR_FILE: str = "error.log" LOG_PAYMENTS_FILE: str = "payments.log" + # === Ban Notification Messages === + + # Сообщение о блокировке за превышение лимита устройств + # Переменные: {ip_count}, {limit}, {ban_minutes}, {node_info} + BAN_MSG_PUNISHMENT: str = ( + "🚫 АККАУНТ ЗАБЛОКИРОВАН\n" + "━━━━━━━━━━━━━━━━━━━━━\n\n" + "❌ Причина: Превышен лимит устройств\n" + "{node_info}\n" + "📊 Детали нарушения:\n" + "├ 📱 Устройств подключено: {ip_count}\n" + "├ 📋 Разрешено по тарифу: {limit}\n" + "└ ⏱ Время блокировки: {ban_minutes} мин\n\n" + "━━━━━━━━━━━━━━━━━━━━━\n" + "💡 Что делать:\n" + "1. Отключите лишние устройства от VPN\n" + "2. Дождитесь окончания блокировки\n" + "3. Подключитесь заново\n\n" + "🔄 Доступ восстановится автоматически" + ) + + # Сообщение о разблокировке + BAN_MSG_ENABLED: str = ( + "✅ АККАУНТ РАЗБЛОКИРОВАН\n" + "━━━━━━━━━━━━━━━━━━━━━\n\n" + "🎉 Ваш аккаунт успешно разблокирован!\n\n" + "Теперь вы можете снова пользоваться VPN.\n\n" + "━━━━━━━━━━━━━━━━━━━━━\n" + "⚠️ Рекомендации:\n" + "• Следите за количеством устройств\n" + "• Отключайте VPN когда не используете\n" + "• Не превышайте лимит по тарифу" + ) + + # Сообщение о блокировке за WiFi + # Переменные: {ban_minutes}, {network_info}, {node_info} + BAN_MSG_WIFI: str = ( + "🚫 АККАУНТ ЗАБЛОКИРОВАН\n" + "━━━━━━━━━━━━━━━━━━━━━\n\n" + "❌ Причина: Использование WiFi сети\n" + "{node_info}\n" + "📊 Детали:\n" + "├ 📶 Тип подключения: WiFi\n" + "{network_info}" + "└ ⏱ Время блокировки: {ban_minutes} мин\n\n" + "━━━━━━━━━━━━━━━━━━━━━\n" + "💡 Что делать:\n" + "1. Отключитесь от WiFi\n" + "2. Используйте мобильный интернет\n" + "3. Дождитесь окончания блокировки\n\n" + "🔄 Доступ восстановится автоматически" + ) + + # Сообщение о блокировке за мобильную сеть + # Переменные: {ban_minutes}, {network_info}, {node_info} + BAN_MSG_MOBILE: str = ( + "🚫 АККАУНТ ЗАБЛОКИРОВАН\n" + "━━━━━━━━━━━━━━━━━━━━━\n\n" + "❌ Причина: Использование мобильной сети\n" + "{node_info}\n" + "📊 Детали:\n" + "├ 📱 Тип подключения: Мобильная сеть\n" + "{network_info}" + "└ ⏱ Время блокировки: {ban_minutes} мин\n\n" + "━━━━━━━━━━━━━━━━━━━━━\n" + "💡 Что делать:\n" + "1. Подключитесь к WiFi\n" + "2. Дождитесь окончания блокировки\n" + "3. Используйте VPN только через WiFi\n\n" + "🔄 Доступ восстановится автоматически" + ) + + # Сообщение-предупреждение + # Переменные: {warning_message} + BAN_MSG_WARNING: str = ( + "⚠️ ПРЕДУПРЕЖДЕНИЕ\n" + "━━━━━━━━━━━━━━━━━━━━━\n\n" + "{warning_message}\n\n" + "━━━━━━━━━━━━━━━━━━━━━\n" + "❗ При повторном нарушении аккаунт будет заблокирован" + ) + DEBUG: bool = False WEBHOOK_URL: Optional[str] = None WEBHOOK_PATH: str = "/webhook" @@ -1696,42 +1789,110 @@ class Settings(BaseSettings): return self.MAINTENANCE_MONITORING_ENABLED def get_available_subscription_periods(self) -> List[int]: + """ + Возвращает доступные периоды подписки. + Использует AVAILABLE_SUBSCRIPTION_PERIODS для фильтрации, + а PERIOD_PRICES (из БД или .env) для проверки что цена > 0. + """ + from app.config import PERIOD_PRICES, get_db_period_prices + + # Получаем разрешённые периоды из настройки try: periods_str = self.AVAILABLE_SUBSCRIPTION_PERIODS - if not periods_str.strip(): - return [30, 90, 180] - - periods = [] - for period_str in periods_str.split(','): - period_str = period_str.strip() - if period_str: - period = int(period_str) - if hasattr(self, f'PRICE_{period}_DAYS'): - periods.append(period) - - return periods if periods else [30, 90, 180] - + if not periods_str or not periods_str.strip(): + allowed_periods = {14, 30, 60, 90, 180, 360} + else: + allowed_periods = set() + for period_str in periods_str.split(','): + period_str = period_str.strip() + if period_str: + allowed_periods.add(int(period_str)) except (ValueError, AttributeError): - return [30, 90, 180] + allowed_periods = {14, 30, 60, 90, 180, 360} + + # Получаем цены из БД или .env + db_prices = get_db_period_prices() + prices = db_prices if db_prices else PERIOD_PRICES + + # Возвращаем только разрешённые периоды с ценой > 0 + periods = sorted([ + days for days in allowed_periods + if days in prices and prices.get(days, 0) > 0 + ]) + + return periods if periods else [30, 90, 180] def get_available_renewal_periods(self) -> List[int]: + """ + Возвращает доступные периоды продления. + Использует AVAILABLE_RENEWAL_PERIODS для фильтрации, + а PERIOD_PRICES (из БД или .env) для проверки что цена > 0. + """ + from app.config import PERIOD_PRICES, get_db_period_prices + + # Получаем разрешённые периоды из настройки try: periods_str = self.AVAILABLE_RENEWAL_PERIODS - if not periods_str.strip(): - return [30, 90, 180] - + if not periods_str or not periods_str.strip(): + allowed_periods = {30, 60, 90, 180, 360} + else: + allowed_periods = set() + for period_str in periods_str.split(','): + period_str = period_str.strip() + if period_str: + allowed_periods.add(int(period_str)) + except (ValueError, AttributeError): + allowed_periods = {30, 60, 90, 180, 360} + + # Получаем цены из БД или .env + db_prices = get_db_period_prices() + prices = db_prices if db_prices else PERIOD_PRICES + + # Возвращаем только разрешённые периоды с ценой > 0 + periods = sorted([ + days for days in allowed_periods + if days in prices and prices.get(days, 0) > 0 + ]) + + return periods if periods else [30, 90, 180] + + def get_configured_subscription_periods(self) -> List[int]: + """ + Возвращает настроенные периоды подписки из AVAILABLE_SUBSCRIPTION_PERIODS. + БЕЗ фильтрации по ценам - используется для админки. + """ + try: + periods_str = self.AVAILABLE_SUBSCRIPTION_PERIODS + if not periods_str or not periods_str.strip(): + return [14, 30, 60, 90, 180, 360] + periods = [] for period_str in periods_str.split(','): period_str = period_str.strip() if period_str: - period = int(period_str) - if hasattr(self, f'PRICE_{period}_DAYS'): - periods.append(period) - - return periods if periods else [30, 90, 180] - + periods.append(int(period_str)) + return sorted(periods) if periods else [14, 30, 60, 90, 180, 360] except (ValueError, AttributeError): - return [30, 90, 180] + return [14, 30, 60, 90, 180, 360] + + def get_configured_renewal_periods(self) -> List[int]: + """ + Возвращает настроенные периоды продления из AVAILABLE_RENEWAL_PERIODS. + БЕЗ фильтрации по ценам - используется для админки. + """ + try: + periods_str = self.AVAILABLE_RENEWAL_PERIODS + if not periods_str or not periods_str.strip(): + return [30, 60, 90, 180, 360] + + periods = [] + for period_str in periods_str.split(','): + period_str = period_str.strip() + if period_str: + periods.append(int(period_str)) + return sorted(periods) if periods else [30, 60, 90, 180, 360] + except (ValueError, AttributeError): + return [30, 60, 90, 180, 360] def get_balance_payment_description(self, amount_kopeks: int, telegram_user_id: Optional[int] = None) -> str: # Базовое описание @@ -2097,13 +2258,27 @@ class Settings(BaseSettings): def get_support_system_mode(self) -> str: mode = (self.SUPPORT_SYSTEM_MODE or "both").strip().lower() return mode if mode in {"tickets", "contact", "both"} else "both" - + def is_support_tickets_enabled(self) -> bool: return self.get_support_system_mode() in {"tickets", "both"} - + def is_support_contact_enabled(self) -> bool: return self.get_support_system_mode() in {"contact", "both"} + # MiniApp tickets settings + def is_miniapp_tickets_enabled(self) -> bool: + """Check if tickets are enabled in miniapp.""" + return bool(self.MINIAPP_TICKETS_ENABLED) + + def get_miniapp_support_type(self) -> str: + """Get miniapp support type: tickets, profile, or url.""" + support_type = (self.MINIAPP_SUPPORT_TYPE or "tickets").strip().lower() + return support_type if support_type in {"tickets", "profile", "url"} else "tickets" + + def get_miniapp_support_url(self) -> str: + """Get custom support URL for miniapp (when type is 'url').""" + return (self.MINIAPP_SUPPORT_URL or "").strip() + def get_bot_run_mode(self) -> str: mode = (self.BOT_RUN_MODE or "polling").strip().lower() if mode not in {"polling", "webhook", "both"}: @@ -2226,17 +2401,43 @@ _PERIOD_PRICE_FIELDS: Dict[int, str] = { 360: "PRICE_360_DAYS", } +# Хранилище периодов/цен из БД (приоритет над .env) +_DB_PERIOD_PRICES: Optional[Dict[int, int]] = None + + +def set_period_prices_from_db(period_prices: Dict[int, int]) -> None: + """ + Устанавливает периоды/цены из БД. + Вызывается после синхронизации тарифов при запуске бота. + """ + global _DB_PERIOD_PRICES + _DB_PERIOD_PRICES = period_prices.copy() if period_prices else None + refresh_period_prices() + + +def get_db_period_prices() -> Optional[Dict[int, int]]: + """Возвращает периоды/цены из БД если они загружены.""" + return _DB_PERIOD_PRICES + def refresh_period_prices() -> None: - """Rebuild cached period price mapping using the latest settings.""" - + """ + Rebuild cached period price mapping. + Приоритет: БД > .env + """ PERIOD_PRICES.clear() - PERIOD_PRICES.update( - { - days: getattr(settings, field_name, 0) - for days, field_name in _PERIOD_PRICE_FIELDS.items() - } - ) + + if _DB_PERIOD_PRICES: + # Используем цены из БД + PERIOD_PRICES.update(_DB_PERIOD_PRICES) + else: + # Fallback на .env + PERIOD_PRICES.update( + { + days: getattr(settings, field_name, 0) + for days, field_name in _PERIOD_PRICE_FIELDS.items() + } + ) PERIOD_PRICES: Dict[int, int] = {} diff --git a/app/database/crud/server_squad.py b/app/database/crud/server_squad.py index c08a8676..2cbd5dc7 100644 --- a/app/database/crud/server_squad.py +++ b/app/database/crud/server_squad.py @@ -151,6 +151,7 @@ async def get_all_server_squads( async def get_available_server_squads( db: AsyncSession, promo_group_id: Optional[int] = None, + exclude_trial_only: bool = False, ) -> List[ServerSquad]: query = ( @@ -160,6 +161,9 @@ async def get_available_server_squads( .order_by(ServerSquad.sort_order, ServerSquad.display_name) ) + if exclude_trial_only: + query = query.where(ServerSquad.is_trial_eligible.is_(False)) + if promo_group_id is not None: query = query.join(ServerSquad.allowed_promo_groups).where( PromoGroup.id == promo_group_id @@ -526,38 +530,158 @@ async def get_random_trial_squad_uuid( def _generate_display_name(original_name: str) -> str: + """Генерирует отображаемое название сервера на основе оригинального имени.""" country_names = { + # Европа 'NL': '🇳🇱 Нидерланды', - 'DE': '🇩🇪 Германия', - 'US': '🇺🇸 США', + 'DE': '🇩🇪 Германия', 'FR': '🇫🇷 Франция', 'GB': '🇬🇧 Великобритания', + 'UK': '🇬🇧 Великобритания', 'IT': '🇮🇹 Италия', 'ES': '🇪🇸 Испания', + 'PT': '🇵🇹 Португалия', + 'PL': '🇵🇱 Польша', + 'CZ': '🇨🇿 Чехия', + 'AT': '🇦🇹 Австрия', + 'CH': '🇨🇭 Швейцария', + 'SE': '🇸🇪 Швеция', + 'NO': '🇳🇴 Норвегия', + 'FI': '🇫🇮 Финляндия', + 'DK': '🇩🇰 Дания', + 'BE': '🇧🇪 Бельгия', + 'IE': '🇮🇪 Ирландия', + 'RO': '🇷🇴 Румыния', + 'BG': '🇧🇬 Болгария', + 'HU': '🇭🇺 Венгрия', + 'GR': '🇬🇷 Греция', + 'LV': '🇱🇻 Латвия', + 'LT': '🇱🇹 Литва', + 'EE': '🇪🇪 Эстония', + 'SK': '🇸🇰 Словакия', + 'SI': '🇸🇮 Словения', + 'HR': '🇭🇷 Хорватия', + 'RS': '🇷🇸 Сербия', + 'UA': '🇺🇦 Украина', + 'MD': '🇲🇩 Молдова', + 'BY': '🇧🇾 Беларусь', + 'LU': '🇱🇺 Люксембург', + + # СНГ и Азия + 'RU': '🇷🇺 Россия', + 'KZ': '🇰🇿 Казахстан', + 'UZ': '🇺🇿 Узбекистан', + 'GE': '🇬🇪 Грузия', + 'AM': '🇦🇲 Армения', + 'AZ': '🇦🇿 Азербайджан', + + # Америка + 'US': '🇺🇸 США', 'CA': '🇨🇦 Канада', + 'MX': '🇲🇽 Мексика', + 'BR': '🇧🇷 Бразилия', + 'AR': '🇦🇷 Аргентина', + 'CL': '🇨🇱 Чили', + 'CO': '🇨🇴 Колумбия', + + # Азия 'JP': '🇯🇵 Япония', + 'KR': '🇰🇷 Южная Корея', + 'CN': '🇨🇳 Китай', + 'HK': '🇭🇰 Гонконг', + 'TW': '🇹🇼 Тайвань', 'SG': '🇸🇬 Сингапур', + 'TH': '🇹🇭 Таиланд', + 'VN': '🇻🇳 Вьетнам', + 'MY': '🇲🇾 Малайзия', + 'ID': '🇮🇩 Индонезия', + 'PH': '🇵🇭 Филиппины', + 'IN': '🇮🇳 Индия', + 'PK': '🇵🇰 Пакистан', + + # Ближний Восток + 'IL': '🇮🇱 Израиль', + 'TR': '🇹🇷 Турция', + 'AE': '🇦🇪 ОАЭ', + 'SA': '🇸🇦 Саудовская Аравия', + 'QA': '🇶🇦 Катар', + 'BH': '🇧🇭 Бахрейн', + 'KW': '🇰🇼 Кувейт', + + # Океания 'AU': '🇦🇺 Австралия', + 'NZ': '🇳🇿 Новая Зеландия', + + # Африка + 'ZA': '🇿🇦 ЮАР', + 'EG': '🇪🇬 Египет', + 'NG': '🇳🇬 Нигерия', + 'KE': '🇰🇪 Кения', } - + name_upper = original_name.upper() + + # Сначала ищем код как отдельный элемент (через - или _) + for code, display_name in country_names.items(): + if f'-{code}' in name_upper or f'_{code}' in name_upper: + return display_name + if name_upper.startswith(code + '-') or name_upper.startswith(code + '_'): + return display_name + if name_upper.endswith('-' + code) or name_upper.endswith('_' + code): + return display_name + if name_upper == code: + return display_name + + # Потом ищем просто вхождение кода for code, display_name in country_names.items(): if code in name_upper: return display_name - + return f"🌍 {original_name}" def _extract_country_code(original_name: str) -> Optional[str]: - - codes = ['NL', 'DE', 'US', 'FR', 'GB', 'IT', 'ES', 'CA', 'JP', 'SG', 'AU'] + """Извлекает код страны из оригинального названия.""" + + # Полный список кодов стран + codes = [ + # Европа + 'NL', 'DE', 'FR', 'GB', 'UK', 'IT', 'ES', 'PT', 'PL', 'CZ', 'AT', 'CH', + 'SE', 'NO', 'FI', 'DK', 'BE', 'IE', 'RO', 'BG', 'HU', 'GR', 'LV', 'LT', + 'EE', 'SK', 'SI', 'HR', 'RS', 'UA', 'MD', 'BY', 'LU', + # СНГ + 'RU', 'KZ', 'UZ', 'GE', 'AM', 'AZ', + # Америка + 'US', 'CA', 'MX', 'BR', 'AR', 'CL', 'CO', + # Азия + 'JP', 'KR', 'CN', 'HK', 'TW', 'SG', 'TH', 'VN', 'MY', 'ID', 'PH', 'IN', 'PK', + # Ближний Восток + 'IL', 'TR', 'AE', 'SA', 'QA', 'BH', 'KW', + # Океания + 'AU', 'NZ', + # Африка + 'ZA', 'EG', 'NG', 'KE', + ] + name_upper = original_name.upper() - + + # Сначала ищем код как отдельный элемент + for code in codes: + if f'-{code}' in name_upper or f'_{code}' in name_upper: + return code + if name_upper.startswith(code + '-') or name_upper.startswith(code + '_'): + return code + if name_upper.endswith('-' + code) or name_upper.endswith('_' + code): + return code + if name_upper == code: + return code + + # Потом просто ищем вхождение for code in codes: if code in name_upper: return code - + return None @@ -681,6 +805,49 @@ async def get_server_ids_by_uuids( return [row[0] for row in result.fetchall()] +async def ensure_servers_synced(db: AsyncSession) -> None: + """ + Проверяет и синхронизирует серверы при запуске. + Если серверов нет в БД, загружает их из RemnaWave. + Вызывается при старте бота. + """ + try: + # Проверяем есть ли серверы в БД + result = await db.execute(select(func.count(ServerSquad.id))) + server_count = result.scalar() or 0 + + if server_count > 0: + logger.info(f"✅ В базе уже есть {server_count} серверов, пропускаем синхронизацию") + return + + logger.info("🔄 Серверов в БД нет, начинаем синхронизацию с RemnaWave...") + + # Импортируем сервис здесь чтобы избежать циклических импортов + from app.services.subscription_service import SubscriptionService + + subscription_service = SubscriptionService() + if not subscription_service.is_configured: + logger.warning("⚠️ RemnaWave не настроен, серверы не синхронизированы") + return + + # Получаем скводы из RemnaWave + squads = await subscription_service.get_remnawave_squads() + if squads is None: + logger.error("❌ Не удалось получить список серверов из RemnaWave") + return + + if not squads: + logger.warning("⚠️ RemnaWave вернул пустой список серверов") + return + + # Синхронизируем + created, updated, removed = await sync_with_remnawave(db, squads) + logger.info(f"✅ Серверы синхронизированы: +{created} ~{updated} -{removed}") + + except Exception as e: + logger.error(f"❌ Ошибка синхронизации серверов: {e}") + + async def sync_server_user_counts(db: AsyncSession) -> int: try: diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index 76ec15b1..d23f23d6 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -24,10 +24,13 @@ logger = logging.getLogger(__name__) async def get_subscription_by_user_id(db: AsyncSession, user_id: int) -> Optional[Subscription]: result = await db.execute( select(Subscription) - .options(selectinload(Subscription.user)) + .options( + selectinload(Subscription.user), + selectinload(Subscription.tariff), + ) .where(Subscription.user_id == user_id) .order_by(Subscription.created_at.desc()) - .limit(1) + .limit(1) ) subscription = result.scalar_one_or_none() @@ -243,6 +246,7 @@ async def replace_subscription( subscription.traffic_limit_gb = traffic_limit_gb subscription.traffic_used_gb = 0.0 subscription.purchased_traffic_gb = 0 # Сбрасываем докупленный трафик при замене подписки + subscription.traffic_reset_at = None # Сбрасываем дату сброса трафика subscription.device_limit = device_limit subscription.connected_squads = list(new_squads) subscription.subscription_url = None @@ -316,24 +320,22 @@ async def extend_subscription( current_time = datetime.utcnow() logger.info(f"🔄 Продление подписки {subscription.id} на {days} дней") - logger.info(f"📊 Текущие параметры: статус={subscription.status}, окончание={subscription.end_date}") + logger.info(f"📊 Текущие параметры: статус={subscription.status}, окончание={subscription.end_date}, тариф={subscription.tariff_id}") - # НОВОЕ: Вычисляем бонусные дни от триала ДО изменения end_date + # Определяем, происходит ли СМЕНА тарифа (а не продление того же) + is_tariff_change = ( + tariff_id is not None + and subscription.tariff_id is not None + and tariff_id != subscription.tariff_id + ) + + if is_tariff_change: + logger.info(f"🔄 Обнаружена СМЕНА тарифа: {subscription.tariff_id} → {tariff_id}") + + # Бонусные дни от триала - добавляются ТОЛЬКО когда подписка истекла + # и мы начинаем отсчёт с текущей даты. НЕ начисляются при смене тарифа. + # Если подписка ещё активна - просто добавляем дни к существующей дате окончания. bonus_days = 0 - if subscription.is_trial and settings.TRIAL_ADD_REMAINING_DAYS_TO_PAID: - # Вычисляем остаток триала - if subscription.end_date and subscription.end_date > current_time: - remaining = subscription.end_date - current_time - if remaining.total_seconds() > 0: - bonus_days = max(0, remaining.days) - logger.info( - "🎁 Обнаружен остаток триала: %s дней для подписки %s", - bonus_days, - subscription.id, - ) - - # Применяем продление с учетом бонусных дней - total_days = days + bonus_days if days < 0: subscription.end_date = subscription.end_date + timedelta(days=days) @@ -342,12 +344,35 @@ async def extend_subscription( abs(days), subscription.end_date, ) - elif subscription.end_date > current_time: - subscription.end_date = subscription.end_date + timedelta(days=total_days) - logger.info(f"📅 Подписка активна, добавляем {total_days} дней ({days} + {bonus_days} бонус) к текущей дате окончания") - else: + elif is_tariff_change: + # При СМЕНЕ тарифа срок начинается с текущей даты + бонус от триала + if subscription.is_trial and settings.TRIAL_ADD_REMAINING_DAYS_TO_PAID: + if subscription.end_date and subscription.end_date > current_time: + remaining = subscription.end_date - current_time + if remaining.total_seconds() > 0: + bonus_days = max(0, remaining.days) + logger.info( + "🎁 Обнаружен остаток триала: %s дней для подписки %s", + bonus_days, + subscription.id, + ) + total_days = days + bonus_days subscription.end_date = current_time + timedelta(days=total_days) - logger.info(f"📅 Подписка истекла, устанавливаем новую дату окончания на {total_days} дней ({days} + {bonus_days} бонус)") + subscription.start_date = current_time + logger.info(f"📅 СМЕНА тарифа: срок начинается с текущей даты + {total_days} дней") + elif subscription.end_date > current_time: + # Подписка активна - просто добавляем дни к текущей дате окончания + # БЕЗ бонусных дней (они уже учтены в end_date) + subscription.end_date = subscription.end_date + timedelta(days=days) + logger.info(f"📅 Подписка активна, добавляем {days} дней к текущей дате окончания") + else: + # Подписка истекла - начинаем с текущей даты + бонус от триала + if subscription.is_trial and settings.TRIAL_ADD_REMAINING_DAYS_TO_PAID: + # Триал истёк, но бонус всё равно не добавляем (триал уже истёк) + pass + total_days = days + bonus_days + subscription.end_date = current_time + timedelta(days=total_days) + logger.info(f"📅 Подписка истекла, устанавливаем новую дату окончания на {total_days} дней") # УДАЛЕНО: Автоматическая конвертация триала по длительности # Теперь триал конвертируется ТОЛЬКО после успешного коммита продления @@ -389,12 +414,23 @@ async def extend_subscription( old_traffic = subscription.traffic_limit_gb subscription.traffic_limit_gb = traffic_limit_gb subscription.traffic_used_gb = 0.0 + # Сбрасываем все докупки трафика при смене тарифа + from app.database.models import TrafficPurchase + from sqlalchemy import delete as sql_delete + await db.execute(sql_delete(TrafficPurchase).where(TrafficPurchase.subscription_id == subscription.id)) subscription.purchased_traffic_gb = 0 - logger.info(f"📊 Обновлен лимит трафика: {old_traffic} ГБ → {traffic_limit_gb} ГБ") + subscription.traffic_reset_at = None # Сбрасываем дату сброса трафика + logger.info(f"📊 Обновлен лимит трафика: {old_traffic} ГБ → {traffic_limit_gb} ГБ (все докупки сброшены)") elif settings.RESET_TRAFFIC_ON_PAYMENT: subscription.traffic_used_gb = 0.0 - subscription.purchased_traffic_gb = 0 - logger.info("🔄 Сбрасываем использованный и докупленный трафик согласно настройке RESET_TRAFFIC_ON_PAYMENT") + # В режиме тарифов сохраняем докупленный трафик при продлении + if subscription.tariff_id is None: + subscription.purchased_traffic_gb = 0 + subscription.traffic_reset_at = None # Сбрасываем дату сброса трафика + logger.info("🔄 Сбрасываем использованный и докупленный трафик согласно настройке RESET_TRAFFIC_ON_PAYMENT") + else: + # При продлении в режиме тарифов - сохраняем purchased_traffic_gb и traffic_reset_at + logger.info("🔄 Сбрасываем использованный трафик, докупленный сохранен (режим тарифов)") if device_limit is not None: old_devices = subscription.device_limit @@ -406,6 +442,24 @@ async def extend_subscription( subscription.connected_squads = connected_squads logger.info(f"🌍 Обновлены сквады: {old_squads} → {connected_squads}") + # Обработка daily полей при смене тарифа + if is_tariff_change and tariff_id is not None: + # Получаем информацию о новом тарифе для проверки is_daily + from app.database.crud.tariff import get_tariff_by_id + new_tariff = await get_tariff_by_id(db, tariff_id) + old_was_daily = getattr(subscription, 'is_daily_paused', False) or getattr(subscription, 'last_daily_charge_at', None) is not None + + if new_tariff and getattr(new_tariff, 'is_daily', False): + # Переход на суточный тариф - сбрасываем флаги + subscription.is_daily_paused = False + subscription.last_daily_charge_at = None # Будет установлено при первом списании + logger.info(f"🔄 Переход на суточный тариф: сброшены daily флаги") + elif old_was_daily: + # Переход с суточного на обычный тариф - очищаем daily поля + subscription.is_daily_paused = False + subscription.last_daily_charge_at = None + logger.info(f"🔄 Переход с суточного тарифа: очищены daily флаги") + # В режиме fixed_with_topup при продлении сбрасываем трафик до фиксированного лимита # Только если не передан traffic_limit_gb И у подписки нет тарифа (классический режим) # Если у подписки есть tariff_id - трафик определяется тарифом, не сбрасываем @@ -415,6 +469,7 @@ async def extend_subscription( if subscription.traffic_limit_gb != fixed_limit or (subscription.purchased_traffic_gb or 0) > 0: subscription.traffic_limit_gb = fixed_limit subscription.purchased_traffic_gb = 0 + subscription.traffic_reset_at = None # Сбрасываем дату сброса трафика logger.info(f"🔄 Сброс трафика при продлении (fixed_with_topup): {old_limit} ГБ → {fixed_limit} ГБ") subscription.updated_at = current_time @@ -434,14 +489,50 @@ async def add_subscription_traffic( subscription: Subscription, gb: int ) -> Subscription: - + subscription.add_traffic(gb) subscription.updated_at = datetime.utcnow() - + + # Создаём новую запись докупки с индивидуальной датой истечения (30 дней) + from app.database.models import TrafficPurchase + from sqlalchemy import select as sql_select + from datetime import timedelta + + new_expires_at = datetime.utcnow() + timedelta(days=30) + new_purchase = TrafficPurchase( + subscription_id=subscription.id, + traffic_gb=gb, + expires_at=new_expires_at + ) + db.add(new_purchase) + + # Обновляем общий счетчик докупленного трафика + current_purchased = getattr(subscription, 'purchased_traffic_gb', 0) or 0 + subscription.purchased_traffic_gb = current_purchased + gb + + # Устанавливаем traffic_reset_at на ближайшую дату истечения из всех активных докупок + now = datetime.utcnow() + active_purchases_query = ( + sql_select(TrafficPurchase) + .where(TrafficPurchase.subscription_id == subscription.id) + .where(TrafficPurchase.expires_at > now) + ) + active_purchases_result = await db.execute(active_purchases_query) + active_purchases = active_purchases_result.scalars().all() + + if active_purchases: + # Добавляем только что созданную покупку к списку + all_active = list(active_purchases) + [new_purchase] + earliest_expiry = min(p.expires_at for p in all_active) + subscription.traffic_reset_at = earliest_expiry + else: + # Первая докупка + subscription.traffic_reset_at = new_expires_at + await db.commit() await db.refresh(subscription) - - logger.info(f"📈 К подписке пользователя {subscription.user_id} добавлено {gb} ГБ трафика") + + logger.info(f"📈 К подписке пользователя {subscription.user_id} добавлено {gb} ГБ трафика (истекает {new_expires_at.strftime('%d.%m.%Y')})") return subscription @@ -1443,9 +1534,9 @@ async def check_and_update_subscription_status( db: AsyncSession, subscription: Subscription ) -> Subscription: - + current_time = datetime.utcnow() - + logger.info( "🔍 Проверка статуса подписки %s, текущий статус: %s, дата окончания: %s, текущее время: %s", subscription.id, @@ -1453,20 +1544,29 @@ async def check_and_update_subscription_status( format_local_datetime(subscription.end_date), format_local_datetime(current_time), ) - - if (subscription.status == SubscriptionStatus.ACTIVE.value and + + # Для суточных тарифов с паузой не меняем статус на expired + # (время "заморожено" пока пользователь на паузе) + is_daily_paused = getattr(subscription, 'is_daily_paused', False) + if is_daily_paused: + logger.info( + f"⏸️ Суточная подписка {subscription.id} на паузе, пропускаем проверку истечения" + ) + return subscription + + if (subscription.status == SubscriptionStatus.ACTIVE.value and subscription.end_date <= current_time): - + subscription.status = SubscriptionStatus.EXPIRED.value subscription.updated_at = current_time - + await db.commit() await db.refresh(subscription) - + logger.info(f"⏰ Статус подписки пользователя {subscription.user_id} изменен на 'expired'") elif subscription.status == SubscriptionStatus.PENDING.value: logger.info(f"ℹ️ Проверка PENDING подписки {subscription.id}, статус остается без изменений") - + return subscription async def create_subscription_no_commit( @@ -1736,7 +1836,239 @@ async def activate_pending_subscription( await db.commit() await db.refresh(pending_subscription) - + logger.info(f"Подписка пользователя {user_id} активирована, ID: {pending_subscription.id}") - + return pending_subscription + + +async def activate_pending_trial_subscription( + db: AsyncSession, + subscription_id: int, + user_id: int, +) -> Optional[Subscription]: + """Активирует pending триальную подписку по её ID после оплаты.""" + logger.info(f"Активация pending триальной подписки: subscription_id={subscription_id}, user_id={user_id}") + + # Находим pending подписку по ID + result = await db.execute( + select(Subscription) + .where( + and_( + Subscription.id == subscription_id, + Subscription.user_id == user_id, + Subscription.status == SubscriptionStatus.PENDING.value, + Subscription.is_trial == True + ) + ) + ) + pending_subscription = result.scalar_one_or_none() + + if not pending_subscription: + logger.warning(f"Не найдена pending триальная подписка {subscription_id} для пользователя {user_id}") + return None + + logger.info(f"Найдена pending триальная подписка {pending_subscription.id}, статус: {pending_subscription.status}") + + # Обновляем статус подписки на ACTIVE + current_time = datetime.utcnow() + pending_subscription.status = SubscriptionStatus.ACTIVE.value + + # Обновляем даты + if not pending_subscription.start_date or pending_subscription.start_date < current_time: + pending_subscription.start_date = current_time + + # Пересчитываем end_date на основе duration_days если есть + duration_days = pending_subscription.duration_days if hasattr(pending_subscription, 'duration_days') else None + if duration_days: + pending_subscription.end_date = current_time + timedelta(days=duration_days) + elif pending_subscription.end_date and pending_subscription.end_date < current_time: + # Если end_date в прошлом, пересчитываем + from app.config import settings + pending_subscription.end_date = current_time + timedelta(days=settings.TRIAL_DURATION_DAYS) + + await db.commit() + await db.refresh(pending_subscription) + + logger.info(f"Триальная подписка {pending_subscription.id} активирована для пользователя {user_id}") + + return pending_subscription + + +# ==================== СУТОЧНЫЕ ПОДПИСКИ ==================== + + +async def get_daily_subscriptions_for_charge(db: AsyncSession) -> List[Subscription]: + """ + Получает все суточные подписки, которые нужно обработать для списания. + + Критерии: + - Тариф подписки суточный (is_daily=True) + - Подписка активна + - Подписка не приостановлена пользователем + - Прошло более 24 часов с последнего списания (или списания ещё не было) + """ + from app.database.models import Tariff + + now = datetime.utcnow() + one_day_ago = now - timedelta(hours=24) + + query = ( + select(Subscription) + .join(Tariff, Subscription.tariff_id == Tariff.id) + .options( + selectinload(Subscription.user), + selectinload(Subscription.tariff), + ) + .where( + and_( + Tariff.is_daily.is_(True), + Tariff.is_active.is_(True), + Subscription.status == SubscriptionStatus.ACTIVE.value, + Subscription.is_daily_paused.is_(False), + # Списания ещё не было ИЛИ прошло более 24 часов + ( + (Subscription.last_daily_charge_at.is_(None)) | + (Subscription.last_daily_charge_at < one_day_ago) + ), + ) + ) + ) + + result = await db.execute(query) + subscriptions = result.scalars().all() + + logger.info( + f"🔍 Найдено {len(subscriptions)} суточных подписок для списания" + ) + + return list(subscriptions) + + +async def pause_daily_subscription( + db: AsyncSession, + subscription: Subscription, +) -> Subscription: + """Приостанавливает суточную подписку (списание не будет происходить).""" + if not subscription.is_daily_tariff: + logger.warning( + f"Попытка приостановить не-суточную подписку {subscription.id}" + ) + return subscription + + subscription.is_daily_paused = True + await db.commit() + await db.refresh(subscription) + + logger.info( + f"⏸️ Суточная подписка {subscription.id} приостановлена пользователем {subscription.user_id}" + ) + + return subscription + + +async def resume_daily_subscription( + db: AsyncSession, + subscription: Subscription, +) -> Subscription: + """Возобновляет суточную подписку (списание продолжится).""" + if not subscription.is_daily_tariff: + logger.warning( + f"Попытка возобновить не-суточную подписку {subscription.id}" + ) + return subscription + + subscription.is_daily_paused = False + + # Восстанавливаем статус ACTIVE если подписка была DISABLED (недостаток средств) + if subscription.status == SubscriptionStatus.DISABLED.value: + subscription.status = SubscriptionStatus.ACTIVE.value + # Обновляем время последнего списания для корректного расчёта следующего + subscription.last_daily_charge_at = datetime.utcnow() + subscription.end_date = datetime.utcnow() + timedelta(days=1) + logger.info( + f"✅ Суточная подписка {subscription.id} восстановлена из DISABLED в ACTIVE" + ) + + await db.commit() + await db.refresh(subscription) + + logger.info( + f"▶️ Суточная подписка {subscription.id} возобновлена пользователем {subscription.user_id}" + ) + + return subscription + + +async def update_daily_charge_time( + db: AsyncSession, + subscription: Subscription, + charge_time: datetime = None, +) -> Subscription: + """Обновляет время последнего суточного списания и продлевает подписку на 1 день.""" + now = charge_time or datetime.utcnow() + subscription.last_daily_charge_at = now + + # Продлеваем подписку на 1 день от текущего момента + new_end_date = now + timedelta(days=1) + if subscription.end_date is None or subscription.end_date < new_end_date: + subscription.end_date = new_end_date + logger.info(f"📅 Продлена подписка {subscription.id} до {new_end_date}") + + await db.commit() + await db.refresh(subscription) + + return subscription + + +async def suspend_daily_subscription_insufficient_balance( + db: AsyncSession, + subscription: Subscription, +) -> Subscription: + """ + Приостанавливает подписку из-за недостатка баланса. + Отличается от pause_daily_subscription тем, что меняет статус на DISABLED. + """ + subscription.status = SubscriptionStatus.DISABLED.value + await db.commit() + await db.refresh(subscription) + + logger.info( + f"⚠️ Суточная подписка {subscription.id} приостановлена: недостаточно средств (user_id={subscription.user_id})" + ) + + return subscription + + +async def get_subscription_with_tariff( + db: AsyncSession, + user_id: int, +) -> Optional[Subscription]: + """Получает подписку пользователя с загруженным тарифом.""" + result = await db.execute( + select(Subscription) + .options( + selectinload(Subscription.user), + selectinload(Subscription.tariff), + ) + .where(Subscription.user_id == user_id) + .order_by(Subscription.created_at.desc()) + .limit(1) + ) + subscription = result.scalar_one_or_none() + + if subscription: + subscription = await check_and_update_subscription_status(db, subscription) + + return subscription + + +async def toggle_daily_subscription_pause( + db: AsyncSession, + subscription: Subscription, +) -> Subscription: + """Переключает состояние паузы суточной подписки.""" + if subscription.is_daily_paused: + return await resume_daily_subscription(db, subscription) + else: + return await pause_daily_subscription(db, subscription) diff --git a/app/database/crud/subscription_conversion.py b/app/database/crud/subscription_conversion.py index 776c3817..f8764451 100644 --- a/app/database/crud/subscription_conversion.py +++ b/app/database/crud/subscription_conversion.py @@ -52,50 +52,62 @@ async def get_conversion_by_user_id( async def get_conversion_statistics(db: AsyncSession) -> dict: - + from app.database.models import Subscription + + # Получаем количество записей о конверсиях в таблице total_conversions_result = await db.execute( select(func.count(SubscriptionConversion.id)) ) - total_conversions = total_conversions_result.scalar() - + total_conversions = total_conversions_result.scalar() or 0 + + # Подсчитываем пользователей с платными подписками users_with_paid_result = await db.execute( select(func.count(User.id)) .where(User.has_had_paid_subscription == True) ) - users_with_paid = users_with_paid_result.scalar() - - if total_conversions > 0: - conversion_rate = round((total_conversions / max(total_conversions, users_with_paid)) * 100, 1) - elif users_with_paid > 0: - conversion_rate = 100.0 + users_with_paid = users_with_paid_result.scalar() or 0 + + # Подсчитываем всех пользователей с подписками (использовавших триал) + # Считаем что все новые пользователи начинают с триала + total_users_with_subscriptions_result = await db.execute( + select(func.count(func.distinct(Subscription.user_id))) + ) + total_users_with_subscriptions = total_users_with_subscriptions_result.scalar() or 0 + + # Расчёт конверсии: (оплатившие) / (всего с подписками) * 100 + # Это показывает какой % пользователей, получивших подписку, в итоге оплатили + if total_users_with_subscriptions > 0: + conversion_rate = round((users_with_paid / total_users_with_subscriptions) * 100, 1) else: conversion_rate = 0.0 - + avg_trial_duration_result = await db.execute( select(func.avg(SubscriptionConversion.trial_duration_days)) ) avg_trial_duration = avg_trial_duration_result.scalar() or 0 - + avg_first_payment_result = await db.execute( select(func.avg(SubscriptionConversion.first_payment_amount_kopeks)) ) avg_first_payment = avg_first_payment_result.scalar() or 0 - + month_ago = datetime.utcnow() - timedelta(days=30) month_conversions_result = await db.execute( select(func.count(SubscriptionConversion.id)) .where(SubscriptionConversion.converted_at >= month_ago) ) - month_conversions = month_conversions_result.scalar() - + month_conversions = month_conversions_result.scalar() or 0 + logger.info(f"📊 Статистика конверсий:") - logger.info(f" Всего записей о конверсиях: {total_conversions}") - logger.info(f" Пользователей с платными подписками: {users_with_paid}") + logger.info(f" Всего пользователей с подписками: {total_users_with_subscriptions}") + logger.info(f" Оплативших подписку: {users_with_paid}") logger.info(f" Рассчитанная конверсия: {conversion_rate}%") - + return { "total_conversions": total_conversions, "conversion_rate": conversion_rate, + "users_with_trial": total_users_with_subscriptions, + "converted_users": users_with_paid, "avg_trial_duration_days": round(avg_trial_duration, 1), "avg_first_payment_rubles": round((avg_first_payment or 0) / 100, 2), "month_conversions": month_conversions diff --git a/app/database/crud/tariff.py b/app/database/crud/tariff.py index 2ade37cc..b88ca9ca 100644 --- a/app/database/crud/tariff.py +++ b/app/database/crud/tariff.py @@ -162,11 +162,32 @@ async def create_tariff( is_active: bool = True, traffic_limit_gb: int = 100, device_limit: int = 1, + device_price_kopeks: Optional[int] = None, + max_device_limit: Optional[int] = None, allowed_squads: Optional[List[str]] = None, + server_traffic_limits: Optional[Dict[str, dict]] = None, period_prices: Optional[Dict[int, int]] = None, tier_level: int = 1, is_trial_available: bool = False, + allow_traffic_topup: bool = True, promo_group_ids: Optional[List[int]] = None, + traffic_topup_enabled: bool = False, + traffic_topup_packages: Optional[Dict[str, int]] = None, + max_topup_traffic_gb: int = 0, + is_daily: bool = False, + daily_price_kopeks: int = 0, + # Произвольное количество дней + custom_days_enabled: bool = False, + price_per_day_kopeks: int = 0, + min_days: int = 1, + max_days: int = 365, + # Произвольный трафик при покупке + custom_traffic_enabled: bool = False, + traffic_price_per_gb_kopeks: int = 0, + min_traffic_gb: int = 1, + max_traffic_gb: int = 1000, + # Режим сброса трафика + traffic_reset_mode: Optional[str] = None, # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка ) -> Tariff: """Создает новый тариф.""" normalized_prices = _normalize_period_prices(period_prices) @@ -178,10 +199,31 @@ async def create_tariff( is_active=is_active, traffic_limit_gb=max(0, traffic_limit_gb), device_limit=max(1, device_limit), + device_price_kopeks=device_price_kopeks, + max_device_limit=max_device_limit, allowed_squads=allowed_squads or [], + server_traffic_limits=server_traffic_limits or {}, period_prices=normalized_prices, tier_level=max(1, tier_level), is_trial_available=is_trial_available, + allow_traffic_topup=allow_traffic_topup, + traffic_topup_enabled=traffic_topup_enabled, + traffic_topup_packages=traffic_topup_packages or {}, + max_topup_traffic_gb=max(0, max_topup_traffic_gb), + is_daily=is_daily, + daily_price_kopeks=max(0, daily_price_kopeks), + # Произвольное количество дней + custom_days_enabled=custom_days_enabled, + price_per_day_kopeks=max(0, price_per_day_kopeks), + min_days=max(1, min_days), + max_days=max(1, max_days), + # Произвольный трафик при покупке + custom_traffic_enabled=custom_traffic_enabled, + traffic_price_per_gb_kopeks=max(0, traffic_price_per_gb_kopeks), + min_traffic_gb=max(1, min_traffic_gb), + max_traffic_gb=max(1, max_traffic_gb), + # Режим сброса трафика + traffic_reset_mode=traffic_reset_mode, ) db.add(tariff) @@ -222,11 +264,31 @@ async def update_tariff( traffic_limit_gb: Optional[int] = None, device_limit: Optional[int] = None, device_price_kopeks: Optional[int] = ..., # ... = не передан, None = сбросить + max_device_limit: Optional[int] = ..., # ... = не передан, None = сбросить (без лимита) allowed_squads: Optional[List[str]] = None, + server_traffic_limits: Optional[Dict[str, dict]] = None, period_prices: Optional[Dict[int, int]] = None, tier_level: Optional[int] = None, is_trial_available: Optional[bool] = None, + allow_traffic_topup: Optional[bool] = None, promo_group_ids: Optional[List[int]] = None, + traffic_topup_enabled: Optional[bool] = None, + traffic_topup_packages: Optional[Dict[str, int]] = None, + max_topup_traffic_gb: Optional[int] = None, + is_daily: Optional[bool] = None, + daily_price_kopeks: Optional[int] = None, + # Произвольное количество дней + custom_days_enabled: Optional[bool] = None, + price_per_day_kopeks: Optional[int] = None, + min_days: Optional[int] = None, + max_days: Optional[int] = None, + # Произвольный трафик при покупке + custom_traffic_enabled: Optional[bool] = None, + traffic_price_per_gb_kopeks: Optional[int] = None, + min_traffic_gb: Optional[int] = None, + max_traffic_gb: Optional[int] = None, + # Режим сброса трафика + traffic_reset_mode: Optional[str] = ..., # ... = не передан, None = сбросить к глобальной настройке ) -> Tariff: """Обновляет существующий тариф.""" if name is not None: @@ -244,14 +306,52 @@ async def update_tariff( if device_price_kopeks is not ...: # Если передан device_price_kopeks (включая None) - обновляем tariff.device_price_kopeks = device_price_kopeks + if max_device_limit is not ...: + # Если передан max_device_limit (включая None) - обновляем + tariff.max_device_limit = max_device_limit if allowed_squads is not None: tariff.allowed_squads = allowed_squads + if server_traffic_limits is not None: + tariff.server_traffic_limits = server_traffic_limits + if allow_traffic_topup is not None: + tariff.allow_traffic_topup = allow_traffic_topup if period_prices is not None: tariff.period_prices = _normalize_period_prices(period_prices) if tier_level is not None: tariff.tier_level = max(1, tier_level) if is_trial_available is not None: tariff.is_trial_available = is_trial_available + if traffic_topup_enabled is not None: + tariff.traffic_topup_enabled = traffic_topup_enabled + if traffic_topup_packages is not None: + tariff.traffic_topup_packages = traffic_topup_packages + if max_topup_traffic_gb is not None: + tariff.max_topup_traffic_gb = max(0, max_topup_traffic_gb) + if is_daily is not None: + tariff.is_daily = is_daily + if daily_price_kopeks is not None: + tariff.daily_price_kopeks = max(0, daily_price_kopeks) + # Произвольное количество дней + if custom_days_enabled is not None: + tariff.custom_days_enabled = custom_days_enabled + if price_per_day_kopeks is not None: + tariff.price_per_day_kopeks = max(0, price_per_day_kopeks) + if min_days is not None: + tariff.min_days = max(1, min_days) + if max_days is not None: + tariff.max_days = max(1, max_days) + # Произвольный трафик при покупке + if custom_traffic_enabled is not None: + tariff.custom_traffic_enabled = custom_traffic_enabled + if traffic_price_per_gb_kopeks is not None: + tariff.traffic_price_per_gb_kopeks = max(0, traffic_price_per_gb_kopeks) + if min_traffic_gb is not None: + tariff.min_traffic_gb = max(1, min_traffic_gb) + if max_traffic_gb is not None: + tariff.max_traffic_gb = max(1, max_traffic_gb) + # Режим сброса трафика + if traffic_reset_mode is not ...: + tariff.traffic_reset_mode = traffic_reset_mode # Обновляем промогруппы если указаны if promo_group_ids is not None: @@ -399,3 +499,127 @@ async def reorder_tariffs( await db.commit() logger.info("Изменен порядок тарифов: %s", tariff_order) + + +async def sync_default_tariff_from_config(db: AsyncSession) -> Optional[Tariff]: + """ + Синхронизирует дефолтный тариф из конфига (.env) в БД. + Создаёт тариф "Стандартный" если в БД нет тарифов. + Обновляет цены существующего тарифа если он есть. + + Returns: + Tariff или None если не требуется синхронизация + """ + from app.config import settings, PERIOD_PRICES + + # Проверяем есть ли тарифы в БД + result = await db.execute(select(func.count(Tariff.id))) + tariff_count = result.scalar() or 0 + + # Собираем цены из конфига + period_prices = {} + for period, price in PERIOD_PRICES.items(): + if price > 0: + period_prices[str(period)] = price + + if not period_prices: + logger.warning("Нет цен в конфиге для создания дефолтного тарифа") + return None + + # Ищем тариф с именем "Стандартный" или первый тариф + result = await db.execute( + select(Tariff).where(Tariff.name == "Стандартный").limit(1) + ) + existing_tariff = result.scalar_one_or_none() + + if existing_tariff: + # Обновляем цены существующего тарифа + existing_tariff.period_prices = period_prices + existing_tariff.traffic_limit_gb = settings.DEFAULT_TRAFFIC_LIMIT_GB + existing_tariff.device_limit = settings.DEFAULT_DEVICE_LIMIT + await db.commit() + await db.refresh(existing_tariff) + logger.info("Обновлён дефолтный тариф 'Стандартный' из конфига") + return existing_tariff + + if tariff_count == 0: + # Создаём новый дефолтный тариф + new_tariff = Tariff( + name="Стандартный", + description="Базовый тарифный план", + is_active=True, + is_trial_available=True, + traffic_limit_gb=settings.DEFAULT_TRAFFIC_LIMIT_GB, + device_limit=settings.DEFAULT_DEVICE_LIMIT, + tier_level=1, + display_order=0, + period_prices=period_prices, + allowed_squads=[], # Все серверы по умолчанию + server_traffic_limits={}, + ) + db.add(new_tariff) + await db.commit() + await db.refresh(new_tariff) + logger.info("Создан дефолтный тариф 'Стандартный' из конфига: %s", period_prices) + return new_tariff + + return None + + +async def load_period_prices_from_db(db: AsyncSession) -> None: + """ + Загружает периоды/цены из тарифа в PERIOD_PRICES. + Работает ТОЛЬКО в режиме tariffs. В режиме classic используются цены из .env. + """ + from app.config import set_period_prices_from_db, settings + + # В режиме classic НЕ загружаем цены из тарифов - используем .env + if settings.is_classic_mode(): + logger.info("Режим classic: цены периодов берутся из .env, тарифы игнорируются") + return + + try: + # Ищем тариф "Стандартный" или первый активный тариф + result = await db.execute( + select(Tariff) + .where(Tariff.is_active.is_(True)) + .order_by(Tariff.display_order, Tariff.id) + .limit(1) + ) + tariff = result.scalar_one_or_none() + + if tariff and tariff.period_prices: + # Преобразуем строковые ключи в int + period_prices = { + int(days): int(price) + for days, price in tariff.period_prices.items() + if int(price) > 0 + } + + if period_prices: + set_period_prices_from_db(period_prices) + logger.info( + "Загружены периоды из тарифа '%s': %s", + tariff.name, + {f"{d}д": f"{p//100}₽" for d, p in period_prices.items()} + ) + else: + logger.warning("Тариф '%s' не имеет активных периодов", tariff.name) + else: + logger.info("Активные тарифы не найдены, используются цены из .env") + + except Exception as e: + logger.error("Ошибка загрузки периодов из БД: %s", e) + + +async def ensure_tariffs_synced(db: AsyncSession) -> None: + """ + Проверяет и синхронизирует тарифы при запуске. + Вызывается при старте бота. + """ + try: + await sync_default_tariff_from_config(db) + # Загружаем периоды из БД в PERIOD_PRICES + await load_period_prices_from_db(db) + except Exception as e: + logger.error("Ошибка синхронизации тарифов: %s", e) diff --git a/app/database/crud/wheel.py b/app/database/crud/wheel.py new file mode 100644 index 00000000..30f7cbdf --- /dev/null +++ b/app/database/crud/wheel.py @@ -0,0 +1,438 @@ +""" +CRUD операции для колеса удачи (Fortune Wheel). +""" +import logging +from datetime import datetime, timedelta +from typing import Optional, List, Dict, Any + +from sqlalchemy import select, and_, func, desc +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.database.models import ( + WheelConfig, + WheelPrize, + WheelSpin, + WheelPrizeType, + WheelSpinPaymentType, + User, +) + +logger = logging.getLogger(__name__) + + +# ==================== WHEEL CONFIG ==================== + + +async def get_wheel_config(db: AsyncSession) -> Optional[WheelConfig]: + """Получить текущую конфигурацию колеса (всегда id=1).""" + result = await db.execute( + select(WheelConfig) + .options(selectinload(WheelConfig.prizes)) + .where(WheelConfig.id == 1) + ) + return result.scalar_one_or_none() + + +async def get_or_create_wheel_config(db: AsyncSession) -> WheelConfig: + """Получить или создать конфигурацию колеса.""" + config = await get_wheel_config(db) + if config: + return config + + # Создаем дефолтную конфигурацию + config = WheelConfig( + id=1, + is_enabled=False, + name="Колесо удачи", + spin_cost_stars=10, + spin_cost_days=1, + spin_cost_stars_enabled=True, + spin_cost_days_enabled=True, + rtp_percent=80, + daily_spin_limit=5, + min_subscription_days_for_day_payment=3, + promo_prefix="WHEEL", + promo_validity_days=7, + ) + db.add(config) + await db.commit() + await db.refresh(config) + logger.info("🎡 Создана дефолтная конфигурация колеса удачи") + return config + + +async def update_wheel_config( + db: AsyncSession, + **kwargs +) -> WheelConfig: + """Обновить конфигурацию колеса.""" + config = await get_or_create_wheel_config(db) + + for key, value in kwargs.items(): + if hasattr(config, key) and value is not None: + setattr(config, key, value) + + config.updated_at = datetime.utcnow() + await db.commit() + await db.refresh(config) + logger.info(f"🎡 Обновлена конфигурация колеса: {kwargs}") + return config + + +# ==================== WHEEL PRIZES ==================== + + +async def get_wheel_prizes( + db: AsyncSession, + config_id: int = 1, + active_only: bool = True +) -> List[WheelPrize]: + """Получить список призов колеса.""" + query = select(WheelPrize).where(WheelPrize.config_id == config_id) + + if active_only: + query = query.where(WheelPrize.is_active == True) + + query = query.order_by(WheelPrize.sort_order) + + result = await db.execute(query) + return list(result.scalars().all()) + + +async def get_wheel_prize_by_id(db: AsyncSession, prize_id: int) -> Optional[WheelPrize]: + """Получить приз по ID.""" + result = await db.execute( + select(WheelPrize).where(WheelPrize.id == prize_id) + ) + return result.scalar_one_or_none() + + +async def create_wheel_prize( + db: AsyncSession, + config_id: int, + prize_type: str, + prize_value: int, + display_name: str, + prize_value_kopeks: int, + emoji: str = "🎁", + color: str = "#3B82F6", + sort_order: int = 0, + manual_probability: Optional[float] = None, + is_active: bool = True, + promo_balance_bonus_kopeks: int = 0, + promo_subscription_days: int = 0, + promo_traffic_gb: int = 0, +) -> WheelPrize: + """Создать новый приз на колесе.""" + prize = WheelPrize( + config_id=config_id, + prize_type=prize_type, + prize_value=prize_value, + display_name=display_name, + prize_value_kopeks=prize_value_kopeks, + emoji=emoji, + color=color, + sort_order=sort_order, + manual_probability=manual_probability, + is_active=is_active, + promo_balance_bonus_kopeks=promo_balance_bonus_kopeks, + promo_subscription_days=promo_subscription_days, + promo_traffic_gb=promo_traffic_gb, + ) + db.add(prize) + await db.commit() + await db.refresh(prize) + logger.info(f"🎁 Создан приз колеса: {display_name} ({prize_type})") + return prize + + +async def update_wheel_prize( + db: AsyncSession, + prize_id: int, + **kwargs +) -> Optional[WheelPrize]: + """Обновить приз колеса.""" + prize = await get_wheel_prize_by_id(db, prize_id) + if not prize: + return None + + for key, value in kwargs.items(): + if hasattr(prize, key) and value is not None: + setattr(prize, key, value) + + prize.updated_at = datetime.utcnow() + await db.commit() + await db.refresh(prize) + logger.info(f"🎁 Обновлен приз колеса ID={prize_id}: {kwargs}") + return prize + + +async def delete_wheel_prize(db: AsyncSession, prize_id: int) -> bool: + """Удалить приз колеса.""" + prize = await get_wheel_prize_by_id(db, prize_id) + if not prize: + return False + + await db.delete(prize) + await db.commit() + logger.info(f"🗑️ Удален приз колеса ID={prize_id}") + return True + + +async def reorder_wheel_prizes( + db: AsyncSession, + prize_ids: List[int] +) -> bool: + """Переупорядочить призы колеса.""" + for index, prize_id in enumerate(prize_ids): + prize = await get_wheel_prize_by_id(db, prize_id) + if prize: + prize.sort_order = index + + await db.commit() + logger.info(f"🔄 Переупорядочены призы колеса: {prize_ids}") + return True + + +# ==================== WHEEL SPINS ==================== + + +async def create_wheel_spin( + db: AsyncSession, + user_id: int, + prize_id: int, + payment_type: str, + payment_amount: int, + payment_value_kopeks: int, + prize_type: str, + prize_value: int, + prize_display_name: str, + prize_value_kopeks: int, + generated_promocode_id: Optional[int] = None, + is_applied: bool = False, +) -> WheelSpin: + """Создать запись о спине колеса.""" + spin = WheelSpin( + user_id=user_id, + prize_id=prize_id, + payment_type=payment_type, + payment_amount=payment_amount, + payment_value_kopeks=payment_value_kopeks, + prize_type=prize_type, + prize_value=prize_value, + prize_display_name=prize_display_name, + prize_value_kopeks=prize_value_kopeks, + generated_promocode_id=generated_promocode_id, + is_applied=is_applied, + applied_at=datetime.utcnow() if is_applied else None, + ) + db.add(spin) + await db.commit() + await db.refresh(spin) + logger.info(f"🎰 Создан спин колеса: user_id={user_id}, prize='{prize_display_name}'") + return spin + + +async def mark_spin_applied(db: AsyncSession, spin_id: int) -> Optional[WheelSpin]: + """Отметить спин как примененный.""" + result = await db.execute( + select(WheelSpin).where(WheelSpin.id == spin_id) + ) + spin = result.scalar_one_or_none() + if spin: + spin.is_applied = True + spin.applied_at = datetime.utcnow() + await db.commit() + await db.refresh(spin) + return spin + + +async def get_user_spins_today(db: AsyncSession, user_id: int) -> int: + """Получить количество спинов пользователя за сегодня.""" + today_start = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0) + + result = await db.execute( + select(func.count(WheelSpin.id)) + .where( + and_( + WheelSpin.user_id == user_id, + WheelSpin.created_at >= today_start, + ) + ) + ) + return result.scalar() or 0 + + +async def get_user_spin_history( + db: AsyncSession, + user_id: int, + limit: int = 20, + offset: int = 0 +) -> tuple[List[WheelSpin], int]: + """Получить историю спинов пользователя.""" + # Общее количество + count_result = await db.execute( + select(func.count(WheelSpin.id)) + .where(WheelSpin.user_id == user_id) + ) + total = count_result.scalar() or 0 + + # Спины с пагинацией (eager load prize relationship) + result = await db.execute( + select(WheelSpin) + .options(selectinload(WheelSpin.prize)) + .where(WheelSpin.user_id == user_id) + .order_by(desc(WheelSpin.created_at)) + .limit(limit) + .offset(offset) + ) + spins = list(result.scalars().all()) + + return spins, total + + +async def get_all_spins( + db: AsyncSession, + user_id: Optional[int] = None, + date_from: Optional[datetime] = None, + date_to: Optional[datetime] = None, + limit: int = 50, + offset: int = 0 +) -> tuple[List[WheelSpin], int]: + """Получить все спины с фильтрами (для админки).""" + conditions = [] + + if user_id: + conditions.append(WheelSpin.user_id == user_id) + if date_from: + conditions.append(WheelSpin.created_at >= date_from) + if date_to: + conditions.append(WheelSpin.created_at <= date_to) + + # Общее количество + count_query = select(func.count(WheelSpin.id)) + if conditions: + count_query = count_query.where(and_(*conditions)) + count_result = await db.execute(count_query) + total = count_result.scalar() or 0 + + # Спины с пагинацией + query = select(WheelSpin).options(selectinload(WheelSpin.user)) + if conditions: + query = query.where(and_(*conditions)) + query = query.order_by(desc(WheelSpin.created_at)).limit(limit).offset(offset) + + result = await db.execute(query) + spins = list(result.scalars().all()) + + return spins, total + + +# ==================== STATISTICS ==================== + + +async def get_wheel_statistics( + db: AsyncSession, + date_from: Optional[datetime] = None, + date_to: Optional[datetime] = None +) -> Dict[str, Any]: + """Получить статистику колеса удачи.""" + conditions = [] + if date_from: + conditions.append(WheelSpin.created_at >= date_from) + if date_to: + conditions.append(WheelSpin.created_at <= date_to) + + base_query = select(WheelSpin) + if conditions: + base_query = base_query.where(and_(*conditions)) + + # Общие метрики + result = await db.execute( + select( + func.count(WheelSpin.id).label("total_spins"), + func.coalesce(func.sum(WheelSpin.payment_value_kopeks), 0).label("total_revenue"), + func.coalesce(func.sum(WheelSpin.prize_value_kopeks), 0).label("total_payout"), + ).where(and_(*conditions) if conditions else True) + ) + row = result.one() + total_spins = row.total_spins or 0 + total_revenue = row.total_revenue or 0 + total_payout = row.total_payout or 0 + + # Фактический RTP + actual_rtp = (total_payout / total_revenue * 100) if total_revenue > 0 else 0 + + # Распределение по типу оплаты + payment_dist = await db.execute( + select( + WheelSpin.payment_type, + func.count(WheelSpin.id).label("count"), + func.sum(WheelSpin.payment_value_kopeks).label("total"), + ) + .where(and_(*conditions) if conditions else True) + .group_by(WheelSpin.payment_type) + ) + spins_by_payment_type = { + row.payment_type: {"count": row.count, "total_kopeks": row.total or 0} + for row in payment_dist + } + + # Распределение призов + prizes_dist = await db.execute( + select( + WheelSpin.prize_type, + WheelSpin.prize_display_name, + func.count(WheelSpin.id).label("count"), + func.sum(WheelSpin.prize_value_kopeks).label("total"), + ) + .where(and_(*conditions) if conditions else True) + .group_by(WheelSpin.prize_type, WheelSpin.prize_display_name) + ) + prizes_distribution = [ + { + "prize_type": row.prize_type, + "display_name": row.prize_display_name, + "count": row.count, + "total_kopeks": row.total or 0, + } + for row in prizes_dist + ] + + # Топ выигрышей + top_wins_result = await db.execute( + select(WheelSpin) + .options(selectinload(WheelSpin.user)) + .where(and_(*conditions) if conditions else True) + .where(WheelSpin.prize_value_kopeks > 0) + .order_by(desc(WheelSpin.prize_value_kopeks)) + .limit(10) + ) + top_wins = [ + { + "user_id": spin.user_id, + "username": spin.user.username if spin.user else None, + "prize_display_name": spin.prize_display_name, + "prize_value_kopeks": spin.prize_value_kopeks, + "created_at": spin.created_at.isoformat() if spin.created_at else None, + } + for spin in top_wins_result.scalars().all() + ] + + # Конфигурация для сравнения + config = await get_wheel_config(db) + configured_rtp = config.rtp_percent if config else 80 + + return { + "total_spins": total_spins, + "total_revenue_kopeks": total_revenue, + "total_payout_kopeks": total_payout, + "actual_rtp_percent": round(actual_rtp, 2), + "configured_rtp_percent": configured_rtp, + "spins_by_payment_type": spins_by_payment_type, + "prizes_distribution": prizes_distribution, + "top_wins": top_wins, + "period_from": date_from.isoformat() if date_from else None, + "period_to": date_to.isoformat() if date_to else None, + } diff --git a/app/database/models.py b/app/database/models.py index b7850486..3898464b 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -108,6 +108,7 @@ class PaymentMethod(Enum): CLOUDPAYMENTS = "cloudpayments" FREEKASSA = "freekassa" MANUAL = "manual" + BALANCE = "balance" class MainMenuButtonActionType(Enum): @@ -120,6 +121,21 @@ class MainMenuButtonVisibility(Enum): ADMINS = "admins" SUBSCRIBERS = "subscribers" + +class WheelPrizeType(Enum): + """Типы призов на колесе удачи.""" + SUBSCRIPTION_DAYS = "subscription_days" + BALANCE_BONUS = "balance_bonus" + TRAFFIC_GB = "traffic_gb" + PROMOCODE = "promocode" + NOTHING = "nothing" + + +class WheelSpinPaymentType(Enum): + """Способы оплаты спина колеса.""" + TELEGRAM_STARS = "telegram_stars" + SUBSCRIPTION_DAYS = "subscription_days" + class YooKassaPayment(Base): __tablename__ = "yookassa_payments" @@ -749,10 +765,15 @@ class Tariff(Base): traffic_limit_gb = Column(Integer, nullable=False, default=100) # 0 = безлимит device_limit = Column(Integer, nullable=False, default=1) device_price_kopeks = Column(Integer, nullable=True, default=None) # Цена за доп. устройство (None = нельзя докупить) + max_device_limit = Column(Integer, nullable=True, default=None) # Макс. устройств (None = без ограничений) # Сквады (серверы) доступные в тарифе allowed_squads = Column(JSON, default=list) # список UUID сквадов + # Лимиты трафика по серверам (JSON: {"uuid": {"traffic_limit_gb": 100}, ...}) + # Если сервер не указан - используется общий traffic_limit_gb + server_traffic_limits = Column(JSON, default=dict) + # Цены на периоды в копейках (JSON: {"14": 30000, "30": 50000, "90": 120000, ...}) period_prices = Column(JSON, nullable=False, default=dict) @@ -761,6 +782,33 @@ class Tariff(Base): # Дополнительные настройки is_trial_available = Column(Boolean, default=False, nullable=False) # Можно ли взять триал на этом тарифе + allow_traffic_topup = Column(Boolean, default=True, nullable=False) # Разрешена ли докупка трафика для этого тарифа + + # Докупка трафика + traffic_topup_enabled = Column(Boolean, default=False, nullable=False) # Разрешена ли докупка трафика + # Пакеты трафика: JSON {"5": 5000, "10": 9000, "20": 15000} (ГБ: цена в копейках) + traffic_topup_packages = Column(JSON, default=dict) + # Максимальный лимит трафика после докупки (0 = без ограничений) + max_topup_traffic_gb = Column(Integer, default=0, nullable=False) + + # Суточный тариф - ежедневное списание + is_daily = Column(Boolean, default=False, nullable=False) # Является ли тариф суточным + daily_price_kopeks = Column(Integer, default=0, nullable=False) # Цена за день в копейках + + # Произвольное количество дней + custom_days_enabled = Column(Boolean, default=False, nullable=False) # Разрешить произвольное кол-во дней + price_per_day_kopeks = Column(Integer, default=0, nullable=False) # Цена за 1 день в копейках + min_days = Column(Integer, default=1, nullable=False) # Минимальное количество дней + max_days = Column(Integer, default=365, nullable=False) # Максимальное количество дней + + # Произвольный трафик при покупке + custom_traffic_enabled = Column(Boolean, default=False, nullable=False) # Разрешить произвольный трафик + traffic_price_per_gb_kopeks = Column(Integer, default=0, nullable=False) # Цена за 1 ГБ в копейках + min_traffic_gb = Column(Integer, default=1, nullable=False) # Минимальный трафик в ГБ + max_traffic_gb = Column(Integer, default=1000, nullable=False) # Максимальный трафик в ГБ + + # Режим сброса трафика: DAY, WEEK, MONTH, NO_RESET (по умолчанию берётся из конфига) + traffic_reset_mode = Column(String(20), nullable=True, default=None) # None = использовать глобальную настройку created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) @@ -797,6 +845,21 @@ class Tariff(Base): return price_kopeks / 100 return None + def get_traffic_limit_for_server(self, squad_uuid: str) -> int: + """Возвращает лимит трафика для конкретного сервера. + + Если для сервера настроен отдельный лимит - возвращает его, + иначе возвращает общий traffic_limit_gb тарифа. + """ + limits = self.server_traffic_limits or {} + if squad_uuid in limits: + server_limit = limits[squad_uuid] + if isinstance(server_limit, dict) and 'traffic_limit_gb' in server_limit: + return server_limit['traffic_limit_gb'] + elif isinstance(server_limit, int): + return server_limit + return self.traffic_limit_gb + def is_available_for_promo_group(self, promo_group_id: Optional[int]) -> bool: """Проверяет, доступен ли тариф для указанной промогруппы.""" if not self.allowed_promo_groups: @@ -805,6 +868,57 @@ class Tariff(Base): return True # Если у пользователя нет группы - доступен return any(pg.id == promo_group_id for pg in self.allowed_promo_groups) + def get_traffic_topup_packages(self) -> Dict[int, int]: + """Возвращает пакеты трафика для докупки: {ГБ: цена в копейках}.""" + packages = self.traffic_topup_packages or {} + return {int(gb): int(price) for gb, price in packages.items()} + + def get_traffic_topup_price(self, gb: int) -> Optional[int]: + """Возвращает цену в копейках для указанного пакета трафика.""" + packages = self.get_traffic_topup_packages() + return packages.get(gb) + + def get_available_traffic_packages(self) -> List[int]: + """Возвращает список доступных пакетов трафика в ГБ.""" + packages = self.get_traffic_topup_packages() + return sorted(packages.keys()) + + def can_topup_traffic(self) -> bool: + """Проверяет, можно ли докупить трафик на этом тарифе.""" + return ( + self.traffic_topup_enabled + and bool(self.traffic_topup_packages) + and not self.is_unlimited_traffic + ) + + def get_daily_price_rubles(self) -> float: + """Возвращает суточную цену в рублях.""" + return self.daily_price_kopeks / 100 if self.daily_price_kopeks else 0 + + def get_price_for_custom_days(self, days: int) -> Optional[int]: + """Возвращает цену для произвольного количества дней.""" + if not self.custom_days_enabled or not self.price_per_day_kopeks: + return None + if days < self.min_days or days > self.max_days: + return None + return self.price_per_day_kopeks * days + + def get_price_for_custom_traffic(self, gb: int) -> Optional[int]: + """Возвращает цену для произвольного количества трафика.""" + if not self.custom_traffic_enabled or not self.traffic_price_per_gb_kopeks: + return None + if gb < self.min_traffic_gb or gb > self.max_traffic_gb: + return None + return self.traffic_price_per_gb_kopeks * gb + + def can_purchase_custom_days(self) -> bool: + """Проверяет, можно ли купить произвольное количество дней.""" + return self.custom_days_enabled and self.price_per_day_kopeks > 0 + + def can_purchase_custom_traffic(self) -> bool: + """Проверяет, можно ли купить произвольный трафик.""" + return self.custom_traffic_enabled and self.traffic_price_per_gb_kopeks > 0 + def __repr__(self): return f"" @@ -940,7 +1054,8 @@ class Subscription(Base): traffic_limit_gb = Column(Integer, default=0) traffic_used_gb = Column(Float, default=0.0) - purchased_traffic_gb = Column(Integer, default=0) # Докупленный трафик (для расчета цены сброса) + purchased_traffic_gb = Column(Integer, default=0) # Докупленный трафик + traffic_reset_at = Column(DateTime, nullable=True) # Дата сброса докупленного трафика (30 дней после первой докупки) subscription_url = Column(String, nullable=True) subscription_crypto_link = Column(String, nullable=True) @@ -961,10 +1076,15 @@ class Subscription(Base): # Тариф (для режима продаж "Тарифы") tariff_id = Column(Integer, ForeignKey("tariffs.id", ondelete="SET NULL"), nullable=True, index=True) + # Суточная подписка + is_daily_paused = Column(Boolean, default=False, nullable=False) # Приостановлена ли суточная подписка пользователем + last_daily_charge_at = Column(DateTime, nullable=True) # Время последнего суточного списания + user = relationship("User", back_populates="subscription") tariff = relationship("Tariff", back_populates="subscriptions") discount_offers = relationship("DiscountOffer", back_populates="subscription") temporary_accesses = relationship("SubscriptionTemporaryAccess", back_populates="subscription") + traffic_purchases = relationship("TrafficPurchase", back_populates="subscription", cascade="all, delete-orphan") @property def is_active(self) -> bool: @@ -1093,10 +1213,55 @@ class Subscription(Base): self.status = SubscriptionStatus.ACTIVE.value def add_traffic(self, gb: int): - if self.traffic_limit_gb == 0: + if self.traffic_limit_gb == 0: return self.traffic_limit_gb += gb + @property + def is_daily_tariff(self) -> bool: + """Проверяет, является ли тариф подписки суточным.""" + if self.tariff: + return getattr(self.tariff, 'is_daily', False) + return False + + @property + def daily_price_kopeks(self) -> int: + """Возвращает суточную цену тарифа в копейках.""" + if self.tariff: + return getattr(self.tariff, 'daily_price_kopeks', 0) + return 0 + + @property + def can_charge_daily(self) -> bool: + """Проверяет, можно ли списать суточную оплату.""" + if not self.is_daily_tariff: + return False + if self.is_daily_paused: + return False + if self.status != SubscriptionStatus.ACTIVE.value: + return False + return True + + +class TrafficPurchase(Base): + """Докупка трафика с индивидуальной датой истечения.""" + __tablename__ = "traffic_purchases" + + id = Column(Integer, primary_key=True, index=True) + subscription_id = Column(Integer, ForeignKey("subscriptions.id", ondelete="CASCADE"), nullable=False, index=True) + + traffic_gb = Column(Integer, nullable=False) # Количество ГБ в покупке + expires_at = Column(DateTime, nullable=False, index=True) # Дата истечения (покупка + 30 дней) + + created_at = Column(DateTime, default=func.now()) + + subscription = relationship("Subscription", back_populates="traffic_purchases") + + @property + def is_expired(self) -> bool: + """Проверяет, истекла ли докупка.""" + return datetime.utcnow() >= self.expires_at + class Transaction(Base): __tablename__ = "transactions" @@ -2249,4 +2414,131 @@ class CabinetRefreshToken(Base): def __repr__(self) -> str: status = "valid" if self.is_valid else ("revoked" if self.is_revoked else "expired") - return f"" \ No newline at end of file + return f"" + + +# ==================== FORTUNE WHEEL ==================== + + +class WheelConfig(Base): + """Глобальная конфигурация колеса удачи.""" + __tablename__ = "wheel_configs" + + id = Column(Integer, primary_key=True, index=True) + + # Основные настройки + is_enabled = Column(Boolean, default=False, nullable=False) + name = Column(String(255), default="Колесо удачи", nullable=False) + + # Стоимость спина + spin_cost_stars = Column(Integer, default=10, nullable=False) # Стоимость в Stars + spin_cost_days = Column(Integer, default=1, nullable=False) # Стоимость в днях подписки + spin_cost_stars_enabled = Column(Boolean, default=True, nullable=False) + spin_cost_days_enabled = Column(Boolean, default=True, nullable=False) + + # RTP настройки (Return to Player) - процент возврата 0-100 + rtp_percent = Column(Integer, default=80, nullable=False) + + # Лимиты + daily_spin_limit = Column(Integer, default=5, nullable=False) # 0 = без лимита + min_subscription_days_for_day_payment = Column(Integer, default=3, nullable=False) + + # Генерация промокодов + promo_prefix = Column(String(20), default="WHEEL", nullable=False) + promo_validity_days = Column(Integer, default=7, nullable=False) + + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + + prizes = relationship("WheelPrize", back_populates="config", cascade="all, delete-orphan") + + def __repr__(self) -> str: + return f"" + + +class WheelPrize(Base): + """Приз на колесе удачи.""" + __tablename__ = "wheel_prizes" + + id = Column(Integer, primary_key=True, index=True) + config_id = Column(Integer, ForeignKey("wheel_configs.id", ondelete="CASCADE"), nullable=False) + + # Тип и значение приза + prize_type = Column(String(50), nullable=False) # WheelPrizeType + prize_value = Column(Integer, default=0, nullable=False) # Дни/копейки/GB в зависимости от типа + + # Отображение + display_name = Column(String(100), nullable=False) + emoji = Column(String(10), default="🎁", nullable=False) + color = Column(String(20), default="#3B82F6", nullable=False) # HEX цвет сектора + + # Стоимость приза для расчета RTP (в копейках) + prize_value_kopeks = Column(Integer, default=0, nullable=False) + + # Порядок и вероятность + sort_order = Column(Integer, default=0, nullable=False) + manual_probability = Column(Float, nullable=True) # Если задано - игнорирует RTP расчет (0.0-1.0) + is_active = Column(Boolean, default=True, nullable=False) + + # Настройки генерируемого промокода (только для prize_type=promocode) + promo_balance_bonus_kopeks = Column(Integer, default=0) + promo_subscription_days = Column(Integer, default=0) + promo_traffic_gb = Column(Integer, default=0) + + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + + config = relationship("WheelConfig", back_populates="prizes") + spins = relationship("WheelSpin", back_populates="prize") + + def __repr__(self) -> str: + return f"" + + +class WheelSpin(Base): + """История спинов колеса удачи.""" + __tablename__ = "wheel_spins" + __table_args__ = ( + Index("ix_wheel_spins_user_created", "user_id", "created_at"), + ) + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + prize_id = Column(Integer, ForeignKey("wheel_prizes.id", ondelete="SET NULL"), nullable=True) + + # Способ оплаты + payment_type = Column(String(50), nullable=False) # WheelSpinPaymentType + payment_amount = Column(Integer, nullable=False) # Stars или дни + payment_value_kopeks = Column(Integer, nullable=False) # Эквивалент в копейках для статистики + + # Результат + prize_type = Column(String(50), nullable=False) # Копируем из WheelPrize на момент спина + prize_value = Column(Integer, nullable=False) + prize_display_name = Column(String(100), nullable=False) + prize_value_kopeks = Column(Integer, nullable=False) # Стоимость приза в копейках + + # Сгенерированный промокод (если приз - промокод) + generated_promocode_id = Column(Integer, ForeignKey("promocodes.id"), nullable=True) + + # Флаг успешного начисления + is_applied = Column(Boolean, default=False, nullable=False) + applied_at = Column(DateTime, nullable=True) + + created_at = Column(DateTime, default=func.now()) + + user = relationship("User", backref="wheel_spins") + prize = relationship("WheelPrize", back_populates="spins") + generated_promocode = relationship("PromoCode") + + @property + def prize_value_rubles(self) -> float: + """Стоимость приза в рублях.""" + return self.prize_value_kopeks / 100 + + @property + def payment_value_rubles(self) -> float: + """Стоимость оплаты в рублях.""" + return self.payment_value_kopeks / 100 + + def __repr__(self) -> str: + return f"" diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index 92c9904e..c6fef6d9 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -5135,6 +5135,68 @@ async def create_withdrawal_requests_table() -> bool: return False +# ============================================================================= +# МИГРАЦИЯ ДЛЯ ИНДИВИДУАЛЬНЫХ ДОКУПОК ТРАФИКА +# ============================================================================= + +async def create_traffic_purchases_table() -> bool: + """Создаёт таблицу для индивидуальных докупок трафика с отдельными датами истечения.""" + try: + if await check_table_exists('traffic_purchases'): + logger.info("ℹ️ Таблица traffic_purchases уже существует") + return True + + async with engine.begin() as conn: + db_type = await get_database_type() + + if db_type == 'sqlite': + create_sql = """ + CREATE TABLE traffic_purchases ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + subscription_id INTEGER NOT NULL, + traffic_gb INTEGER NOT NULL, + expires_at DATETIME NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (subscription_id) REFERENCES subscriptions(id) ON DELETE CASCADE + ); + CREATE INDEX idx_traffic_purchases_subscription_id ON traffic_purchases(subscription_id); + CREATE INDEX idx_traffic_purchases_expires_at ON traffic_purchases(expires_at); + """ + elif db_type == 'postgresql': + create_sql = """ + CREATE TABLE traffic_purchases ( + id SERIAL PRIMARY KEY, + subscription_id INTEGER NOT NULL REFERENCES subscriptions(id) ON DELETE CASCADE, + traffic_gb INTEGER NOT NULL, + expires_at TIMESTAMP NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + CREATE INDEX idx_traffic_purchases_subscription_id ON traffic_purchases(subscription_id); + CREATE INDEX idx_traffic_purchases_expires_at ON traffic_purchases(expires_at); + """ + else: # mysql + create_sql = """ + CREATE TABLE traffic_purchases ( + id INT AUTO_INCREMENT PRIMARY KEY, + subscription_id INT NOT NULL, + traffic_gb INT NOT NULL, + expires_at DATETIME NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (subscription_id) REFERENCES subscriptions(id) ON DELETE CASCADE, + INDEX idx_traffic_purchases_subscription_id (subscription_id), + INDEX idx_traffic_purchases_expires_at (expires_at) + ); + """ + + await conn.execute(text(create_sql)) + logger.info("✅ Таблица traffic_purchases создана") + + return True + except Exception as error: + logger.error(f"❌ Ошибка создания таблицы traffic_purchases: {error}") + return False + + # ============================================================================= # МИГРАЦИИ ДЛЯ РЕЖИМА ТАРИФОВ # ============================================================================= @@ -5259,6 +5321,38 @@ async def create_tariff_promo_groups_table() -> bool: return False +async def ensure_tariff_max_device_limit_column() -> bool: + """Добавляет колонку max_device_limit в таблицу tariffs.""" + try: + column_exists = await check_column_exists('tariffs', 'max_device_limit') + if column_exists: + logger.info("ℹ️ Колонка max_device_limit в tariffs уже существует") + return True + + async with engine.begin() as conn: + db_type = await get_database_type() + + if db_type == 'sqlite': + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN max_device_limit INTEGER NULL" + )) + elif db_type == 'postgresql': + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN max_device_limit INTEGER NULL" + )) + else: # MySQL + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN max_device_limit INT NULL" + )) + + logger.info("✅ Колонка max_device_limit добавлена в tariffs") + return True + + except Exception as error: + logger.error(f"❌ Ошибка добавления колонки max_device_limit: {error}") + return False + + async def add_subscription_tariff_id_column() -> bool: """Добавляет колонку tariff_id в таблицу subscriptions.""" try: @@ -5332,9 +5426,682 @@ async def add_tariff_device_price_column() -> bool: return False +async def add_tariff_server_traffic_limits_column() -> bool: + """Добавляет колонку server_traffic_limits в таблицу tariffs.""" + try: + if await check_column_exists('tariffs', 'server_traffic_limits'): + logger.info("ℹ️ Колонка server_traffic_limits уже существует в tariffs") + return True + + async with engine.begin() as conn: + db_type = await get_database_type() + + if db_type == 'sqlite': + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN server_traffic_limits TEXT DEFAULT '{}'" + )) + elif db_type == 'postgresql': + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN server_traffic_limits JSONB DEFAULT '{}'" + )) + else: # MySQL + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN server_traffic_limits JSON DEFAULT NULL" + )) + + logger.info("✅ Колонка server_traffic_limits добавлена в tariffs") + return True + + except Exception as error: + logger.error(f"❌ Ошибка добавления колонки server_traffic_limits: {error}") + return False + + +async def add_tariff_allow_traffic_topup_column() -> bool: + """Добавляет колонку allow_traffic_topup в таблицу tariffs.""" + try: + if await check_column_exists('tariffs', 'allow_traffic_topup'): + logger.info("ℹ️ Колонка allow_traffic_topup уже существует в tariffs") + return True + + async with engine.begin() as conn: + db_type = await get_database_type() + + if db_type == 'sqlite': + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN allow_traffic_topup INTEGER NOT NULL DEFAULT 1" + )) + elif db_type == 'postgresql': + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN allow_traffic_topup BOOLEAN NOT NULL DEFAULT TRUE" + )) + else: # MySQL + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN allow_traffic_topup BOOLEAN NOT NULL DEFAULT TRUE" + )) + + logger.info("✅ Колонка allow_traffic_topup добавлена в tariffs") + return True + + except Exception as error: + logger.error(f"❌ Ошибка добавления колонки allow_traffic_topup: {error}") + return False + + +async def create_wheel_tables() -> bool: + """Создаёт таблицы для колеса удачи: wheel_config, wheel_prizes, wheel_spins.""" + try: + db_type = await get_database_type() + + # Создание wheel_config + if not await check_table_exists('wheel_config'): + async with engine.begin() as conn: + if db_type == 'sqlite': + create_config_sql = """ + CREATE TABLE wheel_config ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + is_enabled BOOLEAN NOT NULL DEFAULT 0, + name VARCHAR(255) NOT NULL DEFAULT 'Колесо удачи', + spin_cost_stars INTEGER NOT NULL DEFAULT 50, + spin_cost_days INTEGER NOT NULL DEFAULT 3, + spin_cost_stars_enabled BOOLEAN NOT NULL DEFAULT 1, + spin_cost_days_enabled BOOLEAN NOT NULL DEFAULT 1, + rtp_percent REAL NOT NULL DEFAULT 85.0, + daily_spin_limit INTEGER NOT NULL DEFAULT 5, + min_subscription_days_for_day_payment INTEGER NOT NULL DEFAULT 7, + promo_prefix VARCHAR(50) NOT NULL DEFAULT 'WHEEL', + promo_validity_days INTEGER NOT NULL DEFAULT 30, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + """ + elif db_type == 'postgresql': + create_config_sql = """ + CREATE TABLE wheel_config ( + id SERIAL PRIMARY KEY, + is_enabled BOOLEAN NOT NULL DEFAULT FALSE, + name VARCHAR(255) NOT NULL DEFAULT 'Колесо удачи', + spin_cost_stars INTEGER NOT NULL DEFAULT 50, + spin_cost_days INTEGER NOT NULL DEFAULT 3, + spin_cost_stars_enabled BOOLEAN NOT NULL DEFAULT TRUE, + spin_cost_days_enabled BOOLEAN NOT NULL DEFAULT TRUE, + rtp_percent REAL NOT NULL DEFAULT 85.0, + daily_spin_limit INTEGER NOT NULL DEFAULT 5, + min_subscription_days_for_day_payment INTEGER NOT NULL DEFAULT 7, + promo_prefix VARCHAR(50) NOT NULL DEFAULT 'WHEEL', + promo_validity_days INTEGER NOT NULL DEFAULT 30, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """ + else: # mysql + create_config_sql = """ + CREATE TABLE wheel_config ( + id INT AUTO_INCREMENT PRIMARY KEY, + is_enabled BOOLEAN NOT NULL DEFAULT FALSE, + name VARCHAR(255) NOT NULL DEFAULT 'Колесо удачи', + spin_cost_stars INT NOT NULL DEFAULT 50, + spin_cost_days INT NOT NULL DEFAULT 3, + spin_cost_stars_enabled BOOLEAN NOT NULL DEFAULT TRUE, + spin_cost_days_enabled BOOLEAN NOT NULL DEFAULT TRUE, + rtp_percent FLOAT NOT NULL DEFAULT 85.0, + daily_spin_limit INT NOT NULL DEFAULT 5, + min_subscription_days_for_day_payment INT NOT NULL DEFAULT 7, + promo_prefix VARCHAR(50) NOT NULL DEFAULT 'WHEEL', + promo_validity_days INT NOT NULL DEFAULT 30, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) + """ + await conn.execute(text(create_config_sql)) + logger.info("✅ Таблица wheel_config создана") + else: + logger.debug("ℹ️ Таблица wheel_config уже существует") + + # Создание wheel_prizes + if not await check_table_exists('wheel_prizes'): + async with engine.begin() as conn: + if db_type == 'sqlite': + create_prizes_sql = """ + CREATE TABLE wheel_prizes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + config_id INTEGER NOT NULL, + prize_type VARCHAR(50) NOT NULL, + prize_value INTEGER NOT NULL DEFAULT 0, + display_name VARCHAR(255) NOT NULL, + emoji VARCHAR(10) NOT NULL DEFAULT '🎁', + color VARCHAR(20) NOT NULL DEFAULT '#3B82F6', + prize_value_kopeks INTEGER NOT NULL DEFAULT 0, + sort_order INTEGER NOT NULL DEFAULT 0, + manual_probability REAL, + is_active BOOLEAN NOT NULL DEFAULT 1, + promo_balance_bonus_kopeks INTEGER NOT NULL DEFAULT 0, + promo_subscription_days INTEGER NOT NULL DEFAULT 0, + promo_traffic_gb INTEGER NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (config_id) REFERENCES wheel_config(id) ON DELETE CASCADE + ) + """ + elif db_type == 'postgresql': + create_prizes_sql = """ + CREATE TABLE wheel_prizes ( + id SERIAL PRIMARY KEY, + config_id INTEGER NOT NULL REFERENCES wheel_config(id) ON DELETE CASCADE, + prize_type VARCHAR(50) NOT NULL, + prize_value INTEGER NOT NULL DEFAULT 0, + display_name VARCHAR(255) NOT NULL, + emoji VARCHAR(10) NOT NULL DEFAULT '🎁', + color VARCHAR(20) NOT NULL DEFAULT '#3B82F6', + prize_value_kopeks INTEGER NOT NULL DEFAULT 0, + sort_order INTEGER NOT NULL DEFAULT 0, + manual_probability REAL, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + promo_balance_bonus_kopeks INTEGER NOT NULL DEFAULT 0, + promo_subscription_days INTEGER NOT NULL DEFAULT 0, + promo_traffic_gb INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """ + else: # mysql + create_prizes_sql = """ + CREATE TABLE wheel_prizes ( + id INT AUTO_INCREMENT PRIMARY KEY, + config_id INT NOT NULL, + prize_type VARCHAR(50) NOT NULL, + prize_value INT NOT NULL DEFAULT 0, + display_name VARCHAR(255) NOT NULL, + emoji VARCHAR(10) NOT NULL DEFAULT '🎁', + color VARCHAR(20) NOT NULL DEFAULT '#3B82F6', + prize_value_kopeks INT NOT NULL DEFAULT 0, + sort_order INT NOT NULL DEFAULT 0, + manual_probability FLOAT, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + promo_balance_bonus_kopeks INT NOT NULL DEFAULT 0, + promo_subscription_days INT NOT NULL DEFAULT 0, + promo_traffic_gb INT NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (config_id) REFERENCES wheel_config(id) ON DELETE CASCADE + ) + """ + await conn.execute(text(create_prizes_sql)) + # Индексы + try: + await conn.execute(text( + "CREATE INDEX idx_wheel_prizes_config_id ON wheel_prizes(config_id)" + )) + except Exception: + pass + logger.info("✅ Таблица wheel_prizes создана") + else: + logger.debug("ℹ️ Таблица wheel_prizes уже существует") + + # Создание wheel_spins + if not await check_table_exists('wheel_spins'): + async with engine.begin() as conn: + if db_type == 'sqlite': + create_spins_sql = """ + CREATE TABLE wheel_spins ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + config_id INTEGER NOT NULL, + prize_id INTEGER, + payment_type VARCHAR(50) NOT NULL, + payment_amount INTEGER NOT NULL, + payment_value_kopeks INTEGER NOT NULL DEFAULT 0, + prize_type VARCHAR(50) NOT NULL, + prize_value INTEGER NOT NULL DEFAULT 0, + prize_value_kopeks INTEGER NOT NULL DEFAULT 0, + promocode_id INTEGER, + is_applied BOOLEAN NOT NULL DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (config_id) REFERENCES wheel_config(id) ON DELETE CASCADE, + FOREIGN KEY (prize_id) REFERENCES wheel_prizes(id) ON DELETE SET NULL, + FOREIGN KEY (promocode_id) REFERENCES promocodes(id) ON DELETE SET NULL + ) + """ + elif db_type == 'postgresql': + create_spins_sql = """ + CREATE TABLE wheel_spins ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + config_id INTEGER NOT NULL REFERENCES wheel_config(id) ON DELETE CASCADE, + prize_id INTEGER REFERENCES wheel_prizes(id) ON DELETE SET NULL, + payment_type VARCHAR(50) NOT NULL, + payment_amount INTEGER NOT NULL, + payment_value_kopeks INTEGER NOT NULL DEFAULT 0, + prize_type VARCHAR(50) NOT NULL, + prize_value INTEGER NOT NULL DEFAULT 0, + prize_value_kopeks INTEGER NOT NULL DEFAULT 0, + promocode_id INTEGER REFERENCES promocodes(id) ON DELETE SET NULL, + is_applied BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """ + else: # mysql + create_spins_sql = """ + CREATE TABLE wheel_spins ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + config_id INT NOT NULL, + prize_id INT, + payment_type VARCHAR(50) NOT NULL, + payment_amount INT NOT NULL, + payment_value_kopeks INT NOT NULL DEFAULT 0, + prize_type VARCHAR(50) NOT NULL, + prize_value INT NOT NULL DEFAULT 0, + prize_value_kopeks INT NOT NULL DEFAULT 0, + promocode_id INT, + is_applied BOOLEAN NOT NULL DEFAULT TRUE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (config_id) REFERENCES wheel_config(id) ON DELETE CASCADE, + FOREIGN KEY (prize_id) REFERENCES wheel_prizes(id) ON DELETE SET NULL, + FOREIGN KEY (promocode_id) REFERENCES promocodes(id) ON DELETE SET NULL + ) + """ + await conn.execute(text(create_spins_sql)) + # Индексы + try: + await conn.execute(text( + "CREATE INDEX idx_wheel_spins_user_id ON wheel_spins(user_id)" + )) + await conn.execute(text( + "CREATE INDEX idx_wheel_spins_created_at ON wheel_spins(created_at)" + )) + except Exception: + pass + logger.info("✅ Таблица wheel_spins создана") + else: + logger.debug("ℹ️ Таблица wheel_spins уже существует") + + return True + + except Exception as error: + logger.error(f"❌ Ошибка создания таблиц для колеса удачи: {error}") + return False + + +async def add_tariff_traffic_topup_columns() -> bool: + """Добавляет колонки для докупки трафика в тарифах.""" + try: + columns_added = 0 + + # Колонка traffic_topup_enabled + if not await check_column_exists('tariffs', 'traffic_topup_enabled'): + async with engine.begin() as conn: + db_type = await get_database_type() + + if db_type == 'sqlite': + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN traffic_topup_enabled INTEGER DEFAULT 0 NOT NULL" + )) + elif db_type == 'postgresql': + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN traffic_topup_enabled BOOLEAN DEFAULT FALSE NOT NULL" + )) + else: # MySQL + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN traffic_topup_enabled TINYINT(1) DEFAULT 0 NOT NULL" + )) + + logger.info("✅ Колонка traffic_topup_enabled добавлена в tariffs") + columns_added += 1 + else: + logger.info("ℹ️ Колонка traffic_topup_enabled уже существует в tariffs") + + # Колонка traffic_topup_packages (JSON) + if not await check_column_exists('tariffs', 'traffic_topup_packages'): + async with engine.begin() as conn: + db_type = await get_database_type() + + if db_type == 'sqlite': + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN traffic_topup_packages TEXT DEFAULT '{}'" + )) + elif db_type == 'postgresql': + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN traffic_topup_packages JSONB DEFAULT '{}'" + )) + else: # MySQL + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN traffic_topup_packages JSON DEFAULT NULL" + )) + + logger.info("✅ Колонка traffic_topup_packages добавлена в tariffs") + columns_added += 1 + else: + logger.info("ℹ️ Колонка traffic_topup_packages уже существует в tariffs") + + # Колонка max_topup_traffic_gb (максимальный лимит трафика после докупок) + if not await check_column_exists('tariffs', 'max_topup_traffic_gb'): + async with engine.begin() as conn: + db_type = await get_database_type() + + if db_type == 'sqlite': + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN max_topup_traffic_gb INTEGER DEFAULT 0 NOT NULL" + )) + elif db_type == 'postgresql': + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN max_topup_traffic_gb INTEGER DEFAULT 0 NOT NULL" + )) + else: # MySQL + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN max_topup_traffic_gb INT DEFAULT 0 NOT NULL" + )) + + logger.info("✅ Колонка max_topup_traffic_gb добавлена в tariffs") + columns_added += 1 + else: + logger.info("ℹ️ Колонка max_topup_traffic_gb уже существует в tariffs") + + return True + + except Exception as error: + logger.error(f"❌ Ошибка добавления колонок для докупки трафика: {error}") + return False + + +async def add_tariff_daily_columns() -> bool: + """Добавляет колонки для суточных тарифов.""" + try: + columns_added = 0 + + # Колонка is_daily + if not await check_column_exists('tariffs', 'is_daily'): + async with engine.begin() as conn: + db_type = await get_database_type() + + if db_type == 'sqlite': + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN is_daily INTEGER DEFAULT 0 NOT NULL" + )) + elif db_type == 'postgresql': + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN is_daily BOOLEAN DEFAULT FALSE NOT NULL" + )) + else: # MySQL + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN is_daily TINYINT(1) DEFAULT 0 NOT NULL" + )) + + logger.info("✅ Колонка is_daily добавлена в tariffs") + columns_added += 1 + else: + logger.info("ℹ️ Колонка is_daily уже существует в tariffs") + + # Колонка daily_price_kopeks + if not await check_column_exists('tariffs', 'daily_price_kopeks'): + async with engine.begin() as conn: + db_type = await get_database_type() + + if db_type == 'sqlite': + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN daily_price_kopeks INTEGER DEFAULT 0 NOT NULL" + )) + elif db_type == 'postgresql': + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN daily_price_kopeks INTEGER DEFAULT 0 NOT NULL" + )) + else: # MySQL + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN daily_price_kopeks INT DEFAULT 0 NOT NULL" + )) + + logger.info("✅ Колонка daily_price_kopeks добавлена в tariffs") + columns_added += 1 + else: + logger.info("ℹ️ Колонка daily_price_kopeks уже существует в tariffs") + + return True + + except Exception as error: + logger.error(f"❌ Ошибка добавления колонок суточного тарифа: {error}") + return False + + +async def add_tariff_custom_days_traffic_columns() -> bool: + """Добавляет колонки для произвольных дней и трафика в тарифы.""" + try: + columns_added = 0 + db_type = await get_database_type() + + # === ПРОИЗВОЛЬНОЕ КОЛИЧЕСТВО ДНЕЙ === + # custom_days_enabled + if not await check_column_exists('tariffs', 'custom_days_enabled'): + async with engine.begin() as conn: + if db_type == 'sqlite': + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN custom_days_enabled INTEGER DEFAULT 0 NOT NULL" + )) + elif db_type == 'postgresql': + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN custom_days_enabled BOOLEAN DEFAULT FALSE NOT NULL" + )) + else: # MySQL + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN custom_days_enabled TINYINT(1) DEFAULT 0 NOT NULL" + )) + logger.info("✅ Колонка custom_days_enabled добавлена в tariffs") + columns_added += 1 + else: + logger.info("ℹ️ Колонка custom_days_enabled уже существует в tariffs") + + # price_per_day_kopeks + if not await check_column_exists('tariffs', 'price_per_day_kopeks'): + async with engine.begin() as conn: + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN price_per_day_kopeks INTEGER DEFAULT 0 NOT NULL" + )) + logger.info("✅ Колонка price_per_day_kopeks добавлена в tariffs") + columns_added += 1 + else: + logger.info("ℹ️ Колонка price_per_day_kopeks уже существует в tariffs") + + # min_days + if not await check_column_exists('tariffs', 'min_days'): + async with engine.begin() as conn: + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN min_days INTEGER DEFAULT 1 NOT NULL" + )) + logger.info("✅ Колонка min_days добавлена в tariffs") + columns_added += 1 + else: + logger.info("ℹ️ Колонка min_days уже существует в tariffs") + + # max_days + if not await check_column_exists('tariffs', 'max_days'): + async with engine.begin() as conn: + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN max_days INTEGER DEFAULT 365 NOT NULL" + )) + logger.info("✅ Колонка max_days добавлена в tariffs") + columns_added += 1 + else: + logger.info("ℹ️ Колонка max_days уже существует в tariffs") + + # === ПРОИЗВОЛЬНЫЙ ТРАФИК ПРИ ПОКУПКЕ === + # custom_traffic_enabled + if not await check_column_exists('tariffs', 'custom_traffic_enabled'): + async with engine.begin() as conn: + if db_type == 'sqlite': + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN custom_traffic_enabled INTEGER DEFAULT 0 NOT NULL" + )) + elif db_type == 'postgresql': + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN custom_traffic_enabled BOOLEAN DEFAULT FALSE NOT NULL" + )) + else: # MySQL + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN custom_traffic_enabled TINYINT(1) DEFAULT 0 NOT NULL" + )) + logger.info("✅ Колонка custom_traffic_enabled добавлена в tariffs") + columns_added += 1 + else: + logger.info("ℹ️ Колонка custom_traffic_enabled уже существует в tariffs") + + # traffic_price_per_gb_kopeks + if not await check_column_exists('tariffs', 'traffic_price_per_gb_kopeks'): + async with engine.begin() as conn: + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN traffic_price_per_gb_kopeks INTEGER DEFAULT 0 NOT NULL" + )) + logger.info("✅ Колонка traffic_price_per_gb_kopeks добавлена в tariffs") + columns_added += 1 + else: + logger.info("ℹ️ Колонка traffic_price_per_gb_kopeks уже существует в tariffs") + + # min_traffic_gb + if not await check_column_exists('tariffs', 'min_traffic_gb'): + async with engine.begin() as conn: + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN min_traffic_gb INTEGER DEFAULT 1 NOT NULL" + )) + logger.info("✅ Колонка min_traffic_gb добавлена в tariffs") + columns_added += 1 + else: + logger.info("ℹ️ Колонка min_traffic_gb уже существует в tariffs") + + # max_traffic_gb + if not await check_column_exists('tariffs', 'max_traffic_gb'): + async with engine.begin() as conn: + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN max_traffic_gb INTEGER DEFAULT 1000 NOT NULL" + )) + logger.info("✅ Колонка max_traffic_gb добавлена в tariffs") + columns_added += 1 + else: + logger.info("ℹ️ Колонка max_traffic_gb уже существует в tariffs") + + if columns_added > 0: + logger.info(f"✅ Добавлено {columns_added} колонок для произвольных дней/трафика") + + return True + + except Exception as error: + logger.error(f"❌ Ошибка добавления колонок произвольных дней/трафика: {error}") + return False + + +async def add_tariff_traffic_reset_mode_column() -> bool: + """Добавляет колонку traffic_reset_mode в tariffs для настройки режима сброса трафика. + + Значения: DAY, WEEK, MONTH, NO_RESET (NULL = использовать глобальную настройку) + """ + try: + if not await check_column_exists('tariffs', 'traffic_reset_mode'): + async with engine.begin() as conn: + await conn.execute(text( + "ALTER TABLE tariffs ADD COLUMN traffic_reset_mode VARCHAR(20) NULL" + )) + logger.info("✅ Колонка traffic_reset_mode добавлена в tariffs") + return True + else: + logger.info("ℹ️ Колонка traffic_reset_mode уже существует в tariffs") + return True + + except Exception as error: + logger.error(f"❌ Ошибка добавления колонки traffic_reset_mode: {error}") + return False + + +async def add_subscription_daily_columns() -> bool: + """Добавляет колонки для суточных подписок.""" + try: + columns_added = 0 + + # Колонка is_daily_paused + if not await check_column_exists('subscriptions', 'is_daily_paused'): + async with engine.begin() as conn: + db_type = await get_database_type() + + if db_type == 'sqlite': + await conn.execute(text( + "ALTER TABLE subscriptions ADD COLUMN is_daily_paused INTEGER DEFAULT 0 NOT NULL" + )) + elif db_type == 'postgresql': + await conn.execute(text( + "ALTER TABLE subscriptions ADD COLUMN is_daily_paused BOOLEAN DEFAULT FALSE NOT NULL" + )) + else: # MySQL + await conn.execute(text( + "ALTER TABLE subscriptions ADD COLUMN is_daily_paused TINYINT(1) DEFAULT 0 NOT NULL" + )) + + logger.info("✅ Колонка is_daily_paused добавлена в subscriptions") + columns_added += 1 + else: + logger.info("ℹ️ Колонка is_daily_paused уже существует в subscriptions") + + # Колонка last_daily_charge_at + if not await check_column_exists('subscriptions', 'last_daily_charge_at'): + async with engine.begin() as conn: + db_type = await get_database_type() + + if db_type == 'sqlite': + await conn.execute(text( + "ALTER TABLE subscriptions ADD COLUMN last_daily_charge_at DATETIME NULL" + )) + elif db_type == 'postgresql': + await conn.execute(text( + "ALTER TABLE subscriptions ADD COLUMN last_daily_charge_at TIMESTAMP NULL" + )) + else: # MySQL + await conn.execute(text( + "ALTER TABLE subscriptions ADD COLUMN last_daily_charge_at DATETIME NULL" + )) + + logger.info("✅ Колонка last_daily_charge_at добавлена в subscriptions") + columns_added += 1 + else: + logger.info("ℹ️ Колонка last_daily_charge_at уже существует в subscriptions") + + return True + + except Exception as error: + logger.error(f"❌ Ошибка добавления колонок суточной подписки: {error}") + return False + + +async def add_subscription_traffic_reset_at_column() -> bool: + """Добавляет колонку traffic_reset_at в subscriptions для сброса докупленного трафика через 30 дней.""" + try: + if not await check_column_exists('subscriptions', 'traffic_reset_at'): + async with engine.begin() as conn: + db_type = await get_database_type() + + if db_type == 'sqlite': + await conn.execute(text( + "ALTER TABLE subscriptions ADD COLUMN traffic_reset_at DATETIME NULL" + )) + elif db_type == 'postgresql': + await conn.execute(text( + "ALTER TABLE subscriptions ADD COLUMN traffic_reset_at TIMESTAMP NULL" + )) + else: # MySQL + await conn.execute(text( + "ALTER TABLE subscriptions ADD COLUMN traffic_reset_at DATETIME NULL" + )) + + logger.info("✅ Колонка traffic_reset_at добавлена в subscriptions") + return True + else: + logger.info("ℹ️ Колонка traffic_reset_at уже существует в subscriptions") + return True + + except Exception as error: + logger.error(f"❌ Ошибка добавления колонки traffic_reset_at: {error}") + return False + + async def run_universal_migration(): logger.info("=== НАЧАЛО УНИВЕРСАЛЬНОЙ МИГРАЦИИ ===") - + try: db_type = await get_database_type() logger.info(f"Тип базы данных: {db_type}") @@ -5809,6 +6576,13 @@ async def run_universal_migration(): else: logger.warning("⚠️ Проблемы с настройкой доступа серверов к промогруппам") + logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ ДОКУПОК ТРАФИКА ===") + traffic_purchases_ready = await create_traffic_purchases_table() + if traffic_purchases_ready: + logger.info("✅ Таблица traffic_purchases готова") + else: + logger.warning("⚠️ Проблемы с таблицей traffic_purchases") + logger.info("=== СОЗДАНИЕ ТАБЛИЦ ДЛЯ РЕЖИМА ТАРИФОВ ===") tariffs_table_ready = await create_tariffs_table() if tariffs_table_ready: @@ -5834,6 +6608,65 @@ async def run_universal_migration(): else: logger.warning("⚠️ Проблемы с колонкой device_price_kopeks в tariffs") + max_device_limit_ready = await ensure_tariff_max_device_limit_column() + if max_device_limit_ready: + logger.info("✅ Колонка max_device_limit в tariffs готова") + else: + logger.warning("⚠️ Проблемы с колонкой max_device_limit в tariffs") + + server_traffic_limits_ready = await add_tariff_server_traffic_limits_column() + if server_traffic_limits_ready: + logger.info("✅ Колонка server_traffic_limits в tariffs готова") + else: + logger.warning("⚠️ Проблемы с колонкой server_traffic_limits в tariffs") + + allow_traffic_topup_ready = await add_tariff_allow_traffic_topup_column() + if allow_traffic_topup_ready: + logger.info("✅ Колонка allow_traffic_topup в tariffs готова") + else: + logger.warning("⚠️ Проблемы с колонкой allow_traffic_topup в tariffs") + + traffic_topup_columns_ready = await add_tariff_traffic_topup_columns() + if traffic_topup_columns_ready: + logger.info("✅ Колонки докупки трафика в tariffs готовы") + else: + logger.warning("⚠️ Проблемы с колонками докупки трафика в tariffs") + + logger.info("=== ДОБАВЛЕНИЕ КОЛОНОК СУТОЧНЫХ ТАРИФОВ ===") + daily_tariff_columns_ready = await add_tariff_daily_columns() + if daily_tariff_columns_ready: + logger.info("✅ Колонки суточных тарифов в tariffs готовы") + else: + logger.warning("⚠️ Проблемы с колонками суточных тарифов в tariffs") + + logger.info("=== ДОБАВЛЕНИЕ КОЛОНОК ПРОИЗВОЛЬНЫХ ДНЕЙ/ТРАФИКА ===") + custom_days_traffic_ready = await add_tariff_custom_days_traffic_columns() + if custom_days_traffic_ready: + logger.info("✅ Колонки произвольных дней/трафика в tariffs готовы") + else: + logger.warning("⚠️ Проблемы с колонками произвольных дней/трафика в tariffs") + + logger.info("=== ДОБАВЛЕНИЕ КОЛОНКИ РЕЖИМА СБРОСА ТРАФИКА В ТАРИФАХ ===") + traffic_reset_mode_ready = await add_tariff_traffic_reset_mode_column() + if traffic_reset_mode_ready: + logger.info("✅ Колонка traffic_reset_mode в tariffs готова") + else: + logger.warning("⚠️ Проблемы с колонкой traffic_reset_mode в tariffs") + + logger.info("=== ДОБАВЛЕНИЕ КОЛОНОК СУТОЧНЫХ ПОДПИСОК ===") + daily_subscription_columns_ready = await add_subscription_daily_columns() + if daily_subscription_columns_ready: + logger.info("✅ Колонки суточных подписок в subscriptions готовы") + else: + logger.warning("⚠️ Проблемы с колонками суточных подписок в subscriptions") + + logger.info("=== ДОБАВЛЕНИЕ КОЛОНКИ СБРОСА ТРАФИКА ===") + traffic_reset_column_ready = await add_subscription_traffic_reset_at_column() + if traffic_reset_column_ready: + logger.info("✅ Колонка traffic_reset_at в subscriptions готова") + else: + logger.warning("⚠️ Проблемы с колонкой traffic_reset_at в subscriptions") + logger.info("=== ОБНОВЛЕНИЕ ВНЕШНИХ КЛЮЧЕЙ ===") fk_updated = await fix_foreign_keys_for_user_deletion() if fk_updated: @@ -5869,6 +6702,13 @@ async def run_universal_migration(): else: logger.warning("⚠️ Проблемы с таблицей withdrawal_requests") + logger.info("=== СОЗДАНИЕ ТАБЛИЦ КОЛЕСА УДАЧИ ===") + wheel_tables_ready = await create_wheel_tables() + if wheel_tables_ready: + logger.info("✅ Таблицы колеса удачи готовы") + else: + logger.warning("⚠️ Проблемы с таблицами колеса удачи") + async with engine.begin() as conn: total_subs = await conn.execute(text("SELECT COUNT(*) FROM subscriptions")) unique_users = await conn.execute(text("SELECT COUNT(DISTINCT user_id) FROM subscriptions")) diff --git a/app/external/yookassa_webhook.py b/app/external/yookassa_webhook.py index 6ea1811d..246c948a 100644 --- a/app/external/yookassa_webhook.py +++ b/app/external/yookassa_webhook.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import asyncio import logging import json @@ -9,13 +11,15 @@ from ipaddress import ( ip_address, ip_network, ) -from typing import Iterable, Optional, Dict, Any, List, Union, Tuple +from typing import Iterable, Optional, Dict, Any, List, Union, Tuple, TYPE_CHECKING from aiohttp import web from app.config import settings -from app.services.payment_service import PaymentService from app.database.database import get_db +if TYPE_CHECKING: + from app.services.payment_service import PaymentService + logger = logging.getLogger(__name__) diff --git a/app/handlers/admin/messages.py b/app/handlers/admin/messages.py index d4b34980..61ad9db4 100644 --- a/app/handlers/admin/messages.py +++ b/app/handlers/admin/messages.py @@ -18,6 +18,7 @@ from app.database.models import ( Subscription, SubscriptionStatus, BroadcastHistory, + Tariff, ) from app.database.database import AsyncSessionLocal from app.keyboards.admin import ( @@ -31,6 +32,7 @@ from app.keyboards.admin import ( from app.localization.texts import get_texts from app.database.crud.user import get_users_list from app.database.crud.subscription import get_expiring_subscriptions +from app.database.crud.tariff import get_all_tariffs from app.utils.decorators import admin_required, error_handler from app.utils.miniapp_buttons import build_miniapp_or_callback_button from app.services.pinned_message_service import ( @@ -42,6 +44,41 @@ from app.services.pinned_message_service import ( logger = logging.getLogger(__name__) + +async def safe_edit_or_send_text( + callback: types.CallbackQuery, + text: str, + reply_markup=None, + parse_mode: str = "HTML" +): + """ + Безопасно редактирует сообщение или удаляет и отправляет новое. + Нужно для случаев, когда текущее сообщение - медиа (фото/видео), + которое нельзя отредактировать через edit_text. + """ + try: + await callback.message.edit_text( + text, + reply_markup=reply_markup, + parse_mode=parse_mode + ) + except TelegramBadRequest as e: + if "there is no text in the message to edit" in str(e): + # Сообщение - медиа без текста, удаляем и отправляем новое + try: + await callback.message.delete() + except Exception: + pass + await callback.bot.send_message( + chat_id=callback.message.chat.id, + text=text, + reply_markup=reply_markup, + parse_mode=parse_mode + ) + else: + raise + + BUTTON_ROWS = BROADCAST_BUTTON_ROWS DEFAULT_SELECTED_BUTTONS = DEFAULT_BROADCAST_BUTTONS @@ -166,10 +203,11 @@ async def show_messages_menu( ⚠️ Будьте осторожны с массовыми рассылками! """ - await callback.message.edit_text( + await safe_edit_or_send_text( + callback, text, reply_markup=get_admin_messages_keyboard(db_user.language), - parse_mode="HTML" + parse_mode="HTML" ) await callback.answer() @@ -481,7 +519,63 @@ async def show_broadcast_targets( "🎯 Выбор целевой аудитории\n\n" "Выберите категорию пользователей для рассылки:", reply_markup=get_broadcast_target_keyboard(db_user.language), - parse_mode="HTML" + parse_mode="HTML" + ) + await callback.answer() + + +@admin_required +@error_handler +async def show_tariff_filter( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + """Показывает список тарифов для фильтрации рассылки.""" + tariffs = await get_all_tariffs(db, include_inactive=False) + + if not tariffs: + await callback.message.edit_text( + "❌ Нет доступных тарифов\n\n" + "Создайте тарифы в разделе управления тарифами.", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_msg_by_sub")] + ]), + parse_mode="HTML" + ) + await callback.answer() + return + + # Получаем количество подписчиков на каждом тарифе + tariff_counts = {} + for tariff in tariffs: + count_query = ( + select(func.count(Subscription.id)) + .where( + Subscription.tariff_id == tariff.id, + Subscription.status == SubscriptionStatus.ACTIVE.value, + ) + ) + result = await db.execute(count_query) + tariff_counts[tariff.id] = result.scalar() or 0 + + buttons = [] + for tariff in tariffs: + count = tariff_counts.get(tariff.id, 0) + buttons.append([ + types.InlineKeyboardButton( + text=f"{tariff.name} ({count} чел.)", + callback_data=f"broadcast_tariff_{tariff.id}" + ) + ]) + + buttons.append([types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_msg_by_sub")]) + + await callback.message.edit_text( + "📦 Рассылка по тарифу\n\n" + "Выберите тариф для рассылки пользователям с активной подпиской на этот тариф:", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=buttons), + parse_mode="HTML" ) await callback.answer() @@ -654,23 +748,34 @@ async def select_broadcast_target( "active_zero": "Активная подписка, трафик 0 ГБ", "trial_zero": "Триальная подписка, трафик 0 ГБ", } - + + # Обработка фильтра по тарифу + target_name = target_names.get(target, target) + if target.startswith("tariff_"): + tariff_id = int(target.split("_")[1]) + from app.database.crud.tariff import get_tariff_by_id + tariff = await get_tariff_by_id(db, tariff_id) + if tariff: + target_name = f"Тариф «{tariff.name}»" + else: + target_name = f"Тариф #{tariff_id}" + user_count = await get_target_users_count(db, target) - + await state.update_data(broadcast_target=target) - + await callback.message.edit_text( f"📨 Создание рассылки\n\n" - f"🎯 Аудитория: {target_names.get(target, target)}\n" + f"🎯 Аудитория: {target_name}\n" f"👥 Получателей: {user_count}\n\n" f"Введите текст сообщения для рассылки:\n\n" f"Поддерживается HTML разметка", reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ [types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")] ]), - parse_mode="HTML" + parse_mode="HTML" ) - + await state.set_state(AdminStates.waiting_for_broadcast_message) await callback.answer() @@ -865,7 +970,8 @@ async def handle_change_media( db_user: User, state: FSMContext ): - await callback.message.edit_text( + await safe_edit_or_send_text( + callback, "🖼️ Изменение медиафайла\n\n" "Выберите новый тип медиа:", reply_markup=get_broadcast_media_keyboard(db_user.language), @@ -1092,7 +1198,10 @@ async def confirm_button_selection( media_file_id = data.get('media_file_id') if media_file_id: # Удаляем текущее сообщение и отправляем новое с фото - await callback.message.delete() + try: + await callback.message.delete() + except Exception: + pass await callback.bot.send_photo( chat_id=callback.message.chat.id, photo=media_file_id, @@ -1101,21 +1210,25 @@ async def confirm_button_selection( parse_mode="HTML" ) else: - # Если нет file_id, используем обычное редактирование - await callback.message.edit_text( + # Если нет file_id, используем safe редактирование + await safe_edit_or_send_text( + callback, preview_text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), parse_mode="HTML" ) else: - # Для текстовых сообщений или других типов медиа используем обычное редактирование - await callback.message.edit_text( + # Для текстовых сообщений или других типов медиа используем safe редактирование + await safe_edit_or_send_text( + callback, preview_text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), parse_mode="HTML" ) - + await callback.answer() + + @admin_required @error_handler async def confirm_broadcast( @@ -1135,13 +1248,14 @@ async def confirm_broadcast( media_file_id = data.get('media_file_id') media_caption = data.get('media_caption') - await callback.message.edit_text( + await safe_edit_or_send_text( + callback, "📨 Начинаю рассылку...\n\n" "⏳ Это может занять несколько минут.", reply_markup=None, - parse_mode="HTML" + parse_mode="HTML" ) - + if target.startswith('custom_'): users = await get_custom_users(db, target.replace('custom_', '')) else: @@ -1284,19 +1398,221 @@ async def confirm_broadcast( Администратор: {db_user.full_name} """ - await callback.message.edit_text( - result_text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="📨 К рассылкам", callback_data="admin_messages")] - ]), - parse_mode="HTML" - ) - + try: + await callback.message.edit_text( + result_text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="📨 К рассылкам", callback_data="admin_messages")] + ]), + parse_mode="HTML" + ) + except TelegramBadRequest as e: + error_msg = str(e).lower() + if "message to edit not found" in error_msg or "there is no text" in error_msg or "message can't be edited" in error_msg: + # Сообщение удалено или это медиа - отправляем новое + await callback.bot.send_message( + chat_id=callback.message.chat.id, + text=result_text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="📨 К рассылкам", callback_data="admin_messages")] + ]), + parse_mode="HTML" + ) + else: + raise + await state.clear() logger.info(f"Рассылка выполнена админом {db_user.telegram_id}: {sent_count}/{len(users)} (медиа: {has_media})") async def get_target_users_count(db: AsyncSession, target: str) -> int: + """Быстрый подсчёт пользователей через SQL COUNT вместо загрузки всех в память.""" + from sqlalchemy import func as sql_func, distinct + from datetime import datetime, timedelta + + base_filter = User.status == UserStatus.ACTIVE.value + + if target == "all": + query = select(sql_func.count(User.id)).where(base_filter) + result = await db.execute(query) + return result.scalar() or 0 + + if target == "active": + # Активные платные подписки (не триал) + query = ( + select(sql_func.count(distinct(User.id))) + .join(Subscription, User.id == Subscription.user_id) + .where( + base_filter, + Subscription.status == SubscriptionStatus.ACTIVE.value, + Subscription.is_trial == False, + ) + ) + result = await db.execute(query) + return result.scalar() or 0 + + if target == "trial": + # Триальные подписки (без проверки is_active, как в оригинале) + query = ( + select(sql_func.count(distinct(User.id))) + .join(Subscription, User.id == Subscription.user_id) + .where( + base_filter, + Subscription.is_trial == True, + ) + ) + result = await db.execute(query) + return result.scalar() or 0 + + if target == "no": + # Без активной подписки - используем NOT EXISTS для корректности + subquery = ( + select(Subscription.id) + .where( + Subscription.user_id == User.id, + Subscription.status == SubscriptionStatus.ACTIVE.value, + ) + .exists() + ) + query = ( + select(sql_func.count(User.id)) + .where(base_filter, ~subquery) + ) + result = await db.execute(query) + return result.scalar() or 0 + + if target == "expiring": + # Истекающие в ближайшие 3 дня + now = datetime.utcnow() + expiry_threshold = now + timedelta(days=3) + query = ( + select(sql_func.count(distinct(User.id))) + .join(Subscription, User.id == Subscription.user_id) + .where( + base_filter, + Subscription.status == SubscriptionStatus.ACTIVE.value, + Subscription.end_date <= expiry_threshold, + Subscription.end_date > now, + ) + ) + result = await db.execute(query) + return result.scalar() or 0 + + if target == "expiring_subscribers": + # Истекающие в ближайшие 7 дней + now = datetime.utcnow() + expiry_threshold = now + timedelta(days=7) + query = ( + select(sql_func.count(distinct(User.id))) + .join(Subscription, User.id == Subscription.user_id) + .where( + base_filter, + Subscription.status == SubscriptionStatus.ACTIVE.value, + Subscription.end_date <= expiry_threshold, + Subscription.end_date > now, + ) + ) + result = await db.execute(query) + return result.scalar() or 0 + + if target == "expired": + # Истекшие подписки + now = datetime.utcnow() + expired_statuses = [SubscriptionStatus.EXPIRED.value, SubscriptionStatus.DISABLED.value] + query = ( + select(sql_func.count(distinct(User.id))) + .outerjoin(Subscription, User.id == Subscription.user_id) + .where( + base_filter, + or_( + Subscription.status.in_(expired_statuses), + and_(Subscription.end_date <= now, Subscription.status != SubscriptionStatus.ACTIVE.value), + and_(Subscription.id == None, User.has_had_paid_subscription == True), + ) + ) + ) + result = await db.execute(query) + return result.scalar() or 0 + + if target == "expired_subscribers": + # То же что и expired + now = datetime.utcnow() + expired_statuses = [SubscriptionStatus.EXPIRED.value, SubscriptionStatus.DISABLED.value] + query = ( + select(sql_func.count(distinct(User.id))) + .outerjoin(Subscription, User.id == Subscription.user_id) + .where( + base_filter, + or_( + Subscription.status.in_(expired_statuses), + and_(Subscription.end_date <= now, Subscription.status != SubscriptionStatus.ACTIVE.value), + and_(Subscription.id == None, User.has_had_paid_subscription == True), + ) + ) + ) + result = await db.execute(query) + return result.scalar() or 0 + + if target == "active_zero": + # Активные платные с нулевым трафиком + query = ( + select(sql_func.count(distinct(User.id))) + .join(Subscription, User.id == Subscription.user_id) + .where( + base_filter, + Subscription.status == SubscriptionStatus.ACTIVE.value, + Subscription.is_trial == False, + or_(Subscription.traffic_used_gb == None, Subscription.traffic_used_gb <= 0), + ) + ) + result = await db.execute(query) + return result.scalar() or 0 + + if target == "trial_zero": + # Триальные с нулевым трафиком + query = ( + select(sql_func.count(distinct(User.id))) + .join(Subscription, User.id == Subscription.user_id) + .where( + base_filter, + Subscription.is_trial == True, + Subscription.status == SubscriptionStatus.ACTIVE.value, + or_(Subscription.traffic_used_gb == None, Subscription.traffic_used_gb <= 0), + ) + ) + result = await db.execute(query) + return result.scalar() or 0 + + if target == "zero": + # Все активные с нулевым трафиком + query = ( + select(sql_func.count(distinct(User.id))) + .join(Subscription, User.id == Subscription.user_id) + .where( + base_filter, + Subscription.status == SubscriptionStatus.ACTIVE.value, + or_(Subscription.traffic_used_gb == None, Subscription.traffic_used_gb <= 0), + ) + ) + result = await db.execute(query) + return result.scalar() or 0 + + # Фильтр по тарифу + if target.startswith("tariff_"): + tariff_id = int(target.split("_")[1]) + query = ( + select(sql_func.count(distinct(User.id))) + .join(Subscription, User.id == Subscription.user_id) + .where( + base_filter, + Subscription.status == SubscriptionStatus.ACTIVE.value, + Subscription.tariff_id == tariff_id, + ) + ) + result = await db.execute(query) + return result.scalar() or 0 + + # Для остальных фильтров (custom_ и неизвестные) - fallback на старый метод users = await get_target_users(db, target) return len(users) @@ -1500,6 +1816,17 @@ async def get_target_users(db: AsyncSession, target: str) -> list: if user.last_activity and user.last_activity < threshold ] + # Фильтр по тарифу + if target.startswith("tariff_"): + tariff_id = int(target.split("_")[1]) + return [ + user + for user in users + if user.subscription + and user.subscription.is_active + and user.subscription.tariff_id == tariff_id + ] + return [] @@ -1639,6 +1966,10 @@ def get_target_name(target_type: str) -> str: "custom_referrals": "Через рефералов", "custom_direct": "Прямая регистрация" } + # Обработка фильтра по тарифу + if target_type.startswith("tariff_"): + tariff_id = target_type.split("_")[1] + return f"По тарифу #{tariff_id}" return names.get(target_type, target_type) @@ -1656,6 +1987,7 @@ def register_handlers(dp: Dispatcher): dp.callback_query.register(handle_pinned_broadcast_now, F.data.startswith("admin_pinned_broadcast_now:")) dp.callback_query.register(handle_pinned_broadcast_skip, F.data.startswith("admin_pinned_broadcast_skip:")) dp.callback_query.register(show_broadcast_targets, F.data.in_(["admin_msg_all", "admin_msg_by_sub"])) + dp.callback_query.register(show_tariff_filter, F.data == "broadcast_by_tariff") dp.callback_query.register(select_broadcast_target, F.data.startswith("broadcast_")) dp.callback_query.register(confirm_broadcast, F.data == "admin_confirm_broadcast") diff --git a/app/handlers/admin/payments.py b/app/handlers/admin/payments.py index 1d4ef7c3..3205e01c 100644 --- a/app/handlers/admin/payments.py +++ b/app/handlers/admin/payments.py @@ -135,6 +135,16 @@ def _status_info( return "✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid") return "⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending") + if record.method == PaymentMethod.FREEKASSA: + mapping = { + "pending": ("⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")), + "success": ("✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")), + "paid": ("✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")), + "canceled": ("❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")), + "error": ("❌", texts.t("ADMIN_PAYMENT_STATUS_FAILED", "❌ Failed")), + } + return mapping.get(status, ("❓", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown"))) + return "❓", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown") @@ -158,6 +168,8 @@ def _is_checkable(record: PendingPayment) -> bool: return status in {"pending", "waiting_for_capture"} if record.method == PaymentMethod.CRYPTOBOT: return status in {"active"} + if record.method == PaymentMethod.FREEKASSA: + return status in {"pending", ""} return False diff --git a/app/handlers/admin/pricing.py b/app/handlers/admin/pricing.py index ab6a4592..69ffc298 100644 --- a/app/handlers/admin/pricing.py +++ b/app/handlers/admin/pricing.py @@ -364,11 +364,12 @@ def _format_core_summary(lang_code: str) -> str: def _get_period_items(lang_code: str) -> List[PriceItem]: + from app.config import PERIOD_PRICES items: List[PriceItem] = [] for days in settings.get_available_subscription_periods(): key = f"PRICE_{days}_DAYS" - if hasattr(settings, key): - price = getattr(settings, key) + price = PERIOD_PRICES.get(days, 0) + if price > 0: items.append((key, _format_period_label(days, lang_code), price)) return items @@ -610,8 +611,9 @@ def _build_period_options_section(language: str) -> Tuple[str, types.InlineKeybo lang_code = _language_code(language) suffix = "д" if lang_code == "ru" else "d" - available_subscription = set(settings.get_available_subscription_periods()) - available_renewal = set(settings.get_available_renewal_periods()) + # Используем методы без фильтрации по ценам для админки + available_subscription = set(settings.get_configured_subscription_periods()) + available_renewal = set(settings.get_configured_renewal_periods()) subscription_options = (14, 30, 60, 90, 180, 360) renewal_options = (30, 60, 90, 180, 360) @@ -1335,11 +1337,13 @@ async def toggle_period_option( texts = get_texts(db_user.language) if target == "subscription": - current = set(settings.get_available_subscription_periods()) + # Используем метод без фильтрации по ценам для админки + current = set(settings.get_configured_subscription_periods()) options = {14, 30, 60, 90, 180, 360} setting_key = "AVAILABLE_SUBSCRIPTION_PERIODS" elif target == "renewal": - current = set(settings.get_available_renewal_periods()) + # Используем метод без фильтрации по ценам для админки + current = set(settings.get_configured_renewal_periods()) options = {30, 60, 90, 180, 360} setting_key = "AVAILABLE_RENEWAL_PERIODS" else: diff --git a/app/handlers/admin/tariffs.py b/app/handlers/admin/tariffs.py index 86bd7ba4..bdaee15c 100644 --- a/app/handlers/admin/tariffs.py +++ b/app/handlers/admin/tariffs.py @@ -183,19 +183,43 @@ def get_tariff_view_keyboard( InlineKeyboardButton(text="📊 Трафик", callback_data=f"admin_tariff_edit_traffic:{tariff.id}"), InlineKeyboardButton(text="📱 Устройства", callback_data=f"admin_tariff_edit_devices:{tariff.id}"), ]) - buttons.append([ - InlineKeyboardButton(text="💰 Цены", callback_data=f"admin_tariff_edit_prices:{tariff.id}"), - InlineKeyboardButton(text="🎚️ Уровень", callback_data=f"admin_tariff_edit_tier:{tariff.id}"), - ]) + # Цены за периоды только для обычных тарифов (не суточных) + is_daily = getattr(tariff, 'is_daily', False) + if not is_daily: + buttons.append([ + InlineKeyboardButton(text="💰 Цены", callback_data=f"admin_tariff_edit_prices:{tariff.id}"), + InlineKeyboardButton(text="🎚️ Уровень", callback_data=f"admin_tariff_edit_tier:{tariff.id}"), + ]) + else: + buttons.append([ + InlineKeyboardButton(text="🎚️ Уровень", callback_data=f"admin_tariff_edit_tier:{tariff.id}"), + ]) buttons.append([ InlineKeyboardButton(text="📱💰 Цена за устройство", callback_data=f"admin_tariff_edit_device_price:{tariff.id}"), + InlineKeyboardButton(text="📱🔒 Макс. устройств", callback_data=f"admin_tariff_edit_max_devices:{tariff.id}"), + ]) + buttons.append([ InlineKeyboardButton(text="⏰ Дни триала", callback_data=f"admin_tariff_edit_trial_days:{tariff.id}"), ]) + buttons.append([ + InlineKeyboardButton(text="📈 Докупка трафика", callback_data=f"admin_tariff_edit_traffic_topup:{tariff.id}"), + ]) + buttons.append([ + InlineKeyboardButton(text="🔄 Сброс трафика", callback_data=f"admin_tariff_edit_reset_mode:{tariff.id}"), + ]) buttons.append([ InlineKeyboardButton(text="🌐 Серверы", callback_data=f"admin_tariff_edit_squads:{tariff.id}"), InlineKeyboardButton(text="👥 Промогруппы", callback_data=f"admin_tariff_edit_promo:{tariff.id}"), ]) + # Суточный режим - только для уже суточных тарифов показываем настройки + # Новые тарифы делаются суточными только при создании + if is_daily: + buttons.append([ + InlineKeyboardButton(text="💰 Суточная цена", callback_data=f"admin_tariff_edit_daily_price:{tariff.id}"), + ]) + # Примечание: отключение суточного режима убрано - это необратимое решение при создании + # Переключение триала if tariff.is_trial_available: buttons.append([ @@ -229,6 +253,36 @@ def get_tariff_view_keyboard( return InlineKeyboardMarkup(inline_keyboard=buttons) +def _format_traffic_reset_mode(mode: Optional[str]) -> str: + """Форматирует режим сброса трафика для отображения.""" + mode_labels = { + 'DAY': '📅 Ежедневно', + 'WEEK': '📆 Еженедельно', + 'MONTH': '🗓️ Ежемесячно', + 'NO_RESET': '🚫 Никогда', + } + if mode is None: + return f"🌐 Глобальная настройка ({settings.DEFAULT_TRAFFIC_RESET_STRATEGY})" + return mode_labels.get(mode, f"⚠️ Неизвестно ({mode})") + + +def _format_traffic_topup_packages(tariff: Tariff) -> str: + """Форматирует пакеты докупки трафика для отображения.""" + if not getattr(tariff, 'traffic_topup_enabled', False): + return "❌ Отключено" + + packages = tariff.get_traffic_topup_packages() if hasattr(tariff, 'get_traffic_topup_packages') else {} + if not packages: + return "✅ Включено, но пакеты не настроены" + + lines = ["✅ Включено"] + for gb in sorted(packages.keys()): + price = packages[gb] + lines.append(f" • {gb} ГБ: {_format_price_kopeks(price)}") + + return "\n".join(lines) + + def format_tariff_info(tariff: Tariff, language: str, subs_count: int = 0) -> str: """Форматирует информацию о тарифе.""" texts = get_texts(language) @@ -264,21 +318,52 @@ def format_tariff_info(tariff: Tariff, language: str, subs_count: int = 0) -> st else: device_price_display = "Недоступно" + # Форматируем макс. устройств + max_devices = getattr(tariff, 'max_device_limit', None) + if max_devices is not None and max_devices > 0: + max_devices_display = str(max_devices) + else: + max_devices_display = "∞ (без лимита)" + + # Форматируем докупку трафика + traffic_topup_display = _format_traffic_topup_packages(tariff) + + # Форматируем режим сброса трафика + traffic_reset_mode = getattr(tariff, 'traffic_reset_mode', None) + traffic_reset_display = _format_traffic_reset_mode(traffic_reset_mode) + + # Форматируем суточный тариф + is_daily = getattr(tariff, 'is_daily', False) + daily_price_kopeks = getattr(tariff, 'daily_price_kopeks', 0) + + # Формируем блок цен в зависимости от типа тарифа + if is_daily: + price_block = f"💰 Суточная цена: {_format_price_kopeks(daily_price_kopeks)}/день" + tariff_type = "🔄 Суточный" + else: + price_block = f"Цены:\n{prices_display}" + tariff_type = "📅 Периодный" + return f"""📦 Тариф: {tariff.name} -{status} +{status} | {tariff_type} 🎚️ Уровень: {tariff.tier_level} 📊 Порядок: {tariff.display_order} Параметры: • Трафик: {traffic} • Устройств: {tariff.device_limit} +• Макс. устройств: {max_devices_display} • Цена за доп. устройство: {device_price_display} • Триал: {trial_status} • Дней триала: {trial_days_display} -Цены: -{prices_display} +Докупка трафика: +{traffic_topup_display} + +Сброс трафика: {traffic_reset_display} + +{price_block} Серверы: {squads_display} Промогруппы: {promo_display} @@ -470,6 +555,160 @@ async def toggle_trial_tariff( ) +@admin_required +@error_handler +async def toggle_daily_tariff( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + """Переключает суточный режим тарифа.""" + tariff_id = int(callback.data.split(":")[1]) + tariff = await get_tariff_by_id(db, tariff_id) + + if not tariff: + await callback.answer("Тариф не найден", show_alert=True) + return + + is_daily = getattr(tariff, 'is_daily', False) + + if is_daily: + # Отключаем суточный режим + tariff = await update_tariff(db, tariff, is_daily=False, daily_price_kopeks=0) + await callback.answer("Суточный режим отключен", show_alert=True) + else: + # Включаем суточный режим (с ценой по умолчанию) + tariff = await update_tariff(db, tariff, is_daily=True, daily_price_kopeks=5000) # 50 руб по умолчанию + await callback.answer( + f"Суточный режим включен. Цена: 50 ₽/день\n" + "Настройте цену через кнопку «💰 Суточная цена»", + show_alert=True + ) + + subs_count = await get_tariff_subscriptions_count(db, tariff_id) + + await callback.message.edit_text( + format_tariff_info(tariff, db_user.language, subs_count), + reply_markup=get_tariff_view_keyboard(tariff, db_user.language), + parse_mode="HTML" + ) + + +@admin_required +@error_handler +async def start_edit_daily_price( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Начинает редактирование суточной цены.""" + texts = get_texts(db_user.language) + + tariff_id = int(callback.data.split(":")[1]) + tariff = await get_tariff_by_id(db, tariff_id) + + if not tariff: + await callback.answer("Тариф не найден", show_alert=True) + return + + current_price = getattr(tariff, 'daily_price_kopeks', 0) + current_rubles = current_price / 100 if current_price else 0 + + await state.set_state(AdminStates.editing_tariff_daily_price) + await state.update_data(tariff_id=tariff_id, language=db_user.language) + + await callback.message.edit_text( + f"💰 Редактирование суточной цены\n\n" + f"Тариф: {tariff.name}\n" + f"Текущая цена: {_format_price_kopeks(current_price)}/день\n\n" + "Введите новую цену за день в рублях.\n" + "Пример: 50 или 99.90", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text=texts.CANCEL, callback_data=f"admin_tariff_view:{tariff_id}")] + ]), + parse_mode="HTML" + ) + await callback.answer() + + +@admin_required +@error_handler +async def process_daily_price_input( + message: types.Message, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Обрабатывает ввод суточной цены (создание и редактирование).""" + texts = get_texts(db_user.language) + data = await state.get_data() + tariff_id = data.get("tariff_id") + + # Парсим цену + try: + price_rubles = float(message.text.strip().replace(",", ".")) + if price_rubles <= 0: + raise ValueError("Цена должна быть положительной") + + price_kopeks = int(price_rubles * 100) + except ValueError: + await message.answer( + "❌ Некорректная цена. Введите положительное число.\n" + "Пример: 50 или 99.90", + parse_mode="HTML" + ) + return + + # Проверяем - это создание или редактирование + is_creating = data.get("tariff_is_daily") and not tariff_id + + if is_creating: + # Создаем новый суточный тариф + tariff = await create_tariff( + db, + name=data['tariff_name'], + traffic_limit_gb=data['tariff_traffic'], + device_limit=data['tariff_devices'], + tier_level=data['tariff_tier'], + period_prices={}, + is_active=True, + is_daily=True, + daily_price_kopeks=price_kopeks, + ) + await state.clear() + + await message.answer( + f"✅ Суточный тариф создан!\n\n" + + format_tariff_info(tariff, db_user.language, 0), + reply_markup=get_tariff_view_keyboard(tariff, db_user.language), + parse_mode="HTML" + ) + else: + # Редактируем существующий тариф + if not tariff_id: + await state.clear() + return + + tariff = await get_tariff_by_id(db, tariff_id) + if not tariff: + await message.answer("Тариф не найден") + await state.clear() + return + + tariff = await update_tariff(db, tariff, daily_price_kopeks=price_kopeks) + await state.clear() + + subs_count = await get_tariff_subscriptions_count(db, tariff_id) + + await message.answer( + f"✅ Суточная цена установлена: {_format_price_kopeks(price_kopeks)}/день\n\n" + + format_tariff_info(tariff, db_user.language, subs_count), + reply_markup=get_tariff_view_keyboard(tariff, db_user.language), + parse_mode="HTML" + ) + + # ============ СОЗДАНИЕ ТАРИФА ============ @admin_required @@ -634,17 +873,51 @@ async def process_tariff_tier( data = await state.get_data() await state.update_data(tariff_tier=tier) - await state.set_state(AdminStates.creating_tariff_prices) traffic_display = _format_traffic(data['tariff_traffic']) + # Шаг 5/6: Выбор типа тарифа await message.answer( "📦 Создание тарифа\n\n" f"Название: {data['tariff_name']}\n" f"Трафик: {traffic_display}\n" f"Устройств: {data['tariff_devices']}\n" f"Уровень: {tier}\n\n" - "Шаг 5/6: Введите цены на периоды\n\n" + "Шаг 5/6: Выберите тип тарифа", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="📅 Периодный (месяцы)", callback_data="tariff_type_periodic")], + [InlineKeyboardButton(text="🔄 Суточный (оплата за день)", callback_data="tariff_type_daily")], + [InlineKeyboardButton(text=texts.CANCEL, callback_data="admin_tariffs")] + ]), + parse_mode="HTML" + ) + + +@admin_required +@error_handler +async def select_tariff_type_periodic( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Выбирает периодный тип тарифа.""" + texts = get_texts(db_user.language) + data = await state.get_data() + + await state.update_data(tariff_is_daily=False) + await state.set_state(AdminStates.creating_tariff_prices) + + traffic_display = _format_traffic(data['tariff_traffic']) + + await callback.message.edit_text( + "📦 Создание тарифа\n\n" + f"Название: {data['tariff_name']}\n" + f"Трафик: {traffic_display}\n" + f"Устройств: {data['tariff_devices']}\n" + f"Уровень: {data['tariff_tier']}\n" + f"Тип: 📅 Периодный\n\n" + "Шаг 6/6: Введите цены на периоды\n\n" "Формат: дней:цена_в_копейках\n" "Несколько периодов через запятую\n\n" "Пример:\n30:9900, 90:24900, 180:44900, 360:79900", @@ -653,6 +926,43 @@ async def process_tariff_tier( ]), parse_mode="HTML" ) + await callback.answer() + + +@admin_required +@error_handler +async def select_tariff_type_daily( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Выбирает суточный тип тарифа.""" + from app.states import AdminStates + + texts = get_texts(db_user.language) + data = await state.get_data() + + await state.update_data(tariff_is_daily=True) + await state.set_state(AdminStates.editing_tariff_daily_price) + + traffic_display = _format_traffic(data['tariff_traffic']) + + await callback.message.edit_text( + "📦 Создание суточного тарифа\n\n" + f"Название: {data['tariff_name']}\n" + f"Трафик: {traffic_display}\n" + f"Устройств: {data['tariff_devices']}\n" + f"Уровень: {data['tariff_tier']}\n" + f"Тип: 🔄 Суточный\n\n" + "Шаг 6/6: Введите суточную цену в рублях\n\n" + "Пример: 50 (50 ₽/день), 99.90 (99.90 ₽/день)", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text=texts.CANCEL, callback_data="admin_tariffs")] + ]), + parse_mode="HTML" + ) + await callback.answer() @admin_required @@ -1221,6 +1531,96 @@ async def process_edit_tariff_device_price( ) +# ============ РЕДАКТИРОВАНИЕ МАКС. УСТРОЙСТВ ============ + +@admin_required +@error_handler +async def start_edit_tariff_max_devices( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Начинает редактирование макс. устройств.""" + texts = get_texts(db_user.language) + tariff_id = int(callback.data.split(":")[1]) + tariff = await get_tariff_by_id(db, tariff_id) + + if not tariff: + await callback.answer("Тариф не найден", show_alert=True) + return + + await state.set_state(AdminStates.editing_tariff_max_devices) + await state.update_data(tariff_id=tariff_id, language=db_user.language) + + max_devices = getattr(tariff, 'max_device_limit', None) + if max_devices is not None and max_devices > 0: + current_max = str(max_devices) + else: + current_max = "∞ (без лимита)" + + await callback.message.edit_text( + f"📱🔒 Редактирование макс. устройств\n\n" + f"Текущее значение: {current_max}\n" + f"Базовое кол-во устройств: {tariff.device_limit}\n\n" + "Введите максимальное количество устройств, которое пользователь может докупить.\n\n" + "• 0 или - — без ограничений\n" + "• Например: 5 = максимум 5 устройств на тарифе", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text=texts.CANCEL, callback_data=f"admin_tariff_view:{tariff_id}")] + ]), + parse_mode="HTML" + ) + await callback.answer() + + +@admin_required +@error_handler +async def process_edit_tariff_max_devices( + message: types.Message, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Обрабатывает новое макс. кол-во устройств.""" + data = await state.get_data() + tariff_id = data.get("tariff_id") + + tariff = await get_tariff_by_id(db, tariff_id) + if not tariff: + await message.answer("Тариф не найден") + await state.clear() + return + + text = message.text.strip() + + if text == "-" or text == "0": + max_devices = None + else: + try: + max_devices = int(text) + if max_devices < 1: + raise ValueError + except ValueError: + await message.answer( + "Введите корректное число (1 или больше).\n" + "Для снятия ограничения введите 0 или -", + parse_mode="HTML" + ) + return + + tariff = await update_tariff(db, tariff, max_device_limit=max_devices) + await state.clear() + + subs_count = await get_tariff_subscriptions_count(db, tariff_id) + + await message.answer( + f"✅ Макс. устройств изменено!\n\n" + format_tariff_info(tariff, db_user.language, subs_count), + reply_markup=get_tariff_view_keyboard(tariff, db_user.language), + parse_mode="HTML" + ) + + # ============ РЕДАКТИРОВАНИЕ ДНЕЙ ТРИАЛА ============ @admin_required @@ -1310,6 +1710,419 @@ async def process_edit_tariff_trial_days( ) +# ============ РЕДАКТИРОВАНИЕ ДОКУПКИ ТРАФИКА ============ + +def _parse_traffic_topup_packages(text: str) -> Dict[int, int]: + """ + Парсит строку с пакетами докупки трафика. + Формат: "5:5000, 10:9000, 20:15000" (ГБ:цена_в_копейках) + """ + packages = {} + text = text.replace(";", ",").replace("=", ":") + + for part in text.split(","): + part = part.strip() + if not part: + continue + + if ":" not in part: + continue + + gb_str, price_str = part.split(":", 1) + try: + gb = int(gb_str.strip()) + price = int(price_str.strip()) + if gb > 0 and price > 0: + packages[gb] = price + except ValueError: + continue + + return packages + + +def _format_traffic_topup_packages_for_edit(packages: Dict[int, int]) -> str: + """Форматирует пакеты докупки для редактирования.""" + if not packages: + return "5:5000, 10:9000, 20:15000" + + parts = [] + for gb in sorted(packages.keys()): + parts.append(f"{gb}:{packages[gb]}") + + return ", ".join(parts) + + +@admin_required +@error_handler +async def start_edit_tariff_traffic_topup( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Показывает меню настройки докупки трафика.""" + texts = get_texts(db_user.language) + tariff_id = int(callback.data.split(":")[1]) + tariff = await get_tariff_by_id(db, tariff_id) + + if not tariff: + await callback.answer("Тариф не найден", show_alert=True) + return + + # Проверяем, безлимитный ли тариф + if tariff.is_unlimited_traffic: + await callback.answer("Докупка недоступна для безлимитного тарифа", show_alert=True) + return + + is_enabled = getattr(tariff, 'traffic_topup_enabled', False) + packages = tariff.get_traffic_topup_packages() if hasattr(tariff, 'get_traffic_topup_packages') else {} + max_topup_traffic = getattr(tariff, 'max_topup_traffic_gb', 0) or 0 + + # Форматируем текущие настройки + if is_enabled: + status = "✅ Включено" + if packages: + packages_display = "\n".join(f" • {gb} ГБ: {_format_price_kopeks(price)}" for gb, price in sorted(packages.items())) + else: + packages_display = " Пакеты не настроены" + else: + status = "❌ Отключено" + packages_display = " -" + + # Форматируем лимит + if max_topup_traffic > 0: + max_limit_display = f"{max_topup_traffic} ГБ" + else: + max_limit_display = "Без ограничений" + + buttons = [] + + # Переключение вкл/выкл + if is_enabled: + buttons.append([ + InlineKeyboardButton(text="❌ Отключить", callback_data=f"admin_tariff_toggle_traffic_topup:{tariff_id}") + ]) + else: + buttons.append([ + InlineKeyboardButton(text="✅ Включить", callback_data=f"admin_tariff_toggle_traffic_topup:{tariff_id}") + ]) + + # Редактирование пакетов и лимита (только если включено) + if is_enabled: + buttons.append([ + InlineKeyboardButton(text="📦 Настроить пакеты", callback_data=f"admin_tariff_edit_topup_packages:{tariff_id}") + ]) + buttons.append([ + InlineKeyboardButton(text="📊 Макс. лимит трафика", callback_data=f"admin_tariff_edit_max_topup:{tariff_id}") + ]) + + buttons.append([ + InlineKeyboardButton(text=texts.BACK, callback_data=f"admin_tariff_view:{tariff_id}") + ]) + + await callback.message.edit_text( + f"📈 Докупка трафика для «{tariff.name}»\n\n" + f"Статус: {status}\n\n" + f"Пакеты:\n{packages_display}\n\n" + f"Макс. лимит: {max_limit_display}\n\n" + "Пользователи смогут докупать трафик по заданным ценам.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons), + parse_mode="HTML" + ) + await callback.answer() + + +@admin_required +@error_handler +async def toggle_tariff_traffic_topup( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + """Переключает включение/выключение докупки трафика.""" + tariff_id = int(callback.data.split(":")[1]) + tariff = await get_tariff_by_id(db, tariff_id) + + if not tariff: + await callback.answer("Тариф не найден", show_alert=True) + return + + is_enabled = getattr(tariff, 'traffic_topup_enabled', False) + new_value = not is_enabled + + tariff = await update_tariff(db, tariff, traffic_topup_enabled=new_value) + + status_text = "включена" if new_value else "отключена" + await callback.answer(f"Докупка трафика {status_text}") + + # Перерисовываем меню + texts = get_texts(db_user.language) + packages = tariff.get_traffic_topup_packages() if hasattr(tariff, 'get_traffic_topup_packages') else {} + max_topup_traffic = getattr(tariff, 'max_topup_traffic_gb', 0) or 0 + + if new_value: + status = "✅ Включено" + if packages: + packages_display = "\n".join(f" • {gb} ГБ: {_format_price_kopeks(price)}" for gb, price in sorted(packages.items())) + else: + packages_display = " Пакеты не настроены" + else: + status = "❌ Отключено" + packages_display = " -" + + # Форматируем лимит + if max_topup_traffic > 0: + max_limit_display = f"{max_topup_traffic} ГБ" + else: + max_limit_display = "Без ограничений" + + buttons = [] + + if new_value: + buttons.append([ + InlineKeyboardButton(text="❌ Отключить", callback_data=f"admin_tariff_toggle_traffic_topup:{tariff_id}") + ]) + buttons.append([ + InlineKeyboardButton(text="📦 Настроить пакеты", callback_data=f"admin_tariff_edit_topup_packages:{tariff_id}") + ]) + buttons.append([ + InlineKeyboardButton(text="📊 Макс. лимит трафика", callback_data=f"admin_tariff_edit_max_topup:{tariff_id}") + ]) + else: + buttons.append([ + InlineKeyboardButton(text="✅ Включить", callback_data=f"admin_tariff_toggle_traffic_topup:{tariff_id}") + ]) + + buttons.append([ + InlineKeyboardButton(text=texts.BACK, callback_data=f"admin_tariff_view:{tariff_id}") + ]) + + try: + await callback.message.edit_text( + f"📈 Докупка трафика для «{tariff.name}»\n\n" + f"Статус: {status}\n\n" + f"Пакеты:\n{packages_display}\n\n" + f"Макс. лимит: {max_limit_display}\n\n" + "Пользователи смогут докупать трафик по заданным ценам.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons), + parse_mode="HTML" + ) + except TelegramBadRequest: + pass + + +@admin_required +@error_handler +async def start_edit_traffic_topup_packages( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Начинает редактирование пакетов докупки трафика.""" + texts = get_texts(db_user.language) + tariff_id = int(callback.data.split(":")[1]) + tariff = await get_tariff_by_id(db, tariff_id) + + if not tariff: + await callback.answer("Тариф не найден", show_alert=True) + return + + await state.set_state(AdminStates.editing_tariff_traffic_topup_packages) + await state.update_data(tariff_id=tariff_id, language=db_user.language) + + packages = tariff.get_traffic_topup_packages() if hasattr(tariff, 'get_traffic_topup_packages') else {} + current_packages = _format_traffic_topup_packages_for_edit(packages) + + if packages: + packages_display = "\n".join(f" • {gb} ГБ: {_format_price_kopeks(price)}" for gb, price in sorted(packages.items())) + else: + packages_display = " Не настроены" + + await callback.message.edit_text( + f"📦 Настройка пакетов докупки трафика\n\n" + f"Тариф: {tariff.name}\n\n" + f"Текущие пакеты:\n{packages_display}\n\n" + "Введите пакеты в формате:\n" + f"{current_packages}\n\n" + "(ГБ:цена_в_копейках, через запятую)\n" + "Например: 5:5000, 10:9000 = 5ГБ за 50₽, 10ГБ за 90₽", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text=texts.CANCEL, callback_data=f"admin_tariff_edit_traffic_topup:{tariff_id}")] + ]), + parse_mode="HTML" + ) + await callback.answer() + + +@admin_required +@error_handler +async def process_edit_traffic_topup_packages( + message: types.Message, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Обрабатывает новые пакеты докупки трафика.""" + data = await state.get_data() + tariff_id = data.get("tariff_id") + + tariff = await get_tariff_by_id(db, tariff_id) + if not tariff: + await message.answer("Тариф не найден") + await state.clear() + return + + packages = _parse_traffic_topup_packages(message.text.strip()) + + if not packages: + await message.answer( + "Не удалось распознать пакеты.\n\n" + "Формат: ГБ:цена_в_копейках\n" + "Пример: 5:5000, 10:9000, 20:15000", + parse_mode="HTML" + ) + return + + # Преобразуем в формат для JSON (строковые ключи) + packages_json = {str(gb): price for gb, price in packages.items()} + + tariff = await update_tariff(db, tariff, traffic_topup_packages=packages_json) + await state.clear() + + # Показываем обновленное меню + texts = get_texts(db_user.language) + packages_display = "\n".join(f" • {gb} ГБ: {_format_price_kopeks(price)}" for gb, price in sorted(packages.items())) + max_topup_traffic = getattr(tariff, 'max_topup_traffic_gb', 0) or 0 + max_limit_display = f"{max_topup_traffic} ГБ" if max_topup_traffic > 0 else "Без ограничений" + + buttons = [ + [InlineKeyboardButton(text="❌ Отключить", callback_data=f"admin_tariff_toggle_traffic_topup:{tariff_id}")], + [InlineKeyboardButton(text="📦 Настроить пакеты", callback_data=f"admin_tariff_edit_topup_packages:{tariff_id}")], + [InlineKeyboardButton(text="📊 Макс. лимит трафика", callback_data=f"admin_tariff_edit_max_topup:{tariff_id}")], + [InlineKeyboardButton(text=texts.BACK, callback_data=f"admin_tariff_view:{tariff_id}")] + ] + + await message.answer( + f"✅ Пакеты обновлены!\n\n" + f"📈 Докупка трафика для «{tariff.name}»\n\n" + f"Статус: ✅ Включено\n\n" + f"Пакеты:\n{packages_display}\n\n" + f"Макс. лимит: {max_limit_display}\n\n" + "Пользователи смогут докупать трафик по заданным ценам.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons), + parse_mode="HTML" + ) + + +# ============ МАКСИМАЛЬНЫЙ ЛИМИТ ДОКУПКИ ТРАФИКА ============ + +@admin_required +@error_handler +async def start_edit_max_topup_traffic( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Начинает редактирование максимального лимита докупки трафика.""" + texts = get_texts(db_user.language) + tariff_id = int(callback.data.split(":")[1]) + tariff = await get_tariff_by_id(db, tariff_id) + + if not tariff: + await callback.answer("Тариф не найден", show_alert=True) + return + + await state.set_state(AdminStates.editing_tariff_max_topup_traffic) + await state.update_data(tariff_id=tariff_id) + + current_limit = getattr(tariff, 'max_topup_traffic_gb', 0) or 0 + if current_limit > 0: + current_display = f"{current_limit} ГБ" + else: + current_display = "Без ограничений" + + await callback.message.edit_text( + f"📊 Максимальный лимит трафика\n\n" + f"Тариф: {tariff.name}\n" + f"Текущий лимит: {current_display}\n\n" + f"Введите максимальный общий объем трафика (в ГБ), который может быть на подписке после всех докупок.\n\n" + f"• Например, если тариф дает 100 ГБ и лимит 200 ГБ — пользователь сможет докупить еще 100 ГБ\n" + f"• Введите 0 для снятия ограничения", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text=texts.CANCEL, callback_data=f"admin_tariff_edit_traffic_topup:{tariff_id}")] + ]), + parse_mode="HTML" + ) + await callback.answer() + + +@admin_required +@error_handler +async def process_edit_max_topup_traffic( + message: types.Message, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Обрабатывает новое значение максимального лимита докупки трафика.""" + texts = get_texts(db_user.language) + state_data = await state.get_data() + tariff_id = state_data.get("tariff_id") + + tariff = await get_tariff_by_id(db, tariff_id) + if not tariff: + await message.answer("Тариф не найден") + await state.clear() + return + + # Парсим значение + text = message.text.strip() + try: + new_limit = int(text) + if new_limit < 0: + raise ValueError("Negative value") + except ValueError: + await message.answer( + "Введите целое число (0 или больше).\n\n" + "• 0 — без ограничений\n" + "• 200 — максимум 200 ГБ на подписке", + parse_mode="HTML" + ) + return + + tariff = await update_tariff(db, tariff, max_topup_traffic_gb=new_limit) + await state.clear() + + # Показываем обновленное меню + packages = tariff.get_traffic_topup_packages() if hasattr(tariff, 'get_traffic_topup_packages') else {} + if packages: + packages_display = "\n".join(f" • {gb} ГБ: {_format_price_kopeks(price)}" for gb, price in sorted(packages.items())) + else: + packages_display = " Пакеты не настроены" + + max_limit_display = f"{new_limit} ГБ" if new_limit > 0 else "Без ограничений" + + buttons = [ + [InlineKeyboardButton(text="❌ Отключить", callback_data=f"admin_tariff_toggle_traffic_topup:{tariff_id}")], + [InlineKeyboardButton(text="📦 Настроить пакеты", callback_data=f"admin_tariff_edit_topup_packages:{tariff_id}")], + [InlineKeyboardButton(text="📊 Макс. лимит трафика", callback_data=f"admin_tariff_edit_max_topup:{tariff_id}")], + [InlineKeyboardButton(text=texts.BACK, callback_data=f"admin_tariff_view:{tariff_id}")] + ] + + await message.answer( + f"✅ Лимит обновлен!\n\n" + f"📈 Докупка трафика для «{tariff.name}»\n\n" + f"Статус: ✅ Включено\n\n" + f"Пакеты:\n{packages_display}\n\n" + f"Макс. лимит: {max_limit_display}\n\n" + "Пользователи смогут докупать трафик по заданным ценам.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons), + parse_mode="HTML" + ) + + # ============ УДАЛЕНИЕ ТАРИФА ============ @admin_required @@ -1804,6 +2617,124 @@ async def clear_tariff_promo_groups( pass +# ==================== Режим сброса трафика ==================== + +TRAFFIC_RESET_MODES = [ + ('DAY', '📅 Ежедневно', 'Трафик сбрасывается каждый день'), + ('WEEK', '📆 Еженедельно', 'Трафик сбрасывается каждую неделю'), + ('MONTH', '🗓️ Ежемесячно', 'Трафик сбрасывается каждый месяц'), + ('NO_RESET', '🚫 Никогда', 'Трафик не сбрасывается автоматически'), +] + + +def get_traffic_reset_mode_keyboard(tariff_id: int, current_mode: Optional[str], language: str) -> InlineKeyboardMarkup: + """Создает клавиатуру для выбора режима сброса трафика.""" + texts = get_texts(language) + buttons = [] + + # Кнопка "Глобальная настройка" + global_label = f"{'✅ ' if current_mode is None else ''}🌐 Глобальная настройка ({settings.DEFAULT_TRAFFIC_RESET_STRATEGY})" + buttons.append([ + InlineKeyboardButton( + text=global_label, + callback_data=f"admin_tariff_set_reset_mode:{tariff_id}:GLOBAL" + ) + ]) + + # Кнопки для каждого режима + for mode_value, mode_label, mode_desc in TRAFFIC_RESET_MODES: + is_selected = current_mode == mode_value + label = f"{'✅ ' if is_selected else ''}{mode_label}" + buttons.append([ + InlineKeyboardButton( + text=label, + callback_data=f"admin_tariff_set_reset_mode:{tariff_id}:{mode_value}" + ) + ]) + + # Кнопка назад + buttons.append([ + InlineKeyboardButton(text=texts.BACK, callback_data=f"admin_tariff_view:{tariff_id}") + ]) + + return InlineKeyboardMarkup(inline_keyboard=buttons) + + +@admin_required +@error_handler +async def start_edit_traffic_reset_mode( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + """Начинает редактирование режима сброса трафика.""" + tariff_id = int(callback.data.split(":")[1]) + tariff = await get_tariff_by_id(db, tariff_id) + + if not tariff: + await callback.answer("Тариф не найден", show_alert=True) + return + + current_mode = getattr(tariff, 'traffic_reset_mode', None) + + await callback.message.edit_text( + f"🔄 Режим сброса трафика для тарифа «{tariff.name}»\n\n" + f"Текущий режим: {_format_traffic_reset_mode(current_mode)}\n\n" + "Выберите, когда сбрасывать использованный трафик у подписчиков этого тарифа:\n\n" + "• Глобальная настройка — использовать значение из конфига бота\n" + "• Ежедневно — сброс каждый день\n" + "• Еженедельно — сброс каждую неделю\n" + "• Ежемесячно — сброс каждый месяц\n" + "• Никогда — трафик накапливается за весь период подписки", + reply_markup=get_traffic_reset_mode_keyboard(tariff_id, current_mode, db_user.language), + parse_mode="HTML" + ) + await callback.answer() + + +@admin_required +@error_handler +async def set_traffic_reset_mode( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + """Устанавливает режим сброса трафика для тарифа.""" + parts = callback.data.split(":") + tariff_id = int(parts[1]) + new_mode = parts[2] + + tariff = await get_tariff_by_id(db, tariff_id) + + if not tariff: + await callback.answer("Тариф не найден", show_alert=True) + return + + # Преобразуем GLOBAL в None + if new_mode == "GLOBAL": + new_mode = None + + # Обновляем тариф + tariff = await update_tariff(db, tariff, traffic_reset_mode=new_mode) + + mode_display = _format_traffic_reset_mode(new_mode) + await callback.answer(f"Режим сброса изменён: {mode_display}", show_alert=True) + + # Обновляем клавиатуру + await callback.message.edit_text( + f"🔄 Режим сброса трафика для тарифа «{tariff.name}»\n\n" + f"Текущий режим: {mode_display}\n\n" + "Выберите, когда сбрасывать использованный трафик у подписчиков этого тарифа:\n\n" + "• Глобальная настройка — использовать значение из конфига бота\n" + "• Ежедневно — сброс каждый день\n" + "• Еженедельно — сброс каждую неделю\n" + "• Ежемесячно — сброс каждый месяц\n" + "• Никогда — трафик накапливается за весь период подписки", + reply_markup=get_traffic_reset_mode_keyboard(tariff_id, new_mode, db_user.language), + parse_mode="HTML" + ) + + def register_handlers(dp: Dispatcher): """Регистрирует обработчики для управления тарифами.""" # Список тарифов @@ -1821,6 +2752,8 @@ def register_handlers(dp: Dispatcher): dp.message.register(process_tariff_traffic, AdminStates.creating_tariff_traffic) dp.message.register(process_tariff_devices, AdminStates.creating_tariff_devices) dp.message.register(process_tariff_tier, AdminStates.creating_tariff_tier) + dp.callback_query.register(select_tariff_type_periodic, F.data == "tariff_type_periodic") + dp.callback_query.register(select_tariff_type_daily, F.data == "tariff_type_daily") dp.message.register(process_tariff_prices, AdminStates.creating_tariff_prices) # Редактирование названия @@ -1851,10 +2784,24 @@ def register_handlers(dp: Dispatcher): dp.callback_query.register(start_edit_tariff_device_price, F.data.startswith("admin_tariff_edit_device_price:")) dp.message.register(process_edit_tariff_device_price, AdminStates.editing_tariff_device_price) + # Редактирование макс. устройств + dp.callback_query.register(start_edit_tariff_max_devices, F.data.startswith("admin_tariff_edit_max_devices:")) + dp.message.register(process_edit_tariff_max_devices, AdminStates.editing_tariff_max_devices) + # Редактирование дней триала dp.callback_query.register(start_edit_tariff_trial_days, F.data.startswith("admin_tariff_edit_trial_days:")) dp.message.register(process_edit_tariff_trial_days, AdminStates.editing_tariff_trial_days) + # Редактирование докупки трафика + dp.callback_query.register(start_edit_tariff_traffic_topup, F.data.startswith("admin_tariff_edit_traffic_topup:")) + dp.callback_query.register(toggle_tariff_traffic_topup, F.data.startswith("admin_tariff_toggle_traffic_topup:")) + dp.callback_query.register(start_edit_traffic_topup_packages, F.data.startswith("admin_tariff_edit_topup_packages:")) + dp.message.register(process_edit_traffic_topup_packages, AdminStates.editing_tariff_traffic_topup_packages) + + # Редактирование макс. лимита докупки трафика + dp.callback_query.register(start_edit_max_topup_traffic, F.data.startswith("admin_tariff_edit_max_topup:")) + dp.message.register(process_edit_max_topup_traffic, AdminStates.editing_tariff_max_topup_traffic) + # Удаление dp.callback_query.register(confirm_delete_tariff, F.data.startswith("admin_tariff_delete:")) dp.callback_query.register(delete_tariff_confirmed, F.data.startswith("admin_tariff_delete_confirm:")) @@ -1869,3 +2816,12 @@ def register_handlers(dp: Dispatcher): dp.callback_query.register(start_edit_tariff_promo_groups, F.data.startswith("admin_tariff_edit_promo:")) dp.callback_query.register(toggle_tariff_promo_group, F.data.startswith("admin_tariff_toggle_promo:")) dp.callback_query.register(clear_tariff_promo_groups, F.data.startswith("admin_tariff_clear_promo:")) + + # Суточный режим + dp.callback_query.register(toggle_daily_tariff, F.data.startswith("admin_tariff_toggle_daily:")) + dp.callback_query.register(start_edit_daily_price, F.data.startswith("admin_tariff_edit_daily_price:")) + dp.message.register(process_daily_price_input, AdminStates.editing_tariff_daily_price) + + # Режим сброса трафика + dp.callback_query.register(start_edit_traffic_reset_mode, F.data.startswith("admin_tariff_edit_reset_mode:")) + dp.callback_query.register(set_traffic_reset_mode, F.data.startswith("admin_tariff_set_reset_mode:")) diff --git a/app/handlers/admin/users.py b/app/handlers/admin/users.py index 8eb315ad..a52214b7 100644 --- a/app/handlers/admin/users.py +++ b/app/handlers/admin/users.py @@ -1063,12 +1063,16 @@ async def _render_user_subscription_overview( ) ]) - # Кнопка смены тарифа в режиме тарифов + # Кнопки тарифов в режиме тарифов if settings.is_tariffs_mode(): keyboard.append([ types.InlineKeyboardButton( text="📦 Сменить тариф", callback_data=f"admin_sub_change_tariff_{user_id}" + ), + types.InlineKeyboardButton( + text="💳 Купить тариф", + callback_data=f"admin_tariff_buy_{user_id}" ) ]) @@ -4983,6 +4987,366 @@ async def admin_buy_subscription_execute( await db.rollback() +# ==================== Покупка тарифа администратором ==================== + +@admin_required +@error_handler +async def admin_buy_tariff( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + """Показывает список тарифов для покупки админом.""" + user_id = int(callback.data.split('_')[-1]) + + user_service = UserService() + profile = await user_service.get_user_profile(db, user_id) + + if not profile: + await callback.answer("❌ Пользователь не найден", show_alert=True) + return + + target_user = profile["user"] + + # Получаем доступные тарифы + from app.database.crud.tariff import get_tariffs_for_user + tariffs = await get_tariffs_for_user(db, target_user) + + if not tariffs: + await callback.message.edit_text( + "❌ Нет доступных тарифов\n\n" + "Создайте тарифы в разделе управления тарифами.", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_user_subscription_{user_id}")] + ]) + ) + await callback.answer() + return + + target_user_link = f'{target_user.full_name}' + text = f"💳 Покупка тарифа для пользователя\n\n" + text += f"👤 {target_user_link} (ID: {target_user.telegram_id})\n" + text += f"💰 Баланс: {settings.format_price(target_user.balance_kopeks)}\n\n" + text += "📦 Выберите тариф:\n\n" + + for tariff in tariffs: + traffic = "♾️" if tariff.traffic_limit_gb == 0 else f"{tariff.traffic_limit_gb} ГБ" + prices = tariff.period_prices or {} + min_price = min(prices.values()) if prices else 0 + text += f"{tariff.name} — {traffic}/{tariff.device_limit}📱 от {settings.format_price(min_price)}\n" + + keyboard = [] + for tariff in tariffs: + keyboard.append([ + types.InlineKeyboardButton( + text=tariff.name, + callback_data=f"admin_tariff_buy_select_{user_id}_{tariff.id}" + ) + ]) + + keyboard.append([ + types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_user_subscription_{user_id}") + ]) + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), + parse_mode="HTML" + ) + await callback.answer() + + +@admin_required +@error_handler +async def admin_buy_tariff_period( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + """Показывает выбор периода для тарифа.""" + parts = callback.data.split('_') + user_id = int(parts[4]) + tariff_id = int(parts[5]) + + user_service = UserService() + profile = await user_service.get_user_profile(db, user_id) + + if not profile: + await callback.answer("❌ Пользователь не найден", show_alert=True) + return + + target_user = profile["user"] + + from app.database.crud.tariff import get_tariff_by_id + tariff = await get_tariff_by_id(db, tariff_id) + + if not tariff or not tariff.is_active: + await callback.answer("❌ Тариф недоступен", show_alert=True) + return + + target_user_link = f'{target_user.full_name}' + traffic = "♾️ Безлимит" if tariff.traffic_limit_gb == 0 else f"{tariff.traffic_limit_gb} ГБ" + + text = f"💳 Покупка тарифа для пользователя\n\n" + text += f"👤 {target_user_link} (ID: {target_user.telegram_id})\n" + text += f"💰 Баланс: {settings.format_price(target_user.balance_kopeks)}\n\n" + text += f"📦 Тариф: {tariff.name}\n" + text += f"📊 Трафик: {traffic}\n" + text += f"📱 Устройств: {tariff.device_limit}\n" + text += f"🌐 Серверов: {len(tariff.allowed_squads) if tariff.allowed_squads else 0}\n\n" + text += "Выберите период:" + + prices = tariff.period_prices or {} + keyboard = [] + + for period_str, price in sorted(prices.items(), key=lambda x: int(x[0])): + period = int(period_str) + keyboard.append([ + types.InlineKeyboardButton( + text=f"{period} дней — {settings.format_price(price)}", + callback_data=f"admin_tariff_buy_confirm_{user_id}_{tariff_id}_{period}_{price}" + ) + ]) + + keyboard.append([ + types.InlineKeyboardButton(text="⬅️ К тарифам", callback_data=f"admin_tariff_buy_{user_id}") + ]) + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), + parse_mode="HTML" + ) + await callback.answer() + + +@admin_required +@error_handler +async def admin_buy_tariff_confirm( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + """Подтверждение покупки тарифа.""" + parts = callback.data.split('_') + user_id = int(parts[4]) + tariff_id = int(parts[5]) + period = int(parts[6]) + price_kopeks = int(parts[7]) + + user_service = UserService() + profile = await user_service.get_user_profile(db, user_id) + + if not profile: + await callback.answer("❌ Пользователь не найден", show_alert=True) + return + + target_user = profile["user"] + + from app.database.crud.tariff import get_tariff_by_id + tariff = await get_tariff_by_id(db, tariff_id) + + if not tariff or not tariff.is_active: + await callback.answer("❌ Тариф недоступен", show_alert=True) + return + + # Проверяем баланс + if target_user.balance_kopeks < price_kopeks: + missing = price_kopeks - target_user.balance_kopeks + await callback.message.edit_text( + f"❌ Недостаточно средств\n\n" + f"💰 Баланс: {settings.format_price(target_user.balance_kopeks)}\n" + f"💳 Стоимость: {settings.format_price(price_kopeks)}\n" + f"📉 Не хватает: {settings.format_price(missing)}\n\n" + f"Пополните баланс пользователя перед покупкой.", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_tariff_buy_select_{user_id}_{tariff_id}")] + ]), + parse_mode="HTML" + ) + await callback.answer() + return + + target_user_link = f'{target_user.full_name}' + traffic = "♾️ Безлимит" if tariff.traffic_limit_gb == 0 else f"{tariff.traffic_limit_gb} ГБ" + + text = f"💳 Подтверждение покупки тарифа\n\n" + text += f"👤 {target_user_link} (ID: {target_user.telegram_id})\n" + text += f"💰 Баланс: {settings.format_price(target_user.balance_kopeks)}\n\n" + text += f"📦 Тариф: {tariff.name}\n" + text += f"📊 Трафик: {traffic}\n" + text += f"📱 Устройств: {tariff.device_limit}\n" + text += f"📅 Период: {period} дней\n" + text += f"💰 Стоимость: {settings.format_price(price_kopeks)}\n\n" + text += "Подтвердить покупку?" + + keyboard = [ + [ + types.InlineKeyboardButton( + text="✅ Подтвердить", + callback_data=f"admin_tariff_buy_exec_{user_id}_{tariff_id}_{period}_{price_kopeks}" + ) + ], + [ + types.InlineKeyboardButton( + text="❌ Отмена", + callback_data=f"admin_tariff_buy_select_{user_id}_{tariff_id}" + ) + ] + ] + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), + parse_mode="HTML" + ) + await callback.answer() + + +@admin_required +@error_handler +async def admin_buy_tariff_execute( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + """Выполняет покупку тарифа для пользователя.""" + parts = callback.data.split('_') + user_id = int(parts[4]) + tariff_id = int(parts[5]) + period = int(parts[6]) + price_kopeks = int(parts[7]) + + user_service = UserService() + profile = await user_service.get_user_profile(db, user_id) + + if not profile: + await callback.answer("❌ Пользователь не найден", show_alert=True) + return + + target_user = profile["user"] + + from app.database.crud.tariff import get_tariff_by_id + tariff = await get_tariff_by_id(db, tariff_id) + + if not tariff or not tariff.is_active: + await callback.answer("❌ Тариф недоступен", show_alert=True) + return + + # Проверяем баланс ещё раз + if target_user.balance_kopeks < price_kopeks: + await callback.answer("❌ Недостаточно средств на балансе", show_alert=True) + return + + try: + from app.database.crud.user import subtract_user_balance + from app.database.crud.subscription import extend_subscription, create_paid_subscription, get_subscription_by_user_id + from app.database.crud.transaction import create_transaction + from app.services.subscription_service import SubscriptionService + + # Списываем баланс + success = await subtract_user_balance( + db, target_user, price_kopeks, + f"Покупка тарифа {tariff.name} на {period} дней (администратор)" + ) + + if not success: + await callback.answer("❌ Ошибка списания средств", show_alert=True) + return + + # Получаем серверы из тарифа + squads = tariff.allowed_squads or [] + + # Проверяем есть ли подписка + existing_subscription = await get_subscription_by_user_id(db, target_user.id) + + if existing_subscription: + # Продлеваем существующую подписку + subscription = await extend_subscription( + db, + existing_subscription, + days=period, + tariff_id=tariff.id, + traffic_limit_gb=tariff.traffic_limit_gb, + device_limit=tariff.device_limit, + connected_squads=squads, + ) + else: + # Создаем новую подписку + subscription = await create_paid_subscription( + db=db, + user_id=target_user.id, + duration_days=period, + traffic_limit_gb=tariff.traffic_limit_gb, + device_limit=tariff.device_limit, + connected_squads=squads, + tariff_id=tariff.id, + ) + + # Обновляем в Remnawave + try: + subscription_service = SubscriptionService() + await subscription_service.create_remnawave_user( + db, + subscription, + reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT, + reset_reason="покупка тарифа (администратор)", + ) + except Exception as e: + logger.error(f"Ошибка обновления Remnawave: {e}") + + # Создаем транзакцию + await create_transaction( + db, + user_id=target_user.id, + type=TransactionType.SUBSCRIPTION_PAYMENT, + amount_kopeks=-price_kopeks, + description=f"Покупка тарифа {tariff.name} на {period} дней (администратор)", + ) + + target_user_link = f'{target_user.full_name}' + traffic = "♾️ Безлимит" if tariff.traffic_limit_gb == 0 else f"{tariff.traffic_limit_gb} ГБ" + + await callback.message.edit_text( + f"✅ Тариф успешно куплен!\n\n" + f"👤 {target_user_link} (ID: {target_user.telegram_id})\n" + f"📦 Тариф: {tariff.name}\n" + f"📊 Трафик: {traffic}\n" + f"📱 Устройств: {tariff.device_limit}\n" + f"📅 Период: {period} дней\n" + f"💰 Списано: {settings.format_price(price_kopeks)}\n" + f"📅 Действует до: {format_datetime(subscription.end_date)}", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")] + ]), + parse_mode="HTML" + ) + + # Уведомляем пользователя + try: + if callback.bot: + await callback.bot.send_message( + chat_id=target_user.telegram_id, + text=f"💳 Администратор оформил вам тариф\n\n" + f"📦 Тариф: {tariff.name}\n" + f"📊 Трафик: {traffic}\n" + f"📱 Устройств: {tariff.device_limit}\n" + f"📅 Период: {period} дней\n" + f"💰 Списано с баланса: {settings.format_price(price_kopeks)}\n" + f"📅 Действует до: {format_datetime(subscription.end_date)}", + parse_mode="HTML" + ) + except Exception as e: + logger.error(f"Ошибка отправки уведомления пользователю: {e}") + + await callback.answer("✅ Тариф куплен!", show_alert=True) + + except Exception as e: + logger.error(f"Ошибка покупки тарифа администратором: {e}", exc_info=True) + await callback.answer("❌ Ошибка при покупке тарифа", show_alert=True) + await db.rollback() + + @admin_required @error_handler async def change_subscription_type_confirm( @@ -5247,6 +5611,13 @@ async def confirm_admin_tariff_change( subscription.connected_squads = tariff.allowed_squads or [] subscription.updated_at = datetime.utcnow() + # Сбрасываем докупленный трафик при смене тарифа + from app.database.models import TrafficPurchase + from sqlalchemy import delete as sql_delete + await db.execute(sql_delete(TrafficPurchase).where(TrafficPurchase.subscription_id == subscription.id)) + subscription.purchased_traffic_gb = 0 + subscription.traffic_reset_at = None + await db.commit() # Синхронизируем с RemnaWave @@ -5673,7 +6044,28 @@ def register_handlers(dp: Dispatcher): admin_buy_subscription_execute, F.data.startswith("admin_buy_sub_execute_") ) - + + # Регистрация обработчиков для покупки тарифа администратором + dp.callback_query.register( + admin_buy_tariff, + F.data.startswith("admin_tariff_buy_") & ~F.data.startswith("admin_tariff_buy_select_") & ~F.data.startswith("admin_tariff_buy_confirm_") & ~F.data.startswith("admin_tariff_buy_exec_") + ) + + dp.callback_query.register( + admin_buy_tariff_period, + F.data.startswith("admin_tariff_buy_select_") + ) + + dp.callback_query.register( + admin_buy_tariff_confirm, + F.data.startswith("admin_tariff_buy_confirm_") + ) + + dp.callback_query.register( + admin_buy_tariff_execute, + F.data.startswith("admin_tariff_buy_exec_") + ) + # Регистрация обработчиков для фильтрации пользователей dp.callback_query.register( show_users_filters, diff --git a/app/handlers/balance/cryptobot.py b/app/handlers/balance/cryptobot.py index 6ae9158b..aa149425 100644 --- a/app/handlers/balance/cryptobot.py +++ b/app/handlers/balance/cryptobot.py @@ -85,7 +85,7 @@ async def start_cryptobot_payment( # Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки if settings.is_quick_amount_buttons_enabled(): from .main import get_quick_amount_buttons - quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user) + quick_amount_buttons = await get_quick_amount_buttons(db_user.language, db_user) if quick_amount_buttons: # Вставляем кнопки быстрого выбора перед кнопкой "Назад" keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard diff --git a/app/handlers/balance/heleket.py b/app/handlers/balance/heleket.py index 7a1a0d7c..2fa48814 100644 --- a/app/handlers/balance/heleket.py +++ b/app/handlers/balance/heleket.py @@ -74,7 +74,7 @@ async def start_heleket_payment( if settings.is_quick_amount_buttons_enabled(): from .main import get_quick_amount_buttons - quick_buttons = get_quick_amount_buttons(db_user.language, db_user) + quick_buttons = await get_quick_amount_buttons(db_user.language, db_user) if quick_buttons: keyboard.inline_keyboard = quick_buttons + keyboard.inline_keyboard diff --git a/app/handlers/balance/main.py b/app/handlers/balance/main.py index 7d57e0c7..d94e1e32 100644 --- a/app/handlers/balance/main.py +++ b/app/handlers/balance/main.py @@ -120,7 +120,7 @@ async def route_payment_by_method( return False -def get_quick_amount_buttons(language: str, user: User) -> list: +async def get_quick_amount_buttons(language: str, user: User) -> list: """ Generate quick amount buttons with user-specific pricing and discounts. @@ -135,17 +135,41 @@ def get_quick_amount_buttons(language: str, user: User) -> list: return [] from app.localization.texts import get_texts + from app.config import PERIOD_PRICES texts = get_texts(language) + # В режиме тарифов получаем цены из тарифа пользователя + tariff_prices = None + tariff_periods = None + if settings.is_tariffs_mode(): + from app.database.database import AsyncSessionLocal + from app.database.crud.subscription import get_subscription_by_user_id + from app.database.crud.tariff import get_tariff_by_id + + async with AsyncSessionLocal() as db: + subscription = await get_subscription_by_user_id(db, user.id) + if subscription and subscription.tariff_id: + tariff = await get_tariff_by_id(db, subscription.tariff_id) + if tariff and tariff.period_prices: + tariff_prices = {int(k): v for k, v in tariff.period_prices.items()} + tariff_periods = sorted(tariff_prices.keys()) + buttons = [] - periods = settings.get_available_subscription_periods() - periods = periods[:6] # Limit to 6 periods + + # Используем периоды тарифа в режиме тарифов, иначе стандартные + if tariff_periods: + periods = tariff_periods[:6] + else: + periods = settings.get_available_subscription_periods()[:6] for period in periods: - price_attr = f"PRICE_{period}_DAYS" - if hasattr(settings, price_attr): - base_price_kopeks = getattr(settings, price_attr) + # Получаем цену из тарифа или из PERIOD_PRICES + if tariff_prices and period in tariff_prices: + base_price_kopeks = tariff_prices[period] + else: + base_price_kopeks = PERIOD_PRICES.get(period, 0) + if base_price_kopeks > 0: # Calculate price with user's promo group discount using unified system price_info = calculate_user_price(user, base_price_kopeks, period, "period") @@ -366,7 +390,19 @@ async def show_payment_methods( try: # Получаем цены для текущих параметров from app.config import PERIOD_PRICES - base_price_original = PERIOD_PRICES.get(duration_days, 0) + from app.database.crud.tariff import get_tariff_by_id + + # В режиме тарифов берём цену из тарифа пользователя + base_price_original = 0 + if settings.is_tariffs_mode() and subscription.tariff_id: + tariff = await get_tariff_by_id(db, subscription.tariff_id) + if tariff and tariff.period_prices: + base_price_original = tariff.period_prices.get(str(duration_days), 0) + + # Если не нашли в тарифе - используем PERIOD_PRICES + if base_price_original <= 0: + base_price_original = PERIOD_PRICES.get(duration_days, 0) + period_discount_percent = db_user.get_promo_discount("period", duration_days) base_price, base_discount_total = apply_percentage_discount( base_price_original, diff --git a/app/handlers/balance/mulenpay.py b/app/handlers/balance/mulenpay.py index d99af39f..f1ded7e5 100644 --- a/app/handlers/balance/mulenpay.py +++ b/app/handlers/balance/mulenpay.py @@ -66,7 +66,7 @@ async def start_mulenpay_payment( if settings.is_quick_amount_buttons_enabled(): from .main import get_quick_amount_buttons - quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user) + quick_amount_buttons = await get_quick_amount_buttons(db_user.language, db_user) if quick_amount_buttons: keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard @@ -160,7 +160,7 @@ async def process_mulenpay_payment_amount( db=db, user_id=db_user.id, amount_kopeks=amount_kopeks, - description=settings.get_balance_payment_description(amount_kopeks), + description=settings.get_balance_payment_description(amount_kopeks, telegram_user_id=db_user.telegram_id), language=db_user.language, ) diff --git a/app/handlers/balance/pal24.py b/app/handlers/balance/pal24.py index 5c93bb0c..b3de04f8 100644 --- a/app/handlers/balance/pal24.py +++ b/app/handlers/balance/pal24.py @@ -48,7 +48,7 @@ async def _send_pal24_payment_message( db=db, user_id=db_user.id, amount_kopeks=amount_kopeks, - description=settings.get_balance_payment_description(amount_kopeks), + description=settings.get_balance_payment_description(amount_kopeks, telegram_user_id=db_user.telegram_id), language=db_user.language, payment_method=payment_method, ) @@ -309,7 +309,7 @@ async def start_pal24_payment( if settings.is_quick_amount_buttons_enabled(): from .main import get_quick_amount_buttons - quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user) + quick_amount_buttons = await get_quick_amount_buttons(db_user.language, db_user) if quick_amount_buttons: keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard diff --git a/app/handlers/balance/platega.py b/app/handlers/balance/platega.py index 4f62d334..40336525 100644 --- a/app/handlers/balance/platega.py +++ b/app/handlers/balance/platega.py @@ -83,7 +83,7 @@ async def _prompt_amount( if settings.is_quick_amount_buttons_enabled(): from .main import get_quick_amount_buttons - quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user) + quick_amount_buttons = await get_quick_amount_buttons(db_user.language, db_user) if quick_amount_buttons: keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard @@ -280,7 +280,7 @@ async def process_platega_payment_amount( db=db, user_id=db_user.id, amount_kopeks=amount_kopeks, - description=settings.get_balance_payment_description(amount_kopeks), + description=settings.get_balance_payment_description(amount_kopeks, telegram_user_id=db_user.telegram_id), language=db_user.language, payment_method_code=method_code, ) diff --git a/app/handlers/balance/stars.py b/app/handlers/balance/stars.py index a5899366..93d67739 100644 --- a/app/handlers/balance/stars.py +++ b/app/handlers/balance/stars.py @@ -59,7 +59,7 @@ async def start_stars_payment( # Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки if settings.is_quick_amount_buttons_enabled(): from .main import get_quick_amount_buttons - quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user) + quick_amount_buttons = await get_quick_amount_buttons(db_user.language, db_user) if quick_amount_buttons: # Вставляем кнопки быстрого выбора перед кнопкой "Назад" keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard diff --git a/app/handlers/balance/wata.py b/app/handlers/balance/wata.py index b39ea393..16c76b21 100644 --- a/app/handlers/balance/wata.py +++ b/app/handlers/balance/wata.py @@ -64,7 +64,7 @@ async def start_wata_payment( if settings.is_quick_amount_buttons_enabled(): from .main import get_quick_amount_buttons - quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user) + quick_amount_buttons = await get_quick_amount_buttons(db_user.language, db_user) if quick_amount_buttons: keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard @@ -140,7 +140,7 @@ async def process_wata_payment_amount( db=db, user_id=db_user.id, amount_kopeks=amount_kopeks, - description=settings.get_balance_payment_description(amount_kopeks), + description=settings.get_balance_payment_description(amount_kopeks, telegram_user_id=db_user.telegram_id), language=db_user.language, ) except Exception as error: # pragma: no cover - handled by decorator logs diff --git a/app/handlers/balance/yookassa.py b/app/handlers/balance/yookassa.py index c80c57ef..bc71a9ad 100644 --- a/app/handlers/balance/yookassa.py +++ b/app/handlers/balance/yookassa.py @@ -69,11 +69,11 @@ async def start_yookassa_payment( # Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки if settings.is_quick_amount_buttons_enabled(): from .main import get_quick_amount_buttons - quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user) + quick_amount_buttons = await get_quick_amount_buttons(db_user.language, db_user) if quick_amount_buttons: # Вставляем кнопки быстрого выбора перед кнопкой "Назад" keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard - + await callback.message.edit_text( message_text, reply_markup=keyboard, @@ -140,11 +140,11 @@ async def start_yookassa_sbp_payment( # Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки if settings.is_quick_amount_buttons_enabled(): from .main import get_quick_amount_buttons - quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user) + quick_amount_buttons = await get_quick_amount_buttons(db_user.language, db_user) if quick_amount_buttons: # Вставляем кнопки быстрого выбора перед кнопкой "Назад" keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard - + await callback.message.edit_text( message_text, reply_markup=keyboard, diff --git a/app/handlers/common.py b/app/handlers/common.py index 7d389163..4d9bcc70 100644 --- a/app/handlers/common.py +++ b/app/handlers/common.py @@ -11,13 +11,25 @@ from app.keyboards.inline import get_back_keyboard logger = logging.getLogger(__name__) +async def handle_delete_ban_notification( + callback: types.CallbackQuery, +): + """Удаляет уведомление о бане при нажатии на кнопку""" + try: + await callback.message.delete() + await callback.answer("Уведомление удалено") + except Exception as e: + logger.warning(f"Не удалось удалить уведомление: {e}") + await callback.answer("Не удалось удалить", show_alert=False) + + async def handle_unknown_callback( callback: types.CallbackQuery, db_user: User ): - + texts = get_texts(db_user.language if db_user else "ru") - + await callback.answer( texts.t( "UNKNOWN_CALLBACK_ALERT", @@ -25,7 +37,7 @@ async def handle_unknown_callback( ), show_alert=True, ) - + logger.warning(f"Неизвестный callback: {callback.data} от пользователя {callback.from_user.id}") @@ -99,7 +111,13 @@ async def show_rules( def register_handlers(dp: Dispatcher): - + + # Удаление уведомлений о банах + dp.callback_query.register( + handle_delete_ban_notification, + F.data == "ban_notify:delete" + ) + dp.callback_query.register( show_rules, F.data == "menu_rules" diff --git a/app/handlers/menu.py b/app/handlers/menu.py index 092b40cc..e22d2006 100644 --- a/app/handlers/menu.py +++ b/app/handlers/menu.py @@ -1092,7 +1092,7 @@ async def handle_back_to_menu( ) await callback.answer() -def _get_subscription_status(user: User, texts) -> str: +def _get_subscription_status(user: User, texts, is_daily_tariff: bool = False) -> str: subscription = getattr(user, "subscription", None) if not subscription: return texts.t("SUB_STATUS_NONE", "❌ Отсутствует") @@ -1144,6 +1144,10 @@ def _get_subscription_status(user: User, texts) -> str: ) if actual_status == "active": + # Для суточных тарифов не показываем предупреждение об истечении + if is_daily_tariff: + return texts.t("SUB_STATUS_DAILY_ACTIVE", "💎 Активна") + if days_left > 7 and end_date_text: return texts.t( "SUB_STATUS_ACTIVE_LONG", @@ -1185,12 +1189,39 @@ def _insert_random_message(base_text: str, random_message: str, action_prompt: s async def get_main_menu_text(user, texts, db: AsyncSession): + from app.config import settings + + # Загружаем информацию о тарифе если включен режим тарифов + tariff = None + is_daily_tariff = False + tariff_info_block = "" + + subscription = getattr(user, "subscription", None) + if settings.is_tariffs_mode() and subscription and subscription.tariff_id: + try: + from app.database.crud.tariff import get_tariff_by_id + tariff = await get_tariff_by_id(db, subscription.tariff_id) + if tariff: + is_daily_tariff = getattr(tariff, 'is_daily', False) + # Формируем краткий блок информации о тарифе для главного меню + tariff_info_block = f"\n📦 Тариф: {tariff.name}" + except Exception as e: + logger.debug(f"Не удалось загрузить тариф для главного меню: {e}") base_text = texts.MAIN_MENU.format( user_name=user.full_name, - subscription_status=_get_subscription_status(user, texts) + subscription_status=_get_subscription_status(user, texts, is_daily_tariff) ) + # Добавляем информацию о тарифе перед "Выберите действие" + if tariff_info_block: + action_prompt_text = texts.t("MAIN_MENU_ACTION_PROMPT", "Выберите действие:") + if action_prompt_text in base_text: + base_text = base_text.replace( + action_prompt_text, + f"{tariff_info_block}\n\n{action_prompt_text}" + ) + action_prompt = texts.t("MAIN_MENU_ACTION_PROMPT", "Выберите действие:") info_sections: list[str] = [] @@ -1392,6 +1423,7 @@ async def handle_activate_button( except Exception as e: logger.error(f"Ошибка автоматической активации для {db_user.telegram_id}: {e}") + await db.rollback() await callback.answer( texts.t("ACTIVATION_ERROR", "❌ Ошибка активации. Попробуйте позже."), show_alert=True, diff --git a/app/handlers/stars_payments.py b/app/handlers/stars_payments.py index 48356450..f64f337b 100644 --- a/app/handlers/stars_payments.py +++ b/app/handlers/stars_payments.py @@ -14,6 +14,232 @@ from app.localization.texts import get_texts logger = logging.getLogger(__name__) +async def _handle_wheel_spin_payment( + message: types.Message, + db: AsyncSession, + user, + stars_amount: int, + payload: str, + texts, +): + """Обработка Stars платежа для колеса удачи.""" + from app.services.wheel_service import wheel_service + from app.database.crud.wheel import get_or_create_wheel_config, get_wheel_prizes + + try: + config = await get_or_create_wheel_config(db) + + if not config.is_enabled: + await message.answer( + "❌ Колесо удачи временно недоступно. Звезды будут возвращены.", + ) + return False + + # Выполняем спин напрямую (оплата уже прошла через Stars) + prizes = await get_or_create_wheel_config(db) + prizes = await get_wheel_prizes(db, config.id, active_only=True) + + if not prizes: + await message.answer( + "❌ Призы не настроены. Обратитесь в поддержку.", + ) + return False + + # Рассчитываем стоимость в копейках для статистики + rubles_amount = TelegramStarsService.calculate_rubles_from_stars(stars_amount) + payment_value_kopeks = int((rubles_amount * Decimal(100)).to_integral_value(rounding=ROUND_HALF_UP)) + + # Рассчитываем вероятности и выбираем приз + prizes_with_probs = wheel_service.calculate_prize_probabilities(config, prizes, payment_value_kopeks) + selected_prize = wheel_service._select_prize(prizes_with_probs) + + # Применяем приз + generated_promocode = await wheel_service._apply_prize(db, user, selected_prize, config) + + # Создаем запись спина + from app.database.crud.wheel import create_wheel_spin + from app.database.models import WheelSpinPaymentType + + promocode_id = None + if generated_promocode: + result = await db.execute( + f"SELECT id FROM promocodes WHERE code = '{generated_promocode}'" + ) + row = result.fetchone() + if row: + promocode_id = row[0] + + logger.info( + f"🎰 Creating wheel spin: user.id={user.id}, user.telegram_id={user.telegram_id}, " + f"prize={selected_prize.display_name}" + ) + + spin = await create_wheel_spin( + db=db, + user_id=user.id, + prize_id=selected_prize.id, + payment_type=WheelSpinPaymentType.TELEGRAM_STARS.value, + payment_amount=stars_amount, + payment_value_kopeks=payment_value_kopeks, + prize_type=selected_prize.prize_type, + prize_value=selected_prize.prize_value, + prize_display_name=selected_prize.display_name, + prize_value_kopeks=selected_prize.prize_value_kopeks, + generated_promocode_id=promocode_id, + is_applied=True, + ) + + logger.info(f"🎰 Wheel spin created: spin.id={spin.id}, spin.user_id={spin.user_id}") + + # Ensure all changes are committed (subscription days, traffic GB, etc.) + await db.commit() + + # Отправляем результат + prize_message = wheel_service._get_prize_message(selected_prize, generated_promocode) + + emoji = selected_prize.emoji or "🎁" + await message.answer( + f"🎰 Колесо удачи!\n\n" + f"{emoji} {selected_prize.display_name}\n\n" + f"{prize_message}\n\n" + f"⭐ Потрачено: {stars_amount} Stars", + parse_mode="HTML", + ) + + logger.info( + f"🎰 Wheel spin via Stars: user={user.id}, prize={selected_prize.display_name}, " + f"stars={stars_amount}" + ) + return True + + except Exception as e: + logger.error(f"Ошибка обработки wheel spin payment: {e}", exc_info=True) + await message.answer( + "❌ Произошла ошибка при обработке спина. Обратитесь в поддержку.", + ) + return False + + +async def _handle_trial_payment( + message: types.Message, + db: AsyncSession, + user, + stars_amount: int, + payload: str, + texts, +): + """Обработка Stars платежа для платного триала.""" + from app.database.crud.subscription import activate_pending_trial_subscription + from app.services.subscription_service import SubscriptionService + from app.services.admin_notification_service import AdminNotificationService + from app.database.crud.transaction import create_transaction + from app.database.models import TransactionType, PaymentMethod + + try: + # Парсим payload: trial_{subscription_id} + parts = payload.split("_") + if len(parts) < 2: + logger.error(f"Невалидный trial payload: {payload}") + await message.answer( + "❌ Ошибка: неверный формат платежа. Обратитесь в поддержку.", + ) + return False + + try: + subscription_id = int(parts[1]) + except ValueError: + logger.error(f"Невалидный subscription_id в trial payload: {payload}") + await message.answer( + "❌ Ошибка: неверный ID подписки. Обратитесь в поддержку.", + ) + return False + + # Рассчитываем стоимость в копейках + rubles_amount = TelegramStarsService.calculate_rubles_from_stars(stars_amount) + amount_kopeks = int((rubles_amount * Decimal(100)).to_integral_value(rounding=ROUND_HALF_UP)) + + # Создаём транзакцию + transaction = await create_transaction( + db=db, + user_id=user.id, + type=TransactionType.SUBSCRIPTION_PAYMENT, + amount_kopeks=amount_kopeks, + description=f"Оплата пробной подписки через Telegram Stars ({stars_amount} ⭐)", + payment_method=PaymentMethod.TELEGRAM_STARS, + external_id=f"trial_stars_{subscription_id}", + is_completed=True, + ) + + # Активируем pending триальную подписку + subscription = await activate_pending_trial_subscription( + db=db, + subscription_id=subscription_id, + user_id=user.id, + ) + + if not subscription: + logger.error(f"Не удалось активировать триальную подписку {subscription_id} для пользователя {user.id}") + # Возвращаем деньги на баланс + from app.database.crud.user import add_user_balance + await add_user_balance( + db, + user, + amount_kopeks, + "Возврат за неудачную активацию триала", + transaction_type=TransactionType.REFUND, + ) + await message.answer( + "❌ Не удалось активировать пробную подписку. Средства возвращены на баланс.", + ) + return False + + # Создаем пользователя в RemnaWave + subscription_service = SubscriptionService() + try: + await subscription_service.create_remnawave_user(db, subscription) + except Exception as rw_error: + logger.error(f"Ошибка создания пользователя RemnaWave для триала: {rw_error}") + # Не откатываем подписку, просто логируем - RemnaWave может быть временно недоступен + + await db.commit() + await db.refresh(user) + + # Отправляем уведомление админам + try: + admin_notification_service = AdminNotificationService(message.bot) + await admin_notification_service.send_trial_activation_notification( + user=user, + subscription=subscription, + paid_amount=amount_kopeks, + payment_method="Telegram Stars", + ) + except Exception as admin_error: + logger.warning(f"Ошибка отправки уведомления админам о триале: {admin_error}") + + # Отправляем сообщение пользователю + await message.answer( + f"🎉 Пробная подписка активирована!\n\n" + f"⭐ Потрачено: {stars_amount} Stars\n" + f"📅 Период: {settings.TRIAL_DURATION_DAYS} дней\n" + f"📱 Устройств: {subscription.device_limit}\n\n" + f"Используйте меню для подключения к VPN.", + parse_mode="HTML", + ) + + logger.info( + f"✅ Платный триал активирован через Stars: user={user.id}, " + f"subscription={subscription.id}, stars={stars_amount}" + ) + return True + + except Exception as e: + logger.error(f"Ошибка обработки trial payment: {e}", exc_info=True) + await message.answer( + "❌ Произошла ошибка при активации пробной подписки. Обратитесь в поддержку.", + ) + return False + + async def handle_pre_checkout_query(query: types.PreCheckoutQuery): texts = get_texts(DEFAULT_LANGUAGE) @@ -22,7 +248,7 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery): f"📋 Pre-checkout query от {query.from_user.id}: {query.total_amount} XTR, payload: {query.invoice_payload}" ) - allowed_prefixes = ("balance_", "admin_stars_test_", "simple_sub_") + allowed_prefixes = ("balance_", "admin_stars_test_", "simple_sub_", "wheel_spin_", "trial_") if not query.invoice_payload or not query.invoice_payload.startswith(allowed_prefixes): logger.warning(f"Невалидный payload: {query.invoice_payload}") @@ -109,6 +335,30 @@ async def handle_successful_payment( ) return + # Обработка оплаты спина колеса удачи + if payment.invoice_payload and payment.invoice_payload.startswith("wheel_spin_"): + await _handle_wheel_spin_payment( + message=message, + db=db, + user=user, + stars_amount=payment.total_amount, + payload=payment.invoice_payload, + texts=texts, + ) + return + + # Обработка оплаты платного триала + if payment.invoice_payload and payment.invoice_payload.startswith("trial_"): + await _handle_trial_payment( + message=message, + db=db, + user=user, + stars_amount=payment.total_amount, + payload=payment.invoice_payload, + texts=texts, + ) + return + payment_service = PaymentService(message.bot) state_data = await state.get_data() diff --git a/app/handlers/subscription/countries.py b/app/handlers/subscription/countries.py index f50f47d8..c1344422 100644 --- a/app/handlers/subscription/countries.py +++ b/app/handlers/subscription/countries.py @@ -555,7 +555,10 @@ async def select_country( await callback.answer("❌ Сервер недоступен для вашей промогруппы", show_alert=True) return - period_base_price = PERIOD_PRICES[data['period_days']] + period_base_price = PERIOD_PRICES.get(data['period_days'], 0) + if period_base_price <= 0: + await callback.answer("❌ Цена для этого периода не настроена", show_alert=True) + return discounted_base_price, _ = apply_percentage_discount( period_base_price, diff --git a/app/handlers/subscription/pricing.py b/app/handlers/subscription/pricing.py index 8d600bbe..1a3796cd 100644 --- a/app/handlers/subscription/pricing.py +++ b/app/handlers/subscription/pricing.py @@ -95,7 +95,9 @@ async def _prepare_subscription_summary( months_in_period = calculate_months_from_days(summary_data['period_days']) period_display = format_period_description(summary_data['period_days'], db_user.language) - base_price_original = PERIOD_PRICES[summary_data['period_days']] + base_price_original = PERIOD_PRICES.get(summary_data['period_days'], 0) + if base_price_original <= 0: + raise ValueError(f"Цена для периода {summary_data['period_days']} дней не настроена") period_discount_percent = db_user.get_promo_discount( "period", summary_data['period_days'], @@ -508,6 +510,54 @@ async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSess if subscription_cost > 0: info_text += f"\n💰 Стоимость подписки в месяц: {texts.format_price(subscription_cost)}" + # Отображаем докупленный трафик + if subscription.traffic_limit_gb > 0: # Только для лимитированных тарифов + from app.database.models import TrafficPurchase + from sqlalchemy import select as sql_select + from datetime import datetime + + now = datetime.utcnow() + purchases_query = ( + sql_select(TrafficPurchase) + .where(TrafficPurchase.subscription_id == subscription.id) + .where(TrafficPurchase.expires_at > now) + .order_by(TrafficPurchase.expires_at.asc()) + ) + purchases_result = await db.execute(purchases_query) + purchases = purchases_result.scalars().all() + + if purchases: + info_text += "\n\n📦 Докупленный трафик:" + + for purchase in purchases: + time_remaining = purchase.expires_at - now + days_remaining = max(0, int(time_remaining.total_seconds() / 86400)) + + # Генерируем прогресс-бар + total_duration_seconds = (purchase.expires_at - purchase.created_at).total_seconds() + elapsed_seconds = (now - purchase.created_at).total_seconds() + progress_percent = min(100.0, max(0.0, (elapsed_seconds / total_duration_seconds * 100) if total_duration_seconds > 0 else 0)) + + bar_length = 10 + filled = int((progress_percent / 100) * bar_length) + bar = "▰" * filled + "▱" * (bar_length - filled) + + # Форматируем дату истечения + expire_date = purchase.expires_at.strftime("%d.%m.%Y") + + # Формируем текст о времени + if days_remaining == 0: + time_text = "истекает сегодня" + elif days_remaining == 1: + time_text = "остался 1 день" + elif days_remaining < 5: + time_text = f"осталось {days_remaining} дня" + else: + time_text = f"осталось {days_remaining} дней" + + info_text += f"\n• {purchase.traffic_gb} ГБ — {time_text}" + info_text += f"\n {bar} {progress_percent:.0f}% | до {expire_date}" + if ( subscription_url and subscription_url != "Генерируется..." diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py index 3a1680a2..a214c866 100644 --- a/app/handlers/subscription/purchase.py +++ b/app/handlers/subscription/purchase.py @@ -2,6 +2,8 @@ import base64 import json import logging from datetime import datetime, timedelta + +logger = logging.getLogger(__name__) from typing import Dict, List, Any, Tuple, Optional from urllib.parse import quote from aiogram import Dispatcher, types, F @@ -134,7 +136,7 @@ from app.handlers.simple_subscription import ( _get_simple_subscription_payment_keyboard, ) -from .common import _apply_promo_offer_discount, _get_promo_offer_discount_percent, logger, update_traffic_prices +from .common import _apply_promo_offer_discount, _get_promo_offer_discount_percent, update_traffic_prices from .autopay import ( handle_autopay_menu, handle_subscription_cancel, @@ -333,31 +335,116 @@ async def show_subscription_info( else texts.t("SUBSCRIPTION_NO_SERVERS", "Нет серверов") ) - # Получаем название тарифа для режима тарифов - tariff_line = "" + # Получаем информацию о тарифе для режима тарифов + tariff_info_block = "" + tariff = None if settings.is_tariffs_mode() and subscription.tariff_id: try: from app.database.crud.tariff import get_tariff_by_id tariff = await get_tariff_by_id(db, subscription.tariff_id) if tariff: - tariff_line = f"\n📦 Тариф: {tariff.name}" - except Exception as e: - logger.warning(f"Ошибка получения тарифа: {e}") + # Прикрепляем тариф к подписке для использования в клавиатуре + subscription.tariff = tariff - message_template = texts.t( - "SUBSCRIPTION_OVERVIEW_TEMPLATE", - """👤 {full_name} + # Формируем блок информации о тарифе + is_daily = getattr(tariff, 'is_daily', False) + tariff_type_str = "🔄 Суточный" if is_daily else "📅 Периодный" + + tariff_info_lines = [ + f"📦 {tariff.name}", + f"Тип: {tariff_type_str}", + f"Трафик: {tariff.traffic_limit_gb} ГБ" if tariff.traffic_limit_gb > 0 else "Трафик: ∞ Безлимит", + f"Устройства: {tariff.device_limit}", + ] + + if is_daily: + # Для суточного тарифа показываем цену и прогресс-бар + daily_price = getattr(tariff, 'daily_price_kopeks', 0) / 100 + tariff_info_lines.append(f"Цена: {daily_price:.2f} ₽/день") + + # Прогресс-бар до следующего списания + last_charge = getattr(subscription, 'last_daily_charge_at', None) + is_paused = getattr(subscription, 'is_daily_paused', False) + + if is_paused: + tariff_info_lines.append("") + tariff_info_lines.append("⏸️ Подписка приостановлена") + # Показываем оставшееся время даже при паузе + if last_charge: + from datetime import timedelta + next_charge = last_charge + timedelta(hours=24) + now = datetime.utcnow() + if next_charge > now: + time_until = next_charge - now + hours_left = time_until.seconds // 3600 + minutes_left = (time_until.seconds % 3600) // 60 + tariff_info_lines.append(f"⏳ Осталось: {hours_left}ч {minutes_left}мин") + tariff_info_lines.append("💤 Списание приостановлено") + elif last_charge: + from datetime import timedelta + next_charge = last_charge + timedelta(hours=24) + now = datetime.utcnow() + + if next_charge > now: + time_until = next_charge - now + hours_left = time_until.seconds // 3600 + minutes_left = (time_until.seconds % 3600) // 60 + + # Процент оставшегося времени (24 часа = 100%) + total_seconds = 24 * 3600 + remaining_seconds = time_until.total_seconds() + percent = min(100, max(0, (remaining_seconds / total_seconds) * 100)) + + # Генерируем прогресс-бар + bar_length = 10 + filled = int(bar_length * percent / 100) + empty = bar_length - filled + progress_bar = "▓" * filled + "░" * empty + + tariff_info_lines.append("") + tariff_info_lines.append(f"⏳ До списания: {hours_left}ч {minutes_left}мин") + tariff_info_lines.append(f"[{progress_bar}] {percent:.0f}%") + else: + tariff_info_lines.append("") + tariff_info_lines.append("⏳ Первое списание скоро") + + tariff_info_block = "\n
" + "\n".join(tariff_info_lines) + "
" + + except Exception as e: + logger.warning(f"Ошибка получения тарифа: {e}", exc_info=True) + + # Определяем, суточный ли тариф для выбора шаблона + is_daily_tariff = tariff and getattr(tariff, 'is_daily', False) + + if is_daily_tariff: + # Для суточных тарифов другой шаблон без "Действует до" и "Осталось" + message_template = texts.t( + "SUBSCRIPTION_DAILY_OVERVIEW_TEMPLATE", + """👤 {full_name} 💰 Баланс: {balance} -📱 Подписка: {status_emoji} {status_display}{warning} +📱 Подписка: {status_emoji} {status_display}{warning}{tariff_info_block} 📱 Информация о подписке -🎭 Тип: {subscription_type}{tariff_line} +🎭 Тип: {subscription_type} +📈 Трафик: {traffic} +🌍 Серверы: {servers} +📱 Устройства: {devices_used} / {device_limit}""", + ) + else: + message_template = texts.t( + "SUBSCRIPTION_OVERVIEW_TEMPLATE", + """👤 {full_name} +💰 Баланс: {balance} +📱 Подписка: {status_emoji} {status_display}{warning}{tariff_info_block} + +📱 Информация о подписке +🎭 Тип: {subscription_type} 📅 Действует до: {end_date} ⏰ Осталось: {time_left} 📈 Трафик: {traffic} 🌍 Серверы: {servers} 📱 Устройства: {devices_used} / {device_limit}""", - ) + ) if not show_devices: message_template = message_template.replace( @@ -380,8 +467,8 @@ async def show_subscription_info( status_emoji=status_emoji, status_display=status_display, warning=warning_text, + tariff_info_block=tariff_info_block, subscription_type=subscription_type, - tariff_line=tariff_line, end_date=format_local_datetime(subscription.end_date, "%d.%m.%Y %H:%M"), time_left=time_left_text, traffic=traffic_used_display, @@ -405,6 +492,58 @@ async def show_subscription_info( message += f"• {device_info}\n" message += texts.t("SUBSCRIPTION_CONNECTED_DEVICES_FOOTER", "") + # Отображаем докупленный трафик + if subscription.traffic_limit_gb > 0: # Только для лимитированных тарифов + from app.database.models import TrafficPurchase + from sqlalchemy import select as sql_select + + now = datetime.utcnow() + purchases_query = ( + sql_select(TrafficPurchase) + .where(TrafficPurchase.subscription_id == subscription.id) + .where(TrafficPurchase.expires_at > now) + .order_by(TrafficPurchase.expires_at.asc()) + ) + purchases_result = await db.execute(purchases_query) + purchases = purchases_result.scalars().all() + + if purchases: + message += "\n\n" + texts.t( + "SUBSCRIPTION_PURCHASED_TRAFFIC_TITLE", + "
📦 Докупленный трафик:\n", + ) + + for purchase in purchases: + time_remaining = purchase.expires_at - now + days_remaining = max(0, int(time_remaining.total_seconds() / 86400)) + + # Генерируем прогресс-бар + total_duration_seconds = (purchase.expires_at - purchase.created_at).total_seconds() + elapsed_seconds = (now - purchase.created_at).total_seconds() + progress_percent = min(100.0, max(0.0, (elapsed_seconds / total_duration_seconds * 100) if total_duration_seconds > 0 else 0)) + + bar_length = 10 + filled = int((progress_percent / 100) * bar_length) + bar = "▰" * filled + "▱" * (bar_length - filled) + + # Форматируем дату истечения + expire_date = purchase.expires_at.strftime("%d.%m.%Y") + + # Формируем текст о времени + if days_remaining == 0: + time_text = "истекает сегодня" + elif days_remaining == 1: + time_text = "остался 1 день" + elif days_remaining < 5: + time_text = f"осталось {days_remaining} дня" + else: + time_text = f"осталось {days_remaining} дней" + + message += f"• {purchase.traffic_gb} ГБ — {time_text}\n" + message += f" {bar} {progress_percent:.0f}% | до {expire_date}\n" + + message += texts.t("SUBSCRIPTION_PURCHASED_TRAFFIC_FOOTER", "
") + subscription_link = get_display_subscription_link(subscription) hide_subscription_link = settings.should_hide_subscription_link() @@ -1649,6 +1788,16 @@ async def confirm_extend_subscription( days = int(callback.data.split('_')[2]) texts = get_texts(db_user.language) + + # Валидация что период доступен для продления + available_renewal_periods = settings.get_available_renewal_periods() + if days not in available_renewal_periods: + await callback.answer( + texts.t("RENEWAL_PERIOD_NOT_AVAILABLE", "❌ Этот период больше недоступен для продления"), + show_alert=True + ) + return + subscription = db_user.subscription if not subscription: @@ -1880,6 +2029,7 @@ async def confirm_extend_subscription( traffic_was_reset = True subscription.traffic_limit_gb = fixed_limit subscription.purchased_traffic_gb = 0 + subscription.traffic_reset_at = None # Сбрасываем дату сброса трафика logger.info(f"🔄 Сброс трафика при продлении: {old_traffic_limit} ГБ → {fixed_limit} ГБ") await db.commit() @@ -1992,9 +2142,27 @@ async def select_period( period_days = int(callback.data.split('_')[1]) texts = get_texts(db_user.language) + # Валидация что период доступен + available_periods = settings.get_available_subscription_periods() + if period_days not in available_periods: + await callback.answer( + texts.t("PERIOD_NOT_AVAILABLE", "❌ Этот период больше недоступен"), + show_alert=True + ) + return + + # Получаем цену с защитой от KeyError + period_price = PERIOD_PRICES.get(period_days, 0) + if period_price <= 0: + await callback.answer( + texts.t("PERIOD_PRICE_NOT_SET", "❌ Цена для этого периода не настроена"), + show_alert=True + ) + return + data = await state.get_data() data['period_days'] = period_days - data['total_price'] = PERIOD_PRICES[period_days] + data['total_price'] = period_price if settings.is_traffic_fixed(): fixed_traffic_price = settings.get_traffic_price(settings.get_fixed_traffic_limit()) @@ -2080,9 +2248,18 @@ async def select_devices( data = await state.get_data() + # Получаем цену периода с защитой от KeyError + period_days = data.get('period_days') + if not period_days or period_days not in PERIOD_PRICES: + await callback.answer( + texts.t("PERIOD_NOT_AVAILABLE", "❌ Период больше недоступен, начните заново"), + show_alert=True + ) + return + base_price = ( - PERIOD_PRICES[data['period_days']] + - settings.get_traffic_price(data['traffic_gb']) + PERIOD_PRICES.get(period_days, 0) + + settings.get_traffic_price(data.get('traffic_gb', 0)) ) countries = await _get_available_countries(db_user.promo_group_id) @@ -2198,7 +2375,13 @@ async def confirm_purchase( # Всегда пересчитываем base_price из PERIOD_PRICES для безопасности # (не доверяем кэшированным значениям из FSM данных) - base_price_original = PERIOD_PRICES[period_days] + base_price_original = PERIOD_PRICES.get(period_days, 0) + if base_price_original <= 0: + await callback.answer( + texts.t("PERIOD_PRICE_NOT_SET", "❌ Цена для этого периода не настроена"), + show_alert=True + ) + return base_discount_percent = db_user.get_promo_discount( "period", period_days, @@ -3016,7 +3199,12 @@ async def handle_subscription_settings( await callback.message.edit_text( settings_text, - reply_markup=get_updated_subscription_settings_keyboard(db_user.language, show_countries, tariff=tariff), + reply_markup=get_updated_subscription_settings_keyboard( + db_user.language, + show_countries, + tariff=tariff, + subscription=subscription + ), parse_mode="HTML" ) await callback.answer() @@ -3037,6 +3225,95 @@ async def clear_saved_cart( await callback.answer("🗑️ Корзина очищена") +# ============== ХЕНДЛЕР ПАУЗЫ СУТОЧНОЙ ПОДПИСКИ ============== + +async def handle_toggle_daily_subscription_pause( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession +): + """Переключает паузу суточной подписки.""" + from app.database.crud.subscription import toggle_daily_subscription_pause + from app.database.crud.tariff import get_tariff_by_id + + texts = get_texts(db_user.language) + subscription = db_user.subscription + + if not subscription: + await callback.answer( + texts.t("NO_SUBSCRIPTION_ERROR", "❌ У вас нет активной подписки"), + show_alert=True + ) + return + + # Проверяем что это суточный тариф + tariff = None + if subscription.tariff_id: + tariff = await get_tariff_by_id(db, subscription.tariff_id) + + if not tariff or not getattr(tariff, 'is_daily', False): + await callback.answer( + texts.t("NOT_DAILY_TARIFF_ERROR", "❌ Эта функция доступна только для суточных тарифов"), + show_alert=True + ) + return + + # Прикрепляем тариф к подписке для CRUD функций + subscription.tariff = tariff + + # Переключаем статус паузы + was_paused = getattr(subscription, 'is_daily_paused', False) + + # При возобновлении проверяем баланс + if was_paused: + daily_price = getattr(tariff, 'daily_price_kopeks', 0) + if daily_price > 0 and db_user.balance_kopeks < daily_price: + await callback.answer( + texts.t( + "INSUFFICIENT_BALANCE_FOR_RESUME", + f"❌ Недостаточно средств для возобновления. Требуется: {settings.format_price(daily_price)}" + ), + show_alert=True + ) + return + + subscription = await toggle_daily_subscription_pause(db, subscription) + + if was_paused: + # Была пауза, теперь возобновили + message = texts.t( + "DAILY_SUBSCRIPTION_RESUMED", + "▶️ Подписка возобновлена!" + ) + # Синхронизируем с Remnawave - активируем пользователя + try: + from app.services.subscription_service import SubscriptionService + subscription_service = SubscriptionService() + await subscription_service.create_remnawave_user( + db, + subscription, + reset_traffic=False, + reset_reason=None, + ) + logger.info(f"✅ Синхронизировано с Remnawave после возобновления суточной подписки {subscription.id}") + except Exception as e: + logger.error(f"Ошибка синхронизации с Remnawave при возобновлении: {e}") + else: + # Была активна, теперь на паузе + message = texts.t( + "DAILY_SUBSCRIPTION_PAUSED", + "⏸️ Подписка приостановлена!" + ) + # При паузе можно отключить пользователя в Remnawave (опционально) + # Пока оставляем активным, т.к. пауза - это только остановка списания + + await callback.answer(message, show_alert=True) + + # Возвращаемся в меню подписки - вызываем show_subscription_info + await db.refresh(db_user) + await show_subscription_info(callback, db_user, db) + + # ============== ХЕНДЛЕРЫ ПЛАТНОГО ТРИАЛА ============== @error_handler @@ -3993,6 +4270,11 @@ def register_handlers(dp: Dispatcher): F.data == "subscription_settings" ) + dp.callback_query.register( + handle_toggle_daily_subscription_pause, + F.data == "toggle_daily_subscription_pause" + ) + dp.callback_query.register( handle_no_traffic_packages, F.data == "no_traffic_packages" diff --git a/app/handlers/subscription/tariff_purchase.py b/app/handlers/subscription/tariff_purchase.py index 4b27debd..4b51d0e8 100644 --- a/app/handlers/subscription/tariff_purchase.py +++ b/app/handlers/subscription/tariff_purchase.py @@ -1,5 +1,6 @@ """Покупка подписки по тарифам.""" import logging +from datetime import timedelta from typing import List, Optional from aiogram import Dispatcher, types, F @@ -69,13 +70,12 @@ def _apply_promo_discount(price: int, discount_percent: int) -> int: def _get_user_period_discount(db_user: User, period_days: int) -> int: """Получает скидку пользователя на период из промогруппы.""" promo_group = getattr(db_user, 'promo_group', None) + if promo_group: - # Используем метод get_discount_percent с категорией "period" discount = promo_group.get_discount_percent("period", period_days) if discount > 0: return discount - # Проверяем персональную скидку personal_discount = get_user_active_promo_discount_percent(db_user) return personal_discount @@ -99,19 +99,27 @@ def format_tariffs_list_text( traffic = "∞" if traffic_gb == 0 else f"{traffic_gb}ГБ" # Цена - prices = tariff.period_prices or {} + is_daily = getattr(tariff, 'is_daily', False) price_text = "" discount_icon = "" - if prices: - min_period = min(prices.keys(), key=int) - min_price = prices[min_period] - discount_percent = 0 - if db_user: - discount_percent = _get_user_period_discount(db_user, int(min_period)) - if discount_percent > 0: - min_price = _apply_promo_discount(min_price, discount_percent) - discount_icon = "🔥" - price_text = f"от {_format_price_kopeks(min_price, compact=True)}{discount_icon}" + + if is_daily: + # Для суточных тарифов показываем цену за день + daily_price = getattr(tariff, 'daily_price_kopeks', 0) + price_text = f"🔄 {_format_price_kopeks(daily_price, compact=True)}/день" + else: + # Для периодных тарифов показываем минимальную цену + prices = tariff.period_prices or {} + if prices: + min_period = min(prices.keys(), key=int) + min_price = prices[min_period] + discount_percent = 0 + if db_user: + discount_percent = _get_user_period_discount(db_user, int(min_period)) + if discount_percent > 0: + min_price = _apply_promo_discount(min_price, discount_percent) + discount_icon = "🔥" + price_text = f"от {_format_price_kopeks(min_price, compact=True)}{discount_icon}" # Компактный формат: Название — 250ГБ/10📱 от 179₽🔥 lines.append(f"{tariff.name} — {traffic}/{tariff.device_limit}📱 {price_text}") @@ -136,7 +144,7 @@ def get_tariffs_keyboard( for tariff in tariffs: buttons.append([ InlineKeyboardButton( - text=f"📦 {tariff.name}", + text=tariff.name, callback_data=f"tariff_select:{tariff.id}" ) ]) @@ -189,6 +197,47 @@ def get_tariff_periods_keyboard( return InlineKeyboardMarkup(inline_keyboard=buttons) +def get_tariff_periods_keyboard_with_traffic( + tariff: Tariff, + language: str, + db_user: Optional[User] = None, +) -> InlineKeyboardMarkup: + """Клавиатура выбора периода для тарифа с кастомным трафиком (переход к настройке трафика).""" + texts = get_texts(language) + buttons = [] + + prices = tariff.period_prices or {} + for period_str in sorted(prices.keys(), key=int): + period = int(period_str) + price = prices[period_str] + + # Получаем скидку для конкретного периода + discount_percent = 0 + if db_user: + discount_percent = _get_user_period_discount(db_user, period) + + if discount_percent > 0: + price = _apply_promo_discount(price, discount_percent) + price_text = f"{_format_price_kopeks(price)} 🔥−{discount_percent}%" + else: + price_text = _format_price_kopeks(price) + + button_text = f"{_format_period(period)} — {price_text}" + # Используем другой callback для перехода к настройке трафика + buttons.append([ + InlineKeyboardButton( + text=button_text, + callback_data=f"tariff_period_traffic:{tariff.id}:{period}" + ) + ]) + + buttons.append([ + InlineKeyboardButton(text=texts.BACK, callback_data="tariff_list") + ]) + + return InlineKeyboardMarkup(inline_keyboard=buttons) + + def get_tariff_confirm_keyboard( tariff_id: int, period: int, @@ -258,7 +307,229 @@ def format_tariff_info_for_user( if discount_percent > 0: text += f"\n🎁 Ваша скидка: {discount_percent}%\n" - text += "\nВыберите период подписки:" + # Для суточных тарифов не показываем выбор периода + is_daily = getattr(tariff, 'is_daily', False) + if not is_daily: + text += "\nВыберите период подписки:" + + return text + + +def get_daily_tariff_confirm_keyboard( + tariff_id: int, + language: str, +) -> InlineKeyboardMarkup: + """Создает клавиатуру подтверждения покупки суточного тарифа.""" + texts = get_texts(language) + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton( + text="✅ Подтвердить покупку", + callback_data=f"daily_tariff_confirm:{tariff_id}" + ) + ], + [ + InlineKeyboardButton( + text=texts.BACK, + callback_data="tariff_list" + ) + ] + ]) + + +def get_daily_tariff_insufficient_balance_keyboard( + tariff_id: int, + language: str, +) -> InlineKeyboardMarkup: + """Создает клавиатуру при недостаточном балансе для суточного тарифа.""" + texts = get_texts(language) + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton( + text="💳 Пополнить баланс", + callback_data="balance_topup" + ) + ], + [ + InlineKeyboardButton( + text=texts.BACK, + callback_data="tariff_list" + ) + ] + ]) + + +# ==================== Кастомные дни/трафик ==================== + + +def get_custom_tariff_keyboard( + tariff_id: int, + language: str, + days: int, + traffic_gb: int, + can_custom_days: bool, + can_custom_traffic: bool, + min_days: int = 1, + max_days: int = 365, + min_traffic: int = 1, + max_traffic: int = 1000, +) -> InlineKeyboardMarkup: + """Создает клавиатуру для настройки кастомных дней и трафика.""" + texts = get_texts(language) + buttons = [] + + # Кнопки изменения дней + if can_custom_days: + days_row = [] + # -30 / -7 / -1 + if days > min_days: + if days - 30 >= min_days: + days_row.append(InlineKeyboardButton(text="-30", callback_data=f"custom_days:{tariff_id}:-30")) + if days - 7 >= min_days: + days_row.append(InlineKeyboardButton(text="-7", callback_data=f"custom_days:{tariff_id}:-7")) + days_row.append(InlineKeyboardButton(text="-1", callback_data=f"custom_days:{tariff_id}:-1")) + + # Текущее значение + days_row.append(InlineKeyboardButton(text=f"📅 {days} дн.", callback_data="noop")) + + # +1 / +7 / +30 + if days < max_days: + days_row.append(InlineKeyboardButton(text="+1", callback_data=f"custom_days:{tariff_id}:1")) + if days + 7 <= max_days: + days_row.append(InlineKeyboardButton(text="+7", callback_data=f"custom_days:{tariff_id}:7")) + if days + 30 <= max_days: + days_row.append(InlineKeyboardButton(text="+30", callback_data=f"custom_days:{tariff_id}:30")) + + if days_row: + buttons.append(days_row) + + # Кнопки изменения трафика + if can_custom_traffic: + traffic_row = [] + # -100 / -10 / -1 + if traffic_gb > min_traffic: + if traffic_gb - 100 >= min_traffic: + traffic_row.append(InlineKeyboardButton(text="-100", callback_data=f"custom_traffic:{tariff_id}:-100")) + if traffic_gb - 10 >= min_traffic: + traffic_row.append(InlineKeyboardButton(text="-10", callback_data=f"custom_traffic:{tariff_id}:-10")) + traffic_row.append(InlineKeyboardButton(text="-1", callback_data=f"custom_traffic:{tariff_id}:-1")) + + # Текущее значение + traffic_row.append(InlineKeyboardButton(text=f"📊 {traffic_gb} ГБ", callback_data="noop")) + + # +1 / +10 / +100 + if traffic_gb < max_traffic: + traffic_row.append(InlineKeyboardButton(text="+1", callback_data=f"custom_traffic:{tariff_id}:1")) + if traffic_gb + 10 <= max_traffic: + traffic_row.append(InlineKeyboardButton(text="+10", callback_data=f"custom_traffic:{tariff_id}:10")) + if traffic_gb + 100 <= max_traffic: + traffic_row.append(InlineKeyboardButton(text="+100", callback_data=f"custom_traffic:{tariff_id}:100")) + + if traffic_row: + buttons.append(traffic_row) + + # Кнопка подтверждения + buttons.append([ + InlineKeyboardButton( + text="✅ Подтвердить покупку", + callback_data=f"custom_confirm:{tariff_id}" + ) + ]) + + # Кнопка назад + buttons.append([ + InlineKeyboardButton(text=texts.BACK, callback_data="tariff_list") + ]) + + return InlineKeyboardMarkup(inline_keyboard=buttons) + + +def _calculate_custom_tariff_price( + tariff: Tariff, + days: int, + traffic_gb: int, +) -> tuple[int, int, int]: + """ + Рассчитывает цену для кастомного тарифа. + + Логика (как в веб-кабинете): + 1. Цена периода: из period_prices ИЛИ price_per_day * дни (если custom_days) + 2. Трафик: добавляется СВЕРХУ к цене периода (если custom_traffic) + + Returns: + tuple: (period_price, traffic_price, total_price) + """ + period_price = 0 + traffic_price = 0 + + # Цена за период + if tariff.can_purchase_custom_days(): + # Кастомные дни - используем price_per_day + period_price = tariff.get_price_for_custom_days(days) or 0 + else: + # Фиксированные периоды - берём из period_prices + period_price = tariff.get_price_for_period(days) or 0 + + # Цена за трафик (добавляется сверху) + if tariff.can_purchase_custom_traffic(): + traffic_price = tariff.get_price_for_custom_traffic(traffic_gb) or 0 + + total_price = period_price + traffic_price + return period_price, traffic_price, total_price + + +def format_custom_tariff_preview( + tariff: Tariff, + days: int, + traffic_gb: int, + user_balance: int, + discount_percent: int = 0, +) -> str: + """Форматирует предпросмотр покупки с кастомными параметрами.""" + period_price, traffic_price, total_price = _calculate_custom_tariff_price( + tariff, days, traffic_gb + ) + + # Применяем скидку + if discount_percent > 0: + total_price = _apply_promo_discount(total_price, discount_percent) + + traffic_display = f"{traffic_gb} ГБ" if traffic_gb > 0 else _format_traffic(tariff.traffic_limit_gb) + + text = f"""📦 {tariff.name} + +Настройте параметры: +""" + + if tariff.can_purchase_custom_days(): + text += f"📅 Дней: {days} (от {tariff.min_days} до {tariff.max_days})\n" + text += f" 💰 {_format_price_kopeks(period_price)}\n" + else: + # Фиксированный период - показываем без возможности изменения + text += f"📅 Период: {_format_period(days)}\n" + text += f" 💰 {_format_price_kopeks(period_price)}\n" + + if tariff.can_purchase_custom_traffic(): + text += f"📊 Трафик: {traffic_gb} ГБ (от {tariff.min_traffic_gb} до {tariff.max_traffic_gb})\n" + text += f" 💰 +{_format_price_kopeks(traffic_price)}\n" + else: + text += f"📊 Трафик: {traffic_display}\n" + + text += f"📱 Устройств: {tariff.device_limit}\n" + + if discount_percent > 0: + text += f"\n🎁 Скидка: {discount_percent}%\n" + + text += f""" +💰 Итого: {_format_price_kopeks(total_price)} + +💳 Ваш баланс: {_format_price_kopeks(user_balance)}""" + + if user_balance < total_price: + missing = total_price - user_balance + text += f"\n⚠️ Не хватает: {_format_price_kopeks(missing)}" + else: + text += f"\nПосле оплаты: {_format_price_kopeks(user_balance - total_price)}" return text @@ -325,16 +596,447 @@ async def select_tariff( await callback.answer("Тариф недоступен", show_alert=True) return - await callback.message.edit_text( - format_tariff_info_for_user(tariff, db_user.language), - reply_markup=get_tariff_periods_keyboard(tariff, db_user.language, db_user=db_user), - parse_mode="HTML" - ) + # Проверяем, суточный ли это тариф + is_daily = getattr(tariff, 'is_daily', False) + + if is_daily: + # Для суточного тарифа показываем подтверждение без выбора периода + daily_price = getattr(tariff, 'daily_price_kopeks', 0) + user_balance = db_user.balance_kopeks or 0 + traffic = _format_traffic(tariff.traffic_limit_gb) + + if user_balance >= daily_price: + await callback.message.edit_text( + f"✅ Подтверждение покупки\n\n" + f"📦 Тариф: {tariff.name}\n" + f"📊 Трафик: {traffic}\n" + f"📱 Устройств: {tariff.device_limit}\n" + f"🔄 Тип: Суточный\n\n" + f"💰 Цена: {_format_price_kopeks(daily_price)}/день\n\n" + f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n\n" + f"ℹ️ Средства будут списываться автоматически раз в сутки.\n" + f"Вы можете приостановить подписку в любой момент.", + reply_markup=get_daily_tariff_confirm_keyboard(tariff_id, db_user.language), + parse_mode="HTML" + ) + else: + missing = daily_price - user_balance + await callback.message.edit_text( + f"❌ Недостаточно средств\n\n" + f"📦 Тариф: {tariff.name}\n" + f"🔄 Тип: Суточный\n" + f"💰 Цена: {_format_price_kopeks(daily_price)}/день\n\n" + f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n" + f"⚠️ Не хватает: {_format_price_kopeks(missing)}", + reply_markup=get_daily_tariff_insufficient_balance_keyboard(tariff_id, db_user.language), + parse_mode="HTML" + ) + else: + # Проверяем, есть ли кастомные дни или трафик + can_custom_days = tariff.can_purchase_custom_days() + can_custom_traffic = tariff.can_purchase_custom_traffic() + + if can_custom_days: + # Кастомные дни - показываем экран с +/- для дней (и опционально трафика) + user_balance = db_user.balance_kopeks or 0 + + initial_days = tariff.min_days + initial_traffic = tariff.min_traffic_gb if can_custom_traffic else tariff.traffic_limit_gb + + # Вычисляем скидку для начального периода + discount_percent = _get_user_period_discount(db_user, initial_days) + + await state.update_data( + selected_tariff_id=tariff_id, + custom_days=initial_days, + custom_traffic_gb=initial_traffic, + period_discount_percent=discount_percent, + ) + + preview_text = format_custom_tariff_preview( + tariff=tariff, + days=initial_days, + traffic_gb=initial_traffic, + user_balance=user_balance, + discount_percent=discount_percent, + ) + + await callback.message.edit_text( + preview_text, + reply_markup=get_custom_tariff_keyboard( + tariff_id=tariff_id, + language=db_user.language, + days=initial_days, + traffic_gb=initial_traffic, + can_custom_days=can_custom_days, + can_custom_traffic=can_custom_traffic, + min_days=tariff.min_days, + max_days=tariff.max_days, + min_traffic=tariff.min_traffic_gb, + max_traffic=tariff.max_traffic_gb, + ), + parse_mode="HTML" + ) + elif can_custom_traffic: + # Только кастомный трафик - сначала выбираем период из period_prices + # Показываем обычный выбор периода, трафик будет на следующем шаге + await callback.message.edit_text( + format_tariff_info_for_user(tariff, db_user.language) + + "\n\n📊 После выбора периода вы сможете настроить трафик", + reply_markup=get_tariff_periods_keyboard_with_traffic( + tariff, db_user.language, db_user=db_user + ), + parse_mode="HTML" + ) + else: + # Для обычного тарифа показываем выбор периода + await callback.message.edit_text( + format_tariff_info_for_user(tariff, db_user.language), + reply_markup=get_tariff_periods_keyboard(tariff, db_user.language, db_user=db_user), + parse_mode="HTML" + ) await state.update_data(selected_tariff_id=tariff_id) await callback.answer() +@error_handler +async def handle_custom_days_change( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Обрабатывает изменение количества дней.""" + parts = callback.data.split(":") + tariff_id = int(parts[1]) + delta = int(parts[2]) + + tariff = await get_tariff_by_id(db, tariff_id) + if not tariff or not tariff.is_active: + await callback.answer("Тариф недоступен", show_alert=True) + return + + state_data = await state.get_data() + current_days = state_data.get('custom_days', tariff.min_days) + current_traffic = state_data.get('custom_traffic_gb', tariff.min_traffic_gb) + + # Применяем изменение + new_days = current_days + delta + new_days = max(tariff.min_days, min(tariff.max_days, new_days)) + + # При изменении дней пересчитываем скидку для нового периода + discount_percent = _get_user_period_discount(db_user, new_days) + + await state.update_data(custom_days=new_days, period_discount_percent=discount_percent) + + user_balance = db_user.balance_kopeks or 0 + + preview_text = format_custom_tariff_preview( + tariff=tariff, + days=new_days, + traffic_gb=current_traffic, + user_balance=user_balance, + discount_percent=discount_percent, + ) + + await callback.message.edit_text( + preview_text, + reply_markup=get_custom_tariff_keyboard( + tariff_id=tariff_id, + language=db_user.language, + days=new_days, + traffic_gb=current_traffic, + can_custom_days=tariff.can_purchase_custom_days(), + can_custom_traffic=tariff.can_purchase_custom_traffic(), + min_days=tariff.min_days, + max_days=tariff.max_days, + min_traffic=tariff.min_traffic_gb, + max_traffic=tariff.max_traffic_gb, + ), + parse_mode="HTML" + ) + await callback.answer() + + +@error_handler +async def handle_custom_traffic_change( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Обрабатывает изменение количества трафика.""" + parts = callback.data.split(":") + tariff_id = int(parts[1]) + delta = int(parts[2]) + + tariff = await get_tariff_by_id(db, tariff_id) + if not tariff or not tariff.is_active: + await callback.answer("Тариф недоступен", show_alert=True) + return + + state_data = await state.get_data() + current_days = state_data.get('custom_days', tariff.min_days) + current_traffic = state_data.get('custom_traffic_gb', tariff.min_traffic_gb) + discount_percent = state_data.get('period_discount_percent', 0) + + # Применяем изменение + new_traffic = current_traffic + delta + new_traffic = max(tariff.min_traffic_gb, min(tariff.max_traffic_gb, new_traffic)) + + await state.update_data(custom_traffic_gb=new_traffic) + + user_balance = db_user.balance_kopeks or 0 + + preview_text = format_custom_tariff_preview( + tariff=tariff, + days=current_days, + traffic_gb=new_traffic, + user_balance=user_balance, + discount_percent=discount_percent, + ) + + await callback.message.edit_text( + preview_text, + reply_markup=get_custom_tariff_keyboard( + tariff_id=tariff_id, + language=db_user.language, + days=current_days, + traffic_gb=new_traffic, + can_custom_days=tariff.can_purchase_custom_days(), + can_custom_traffic=tariff.can_purchase_custom_traffic(), + min_days=tariff.min_days, + max_days=tariff.max_days, + min_traffic=tariff.min_traffic_gb, + max_traffic=tariff.max_traffic_gb, + ), + parse_mode="HTML" + ) + await callback.answer() + + +@error_handler +async def handle_custom_confirm( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Подтверждает покупку тарифа с кастомными параметрами.""" + tariff_id = int(callback.data.split(":")[1]) + + tariff = await get_tariff_by_id(db, tariff_id) + if not tariff or not tariff.is_active: + await callback.answer("Тариф недоступен", show_alert=True) + return + + state_data = await state.get_data() + custom_days = state_data.get('custom_days', tariff.min_days) + custom_traffic = state_data.get('custom_traffic_gb', tariff.min_traffic_gb) + discount_percent = state_data.get('period_discount_percent', 0) + + # Рассчитываем цену (используем общую функцию) + period_price, traffic_price, total_price = _calculate_custom_tariff_price( + tariff, custom_days, custom_traffic + ) + + # Проверяем, что цена за период валидна + if period_price == 0 and not tariff.can_purchase_custom_days(): + # Период не найден в period_prices - ошибка + await callback.answer("Выбранный период недоступен для этого тарифа", show_alert=True) + return + + # Применяем скидку к цене периода (не к трафику) + if discount_percent > 0: + period_price = _apply_promo_discount(period_price, discount_percent) + total_price = period_price + traffic_price + + # Проверяем баланс + user_balance = db_user.balance_kopeks or 0 + if user_balance < total_price: + await callback.answer("Недостаточно средств на балансе", show_alert=True) + return + + texts = get_texts(db_user.language) + + try: + # Списываем баланс + success = await subtract_user_balance( + db, db_user, total_price, + f"Покупка тарифа {tariff.name} на {custom_days} дней" + ) + if not success: + await callback.answer("Ошибка списания баланса", show_alert=True) + return + + # Получаем список серверов из тарифа + squads = tariff.allowed_squads or [] + + # Если allowed_squads пустой - значит "все серверы", получаем их + if not squads: + from app.database.crud.server_squad import get_all_server_squads + all_servers, _ = await get_all_server_squads(db, available_only=True) + squads = [s.squad_uuid for s in all_servers if s.squad_uuid] + + # Определяем трафик + traffic_limit = custom_traffic if tariff.can_purchase_custom_traffic() else tariff.traffic_limit_gb + + # Проверяем есть ли уже подписка + existing_subscription = await get_subscription_by_user_id(db, db_user.id) + + if existing_subscription: + # Продлеваем существующую подписку и обновляем параметры тарифа + subscription = await extend_subscription( + db, + existing_subscription, + days=custom_days, + tariff_id=tariff.id, + traffic_limit_gb=traffic_limit, + device_limit=tariff.device_limit, + connected_squads=squads, + ) + else: + # Создаем новую подписку + subscription = await create_paid_subscription( + db=db, + user_id=db_user.id, + duration_days=custom_days, + traffic_limit_gb=traffic_limit, + device_limit=tariff.device_limit, + connected_squads=squads, + tariff_id=tariff.id, + ) + + # Обновляем пользователя в Remnawave + try: + subscription_service = SubscriptionService() + await subscription_service.create_remnawave_user( + db, + subscription, + reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT, + reset_reason="покупка тарифа", + ) + except Exception as e: + logger.error(f"Ошибка обновления Remnawave: {e}") + + # Создаем транзакцию + await create_transaction( + db, + user_id=db_user.id, + type=TransactionType.SUBSCRIPTION_PAYMENT, + amount_kopeks=-total_price, + description=f"Покупка тарифа {tariff.name} на {custom_days} дней", + ) + + # Отправляем уведомление админу + try: + admin_notification_service = AdminNotificationService(callback.bot) + await admin_notification_service.send_subscription_purchase_notification( + db, + db_user, + subscription, + None, + custom_days, + was_trial_conversion=False, + amount_kopeks=total_price, + ) + except Exception as e: + logger.error(f"Ошибка отправки уведомления админу: {e}") + + # Очищаем корзину после успешной покупки + try: + await user_cart_service.delete_user_cart(db_user.id) + except Exception as e: + logger.error(f"Ошибка очистки корзины: {e}") + + await state.clear() + + traffic_display = _format_traffic(traffic_limit) + + await callback.message.edit_text( + f"🎉 Подписка успешно оформлена!\n\n" + f"📦 Тариф: {tariff.name}\n" + f"📊 Трафик: {traffic_display}\n" + f"📱 Устройств: {tariff.device_limit}\n" + f"📅 Период: {_format_period(custom_days)}\n" + f"💰 Списано: {_format_price_kopeks(total_price)}\n\n" + f"Перейдите в раздел «Подписка» для подключения.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")], + [InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")] + ]), + parse_mode="HTML" + ) + await callback.answer("Подписка оформлена!", show_alert=True) + + except Exception as e: + logger.error(f"Ошибка при покупке тарифа с кастомными параметрами: {e}", exc_info=True) + await callback.answer("Произошла ошибка при оформлении подписки", show_alert=True) + + +@error_handler +async def select_tariff_period_with_traffic( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Обрабатывает выбор периода для тарифа с кастомным трафиком - показывает экран настройки трафика.""" + parts = callback.data.split(":") + tariff_id = int(parts[1]) + period = int(parts[2]) + + tariff = await get_tariff_by_id(db, tariff_id) + if not tariff or not tariff.is_active: + await callback.answer("Тариф недоступен", show_alert=True) + return + + if not tariff.can_purchase_custom_traffic(): + await callback.answer("Кастомный трафик недоступен для этого тарифа", show_alert=True) + return + + user_balance = db_user.balance_kopeks or 0 + initial_traffic = tariff.min_traffic_gb + + # Получаем скидку для выбранного периода + discount_percent = _get_user_period_discount(db_user, period) + + # Сохраняем выбранный период и скидку в состояние + await state.update_data( + selected_tariff_id=tariff_id, + custom_days=period, # Фиксированный период из period_prices + custom_traffic_gb=initial_traffic, + period_discount_percent=discount_percent, # Сохраняем скидку + ) + + preview_text = format_custom_tariff_preview( + tariff=tariff, + days=period, + traffic_gb=initial_traffic, + user_balance=user_balance, + discount_percent=discount_percent, # Применяем скидку при отображении + ) + + await callback.message.edit_text( + preview_text, + reply_markup=get_custom_tariff_keyboard( + tariff_id=tariff_id, + language=db_user.language, + days=period, + traffic_gb=initial_traffic, + can_custom_days=False, # Период уже выбран, менять нельзя + can_custom_traffic=True, + min_days=period, + max_days=period, + min_traffic=tariff.min_traffic_gb, + max_traffic=tariff.max_traffic_gb, + ), + parse_mode="HTML" + ) + await callback.answer() + + @error_handler async def select_tariff_period( callback: types.CallbackQuery, @@ -453,6 +1155,12 @@ async def confirm_tariff_purchase( # Получаем список серверов из тарифа squads = tariff.allowed_squads or [] + # Если allowed_squads пустой - значит "все серверы", получаем их + if not squads: + from app.database.crud.server_squad import get_all_server_squads + all_servers, _ = await get_all_server_squads(db, available_only=True) + squads = [s.squad_uuid for s in all_servers if s.squad_uuid] + # Проверяем есть ли уже подписка existing_subscription = await get_subscription_by_user_id(db, db_user.id) @@ -547,6 +1255,174 @@ async def confirm_tariff_purchase( await callback.answer("Произошла ошибка при оформлении подписки", show_alert=True) +# ==================== Покупка суточного тарифа ==================== + +@error_handler +async def confirm_daily_tariff_purchase( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Подтверждает покупку суточного тарифа.""" + from datetime import datetime + + tariff_id = int(callback.data.split(":")[1]) + tariff = await get_tariff_by_id(db, tariff_id) + + if not tariff or not tariff.is_active: + await callback.answer("Тариф недоступен", show_alert=True) + return + + is_daily = getattr(tariff, 'is_daily', False) + if not is_daily: + await callback.answer("Это не суточный тариф", show_alert=True) + return + + daily_price = getattr(tariff, 'daily_price_kopeks', 0) + if daily_price <= 0: + await callback.answer("Некорректная цена тарифа", show_alert=True) + return + + # Проверяем баланс + user_balance = db_user.balance_kopeks or 0 + if user_balance < daily_price: + await callback.answer("Недостаточно средств на балансе", show_alert=True) + return + + texts = get_texts(db_user.language) + + try: + # Списываем первый день сразу + success = await subtract_user_balance( + db, db_user, daily_price, + f"Покупка суточного тарифа {tariff.name} (первый день)" + ) + if not success: + await callback.answer("Ошибка списания баланса", show_alert=True) + return + + # Получаем список серверов из тарифа + squads = tariff.allowed_squads or [] + + # Если allowed_squads пустой - значит "все серверы", получаем их + if not squads: + from app.database.crud.server_squad import get_all_server_squads + all_servers, _ = await get_all_server_squads(db, available_only=True) + squads = [s.squad_uuid for s in all_servers if s.squad_uuid] + + # Проверяем есть ли уже подписка + existing_subscription = await get_subscription_by_user_id(db, db_user.id) + + if existing_subscription: + # Обновляем существующую подписку на суточный тариф + existing_subscription.tariff_id = tariff.id + existing_subscription.traffic_limit_gb = tariff.traffic_limit_gb + existing_subscription.device_limit = tariff.device_limit + existing_subscription.connected_squads = squads + existing_subscription.status = "active" + existing_subscription.is_trial = False # Сбрасываем триальный статус + existing_subscription.is_daily_paused = False + existing_subscription.last_daily_charge_at = datetime.utcnow() + # Для суточного тарифа ставим срок на 1 день + existing_subscription.end_date = datetime.utcnow() + timedelta(days=1) + + # Сбрасываем докупленный трафик при смене тарифа + from app.database.models import TrafficPurchase + from sqlalchemy import delete as sql_delete + await db.execute(sql_delete(TrafficPurchase).where(TrafficPurchase.subscription_id == existing_subscription.id)) + existing_subscription.purchased_traffic_gb = 0 + existing_subscription.traffic_reset_at = None + + await db.commit() + await db.refresh(existing_subscription) + subscription = existing_subscription + else: + # Создаем новую подписку на 1 день + subscription = await create_paid_subscription( + db=db, + user_id=db_user.id, + duration_days=1, + traffic_limit_gb=tariff.traffic_limit_gb, + device_limit=tariff.device_limit, + connected_squads=squads, + tariff_id=tariff.id, + ) + # Устанавливаем время последнего списания + subscription.last_daily_charge_at = datetime.utcnow() + subscription.is_daily_paused = False + await db.commit() + await db.refresh(subscription) + + # Обновляем пользователя в Remnawave + try: + subscription_service = SubscriptionService() + await subscription_service.create_remnawave_user( + db, + subscription, + reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT, + reset_reason="покупка суточного тарифа", + ) + except Exception as e: + logger.error(f"Ошибка обновления Remnawave: {e}") + + # Создаем транзакцию + await create_transaction( + db, + user_id=db_user.id, + type=TransactionType.SUBSCRIPTION_PAYMENT, + amount_kopeks=-daily_price, + description=f"Покупка суточного тарифа {tariff.name} (первый день)", + ) + + # Отправляем уведомление админу + try: + admin_notification_service = AdminNotificationService(callback.bot) + await admin_notification_service.send_subscription_purchase_notification( + db, + db_user, + subscription, + None, + 1, # 1 день + was_trial_conversion=False, + amount_kopeks=daily_price, + ) + except Exception as e: + logger.error(f"Ошибка отправки уведомления админу: {e}") + + # Очищаем корзину после успешной покупки + try: + await user_cart_service.delete_user_cart(db_user.id) + logger.info(f"Корзина очищена после покупки суточного тарифа для пользователя {db_user.telegram_id}") + except Exception as e: + logger.error(f"Ошибка очистки корзины: {e}") + + await state.clear() + + traffic = _format_traffic(tariff.traffic_limit_gb) + + await callback.message.edit_text( + f"🎉 Суточная подписка оформлена!\n\n" + f"📦 Тариф: {tariff.name}\n" + f"📊 Трафик: {traffic}\n" + f"📱 Устройств: {tariff.device_limit}\n" + f"🔄 Тип: Суточный\n" + f"💰 Списано: {_format_price_kopeks(daily_price)}\n\n" + f"ℹ️ Следующее списание через 24 часа.\n" + f"Перейдите в раздел «Подписка» для подключения.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")], + [InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")] + ]), + parse_mode="HTML" + ) + await callback.answer("Подписка оформлена!", show_alert=True) + + except Exception as e: + logger.error(f"Ошибка при покупке суточного тарифа: {e}", exc_info=True) + await callback.answer("Произошла ошибка при оформлении подписки", show_alert=True) + + # ==================== Продление по тарифу ==================== def get_tariff_extend_keyboard( @@ -881,19 +1757,27 @@ def format_tariff_switch_list_text( traffic_gb = tariff.traffic_limit_gb traffic = "∞" if traffic_gb == 0 else f"{traffic_gb}ГБ" - prices = tariff.period_prices or {} + # Проверяем суточный ли тариф + is_daily = getattr(tariff, 'is_daily', False) price_text = "" discount_icon = "" - if prices: - min_period = min(prices.keys(), key=int) - min_price = prices[min_period] - discount_percent = 0 - if db_user: - discount_percent = _get_user_period_discount(db_user, int(min_period)) - if discount_percent > 0: - min_price = _apply_promo_discount(min_price, discount_percent) - discount_icon = "🔥" - price_text = f"от {_format_price_kopeks(min_price, compact=True)}{discount_icon}" + + if is_daily: + # Для суточных тарифов показываем цену за день + daily_price = getattr(tariff, 'daily_price_kopeks', 0) + price_text = f"🔄 {_format_price_kopeks(daily_price, compact=True)}/день" + else: + prices = tariff.period_prices or {} + if prices: + min_period = min(prices.keys(), key=int) + min_price = prices[min_period] + discount_percent = 0 + if db_user: + discount_percent = _get_user_period_discount(db_user, int(min_period)) + if discount_percent > 0: + min_price = _apply_promo_discount(min_price, discount_percent) + discount_icon = "🔥" + price_text = f"от {_format_price_kopeks(min_price, compact=True)}{discount_icon}" lines.append(f"{tariff.name} — {traffic}/{tariff.device_limit}📱 {price_text}") @@ -920,7 +1804,7 @@ def get_tariff_switch_keyboard( buttons.append([ InlineKeyboardButton( - text=f"📦 {tariff.name}", + text=tariff.name, callback_data=f"tariff_sw_select:{tariff.id}" ) ]) @@ -1106,23 +1990,89 @@ async def select_tariff_switch( traffic = _format_traffic(tariff.traffic_limit_gb) - info_text = f"""📦 {tariff.name} + # Проверяем, суточный ли это тариф + is_daily = getattr(tariff, 'is_daily', False) + + if is_daily: + # Для суточного тарифа показываем подтверждение без выбора периода + daily_price = getattr(tariff, 'daily_price_kopeks', 0) + user_balance = db_user.balance_kopeks or 0 + + # Проверяем текущую подписку на оставшиеся дни + current_subscription = await get_subscription_by_user_id(db, db_user.id) + days_warning = "" + if current_subscription and current_subscription.end_date: + from datetime import datetime + remaining = current_subscription.end_date - datetime.utcnow() + remaining_days = max(0, remaining.days) + if remaining_days > 1: + days_warning = f"\n\n⚠️ Внимание! У вас осталось {remaining_days} дн. подписки.\nПри смене на суточный тариф они будут утеряны!" + + if user_balance >= daily_price: + await callback.message.edit_text( + f"✅ Подтверждение смены тарифа\n\n" + f"📦 Новый тариф: {tariff.name}\n" + f"📊 Трафик: {traffic}\n" + f"📱 Устройств: {tariff.device_limit}\n" + f"🔄 Тип: Суточный\n\n" + f"💰 Цена: {_format_price_kopeks(daily_price)}/день\n\n" + f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}" + f"{days_warning}\n\n" + f"ℹ️ Средства будут списываться автоматически раз в сутки.\n" + f"Вы можете приостановить подписку в любой момент.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton( + text="✅ Подтвердить смену", + callback_data=f"daily_tariff_switch_confirm:{tariff_id}" + )], + [InlineKeyboardButton( + text=get_texts(db_user.language).BACK, + callback_data="tariff_switch" + )] + ]), + parse_mode="HTML" + ) + else: + missing = daily_price - user_balance + await callback.message.edit_text( + f"❌ Недостаточно средств\n\n" + f"📦 Тариф: {tariff.name}\n" + f"🔄 Тип: Суточный\n" + f"💰 Цена: {_format_price_kopeks(daily_price)}/день\n\n" + f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n" + f"⚠️ Не хватает: {_format_price_kopeks(missing)}" + f"{days_warning}", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton( + text="💳 Пополнить баланс", + callback_data="balance_topup" + )], + [InlineKeyboardButton( + text=get_texts(db_user.language).BACK, + callback_data="tariff_switch" + )] + ]), + parse_mode="HTML" + ) + else: + # Для обычного тарифа показываем выбор периода + info_text = f"""📦 {tariff.name} Параметры нового тарифа: • Трафик: {traffic} • Устройств: {tariff.device_limit} """ - if tariff.description: - info_text += f"\n📝 {tariff.description}\n" + if tariff.description: + info_text += f"\n📝 {tariff.description}\n" - info_text += "\n⚠️ Оплачивается полная стоимость тарифа.\nВыберите период:" + info_text += "\n⚠️ Оплачивается полная стоимость тарифа.\nВыберите период:" - await callback.message.edit_text( - info_text, - reply_markup=get_tariff_switch_periods_keyboard(tariff, db_user.language, db_user=db_user), - parse_mode="HTML" - ) + await callback.message.edit_text( + info_text, + reply_markup=get_tariff_switch_periods_keyboard(tariff, db_user.language, db_user=db_user), + parse_mode="HTML" + ) await state.update_data(switch_tariff_id=tariff_id) await callback.answer() @@ -1176,12 +2126,8 @@ async def select_tariff_switch_period( if subscription and subscription.end_date: remaining_days = max(0, (subscription.end_date - datetime.utcnow()).days) - # Определяем что произойдёт с временем - if remaining_days >= period: - time_info = f"⏰ Осталось дней: {remaining_days} (будет сохранено)" - else: - days_to_add = period - remaining_days - time_info = f"⏰ Осталось дней: {remaining_days} → будет {period} (+{days_to_add})" + # При смене тарифа устанавливается ровно оплаченный период + time_info = f"⏰ Будет установлено: {period} дней" if user_balance >= final_price: discount_text = "" @@ -1275,21 +2221,21 @@ async def confirm_tariff_switch( # Получаем список серверов из тарифа squads = tariff.allowed_squads or [] - # Рассчитываем сколько дней осталось у текущей подписки - from datetime import datetime - remaining_days = (subscription.end_date - datetime.utcnow()).days - if remaining_days < 0: - remaining_days = 0 + # Если allowed_squads пустой - значит "все серверы", получаем их + if not squads: + from app.database.crud.server_squad import get_all_server_squads + all_servers, _ = await get_all_server_squads(db, available_only=True) + squads = [s.squad_uuid for s in all_servers if s.squad_uuid] - # Если выбранный период больше оставшегося - добавляем разницу - # Пользователь должен получить минимум то, за что заплатил - days_to_add = max(0, period - remaining_days) + # При смене тарифа пользователь получает ровно тот период, за который заплатил + # Старые дни не сохраняются - это смена тарифа, а не продление + days_for_new_tariff = period # Обновляем подписку с новыми параметрами тарифа subscription = await extend_subscription( db, subscription, - days=days_to_add, # Добавляем только разницу, если период > остатка + days=days_for_new_tariff, # Даем ровно оплаченный период tariff_id=tariff.id, traffic_limit_gb=tariff.traffic_limit_gb, device_limit=tariff.device_limit, @@ -1325,7 +2271,7 @@ async def confirm_tariff_switch( db_user, subscription, None, # Транзакция отсутствует, оплата с баланса - days_to_add, # Добавленные дни (0 если остаток >= периода) + days_for_new_tariff, # Итоговый срок подписки was_trial_conversion=False, amount_kopeks=final_price, ) @@ -1343,11 +2289,8 @@ async def confirm_tariff_switch( traffic = _format_traffic(tariff.traffic_limit_gb) - # Формируем текст о времени подписки - if days_to_add > 0: - time_info = f"📅 Добавлено дней: {days_to_add}" - else: - time_info = "📅 Остаток времени подписки сохранён" + # При смене тарифа устанавливается оплаченный период + time_info = f"📅 Период: {days_for_new_tariff} дней" await callback.message.edit_text( f"🎉 Тариф успешно изменён!\n\n" @@ -1370,6 +2313,766 @@ async def confirm_tariff_switch( await callback.answer("Произошла ошибка при переключении тарифа", show_alert=True) +# ==================== Смена на суточный тариф ==================== + +@error_handler +async def confirm_daily_tariff_switch( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Подтверждает смену на суточный тариф.""" + from datetime import datetime + + tariff_id = int(callback.data.split(":")[1]) + tariff = await get_tariff_by_id(db, tariff_id) + + if not tariff or not tariff.is_active: + await callback.answer("Тариф недоступен", show_alert=True) + return + + is_daily = getattr(tariff, 'is_daily', False) + if not is_daily: + await callback.answer("Это не суточный тариф", show_alert=True) + return + + daily_price = getattr(tariff, 'daily_price_kopeks', 0) + if daily_price <= 0: + await callback.answer("Некорректная цена тарифа", show_alert=True) + return + + # Проверяем баланс + user_balance = db_user.balance_kopeks or 0 + if user_balance < daily_price: + await callback.answer("Недостаточно средств на балансе", show_alert=True) + return + + # Проверяем наличие подписки + subscription = await get_subscription_by_user_id(db, db_user.id) + if not subscription: + await callback.answer("У вас нет активной подписки", show_alert=True) + return + + texts = get_texts(db_user.language) + + try: + # Списываем первый день сразу + success = await subtract_user_balance( + db, db_user, daily_price, + f"Смена на суточный тариф {tariff.name} (первый день)" + ) + if not success: + await callback.answer("Ошибка списания баланса", show_alert=True) + return + + # Получаем список серверов из тарифа + squads = tariff.allowed_squads or [] + + # Если allowed_squads пустой - значит "все серверы", получаем их + if not squads: + from app.database.crud.server_squad import get_all_server_squads + all_servers, _ = await get_all_server_squads(db, available_only=True) + squads = [s.squad_uuid for s in all_servers if s.squad_uuid] + + # Обновляем подписку на суточный тариф + subscription.tariff_id = tariff.id + subscription.traffic_limit_gb = tariff.traffic_limit_gb + subscription.device_limit = tariff.device_limit + subscription.connected_squads = squads + subscription.status = "active" + subscription.is_trial = False # Сбрасываем триальный статус + subscription.is_daily_paused = False + subscription.last_daily_charge_at = datetime.utcnow() + # Для суточного тарифа ставим срок на 1 день + subscription.end_date = datetime.utcnow() + timedelta(days=1) + + # Сбрасываем докупленный трафик при смене тарифа + from app.database.models import TrafficPurchase + from sqlalchemy import delete as sql_delete + await db.execute(sql_delete(TrafficPurchase).where(TrafficPurchase.subscription_id == subscription.id)) + subscription.purchased_traffic_gb = 0 + subscription.traffic_reset_at = None + + await db.commit() + await db.refresh(subscription) + + # Обновляем пользователя в Remnawave + try: + subscription_service = SubscriptionService() + await subscription_service.create_remnawave_user( + db, + subscription, + reset_traffic=True, + reset_reason="смена на суточный тариф", + ) + except Exception as e: + logger.error(f"Ошибка обновления Remnawave: {e}") + + # Создаем транзакцию + await create_transaction( + db, + user_id=db_user.id, + type=TransactionType.SUBSCRIPTION_PAYMENT, + amount_kopeks=-daily_price, + description=f"Смена на суточный тариф {tariff.name} (первый день)", + ) + + # Отправляем уведомление админу + try: + admin_notification_service = AdminNotificationService(callback.bot) + await admin_notification_service.send_subscription_purchase_notification( + db, + db_user, + subscription, + None, + 1, # 1 день + was_trial_conversion=False, + amount_kopeks=daily_price, + ) + except Exception as e: + logger.error(f"Ошибка отправки уведомления админу: {e}") + + await state.clear() + + traffic = _format_traffic(tariff.traffic_limit_gb) + + await callback.message.edit_text( + f"🎉 Тариф успешно изменён!\n\n" + f"📦 Новый тариф: {tariff.name}\n" + f"📊 Трафик: {traffic}\n" + f"📱 Устройств: {tariff.device_limit}\n" + f"🔄 Тип: Суточный\n" + f"💰 Списано: {_format_price_kopeks(daily_price)}\n\n" + f"ℹ️ Следующее списание через 24 часа.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")], + [InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")] + ]), + parse_mode="HTML" + ) + await callback.answer("Тариф изменён!", show_alert=True) + + except Exception as e: + logger.error(f"Ошибка при смене на суточный тариф: {e}", exc_info=True) + await callback.answer("Произошла ошибка при смене тарифа", show_alert=True) + + +# ==================== Мгновенное переключение тарифов (без выбора периода) ==================== + +def _get_tariff_monthly_price(tariff: Tariff) -> int: + """Получает месячную цену тарифа (30 дней) с fallback на пропорциональный расчёт.""" + price = tariff.get_price_for_period(30) + if price is not None: + return price + + # Fallback: пропорционально пересчитываем из первого доступного периода + periods = tariff.get_available_periods() + if periods: + first_period = periods[0] + first_price = tariff.get_price_for_period(first_period) + if first_price: + return int(first_price * 30 / first_period) + + return 0 + + +def _calculate_instant_switch_cost( + current_tariff: Tariff, + new_tariff: Tariff, + remaining_days: int, + db_user: Optional[User] = None, +) -> tuple[int, bool]: + """ + Рассчитывает стоимость мгновенного переключения тарифа. + + Если новый тариф дороже - доплата пропорционально оставшимся дням. + Если дешевле или равен - бесплатно. + + Формула: (new_monthly - current_monthly) * remaining_days / 30 + Скидка применяется к обоим тарифам одинаково. + + Returns: + (upgrade_cost_kopeks, is_upgrade) + """ + current_monthly = _get_tariff_monthly_price(current_tariff) + new_monthly = _get_tariff_monthly_price(new_tariff) + + discount_percent = 0 + if db_user: + discount_percent = _get_user_period_discount(db_user, 30) + + if discount_percent > 0: + current_monthly = _apply_promo_discount(current_monthly, discount_percent) + new_monthly = _apply_promo_discount(new_monthly, discount_percent) + + price_diff = new_monthly - current_monthly + + if price_diff <= 0: + return 0, False + + upgrade_cost = int(price_diff * remaining_days / 30) + return upgrade_cost, True + + +def format_instant_switch_list_text( + tariffs: List[Tariff], + current_tariff: Tariff, + remaining_days: int, + db_user: Optional[User] = None, +) -> str: + """Форматирует текст со списком тарифов для мгновенного переключения.""" + lines = [ + "📦 Мгновенная смена тарифа", + f"📌 Текущий: {current_tariff.name}", + f"⏰ Осталось: {remaining_days} дн.", + "", + "💡 При переключении остаток дней сохраняется.", + "⬆️ Повышение тарифа = доплата за разницу", + "⬇️ Понижение = бесплатно", + "", + ] + + for tariff in tariffs: + if tariff.id == current_tariff.id: + continue + + traffic_gb = tariff.traffic_limit_gb + traffic = "∞" if traffic_gb == 0 else f"{traffic_gb}ГБ" + + # Рассчитываем стоимость переключения + cost, is_upgrade = _calculate_instant_switch_cost( + current_tariff, tariff, remaining_days, db_user + ) + + if is_upgrade: + cost_text = f"⬆️ +{_format_price_kopeks(cost, compact=True)}" + else: + cost_text = "⬇️ Бесплатно" + + lines.append(f"{tariff.name} — {traffic}/{tariff.device_limit}📱 {cost_text}") + + if tariff.description: + lines.append(f"{tariff.description}") + + lines.append("") + + return "\n".join(lines) + + +def get_instant_switch_keyboard( + tariffs: List[Tariff], + current_tariff: Tariff, + remaining_days: int, + language: str, + db_user: Optional[User] = None, +) -> InlineKeyboardMarkup: + """Создает клавиатуру для мгновенного переключения тарифа.""" + texts = get_texts(language) + buttons = [] + + for tariff in tariffs: + if tariff.id == current_tariff.id: + continue + + # Рассчитываем стоимость + cost, is_upgrade = _calculate_instant_switch_cost( + current_tariff, tariff, remaining_days, db_user + ) + + if is_upgrade: + btn_text = f"{tariff.name} (+{_format_price_kopeks(cost, compact=True)})" + else: + btn_text = f"{tariff.name} (бесплатно)" + + buttons.append([ + InlineKeyboardButton( + text=btn_text, + callback_data=f"instant_sw_preview:{tariff.id}" + ) + ]) + + buttons.append([ + InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription") + ]) + + return InlineKeyboardMarkup(inline_keyboard=buttons) + + +def get_instant_switch_confirm_keyboard( + tariff_id: int, + language: str, +) -> InlineKeyboardMarkup: + """Создает клавиатуру подтверждения мгновенного переключения.""" + texts = get_texts(language) + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton( + text="✅ Подтвердить переключение", + callback_data=f"instant_sw_confirm:{tariff_id}" + ) + ], + [ + InlineKeyboardButton( + text=texts.BACK, + callback_data="instant_switch" + ) + ] + ]) + + +def get_instant_switch_insufficient_balance_keyboard( + tariff_id: int, + language: str, +) -> InlineKeyboardMarkup: + """Создает клавиатуру при недостаточном балансе для мгновенного переключения.""" + texts = get_texts(language) + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton( + text="💳 Пополнить баланс", + callback_data="balance_topup" + ) + ], + [ + InlineKeyboardButton( + text=texts.BACK, + callback_data="instant_switch" + ) + ] + ]) + + +@error_handler +async def show_instant_switch_list( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Показывает список тарифов для мгновенного переключения.""" + from datetime import datetime + + texts = get_texts(db_user.language) + await state.clear() + + # Проверяем наличие активной подписки + subscription = await get_subscription_by_user_id(db, db_user.id) + if not subscription: + await callback.answer("У вас нет активной подписки", show_alert=True) + return + + if not subscription.tariff_id: + await callback.answer("У вашей подписки нет тарифа", show_alert=True) + return + + # Получаем текущий тариф + current_tariff = await get_tariff_by_id(db, subscription.tariff_id) + if not current_tariff: + await callback.answer("Текущий тариф не найден", show_alert=True) + return + + # Рассчитываем оставшиеся дни + remaining_days = 0 + if subscription.end_date: + remaining_days = max(0, (subscription.end_date - datetime.utcnow()).days) + + if remaining_days == 0: + await callback.message.edit_text( + "❌ Переключение недоступно\n\n" + "У вашей подписки не осталось активных дней.\n" + "Используйте продление или покупку нового тарифа.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")] + ]), + parse_mode="HTML" + ) + await callback.answer() + return + + # Получаем доступные тарифы + promo_group_id = getattr(db_user, 'promo_group_id', None) + tariffs = await get_tariffs_for_user(db, promo_group_id) + + # Фильтруем текущий тариф + available_tariffs = [t for t in tariffs if t.id != current_tariff.id] + + if not available_tariffs: + await callback.message.edit_text( + "😔 Нет доступных тарифов для переключения\n\n" + "Вы уже используете единственный доступный тариф.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")] + ]), + parse_mode="HTML" + ) + await callback.answer() + return + + # Формируем текст со списком тарифов + switch_text = format_instant_switch_list_text( + tariffs, current_tariff, remaining_days, db_user + ) + + await callback.message.edit_text( + switch_text, + reply_markup=get_instant_switch_keyboard( + tariffs, current_tariff, remaining_days, db_user.language, db_user + ), + parse_mode="HTML" + ) + + await state.update_data( + current_tariff_id=current_tariff.id, + remaining_days=remaining_days, + ) + await callback.answer() + + +@error_handler +async def preview_instant_switch( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Показывает превью мгновенного переключения тарифа.""" + from datetime import datetime + + tariff_id = int(callback.data.split(":")[1]) + new_tariff = await get_tariff_by_id(db, tariff_id) + + if not new_tariff or not new_tariff.is_active: + await callback.answer("Тариф недоступен", show_alert=True) + return + + # Получаем данные из состояния + data = await state.get_data() + current_tariff_id = data.get('current_tariff_id') + remaining_days = data.get('remaining_days', 0) + + # Если данных нет в state, получаем заново + subscription = await get_subscription_by_user_id(db, db_user.id) + if not subscription or not subscription.tariff_id: + await callback.answer("Подписка не найдена", show_alert=True) + return + + current_tariff_id = current_tariff_id or subscription.tariff_id + current_tariff = await get_tariff_by_id(db, current_tariff_id) + if not current_tariff: + await callback.answer("Текущий тариф не найден", show_alert=True) + return + + if not remaining_days and subscription.end_date: + remaining_days = max(0, (subscription.end_date - datetime.utcnow()).days) + + # Рассчитываем стоимость переключения + upgrade_cost, is_upgrade = _calculate_instant_switch_cost( + current_tariff, new_tariff, remaining_days, db_user + ) + + # Проверяем баланс + user_balance = db_user.balance_kopeks or 0 + + traffic = _format_traffic(new_tariff.traffic_limit_gb) + current_traffic = _format_traffic(current_tariff.traffic_limit_gb) + + texts = get_texts(db_user.language) + + # Проверяем, суточный ли новый тариф + is_new_daily = getattr(new_tariff, 'is_daily', False) + daily_warning = "" + if is_new_daily and remaining_days > 1: + daily_warning = texts.t( + "DAILY_SWITCH_WARNING", + f"\n\n⚠️ Внимание! У вас осталось {remaining_days} дн. подписки.\nПри смене на суточный тариф они будут утеряны!" + ).format(days=remaining_days) + + # Для суточного тарифа особая логика показа + if is_new_daily: + daily_price = getattr(new_tariff, 'daily_price_kopeks', 0) + user_balance = db_user.balance_kopeks or 0 + + if user_balance >= daily_price: + await callback.message.edit_text( + f"🔄 Переключение на суточный тариф\n\n" + f"📌 Текущий: {current_tariff.name}\n" + f" • Трафик: {current_traffic}\n" + f" • Устройств: {current_tariff.device_limit}\n\n" + f"📦 Новый: {new_tariff.name}\n" + f" • Трафик: {traffic}\n" + f" • Устройств: {new_tariff.device_limit}\n" + f" • Тип: 🔄 Суточный\n\n" + f"💰 Цена: {_format_price_kopeks(daily_price)}/день\n\n" + f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}" + f"{daily_warning}\n\n" + f"ℹ️ Средства будут списываться автоматически раз в сутки.", + reply_markup=get_instant_switch_confirm_keyboard(tariff_id, db_user.language), + parse_mode="HTML" + ) + else: + missing = daily_price - user_balance + await callback.message.edit_text( + f"❌ Недостаточно средств\n\n" + f"📦 Тариф: {new_tariff.name}\n" + f"🔄 Тип: Суточный\n" + f"💰 Цена: {_format_price_kopeks(daily_price)}/день\n\n" + f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n" + f"⚠️ Не хватает: {_format_price_kopeks(missing)}" + f"{daily_warning}", + reply_markup=get_instant_switch_insufficient_balance_keyboard(tariff_id, db_user.language), + parse_mode="HTML" + ) + + await state.update_data( + switch_tariff_id=tariff_id, + upgrade_cost=0, + is_upgrade=False, + current_tariff_id=current_tariff_id, + remaining_days=remaining_days, + ) + await callback.answer() + return + + if is_upgrade: + # Upgrade - нужна доплата + if user_balance >= upgrade_cost: + await callback.message.edit_text( + f"⬆️ Повышение тарифа\n\n" + f"📌 Текущий: {current_tariff.name}\n" + f" • Трафик: {current_traffic}\n" + f" • Устройств: {current_tariff.device_limit}\n\n" + f"📦 Новый: {new_tariff.name}\n" + f" • Трафик: {traffic}\n" + f" • Устройств: {new_tariff.device_limit}\n\n" + f"⏰ Осталось дней: {remaining_days}\n" + f"💰 Доплата: {_format_price_kopeks(upgrade_cost)}\n\n" + f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n" + f"После оплаты: {_format_price_kopeks(user_balance - upgrade_cost)}", + reply_markup=get_instant_switch_confirm_keyboard(tariff_id, db_user.language), + parse_mode="HTML" + ) + else: + missing = upgrade_cost - user_balance + await callback.message.edit_text( + f"❌ Недостаточно средств\n\n" + f"📦 Новый тариф: {new_tariff.name}\n" + f"💰 Требуется доплата: {_format_price_kopeks(upgrade_cost)}\n\n" + f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n" + f"⚠️ Не хватает: {_format_price_kopeks(missing)}", + reply_markup=get_instant_switch_insufficient_balance_keyboard(tariff_id, db_user.language), + parse_mode="HTML" + ) + else: + # Downgrade или тот же уровень - бесплатно + await callback.message.edit_text( + f"⬇️ Переключение тарифа\n\n" + f"📌 Текущий: {current_tariff.name}\n" + f" • Трафик: {current_traffic}\n" + f" • Устройств: {current_tariff.device_limit}\n\n" + f"📦 Новый: {new_tariff.name}\n" + f" • Трафик: {traffic}\n" + f" • Устройств: {new_tariff.device_limit}\n\n" + f"⏰ Осталось дней: {remaining_days}\n" + f"💰 Бесплатно (понижение/равный тариф)", + reply_markup=get_instant_switch_confirm_keyboard(tariff_id, db_user.language), + parse_mode="HTML" + ) + + await state.update_data( + switch_tariff_id=tariff_id, + upgrade_cost=upgrade_cost, + is_upgrade=is_upgrade, + current_tariff_id=current_tariff_id, + remaining_days=remaining_days, + ) + await callback.answer() + + +@error_handler +async def confirm_instant_switch( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """Подтверждает мгновенное переключение тарифа.""" + from datetime import datetime, timedelta + + tariff_id = int(callback.data.split(":")[1]) + new_tariff = await get_tariff_by_id(db, tariff_id) + + if not new_tariff or not new_tariff.is_active: + await callback.answer("Тариф недоступен", show_alert=True) + return + + # Получаем данные из состояния + data = await state.get_data() + upgrade_cost = data.get('upgrade_cost', 0) + is_upgrade = data.get('is_upgrade', False) + remaining_days = data.get('remaining_days', 0) + + # Проверяем подписку + subscription = await get_subscription_by_user_id(db, db_user.id) + if not subscription: + await callback.answer("Подписка не найдена", show_alert=True) + return + + # Проверяем баланс если это upgrade + user_balance = db_user.balance_kopeks or 0 + if is_upgrade and user_balance < upgrade_cost: + await callback.answer("Недостаточно средств на балансе", show_alert=True) + return + + texts = get_texts(db_user.language) + + try: + # Списываем баланс если это upgrade + if is_upgrade and upgrade_cost > 0: + success = await subtract_user_balance( + db, db_user, upgrade_cost, + f"Переключение на тариф {new_tariff.name}" + ) + if not success: + await callback.answer("Ошибка списания баланса", show_alert=True) + return + + # Получаем список серверов из нового тарифа + squads = new_tariff.allowed_squads or [] + + # Если allowed_squads пустой - значит "все серверы", получаем их + if not squads: + from app.database.crud.server_squad import get_all_server_squads + all_servers, _ = await get_all_server_squads(db, available_only=True) + squads = [s.squad_uuid for s in all_servers if s.squad_uuid] + + # Проверяем, суточный ли новый тариф + is_new_daily = getattr(new_tariff, 'is_daily', False) + + # Обновляем подписку с новыми параметрами тарифа + subscription.tariff_id = new_tariff.id + subscription.traffic_limit_gb = new_tariff.traffic_limit_gb + subscription.device_limit = new_tariff.device_limit + subscription.connected_squads = squads + + # Сбрасываем докупленный трафик при смене тарифа + from app.database.models import TrafficPurchase + from sqlalchemy import delete as sql_delete + await db.execute(sql_delete(TrafficPurchase).where(TrafficPurchase.subscription_id == subscription.id)) + subscription.purchased_traffic_gb = 0 + subscription.traffic_reset_at = None + + if is_new_daily: + # Для суточного тарифа - сбрасываем на 1 день и настраиваем суточные параметры + daily_price = getattr(new_tariff, 'daily_price_kopeks', 0) + + # Списываем первый день если ещё не списано (upgrade_cost был 0) + if upgrade_cost == 0 and daily_price > 0: + if user_balance >= daily_price: + await subtract_user_balance( + db, db_user, daily_price, + f"Переключение на суточный тариф {new_tariff.name} (первый день)" + ) + await create_transaction( + db, + user_id=db_user.id, + type=TransactionType.SUBSCRIPTION_PAYMENT, + amount_kopeks=-daily_price, + description=f"Переключение на суточный тариф {new_tariff.name} (первый день)", + ) + + subscription.end_date = datetime.utcnow() + timedelta(days=1) + subscription.is_trial = False + subscription.is_daily_paused = False + subscription.last_daily_charge_at = datetime.utcnow() + + await db.commit() + await db.refresh(subscription) + + # Обновляем пользователя в Remnawave + try: + subscription_service = SubscriptionService() + await subscription_service.create_remnawave_user( + db, + subscription, + reset_traffic=False, # Не сбрасываем трафик при переключении + reset_reason="мгновенное переключение тарифа", + ) + except Exception as e: + logger.error(f"Ошибка обновления Remnawave при мгновенном переключении: {e}") + + # Создаем транзакцию если была оплата + if is_upgrade and upgrade_cost > 0: + await create_transaction( + db, + user_id=db_user.id, + type=TransactionType.SUBSCRIPTION_PAYMENT, + amount_kopeks=-upgrade_cost, + description=f"Переключение на тариф {new_tariff.name}", + ) + + # Отправляем уведомление админу + try: + admin_notification_service = AdminNotificationService(callback.bot) + await admin_notification_service.send_subscription_purchase_notification( + db, + db_user, + subscription, + None, + remaining_days, + was_trial_conversion=False, + amount_kopeks=upgrade_cost, + ) + except Exception as e: + logger.error(f"Ошибка отправки уведомления админу: {e}") + + await state.clear() + + traffic = _format_traffic(new_tariff.traffic_limit_gb) + + # Для суточного тарифа другое сообщение об успехе + if is_new_daily: + daily_price = getattr(new_tariff, 'daily_price_kopeks', 0) + await callback.message.edit_text( + f"🎉 Тариф успешно изменён!\n\n" + f"📦 Новый тариф: {new_tariff.name}\n" + f"📊 Трафик: {traffic}\n" + f"📱 Устройств: {new_tariff.device_limit}\n" + f"🔄 Тип: Суточный\n" + f"💰 Списано: {_format_price_kopeks(daily_price)}\n\n" + f"ℹ️ Следующее списание через 24 часа.", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")], + [InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")] + ]), + parse_mode="HTML" + ) + else: + if is_upgrade: + cost_text = f"💰 Списано: {_format_price_kopeks(upgrade_cost)}" + else: + cost_text = "💰 Бесплатно" + + await callback.message.edit_text( + f"🎉 Тариф успешно изменён!\n\n" + f"📦 Новый тариф: {new_tariff.name}\n" + f"📊 Трафик: {traffic}\n" + f"📱 Устройств: {new_tariff.device_limit}\n" + f"⏰ Осталось дней: {remaining_days}\n" + f"{cost_text}", + reply_markup=InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")], + [InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")] + ]), + parse_mode="HTML" + ) + await callback.answer("Тариф изменён!", show_alert=True) + + except Exception as e: + logger.error(f"Ошибка при мгновенном переключении тарифа: {e}", exc_info=True) + await callback.answer("Произошла ошибка при переключении тарифа", show_alert=True) + + def register_tariff_purchase_handlers(dp: Dispatcher): """Регистрирует обработчики покупки по тарифам.""" # Список тарифов (для режима tariffs) @@ -1385,12 +3088,29 @@ def register_tariff_purchase_handlers(dp: Dispatcher): # Подтверждение покупки dp.callback_query.register(confirm_tariff_purchase, F.data.startswith("tariff_confirm:")) + # Подтверждение покупки суточного тарифа + dp.callback_query.register(confirm_daily_tariff_purchase, F.data.startswith("daily_tariff_confirm:")) + + # Кастомные дни/трафик + dp.callback_query.register(handle_custom_days_change, F.data.startswith("custom_days:")) + dp.callback_query.register(handle_custom_traffic_change, F.data.startswith("custom_traffic:")) + dp.callback_query.register(handle_custom_confirm, F.data.startswith("custom_confirm:")) + dp.callback_query.register(select_tariff_period_with_traffic, F.data.startswith("tariff_period_traffic:")) + # Продление по тарифу dp.callback_query.register(select_tariff_extend_period, F.data.startswith("tariff_extend:")) dp.callback_query.register(confirm_tariff_extend, F.data.startswith("tariff_ext_confirm:")) - # Переключение тарифов + # Переключение тарифов (с выбором периода) dp.callback_query.register(show_tariff_switch_list, F.data == "tariff_switch") dp.callback_query.register(select_tariff_switch, F.data.startswith("tariff_sw_select:")) dp.callback_query.register(select_tariff_switch_period, F.data.startswith("tariff_sw_period:")) dp.callback_query.register(confirm_tariff_switch, F.data.startswith("tariff_sw_confirm:")) + + # Смена на суточный тариф + dp.callback_query.register(confirm_daily_tariff_switch, F.data.startswith("daily_tariff_switch_confirm:")) + + # Мгновенное переключение тарифов (без выбора периода) + dp.callback_query.register(show_instant_switch_list, F.data == "instant_switch") + dp.callback_query.register(preview_instant_switch, F.data.startswith("instant_sw_preview:")) + dp.callback_query.register(confirm_instant_switch, F.data.startswith("instant_sw_confirm:")) diff --git a/app/handlers/subscription/traffic.py b/app/handlers/subscription/traffic.py index 1c1a8de5..23833f3b 100644 --- a/app/handlers/subscription/traffic.py +++ b/app/handlers/subscription/traffic.py @@ -31,7 +31,7 @@ from app.keyboards.inline import ( get_countries_keyboard, get_devices_keyboard, get_subscription_confirm_keyboard, get_autopay_keyboard, get_autopay_days_keyboard, get_back_keyboard, - get_add_traffic_keyboard, + get_add_traffic_keyboard, get_add_traffic_keyboard_from_tariff, get_change_devices_keyboard, get_reset_traffic_confirm_keyboard, get_manage_countries_keyboard, get_device_selection_keyboard, get_connection_guide_keyboard, @@ -93,41 +93,9 @@ async def handle_add_traffic( db: AsyncSession ): from app.config import settings + from app.database.crud.tariff import get_tariff_by_id texts = get_texts(db_user.language) - - # Проверяем, включена ли функция докупки трафика - if not settings.is_traffic_topup_enabled(): - await callback.answer( - texts.t( - "TRAFFIC_TOPUP_DISABLED", - "⚠️ Функция докупки трафика отключена", - ), - show_alert=True, - ) - return - - # В режиме тарифов докупка трафика недоступна - if settings.is_tariffs_mode(): - await callback.answer( - texts.t( - "TARIFF_TRAFFIC_TOPUP_DISABLED", - "⚠️ В режиме тарифов докупка трафика недоступна", - ), - show_alert=True, - ) - return - - if settings.is_traffic_topup_blocked(): - await callback.answer( - texts.t( - "TRAFFIC_FIXED_MODE", - "⚠️ В текущем режиме трафик фиксированный и не может быть изменен", - ), - show_alert=True, - ) - return - subscription = db_user.subscription if not subscription or subscription.is_trial: @@ -144,6 +112,74 @@ async def handle_add_traffic( ) return + # Режим тарифов - проверяем настройки тарифа + if settings.is_tariffs_mode() and subscription.tariff_id: + tariff = await get_tariff_by_id(db, subscription.tariff_id) + if not tariff or not tariff.can_topup_traffic(): + await callback.answer( + texts.t( + "TARIFF_TRAFFIC_TOPUP_DISABLED", + "⚠️ На вашем тарифе докупка трафика недоступна", + ), + show_alert=True, + ) + return + + # Показываем пакеты из тарифа + current_traffic = subscription.traffic_limit_gb + packages = tariff.get_traffic_topup_packages() + + period_hint_days = _get_period_hint_from_subscription(subscription) + traffic_discount_percent = _get_addon_discount_percent_for_user( + db_user, + "traffic", + period_hint_days, + ) + + prompt_text = texts.t( + "ADD_TRAFFIC_PROMPT", + ( + "📈 Добавить трафик к подписке\n\n" + "Текущий лимит: {current_traffic}\n" + "Выберите дополнительный трафик:" + ), + ).format(current_traffic=texts.format_traffic(current_traffic)) + + await callback.message.edit_text( + prompt_text, + reply_markup=get_add_traffic_keyboard_from_tariff( + db_user.language, + packages, + subscription.end_date, + traffic_discount_percent, + ), + parse_mode="HTML" + ) + + await callback.answer() + return + + # Стандартный режим - проверяем глобальные настройки + if not settings.is_traffic_topup_enabled(): + await callback.answer( + texts.t( + "TRAFFIC_TOPUP_DISABLED", + "⚠️ Функция докупки трафика отключена", + ), + show_alert=True, + ) + return + + if settings.is_traffic_topup_blocked(): + await callback.answer( + texts.t( + "TRAFFIC_FIXED_MODE", + "⚠️ В текущем режиме трафик фиксированный и не может быть изменен", + ), + show_alert=True, + ) + return + current_traffic = subscription.traffic_limit_gb period_hint_days = _get_period_hint_from_subscription(subscription) traffic_discount_percent = _get_addon_discount_percent_for_user( @@ -472,15 +508,30 @@ async def add_traffic( db_user: User, db: AsyncSession ): - if settings.is_traffic_topup_blocked(): - await callback.answer("⚠️ В текущем режиме трафик фиксированный", show_alert=True) - return + from app.database.crud.tariff import get_tariff_by_id traffic_gb = int(callback.data.split('_')[2]) texts = get_texts(db_user.language) subscription = db_user.subscription - base_price = settings.get_traffic_topup_price(traffic_gb) + # Получаем цену: из тарифа или из глобальных настроек + base_price = 0 + tariff = None + + if settings.is_tariffs_mode() and subscription and subscription.tariff_id: + # Режим тарифов - берем цену из тарифа + tariff = await get_tariff_by_id(db, subscription.tariff_id) + if tariff and tariff.can_topup_traffic(): + base_price = tariff.get_traffic_topup_price(traffic_gb) or 0 + else: + await callback.answer("⚠️ На вашем тарифе докупка трафика недоступна", show_alert=True) + return + else: + # Стандартный режим + if settings.is_traffic_topup_blocked(): + await callback.answer("⚠️ В текущем режиме трафик фиксированный", show_alert=True) + return + base_price = settings.get_traffic_topup_price(traffic_gb) if base_price == 0 and traffic_gb != 0: await callback.answer("⚠️ Цена для этого пакета не настроена", show_alert=True) @@ -550,13 +601,15 @@ async def add_traffic( if traffic_gb == 0: subscription.traffic_limit_gb = 0 - # При переходе на безлимит сбрасываем докупленный трафик + # При переходе на безлимит сбрасываем все докупки + from app.database.models import TrafficPurchase + from sqlalchemy import delete + await db.execute(delete(TrafficPurchase).where(TrafficPurchase.subscription_id == subscription.id)) subscription.purchased_traffic_gb = 0 + subscription.traffic_reset_at = None else: + # add_subscription_traffic уже создаёт TrafficPurchase и обновляет все необходимые поля await add_subscription_traffic(db, subscription, traffic_gb) - # Записываем докупленный трафик для корректного расчета цены сброса - current_purchased = getattr(subscription, 'purchased_traffic_gb', 0) or 0 - subscription.purchased_traffic_gb = current_purchased + traffic_gb subscription_service = SubscriptionService() await subscription_service.update_remnawave_user(db, subscription) @@ -631,6 +684,14 @@ async def handle_switch_traffic( await callback.answer("⚠️ Эта функция доступна только для платных подписок", show_alert=True) return + # Проверяем настройку тарифа + if subscription.tariff_id: + from app.database.crud.tariff import get_tariff_by_id + tariff = await get_tariff_by_id(db, subscription.tariff_id) + if tariff and not tariff.allow_traffic_topup: + await callback.answer("⚠️ Для вашего тарифа переключение трафика недоступно", show_alert=True) + return + current_traffic = subscription.traffic_limit_gb # Вычисляем базовый трафик (без докупленного) для корректного расчёта цен purchased_traffic = getattr(subscription, 'purchased_traffic_gb', 0) or 0 @@ -805,8 +866,12 @@ async def execute_switch_traffic( ) subscription.traffic_limit_gb = new_traffic_gb - # Сбрасываем докупленный трафик при переключении пакета + # Сбрасываем все докупки трафика при переключении пакета + from app.database.models import TrafficPurchase + from sqlalchemy import delete + await db.execute(delete(TrafficPurchase).where(TrafficPurchase.subscription_id == subscription.id)) subscription.purchased_traffic_gb = 0 + subscription.traffic_reset_at = None # Сбрасываем дату сброса трафика subscription.updated_at = datetime.utcnow() await db.commit() diff --git a/app/handlers/tickets.py b/app/handlers/tickets.py index df164c16..52fd564c 100644 --- a/app/handlers/tickets.py +++ b/app/handlers/tickets.py @@ -6,6 +6,7 @@ from aiogram import Dispatcher, types, F, Bot from aiogram.fsm.context import FSMContext from aiogram.fsm.state import StatesGroup, State from sqlalchemy.ext.asyncio import AsyncSession +from app.utils.timezone import format_local_datetime from app.database.models import User, Ticket, TicketStatus from app.database.crud.ticket import TicketCRUD, TicketMessageCRUD @@ -310,7 +311,7 @@ async def handle_ticket_message_input( f"📝 Заголовок: {safe_title}\n" f"📊 Статус: {ticket.status_emoji} " f"{texts.t('TICKET_STATUS_OPEN','Открыт')}\n" - f"📅 Создан: {ticket.created_at.strftime('%d.%m.%Y %H:%M')}\n" + f"📅 Создан: {format_local_datetime(ticket.created_at, '%d.%m.%Y %H:%M')}\n" + ("📎 Вложение: фото\n" if media_type == 'photo' else "") ) @@ -566,7 +567,7 @@ async def view_ticket( f"🎫 Тикет #{ticket.id}\n\n" f"📝 Заголовок: {ticket.title}\n" f"📊 Статус: {ticket.status_emoji} {status_text}\n" - f"📅 Создан: {ticket.created_at.strftime('%d.%m.%Y %H:%M')}\n\n" + f"📅 Создан: {format_local_datetime(ticket.created_at, '%d.%m.%Y %H:%M')}\n\n" ) message_blocks: list[str] = [] if ticket.messages: @@ -574,7 +575,7 @@ async def view_ticket( for msg in ticket.messages: sender = "👤 Вы" if msg.is_user_message else "🛠️ Поддержка" block = ( - f"{sender} ({msg.created_at.strftime('%d.%m %H:%M')}):\n" + f"{sender} ({format_local_datetime(msg.created_at, '%d.%m %H:%M')}):\n" f"{msg.message_text}\n\n" ) if getattr(msg, "has_media", False) and getattr(msg, "media_type", None) == "photo": @@ -1000,7 +1001,7 @@ async def notify_admins_about_new_ticket(ticket: Ticket, db: AsyncSession): f"🆔 Telegram ID: {telegram_id_display}\n" f"📱 Username: @{username_display}\n" f"📝 Заголовок: {title or '—'}\n" - f"📅 Создан: {ticket.created_at.strftime('%d.%m.%Y %H:%M')}\n" + f"📅 Создан: {format_local_datetime(ticket.created_at, '%d.%m.%Y %H:%M')}\n" ) # Клавиатура с быстрыми действиями для админов в топике diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index 827fd294..92b4d63c 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -1377,6 +1377,12 @@ def get_broadcast_target_keyboard(language: str = "ru") -> InlineKeyboardMarkup: callback_data="broadcast_trial_zero" ) ], + [ + InlineKeyboardButton( + text=_t(texts, "ADMIN_BROADCAST_TARGET_BY_TARIFF", "📦 По тарифу"), + callback_data="broadcast_by_tariff" + ) + ], [InlineKeyboardButton(text=texts.BACK, callback_data="admin_messages")] ]) diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index ef0ed9db..1934f4fc 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -508,14 +508,18 @@ def get_main_menu_keyboard( ) # Добавляем кнопку докупки трафика для лимитированных подписок - if ( - settings.BUY_TRAFFIC_BUTTON_VISIBLE - and settings.is_traffic_topup_enabled() - and not settings.is_traffic_topup_blocked() - and subscription - and not subscription.is_trial - and (subscription.traffic_limit_gb or 0) > 0 - ): + # В режиме тарифов проверяем tariff_id (детальная проверка в хендлере) + # В классическом режиме проверяем глобальные настройки + show_traffic_topup = False + if subscription and not subscription.is_trial and (subscription.traffic_limit_gb or 0) > 0: + if settings.is_tariffs_mode() and getattr(subscription, 'tariff_id', None): + # Режим тарифов - показываем кнопку, проверка настроек тарифа в хендлере + show_traffic_topup = settings.BUY_TRAFFIC_BUTTON_VISIBLE + elif settings.is_traffic_topup_enabled() and not settings.is_traffic_topup_blocked(): + # Классический режим - проверяем глобальные настройки + show_traffic_topup = settings.BUY_TRAFFIC_BUTTON_VISIBLE + + if show_traffic_topup: paired_buttons.append( InlineKeyboardButton(text=texts.t("BUY_TRAFFIC_BUTTON", "📈 Докупить трафик"), callback_data="buy_traffic") ) @@ -971,43 +975,63 @@ def get_subscription_keyboard( if happ_row: keyboard.append(happ_row) - if not is_trial: - keyboard.append([ - InlineKeyboardButton(text=texts.MENU_EXTEND_SUBSCRIPTION, callback_data="subscription_extend") - ]) - keyboard.append([ - InlineKeyboardButton( - text=texts.t("AUTOPAY_BUTTON", "💳 Автоплатеж"), - callback_data="subscription_autopay", - ) - ]) - if is_trial: keyboard.append([ InlineKeyboardButton(text=texts.MENU_BUY_SUBSCRIPTION, callback_data="subscription_upgrade") ]) else: - keyboard.append([ - InlineKeyboardButton( - text=texts.t("SUBSCRIPTION_SETTINGS_BUTTON", "⚙️ Настройки подписки"), - callback_data="subscription_settings", - ) - ]) - # Кнопка смены тарифа для режима тарифов - if settings.is_tariffs_mode() and subscription: + # Проверяем, является ли тариф суточным + tariff = getattr(subscription, 'tariff', None) if subscription else None + is_daily_tariff = tariff and getattr(tariff, 'is_daily', False) + + if is_daily_tariff: + # Для суточного тарифа показываем кнопку паузы/возобновления + is_paused = getattr(subscription, 'is_daily_paused', False) + if is_paused: + pause_text = texts.t("RESUME_DAILY_BUTTON", "▶️ Возобновить подписку") + else: + pause_text = texts.t("PAUSE_DAILY_BUTTON", "⏸️ Приостановить подписку") keyboard.append([ + InlineKeyboardButton(text=pause_text, callback_data="toggle_daily_subscription_pause") + ]) + else: + # Для обычного тарифа: [Продлить] [Автоплатеж] + keyboard.append([ + InlineKeyboardButton(text=texts.MENU_EXTEND_SUBSCRIPTION, callback_data="subscription_extend"), InlineKeyboardButton( - text=texts.t("CHANGE_TARIFF_BUTTON", "📦 Сменить тариф"), - callback_data="tariff_switch" + text=texts.t("AUTOPAY_BUTTON", "💳 Автоплатеж"), + callback_data="subscription_autopay", ) ]) + + # Ряд: [Настройки] [Тариф] (если режим тарифов) + settings_row = [ + InlineKeyboardButton( + text=texts.t("SUBSCRIPTION_SETTINGS_BUTTON", "⚙️ Настройки"), + callback_data="subscription_settings", + ) + ] + if settings.is_tariffs_mode() and subscription: + # Для суточных тарифов переходим на список тарифов, для обычных - мгновенное переключение + tariff_callback = "tariff_switch" if is_daily_tariff else "instant_switch" + settings_row.append( + InlineKeyboardButton( + text=texts.t("CHANGE_TARIFF_BUTTON", "📦 Тариф"), + callback_data=tariff_callback + ) + ) + keyboard.append(settings_row) + # Кнопка докупки трафика для платных подписок - if ( - settings.is_traffic_topup_enabled() - and not settings.is_traffic_topup_blocked() - and subscription - and (subscription.traffic_limit_gb or 0) > 0 - ): + # В режиме тарифов проверяем tariff_id, в классическом - глобальные настройки + show_traffic_topup = False + if subscription and (subscription.traffic_limit_gb or 0) > 0: + if settings.is_tariffs_mode() and getattr(subscription, 'tariff_id', None): + show_traffic_topup = True + elif settings.is_traffic_topup_enabled() and not settings.is_traffic_topup_blocked(): + show_traffic_topup = True + + if show_traffic_topup: keyboard.append([ InlineKeyboardButton( text=texts.t("BUY_TRAFFIC_BUTTON", "📈 Докупить трафик"), @@ -1795,7 +1819,81 @@ def get_add_traffic_keyboard( ]) return InlineKeyboardMarkup(inline_keyboard=buttons) - + + +def get_add_traffic_keyboard_from_tariff( + language: str, + packages: dict, # {gb: price_kopeks} + subscription_end_date: datetime = None, + discount_percent: int = 0, +) -> InlineKeyboardMarkup: + """ + Клавиатура для докупки трафика из настроек тарифа. + + Args: + language: Язык интерфейса + packages: Словарь {ГБ: цена_в_копейках} из тарифа + subscription_end_date: Дата окончания подписки для расчета цены + discount_percent: Процент скидки + """ + from app.utils.pricing_utils import get_remaining_months + + texts = get_texts(language) + + months_multiplier = 1 + period_text = "" + if subscription_end_date: + months_multiplier = get_remaining_months(subscription_end_date) + if months_multiplier > 1: + period_text = f" (за {months_multiplier} мес)" + + if not packages: + return InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton( + text=texts.t("NO_TRAFFIC_PACKAGES", "❌ Нет доступных пакетов"), + callback_data="no_traffic_packages" + )], + [InlineKeyboardButton( + text=texts.BACK, + callback_data="menu_subscription" + )] + ]) + + buttons = [] + + # Сортируем пакеты по размеру + sorted_packages = sorted(packages.items(), key=lambda x: x[0]) + + for gb, price_per_month in sorted_packages: + discounted_per_month, discount_per_month = apply_percentage_discount( + price_per_month, + discount_percent, + ) + total_price = discounted_per_month * months_multiplier + total_discount = discount_per_month * months_multiplier + + if language == "ru": + text = f"📊 +{gb} ГБ трафика - {total_price // 100} ₽{period_text}" + else: + text = f"📊 +{gb} GB traffic - {total_price // 100} ₽{period_text}" + + if discount_percent > 0 and total_discount > 0: + text += f" (скидка {discount_percent}%: -{total_discount // 100}₽)" + + buttons.append([ + InlineKeyboardButton(text=text, callback_data=f"add_traffic_{gb}") + ]) + + buttons.append([ + InlineKeyboardButton( + text=texts.BACK, + callback_data="menu_subscription" + ) + ]) + + return InlineKeyboardMarkup(inline_keyboard=buttons) + + def get_change_devices_keyboard( current_devices: int, language: str = DEFAULT_LANGUAGE, @@ -2433,6 +2531,7 @@ def get_updated_subscription_settings_keyboard( language: str = DEFAULT_LANGUAGE, show_countries_management: bool = True, tariff=None, # Тариф подписки (если есть - ограничиваем настройки) + subscription=None, # Подписка (для проверки суточной паузы) ) -> InlineKeyboardMarkup: from app.config import settings @@ -2442,6 +2541,8 @@ def get_updated_subscription_settings_keyboard( # Если подписка на тарифе - отключаем страны, модем, трафик has_tariff = tariff is not None + # Для суточных тарифов кнопка паузы теперь в главном меню подписки + if show_countries_management and not has_tariff: keyboard.append([ InlineKeyboardButton(text=texts.t("ADD_COUNTRIES_BUTTON", "🌐 Добавить страны"), callback_data="subscription_add_countries") diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index 494b82e8..6d394742 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -201,6 +201,7 @@ "ADMIN_MAIN_SETTINGS": "⚙️ Settings", "ADMIN_MAIN_SUPPORT": "🛟 Support", "ADMIN_MAIN_SYSTEM": "🛠️ System", + "ADMIN_MAIN_TARIFFS": "📦 Tariffs", "ADMIN_MAIN_TRIALS": "🧪 Trials", "ADMIN_MAIN_PAYMENTS": "💳 Top-ups", "ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Users / Subscriptions", @@ -889,6 +890,7 @@ "AUTOPAY_SUCCESS": "\n✅ Autopay completed\n\nYour subscription was automatically renewed for {days} days.\nCharged from balance: {amount}\n", "AUTOPAY_TOGGLE_SUCCESS": "✅ Autopay {status}!", "BACK": "⬅️ Back", + "BACK_BUTTON": "◀️ Back", "BACK_TO_MAIN_MENU_BUTTON": "⬅️ Back to main menu", "BACK_TO_MENU": "🏠 Back to menu", "BACK_TO_OPEN_TICKETS": "🔴 Open tickets", @@ -1031,6 +1033,7 @@ "FAQ_PAGE_NOT_AVAILABLE": "This FAQ page is not available.", "FAQ_PAGE_TITLE": "{title}", "FAQ_PAGE_UNTITLED": "Untitled", + "FREEKASSA_ENTER_AMOUNT": "💳 Top up via {name}\n\nEnter the amount in rubles.\n\nMinimum: {min_amount}\nMaximum: {max_amount}", "GO_TO_BALANCE_TOP_UP": "💳 Go to balance top up", "HAPP_DOWNLOAD_BUTTON": "⬇️ Download Happ", "HAPP_DOWNLOAD_LINK_MESSAGE": "⬇️ Download Happ for {platform}:", @@ -1140,6 +1143,7 @@ "PAYMENT_CARD_YOOKASSA": "💳 Bank card (YooKassa)", "PAYMENT_PLATEGA": "💳 Platega", "PAYMENT_CHARGE_ERROR": "⚠️ Failed to charge the payment", + "PAYMENT_CREATE_ERROR": "❌ Failed to create payment. Please try again later.", "PAYMENT_CRYPTOBOT": "🪙 Cryptocurrency (CryptoBot)", "PAYMENT_HELEKET": "🪙 Cryptocurrency (Heleket)", "HELEKET_PAYMENT_ALREADY_PAID": "✅ Payment has already been credited", @@ -1397,7 +1401,7 @@ "SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 Personal {percent}% discount\n\nIt's been {trigger_days} days without a subscription. Come back and activate the extra discount — it stacks with your promo group and is valid until {expires_at}.", "SUBSCRIPTION_EXPIRING": "\n⚠️ Subscription expiring!\n\nYour subscription expires in {days} days.\n\nRenew it now so you don't lose access.\n", "SUBSCRIPTION_EXPIRING_PAID": "\n⚠️ Subscription expires in {days_text}!\n\nYour paid subscription ends on {end_date}.\n\n💳 Autopay: {autopay_status}\n\n{action_text}\n", - "SUBSCRIPTION_EXTEND": "💎 Extend subscription", + "SUBSCRIPTION_EXTEND": "💎 Extend", "SUBSCRIPTION_HAPP_CRYPTOLINK_BLOCK": "
{crypto_link}
", "SUBSCRIPTION_HAPP_LINK_PROMPT": "🔒 Subscription link is ready. Tap the \"Connect\" button below to open it in Happ.", "SUBSCRIPTION_HAPP_OPEN_BUTTON_HINT": "▶️ Tap the \"Connect\" button below to open Happ and add the subscription automatically.", @@ -1420,12 +1424,13 @@ "SUBSCRIPTION_NOT_FOUND": "❌ Subscription not found", "SUBSCRIPTION_NO_ACTIVE_LINK": "⚠ You don't have an active subscription or the link is still being generated", "SUBSCRIPTION_NO_SERVERS": "No servers", - "SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Balance: {balance}\n📱 Subscription: {status_emoji} {status_display}{warning}\n\n📱 Subscription details\n🎭 Type: {subscription_type}\n📅 Valid until: {end_date}\n⏰ Time left: {time_left}\n📈 Traffic: {traffic}\n🌍 Servers: {servers}\n📱 Devices: {devices_used} / {device_limit}", + "SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Balance: {balance}\n📱 Subscription: {status_emoji} {status_display}{warning}{tariff_info_block}\n\n📱 Subscription details\n🎭 Type: {subscription_type}\n📅 Valid until: {end_date}\n⏰ Time left: {time_left}\n📈 Traffic: {traffic}\n🌍 Servers: {servers}\n📱 Devices: {devices_used} / {device_limit}", + "SUBSCRIPTION_DAILY_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Balance: {balance}\n📱 Subscription: {status_emoji} {status_display}{warning}{tariff_info_block}\n\n📱 Subscription details\n🎭 Type: {subscription_type}\n📈 Traffic: {traffic}\n🌍 Servers: {servers}\n📱 Devices: {devices_used} / {device_limit}", "SUBSCRIPTION_PROMO_DISCOUNT_HINT": "⚡ Extra {percent}% discount is active and will apply automatically. It stacks with other discounts.", "SUBSCRIPTION_PROMO_DISCOUNT_NOTE": "⚡ Extra discount {percent}%: -{amount}", "SUBSCRIPTION_PROMO_DISCOUNT_TIMER": "⏳ Discount active for {time_left}\n{bar}", "SUBSCRIPTION_PURCHASED": "🎉 Subscription purchased successfully!", - "SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ Subscription settings", + "SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ Settings", "SUBSCRIPTION_SETTINGS_OVERVIEW": "⚙️ Subscription settings\n\n📊 Current parameters:\n🌐 Countries: {countries_count}\n📈 Traffic: {traffic_used} / {traffic_limit}\n📱 Devices: {devices_used} / {devices_limit}\n\nChoose what you want to change:", "SUBSCRIPTION_SETTINGS_PAID_ONLY": "⚠️ Settings are available only for paid subscriptions", "SUBSCRIPTION_SPECIFIC_APP_TITLE": "📱 {app_name} - {device_name}", @@ -1451,6 +1456,7 @@ "SUB_STATUS_ACTIVE_LONG": "💎 Active\n📅 until {end_date} ({days} days)", "SUB_STATUS_ACTIVE_TODAY": "💎 Active\n⚠️ expires today!", "SUB_STATUS_ACTIVE_TOMORROW": "💎 Active\n⚠️ expires tomorrow!", + "SUB_STATUS_DAILY_ACTIVE": "💎 Active", "SUB_STATUS_EXPIRED": "🔴 Expired\n📅 {end_date}", "SUB_STATUS_DISABLED": "⚫ Disabled", "SUB_STATUS_PENDING": "⏳ Pending activation", @@ -1656,5 +1662,11 @@ "ADMIN_USER_RESTRICTIONS": "⚠️ Restrict", "USER_RESTRICTION_TOPUP_BLOCKED": "🚫 Top-up restricted\n\n{reason}\n\nIf you believe this is an error, you can appeal the decision.", "USER_RESTRICTION_SUBSCRIPTION_BLOCKED": "🚫 Subscription purchase/renewal restricted\n\n{reason}\n\nIf you believe this is an error, you can appeal the decision.", - "USER_RESTRICTION_APPEAL_BUTTON": "🆘 Appeal" + "USER_RESTRICTION_APPEAL_BUTTON": "🆘 Appeal", + + "PAUSE_DAILY_BUTTON": "⏸️ Pause subscription", + "RESUME_DAILY_BUTTON": "▶️ Resume subscription", + "DAILY_SWITCH_WARNING": "⚠️ Warning! You have {days} days left.\nThey will be lost when switching to daily tariff!", + "DAILY_SUBSCRIPTION_PAUSED": "⏸️ Subscription paused", + "DAILY_SUBSCRIPTION_RESUMED": "▶️ Subscription resumed!" } diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index 718672ef..e74c16c7 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -906,6 +906,7 @@ "AUTOPAY_SUCCESS": "\n✅ Автоплатеж выполнен\n\nВаша подписка автоматически продлена на {days} дней.\nСписано с баланса: {amount}\n", "AUTOPAY_TOGGLE_SUCCESS": "✅ Автоплатеж {status}!", "BACK": "⬅️ Назад", + "BACK_BUTTON": "◀️ Назад", "BACK_TO_MAIN_MENU_BUTTON": "⬅️ В главное меню", "BACK_TO_MENU": "🏠 В главное меню", "BACK_TO_OPEN_TICKETS": "🔴 Открытые тикеты", @@ -942,7 +943,7 @@ "CHANGE_DEVICES_SUCCESS_DECREASE": "\n ✅ Количество устройств уменьшено!\n\n 📱 Было: {old_count} → Стало: {new_count}\n ℹ️ Возврат средств не производится\n ", "CHANGE_DEVICES_SUCCESS_INCREASE": "\n ✅ Количество устройств увеличено!\n\n 📱 Было: {old_count} → Стало: {new_count}\n 💰 Списано: {amount}\n ", "CHANGE_DEVICES_TITLE": "📱 Изменение количества устройств", - "CHANGE_TARIFF_BUTTON": "📦 Сменить тариф", + "CHANGE_TARIFF_BUTTON": "📦 Тариф", "CHANNEL_CHECK_BUTTON": "✅ Я подписался", "CHANNEL_REQUIRED_TEXT": "🔒 Для использования бота подпишитесь на новостной канал, а затем нажмите кнопку ниже.", "CHANNEL_SUBSCRIBE_BUTTON": "🔗 Подписаться", @@ -1049,6 +1050,7 @@ "FAQ_PAGE_NOT_AVAILABLE": "Эта страница FAQ недоступна.", "FAQ_PAGE_TITLE": "{title}", "FAQ_PAGE_UNTITLED": "Без названия", + "FREEKASSA_ENTER_AMOUNT": "💳 Пополнение через {name}\n\nВведите сумму пополнения в рублях.\n\nМинимум: {min_amount}\nМаксимум: {max_amount}", "GO_TO_BALANCE_TOP_UP": "💳 Перейти к пополнению баланса", "HAPP_DOWNLOAD_BUTTON": "⬇️ Скачать Happ", "HAPP_DOWNLOAD_LINK_MESSAGE": "⬇️ Скачайте Happ для {platform}:", @@ -1158,6 +1160,7 @@ "PAYMENT_CARD_YOOKASSA": "💳 Банковская карта (YooKassa)", "PAYMENT_PLATEGA": "💳 Platega", "PAYMENT_CHARGE_ERROR": "⚠️ Ошибка списания средств", + "PAYMENT_CREATE_ERROR": "❌ Не удалось создать платёж. Попробуйте позже.", "PAYMENT_CRYPTOBOT": "🪙 Криптовалюта (CryptoBot)", "PAYMENT_HELEKET": "🪙 Криптовалюта (Heleket)", "HELEKET_PAYMENT_ALREADY_PAID": "✅ Платёж уже зачислен", @@ -1415,7 +1418,7 @@ "SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 Индивидуальная скидка {percent}%\n\nПрошло {trigger_days} дней без подписки. Вернитесь и активируйте дополнительную скидку — она суммируется с промогруппой и действует до {expires_at}.", "SUBSCRIPTION_EXPIRING": "\n⚠️ Подписка истекает!\n\nВаша подписка истекает через {days} дней.\n\nНе забудьте продлить подписку, чтобы не потерять доступ к серверам.\n", "SUBSCRIPTION_EXPIRING_PAID": "\n⚠️ Подписка истекает через {days_text}!\n\nВаша платная подписка истекает {end_date}.\n\n💳 Автоплатеж: {autopay_status}\n\n{action_text}\n", - "SUBSCRIPTION_EXTEND": "💎 Продлить подписку", + "SUBSCRIPTION_EXTEND": "💎 Продлить", "SUBSCRIPTION_HAPP_CRYPTOLINK_BLOCK": "
{crypto_link}
", "SUBSCRIPTION_HAPP_LINK_PROMPT": "🔒 Ссылка на подписку создана. Нажмите кнопку \"Подключиться\" ниже, чтобы открыть её в Happ.", "SUBSCRIPTION_HAPP_OPEN_BUTTON_HINT": "▶️ Нажмите кнопку \"Подключиться\" ниже, чтобы открыть Happ и добавить подписку автоматически.", @@ -1438,12 +1441,13 @@ "SUBSCRIPTION_NOT_FOUND": "❌ Подписка не найдена", "SUBSCRIPTION_NO_ACTIVE_LINK": "⚠ У вас нет активной подписки или ссылка еще генерируется", "SUBSCRIPTION_NO_SERVERS": "Нет серверов", - "SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Баланс: {balance}\n📱 Подписка: {status_emoji} {status_display}{warning}\n\n📱 Информация о подписке\n🎭 Тип: {subscription_type}\n📅 Действует до: {end_date}\n⏰ Осталось: {time_left}\n📈 Трафик: {traffic}\n🌍 Серверы: {servers}\n📱 Устройства: {devices_used} / {device_limit}", + "SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Баланс: {balance}\n📱 Подписка: {status_emoji} {status_display}{warning}{tariff_info_block}\n\n📱 Информация о подписке\n🎭 Тип: {subscription_type}\n📅 Действует до: {end_date}\n⏰ Осталось: {time_left}\n📈 Трафик: {traffic}\n🌍 Серверы: {servers}\n📱 Устройства: {devices_used} / {device_limit}", + "SUBSCRIPTION_DAILY_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Баланс: {balance}\n📱 Подписка: {status_emoji} {status_display}{warning}{tariff_info_block}\n\n📱 Информация о подписке\n🎭 Тип: {subscription_type}\n📈 Трафик: {traffic}\n🌍 Серверы: {servers}\n📱 Устройства: {devices_used} / {device_limit}", "SUBSCRIPTION_PROMO_DISCOUNT_HINT": "⚡ Активирована доп. скидка {percent}%. \n\nСуммируется с другими скидками!", "SUBSCRIPTION_PROMO_DISCOUNT_NOTE": "⚡ Доп. скидка {percent}%: -{amount}", "SUBSCRIPTION_PROMO_DISCOUNT_TIMER": "⏳ Скидка действует ещё: {time_left}\n{bar}", "SUBSCRIPTION_PURCHASED": "🎉 Подписка успешно приобретена!", - "SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ Настройки подписки", + "SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ Настройки", "SUBSCRIPTION_SETTINGS_OVERVIEW": "⚙️ Настройки подписки\n\n📊 Текущие параметры:\n🌐 Стран: {countries_count}\n📈 Трафик: {traffic_used} / {traffic_limit}\n📱 Устройства: {devices_used} / {devices_limit}\n\nВыберите что хотите изменить:", "SUBSCRIPTION_SETTINGS_PAID_ONLY": "⚠️ Настройки доступны только для платных подписок", "SUBSCRIPTION_SPECIFIC_APP_TITLE": "📱 {app_name} - {device_name}", @@ -1469,6 +1473,7 @@ "SUB_STATUS_ACTIVE_LONG": "💎 Активна\n📅 до {end_date} ({days} дн.)", "SUB_STATUS_ACTIVE_TODAY": "💎 Активна\n⚠️ истекает сегодня!", "SUB_STATUS_ACTIVE_TOMORROW": "💎 Активна\n⚠️ истекает завтра!", + "SUB_STATUS_DAILY_ACTIVE": "💎 Активна", "SUB_STATUS_EXPIRED": "🔴 Истекла\n📅 {end_date}", "SUB_STATUS_DISABLED": "⚫ Отключена", "SUB_STATUS_PENDING": "⏳ Ожидает активации", @@ -1674,5 +1679,11 @@ "ADMIN_USER_RESTRICTIONS": "⚠️ Ограничить", "USER_RESTRICTION_TOPUP_BLOCKED": "🚫 Пополнение ограничено\n\n{reason}\n\nЕсли вы считаете это ошибкой, вы можете обжаловать решение.", "USER_RESTRICTION_SUBSCRIPTION_BLOCKED": "🚫 Покупка/продление подписки ограничено\n\n{reason}\n\nЕсли вы считаете это ошибкой, вы можете обжаловать решение.", - "USER_RESTRICTION_APPEAL_BUTTON": "🆘 Обжаловать" + "USER_RESTRICTION_APPEAL_BUTTON": "🆘 Обжаловать", + + "PAUSE_DAILY_BUTTON": "⏸️ Приостановить подписку", + "RESUME_DAILY_BUTTON": "▶️ Возобновить подписку", + "DAILY_SWITCH_WARNING": "⚠️ Внимание! У вас осталось {days} дн. подписки.\nПри смене на суточный тариф они будут утеряны!", + "DAILY_SUBSCRIPTION_PAUSED": "⏸️ Подписка приостановлена", + "DAILY_SUBSCRIPTION_RESUMED": "▶️ Подписка возобновлена!" } diff --git a/app/localization/locales/ua.json b/app/localization/locales/ua.json index 5639c0b7..eb8db8d6 100644 --- a/app/localization/locales/ua.json +++ b/app/localization/locales/ua.json @@ -131,6 +131,7 @@ "ADMIN_MAIN_SETTINGS": "⚙️ Налаштування", "ADMIN_MAIN_SUPPORT": "🛟 Підтримка", "ADMIN_MAIN_SYSTEM": "🛠️ Система", + "ADMIN_MAIN_TARIFFS": "📦 Тарифи", "ADMIN_MAIN_TRIALS": "🧪 Тріали", "ADMIN_MAIN_PAYMENTS": "💳 Поповнення", "ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Юзери/Підписки", @@ -1354,7 +1355,8 @@ "SUBSCRIPTION_NOT_FOUND": "❌ Підписку не знайдено", "SUBSCRIPTION_NO_ACTIVE_LINK": "⚠ У вас немає активної підписки або посилання ще генерується", "SUBSCRIPTION_NO_SERVERS": "Немає серверів", - "SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Баланс: {balance}\n📱 Підписка: {status_emoji} {status_display}{warning}\n\n📱 Інформація про підписку\n🎭 Тип: {subscription_type}\n📅 Діє до: {end_date}\n⏰ Залишилося: {time_left}\n📈 Трафік: {traffic}\n🌍 Сервери: {servers}\n📱 Пристрої: {devices_used} / {device_limit}", + "SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Баланс: {balance}\n📱 Підписка: {status_emoji} {status_display}{warning}{tariff_info_block}\n\n📱 Інформація про підписку\n🎭 Тип: {subscription_type}\n📅 Діє до: {end_date}\n⏰ Залишилося: {time_left}\n📈 Трафік: {traffic}\n🌍 Сервери: {servers}\n📱 Пристрої: {devices_used} / {device_limit}", + "SUBSCRIPTION_DAILY_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Баланс: {balance}\n📱 Підписка: {status_emoji} {status_display}{warning}{tariff_info_block}\n\n📱 Інформація про підписку\n🎭 Тип: {subscription_type}\n📈 Трафік: {traffic}\n🌍 Сервери: {servers}\n📱 Пристрої: {devices_used} / {device_limit}", "SUBSCRIPTION_PROMO_DISCOUNT_HINT": "⚡ Активовано дод. знижку {percent}%. \n\nСумується з іншими знижками!", "SUBSCRIPTION_PROMO_DISCOUNT_NOTE": "⚡ Дод. знижка {percent}%: -{amount}", "SUBSCRIPTION_PROMO_DISCOUNT_TIMER": "⏳ Знижка діє ще: {time_left}\n{bar}", @@ -1385,6 +1387,7 @@ "SUB_STATUS_ACTIVE_LONG": "💎 Активна\n📅 до {end_date} ({days} дн.)", "SUB_STATUS_ACTIVE_TODAY": "💎 Активна\n⚠️ закінчується сьогодні!", "SUB_STATUS_ACTIVE_TOMORROW": "💎 Активна\n⚠️ закінчується завтра!", + "SUB_STATUS_DAILY_ACTIVE": "💎 Активна", "SUB_STATUS_EXPIRED": "🔴 Закінчилася\n📅 {end_date}", "SUB_STATUS_DISABLED": "⚫ Вимкнена", "SUB_STATUS_PENDING": "⏳ Очікує активації", diff --git a/app/localization/locales/zh.json b/app/localization/locales/zh.json index 279fcbca..9b792ce7 100644 --- a/app/localization/locales/zh.json +++ b/app/localization/locales/zh.json @@ -130,6 +130,7 @@ "ADMIN_MAIN_SETTINGS":"⚙️设置", "ADMIN_MAIN_SUPPORT":"🛟支持", "ADMIN_MAIN_SYSTEM":"🛠️系统", +"ADMIN_MAIN_TARIFFS":"📦套餐", "ADMIN_MAIN_TRIALS":"🧪试用", "ADMIN_MAIN_PAYMENTS":"💳充值", "ADMIN_MAIN_USERS_SUBSCRIPTIONS":"👥用户/订阅", @@ -1352,7 +1353,8 @@ "SUBSCRIPTION_NOT_FOUND":"❌未找到订阅", "SUBSCRIPTION_NO_ACTIVE_LINK":"⚠您没有活跃的订阅或链接仍在生成中", "SUBSCRIPTION_NO_SERVERS":"没有服务器", -"SUBSCRIPTION_OVERVIEW_TEMPLATE":"👤{full_name}\n💰余额:{balance}\n📱订阅:{status_emoji}{status_display}{warning}\n\n📱订阅信息\n🎭类型:{subscription_type}\n📅有效期至:{end_date}\n⏰剩余时间:{time_left}\n📈流量:{traffic}\n🌍服务器:{servers}\n📱设备:{devices_used}/{device_limit}", +"SUBSCRIPTION_OVERVIEW_TEMPLATE":"👤{full_name}\n💰余额:{balance}\n📱订阅:{status_emoji}{status_display}{warning}{tariff_info_block}\n\n📱订阅信息\n🎭类型:{subscription_type}\n📅有效期至:{end_date}\n⏰剩余时间:{time_left}\n📈流量:{traffic}\n🌍服务器:{servers}\n📱设备:{devices_used}/{device_limit}", +"SUBSCRIPTION_DAILY_OVERVIEW_TEMPLATE":"👤{full_name}\n💰余额:{balance}\n📱订阅:{status_emoji}{status_display}{warning}{tariff_info_block}\n\n📱订阅信息\n🎭类型:{subscription_type}\n📈流量:{traffic}\n🌍服务器:{servers}\n📱设备:{devices_used}/{device_limit}", "SUBSCRIPTION_PROMO_DISCOUNT_HINT":"⚡已激活额外{percent}%折扣。\n\n可与其他折扣叠加!", "SUBSCRIPTION_PROMO_DISCOUNT_NOTE":"⚡额外{percent}%折扣:-{amount}", "SUBSCRIPTION_PROMO_DISCOUNT_TIMER":"⏳折扣剩余时间:{time_left}\n{bar}", @@ -1383,6 +1385,7 @@ "SUB_STATUS_ACTIVE_LONG":"💎活跃\n📅至{end_date}({days}天)", "SUB_STATUS_ACTIVE_TODAY":"💎活跃\n⚠️今天过期!", "SUB_STATUS_ACTIVE_TOMORROW":"💎活跃\n⚠️明天过期!", +"SUB_STATUS_DAILY_ACTIVE":"💎活跃", "SUB_STATUS_EXPIRED":"🔴已过期\n📅{end_date}", "SUB_STATUS_DISABLED":"⚫已禁用", "SUB_STATUS_PENDING":"⏳等待激活", @@ -1680,7 +1683,8 @@ "SUBSCRIPTION_NOT_FOUND":"❌未找到订阅", "SUBSCRIPTION_NO_ACTIVE_LINK":"⚠您没有活跃的订阅或链接仍在生成中", "SUBSCRIPTION_NO_SERVERS":"没有服务器", -"SUBSCRIPTION_OVERVIEW_TEMPLATE":"👤{full_name}\n💰余额:{balance}\n📱订阅:{status_emoji}{status_display}{warning}\n\n📱订阅信息\n🎭类型:{subscription_type}\n📅有效期至:{end_date}\n⏰剩余时间:{time_left}\n📈流量:{traffic}\n🌍服务器:{servers}\n📱设备:{devices_used}/{device_limit}", +"SUBSCRIPTION_OVERVIEW_TEMPLATE":"👤{full_name}\n💰余额:{balance}\n📱订阅:{status_emoji}{status_display}{warning}{tariff_info_block}\n\n📱订阅信息\n🎭类型:{subscription_type}\n📅有效期至:{end_date}\n⏰剩余时间:{time_left}\n📈流量:{traffic}\n🌍服务器:{servers}\n📱设备:{devices_used}/{device_limit}", +"SUBSCRIPTION_DAILY_OVERVIEW_TEMPLATE":"👤{full_name}\n💰余额:{balance}\n📱订阅:{status_emoji}{status_display}{warning}{tariff_info_block}\n\n📱订阅信息\n🎭类型:{subscription_type}\n📈流量:{traffic}\n🌍服务器:{servers}\n📱设备:{devices_used}/{device_limit}", "SUBSCRIPTION_PROMO_DISCOUNT_HINT":"⚡已激活额外{percent}%折扣。\n\n可与其他折扣叠加!", "SUBSCRIPTION_PROMO_DISCOUNT_NOTE":"⚡额外{percent}%折扣:-{amount}", "SUBSCRIPTION_PROMO_DISCOUNT_TIMER":"⏳折扣剩余时间:{time_left}\n{bar}", @@ -1711,6 +1715,7 @@ "SUB_STATUS_ACTIVE_LONG":"💎活跃\n📅至{end_date}({days}天)", "SUB_STATUS_ACTIVE_TODAY":"💎活跃\n⚠️今天过期!", "SUB_STATUS_ACTIVE_TOMORROW":"💎活跃\n⚠️明天过期!", +"SUB_STATUS_DAILY_ACTIVE":"💎活跃", "SUB_STATUS_EXPIRED":"🔴已过期\n📅{end_date}", "SUB_STATUS_DISABLED":"⚫已禁用", "SUB_STATUS_PENDING":"⏳等待激活", diff --git a/app/middlewares/channel_checker.py b/app/middlewares/channel_checker.py index cac399fb..67fa2db0 100644 --- a/app/middlewares/channel_checker.py +++ b/app/middlewares/channel_checker.py @@ -1,17 +1,19 @@ import logging from typing import Callable, Dict, Any, Awaitable, Optional +from datetime import datetime from aiogram import BaseMiddleware, Bot, types from aiogram.exceptions import TelegramForbiddenError, TelegramBadRequest from aiogram.fsm.context import FSMContext from aiogram.types import TelegramObject, Update, Message, CallbackQuery from aiogram.enums import ChatMemberStatus +from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database.database import get_db from app.database.crud.campaign import get_campaign_by_start_parameter from app.database.crud.subscription import deactivate_subscription, reactivate_subscription from app.database.crud.user import get_user_by_telegram_id -from app.database.models import SubscriptionStatus +from app.database.models import SubscriptionStatus, User from app.keyboards.inline import get_channel_sub_keyboard from app.localization.loader import DEFAULT_LANGUAGE from app.localization.texts import get_texts @@ -23,6 +25,11 @@ logger = logging.getLogger(__name__) class ChannelCheckerMiddleware(BaseMiddleware): + """ + Middleware для проверки подписки на канал. + ОПТИМИЗИРОВАНО: создаёт максимум одну сессию БД на запрос. + """ + def __init__(self): self.BAD_MEMBER_STATUS = ( ChatMemberStatus.LEFT, @@ -55,10 +62,7 @@ class ChannelCheckerMiddleware(BaseMiddleware): logger.debug("❌ telegram_id не найден, пропускаем") return await handler(event, data) - - # Админам разрешаем пропускать проверку подписки, чтобы не блокировать - # работу панели управления даже при отсутствии подписки. Важно делать - # это до обращения к состоянию, чтобы не выполнять лишние операции. + # Админам разрешаем пропускать проверку подписки if settings.is_admin(telegram_id): logger.debug( "✅ Пользователь %s является администратором — пропускаем проверку подписки", @@ -72,7 +76,6 @@ class ChannelCheckerMiddleware(BaseMiddleware): if state: current_state = await state.get_state() - is_reg_process = is_registration_process(event, current_state) if is_reg_process: @@ -191,10 +194,10 @@ class ChannelCheckerMiddleware(BaseMiddleware): payload = parts[1] - data = await state.get_data() or {} - if data.get("pending_start_payload") != payload: - data["pending_start_payload"] = payload - await state.set_data(data) + state_data = await state.get_data() or {} + if state_data.get("pending_start_payload") != payload: + state_data["pending_start_payload"] = payload + await state.set_data(state_data) logger.debug("💾 Сохранен start payload %s для последующей обработки", payload) if bot and message.from_user: @@ -213,7 +216,7 @@ class ChannelCheckerMiddleware(BaseMiddleware): payload: str, ) -> None: try: - data = await state.get_data() or {} + state_data = await state.get_data() or {} except Exception as error: logger.error( "❌ Не удалось получить данные состояния для уведомления по кампании %s: %s", @@ -222,7 +225,7 @@ class ChannelCheckerMiddleware(BaseMiddleware): ) return - if data.get("campaign_notification_sent"): + if state_data.get("campaign_notification_sent"): return async for db in get_db(): @@ -246,7 +249,6 @@ class ChannelCheckerMiddleware(BaseMiddleware): ) if sent: await state.update_data(campaign_notification_sent=True) - break except Exception as error: logger.error( "❌ Ошибка отправки уведомления о переходе по кампании %s: %s", @@ -259,40 +261,24 @@ class ChannelCheckerMiddleware(BaseMiddleware): async def _deactivate_subscription_on_unsubscribe( self, telegram_id: int, bot: Bot, channel_link: Optional[str] ) -> None: + """Деактивация подписки при отписке от канала.""" if not settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE and not settings.CHANNEL_REQUIRED_FOR_ALL: - logger.debug( - "ℹ️ Пропускаем деактивацию подписки пользователя %s: отключение при отписке выключено", - telegram_id, - ) return async for db in get_db(): try: user = await get_user_by_telegram_id(db, telegram_id) if not user or not user.subscription: - logger.debug( - "⚠️ Пользователь %s отсутствует или не имеет подписки — пропускаем деактивацию", - telegram_id, - ) break subscription = user.subscription if subscription.status != SubscriptionStatus.ACTIVE.value: - logger.debug( - "ℹ️ Подписка пользователя %s не активна (status=%s) — пропускаем деактивацию", - telegram_id, - subscription.status, - ) break if settings.CHANNEL_REQUIRED_FOR_ALL: pass elif not subscription.is_trial: - logger.debug( - "ℹ️ Подписка пользователя %s платная, CHANNEL_REQUIRED_FOR_ALL=False — пропускаем деактивацию", - telegram_id, - ) break await deactivate_subscription(db, subscription) @@ -324,7 +310,6 @@ class ChannelCheckerMiddleware(BaseMiddleware): ) channel_kb = get_channel_sub_keyboard(channel_link, language=user.language) await bot.send_message(telegram_id, notification_text, reply_markup=channel_kb) - logger.info(f"📨 Уведомление о деактивации отправлено пользователю {telegram_id}") except Exception as notify_error: logger.error( "❌ Не удалось отправить уведомление о деактивации пользователю %s: %s", @@ -341,10 +326,7 @@ class ChannelCheckerMiddleware(BaseMiddleware): break async def _reactivate_subscription_on_subscribe(self, telegram_id: int, bot: Bot) -> None: - """Реактивация подписки после повторной подписки на канал. - - Вызывается только если подписка в статусе DISABLED. - """ + """Реактивация подписки после повторной подписки на канал.""" if not settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE and not settings.CHANNEL_REQUIRED_FOR_ALL: return @@ -356,13 +338,11 @@ class ChannelCheckerMiddleware(BaseMiddleware): subscription = user.subscription - # Реактивируем только DISABLED подписки (деактивированные из-за отписки) - # Тихо выходим если подписка не требует реактивации — без логов + # Реактивируем только DISABLED подписки if subscription.status != SubscriptionStatus.DISABLED.value: break # Проверяем что подписка ещё не истекла - from datetime import datetime if subscription.end_date and subscription.end_date <= datetime.utcnow(): break diff --git a/app/middlewares/display_name_restriction.py b/app/middlewares/display_name_restriction.py index 0060737e..04bd1e58 100644 --- a/app/middlewares/display_name_restriction.py +++ b/app/middlewares/display_name_restriction.py @@ -131,23 +131,18 @@ class DisplayNameRestrictionMiddleware(BaseMiddleware): cleaned = ZERO_WIDTH_PATTERN.sub("", value) lower_value = cleaned.lower() - if "@" in cleaned or "@" in cleaned: - return True - - if any(pattern.search(lower_value) for pattern in LINK_PATTERNS): - return True - - if DOMAIN_OBFUSCATION_PATTERN.search(lower_value): - return True + # Убраны жёсткие проверки на @ и паттерны ссылок - слишком много ложных срабатываний + # Теперь проверяем только по настраиваемым ключевым словам из DISPLAY_NAME_BANNED_KEYWORDS normalized = self._normalize_text(lower_value) collapsed = COLLAPSE_PATTERN.sub("", normalized) - if "tme" in collapsed: - return True - banned_keywords = settings.get_display_name_banned_keywords() + # Если список пустой - не блокируем никого + if not banned_keywords: + return False + return any( keyword in normalized or keyword in collapsed for keyword in banned_keywords diff --git a/app/middlewares/subscription_checker.py b/app/middlewares/subscription_checker.py index 9d9c523f..18ae8e1c 100644 --- a/app/middlewares/subscription_checker.py +++ b/app/middlewares/subscription_checker.py @@ -2,52 +2,46 @@ import logging from typing import Callable, Dict, Any, Awaitable from datetime import datetime from aiogram import BaseMiddleware -from aiogram.types import TelegramObject, Update, Message, CallbackQuery +from aiogram.types import TelegramObject -from app.database.database import get_db -from app.database.crud.user import get_user_by_telegram_id from app.database.models import SubscriptionStatus logger = logging.getLogger(__name__) class SubscriptionStatusMiddleware(BaseMiddleware): - + """ + Проверяет статус подписки пользователя. + ВАЖНО: Использует db и db_user из data, которые уже загружены в AuthMiddleware. + Не создаёт дополнительных сессий БД. + """ + async def __call__( self, handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], event: TelegramObject, data: Dict[str, Any] ) -> Any: - - telegram_id = None - if isinstance(event, (Message, CallbackQuery)): - telegram_id = event.from_user.id - elif isinstance(event, Update): - if event.message: - telegram_id = event.message.from_user.id - elif event.callback_query: - telegram_id = event.callback_query.from_user.id - - if telegram_id: + # Используем db и user из AuthMiddleware - не создаём новую сессию! + db = data.get('db') + user = data.get('db_user') + + if db and user and user.subscription: try: - async for db in get_db(): - user = await get_user_by_telegram_id(db, telegram_id) - if user and user.subscription: - current_time = datetime.utcnow() - subscription = user.subscription - - if (subscription.status == SubscriptionStatus.ACTIVE.value and - subscription.end_date <= current_time): - - subscription.status = SubscriptionStatus.EXPIRED.value - subscription.updated_at = current_time - await db.commit() - - logger.info(f"⏰ Middleware: Статус подписки пользователя {user.id} изменен на 'expired' (время истекло)") - break - + current_time = datetime.utcnow() + subscription = user.subscription + + if (subscription.status == SubscriptionStatus.ACTIVE.value and + subscription.end_date and + subscription.end_date <= current_time): + + subscription.status = SubscriptionStatus.EXPIRED.value + subscription.updated_at = current_time + await db.commit() + + logger.info(f"⏰ Middleware: Статус подписки пользователя {user.id} изменен на 'expired' (время истекло)") + except Exception as e: - logger.error(f"Ошибка проверки статуса подписки для пользователя {telegram_id}: {e}") - + logger.error(f"Ошибка проверки статуса подписки: {e}") + return await handler(event, data) diff --git a/app/services/backup_service.py b/app/services/backup_service.py index 14ff3b2e..08bb4efc 100644 --- a/app/services/backup_service.py +++ b/app/services/backup_service.py @@ -27,7 +27,7 @@ from app.database.models import ( ServerSquad, SubscriptionServer, UserMessage, YooKassaPayment, CryptoBotPayment, WelcomeText, Base, PromoGroup, AdvertisingCampaign, AdvertisingCampaignRegistration, SupportAuditLog, Ticket, TicketMessage, - MulenPayPayment, Pal24Payment, DiscountOffer, WebApiToken, + MulenPayPayment, Pal24Payment, DiscountOffer, WebApiToken, Tariff, server_squad_promo_groups ) @@ -75,6 +75,7 @@ class BackupService: Squad, ServerSquad, PromoGroup, + Tariff, # Tariff должен быть ДО Subscription из-за FK User, PromoCode, WelcomeText, @@ -362,7 +363,7 @@ class BackupService: "tool": pg_dump_path, } - logger.warning( + logger.info( "pg_dump не найден в PATH. Используется ORM-дамп в формате JSON" ) json_info = await self._dump_postgres_json(staging_dir, include_logs) @@ -818,14 +819,14 @@ class BackupService: try: if clear_existing: logger.warning("🗑️ Очищаем существующие данные...") - await self._clear_database_tables(db) + await self._clear_database_tables(db, backup_data) models_for_restore = self._get_models_for_backup(True) models_by_table = { model.__tablename__: model for model in models_for_restore } - pre_restore_tables = {"promo_groups"} + pre_restore_tables = {"promo_groups", "tariffs"} for table_name in pre_restore_tables: model = models_by_table.get(table_name) if not model: @@ -887,6 +888,9 @@ class BackupService: restored_tables += 1 logger.info("✅ Таблица %s восстановлена", table_name) + # Flush все изменения перед обновлением реферальных связей + await db.flush() + await self._update_user_referrals(db, backup_data) assoc_tables, assoc_records = await self._restore_association_tables( @@ -1200,10 +1204,29 @@ class BackupService: ) -> int: restored_count = 0 + # Кешируем существующие tariff_id для проверки FK + existing_tariff_ids = set() + if table_name == "subscriptions": + try: + result = await db.execute(select(Tariff.id)) + existing_tariff_ids = {row[0] for row in result.fetchall()} + logger.info(f"📋 Найдено {len(existing_tariff_ids)} существующих тарифов для валидации FK") + except Exception as e: + logger.warning(f"⚠️ Не удалось получить список тарифов: {e}") + for record_data in records: try: processed_data = self._process_record_data(record_data, model, table_name) + # Валидация FK для subscriptions.tariff_id + if table_name == "subscriptions" and "tariff_id" in processed_data: + tariff_id = processed_data.get("tariff_id") + if tariff_id is not None and tariff_id not in existing_tariff_ids: + logger.warning( + f"⚠️ Тариф {tariff_id} не найден, устанавливаем tariff_id=NULL для подписки" + ) + processed_data["tariff_id"] = None + primary_key_col = self._get_primary_key_column(model) if primary_key_col and primary_key_col in processed_data: @@ -1235,7 +1258,7 @@ class BackupService: return restored_count - async def _clear_database_tables(self, db: AsyncSession): + async def _clear_database_tables(self, db: AsyncSession, backup_data: Optional[Dict[str, Any]] = None): tables_order = [ "server_squad_promo_groups", "ticket_messages", "tickets", "support_audit_logs", @@ -1247,11 +1270,22 @@ class BackupService: "mulenpay_payments", "pal24_payments", "transactions", "welcome_texts", "subscriptions", "promocodes", "users", "promo_groups", + "tariffs", # tariffs должен очищаться ПОСЛЕ subscriptions (FK зависимость) "server_squads", "squads", "service_rules", "system_settings", "web_api_tokens", "monitoring_logs" ] - + + # Таблицы, которые не нужно очищать если в бекапе нет данных для них + # (чтобы сохранить существующие настройки) + preserve_if_no_backup = {"tariffs", "promo_groups", "server_squads", "squads"} + for table_name in tables_order: + # Проверяем, нужно ли сохранить таблицу + if backup_data and table_name in preserve_if_no_backup: + if not backup_data.get(table_name): + logger.info(f"⏭️ Пропускаем очистку {table_name} (нет данных в бекапе)") + continue + try: await db.execute(text(f"DELETE FROM {table_name}")) logger.info(f"🗑️ Очищена таблица {table_name}") diff --git a/app/services/ban_notification_service.py b/app/services/ban_notification_service.py new file mode 100644 index 00000000..c44dbf56 --- /dev/null +++ b/app/services/ban_notification_service.py @@ -0,0 +1,382 @@ +""" +Сервис для отправки уведомлений от ban системы пользователям +""" +import logging +from typing import Optional, Tuple +from datetime import datetime + +from aiogram import Bot +from aiogram.exceptions import TelegramAPIError +from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select + +from app.database.models import User +from app.services.remnawave_service import remnawave_service +from app.config import settings + + +logger = logging.getLogger(__name__) + + +def get_delete_keyboard() -> InlineKeyboardMarkup: + """Клавиатура с кнопкой удаления уведомления""" + return InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="🗑 Удалить", callback_data="ban_notify:delete")] + ]) + + +class BanNotificationService: + """Сервис для отправки уведомлений о банах пользователям""" + + def __init__(self): + self._bot: Optional[Bot] = None + + def set_bot(self, bot: Bot): + """Установить инстанс бота для отправки сообщений""" + self._bot = bot + + async def _find_user_by_identifier( + self, + db: AsyncSession, + user_identifier: str + ) -> Optional[User]: + """ + Найти пользователя по email или user_id из Remnawave Panel + + Args: + db: Сессия БД + user_identifier: Email или user_id пользователя + + Returns: + User или None если не найден + """ + # Сначала пытаемся получить telegram_id через remnawave_service + try: + telegram_id = await remnawave_service.get_telegram_id_by_email(user_identifier) + if telegram_id: + # Ищем пользователя по telegram_id + result = await db.execute( + select(User).where(User.telegram_id == telegram_id) + ) + user = result.scalar_one_or_none() + if user: + return user + except Exception as e: + logger.warning(f"Не удалось получить telegram_id через remnawave: {e}") + + # Если не нашли через remnawave, пытаемся искать по email в подписках + # (это может быть полезно если у пользователя есть подписка с таким email) + try: + # Импортируем здесь чтобы избежать циклических импортов + from app.database.models import Subscription + + result = await db.execute( + select(User) + .join(Subscription) + .where(Subscription.email == user_identifier) + .limit(1) + ) + user = result.scalar_one_or_none() + if user: + return user + except Exception as e: + logger.warning(f"Ошибка поиска пользователя по email в подписках: {e}") + + return None + + async def send_punishment_notification( + self, + db: AsyncSession, + user_identifier: str, + username: str, + ip_count: int, + limit: int, + ban_minutes: int, + node_name: Optional[str] = None + ) -> Tuple[bool, str, Optional[int]]: + """ + Отправить уведомление о блокировке пользователю + + Returns: + (success, message, telegram_id) + """ + if not self._bot: + return False, "Бот не инициализирован", None + + # Находим пользователя + user = await self._find_user_by_identifier(db, user_identifier) + if not user: + logger.warning(f"Пользователь {user_identifier} не найден в базе данных") + return False, f"Пользователь не найден: {user_identifier}", None + + # Формируем информацию о ноде (заметно выделяем) + node_info = f"🖥 Нода: {node_name}" if node_name else "" + + # Формируем сообщение из настроек + # Используем безопасное форматирование - если {node_info} отсутствует в шаблоне, не будет ошибки + format_vars = { + "ip_count": ip_count, + "limit": limit, + "ban_minutes": ban_minutes, + "node_info": node_info + } + try: + message_text = settings.BAN_MSG_PUNISHMENT.format(**format_vars) + except KeyError: + # Старый шаблон без {node_info} - форматируем без него + message_text = settings.BAN_MSG_PUNISHMENT.format( + ip_count=ip_count, + limit=limit, + ban_minutes=ban_minutes + ) + # Добавляем информацию о ноде в конец, если она есть + if node_info: + message_text = message_text.rstrip() + f"\n\n{node_info.rstrip()}" + + # Отправляем сообщение с кнопкой удаления + try: + await self._bot.send_message( + chat_id=user.telegram_id, + text=message_text, + parse_mode="HTML", + reply_markup=get_delete_keyboard() + ) + logger.info( + f"Уведомление о бане отправлено пользователю {username} " + f"(telegram_id: {user.telegram_id})" + ) + return True, "Уведомление отправлено", user.telegram_id + + except TelegramAPIError as e: + logger.error( + f"Ошибка отправки уведомления пользователю {username} " + f"(telegram_id: {user.telegram_id}): {e}" + ) + return False, f"Ошибка Telegram API: {str(e)}", user.telegram_id + + async def send_enabled_notification( + self, + db: AsyncSession, + user_identifier: str, + username: str + ) -> Tuple[bool, str, Optional[int]]: + """ + Отправить уведомление о разблокировке пользователю + + Returns: + (success, message, telegram_id) + """ + if not self._bot: + return False, "Бот не инициализирован", None + + # Находим пользователя + user = await self._find_user_by_identifier(db, user_identifier) + if not user: + logger.warning(f"Пользователь {user_identifier} не найден в базе данных") + return False, f"Пользователь не найден: {user_identifier}", None + + # Формируем сообщение из настроек + message_text = settings.BAN_MSG_ENABLED + + # Отправляем сообщение с кнопкой удаления + try: + await self._bot.send_message( + chat_id=user.telegram_id, + text=message_text, + parse_mode="HTML", + reply_markup=get_delete_keyboard() + ) + logger.info( + f"Уведомление о разбане отправлено пользователю {username} " + f"(telegram_id: {user.telegram_id})" + ) + return True, "Уведомление отправлено", user.telegram_id + + except TelegramAPIError as e: + logger.error( + f"Ошибка отправки уведомления пользователю {username} " + f"(telegram_id: {user.telegram_id}): {e}" + ) + return False, f"Ошибка Telegram API: {str(e)}", user.telegram_id + + async def send_warning_notification( + self, + db: AsyncSession, + user_identifier: str, + username: str, + warning_message: str + ) -> Tuple[bool, str, Optional[int]]: + """ + Отправить предупреждение пользователю + + Returns: + (success, message, telegram_id) + """ + if not self._bot: + return False, "Бот не инициализирован", None + + # Находим пользователя + user = await self._find_user_by_identifier(db, user_identifier) + if not user: + logger.warning(f"Пользователь {user_identifier} не найден в базе данных") + return False, f"Пользователь не найден: {user_identifier}", None + + # Формируем сообщение из настроек + message_text = settings.BAN_MSG_WARNING.format( + warning_message=warning_message + ) + + # Отправляем сообщение с кнопкой удаления + try: + await self._bot.send_message( + chat_id=user.telegram_id, + text=message_text, + parse_mode="HTML", + reply_markup=get_delete_keyboard() + ) + logger.info( + f"Предупреждение отправлено пользователю {username} " + f"(telegram_id: {user.telegram_id})" + ) + return True, "Предупреждение отправлено", user.telegram_id + + except TelegramAPIError as e: + logger.error( + f"Ошибка отправки предупреждения пользователю {username} " + f"(telegram_id: {user.telegram_id}): {e}" + ) + return False, f"Ошибка Telegram API: {str(e)}", user.telegram_id + + async def send_network_wifi_notification( + self, + db: AsyncSession, + user_identifier: str, + username: str, + ban_minutes: int, + network_type: Optional[str] = None, + node_name: Optional[str] = None + ) -> Tuple[bool, str, Optional[int]]: + """ + Отправить уведомление о блокировке за использование WiFi сети + + Returns: + (success, message, telegram_id) + """ + if not self._bot: + return False, "Бот не инициализирован", None + + # Находим пользователя + user = await self._find_user_by_identifier(db, user_identifier) + if not user: + logger.warning(f"Пользователь {user_identifier} не найден в базе данных") + return False, f"Пользователь не найден: {user_identifier}", None + + # Формируем сообщение из настроек (заметно выделяем) + network_info = f"├ 🌐 Сеть: {network_type}\n" if network_type else "" + node_info = f"🖥 Нода: {node_name}" if node_name else "" + + logger.info(f"WiFi notification: node_name={node_name!r}, node_info={node_info!r}") + + # Безопасное форматирование + format_vars = { + "ban_minutes": ban_minutes, + "network_info": network_info, + "node_info": node_info + } + try: + message_text = settings.BAN_MSG_WIFI.format(**format_vars) + except KeyError: + logger.warning("BAN_MSG_WIFI template missing placeholders, adding node_info to end") + message_text = settings.BAN_MSG_WIFI.format(ban_minutes=ban_minutes) + extra_info = (network_info + node_info).strip() + if extra_info: + message_text = message_text.rstrip() + f"\n\n{extra_info}" + + # Отправляем сообщение с кнопкой удаления + try: + await self._bot.send_message( + chat_id=user.telegram_id, + text=message_text, + parse_mode="HTML", + reply_markup=get_delete_keyboard() + ) + logger.info( + f"Уведомление о WiFi бане отправлено пользователю {username} " + f"(telegram_id: {user.telegram_id})" + ) + return True, "Уведомление отправлено", user.telegram_id + + except TelegramAPIError as e: + logger.error( + f"Ошибка отправки WiFi уведомления пользователю {username} " + f"(telegram_id: {user.telegram_id}): {e}" + ) + return False, f"Ошибка Telegram API: {str(e)}", user.telegram_id + + async def send_network_mobile_notification( + self, + db: AsyncSession, + user_identifier: str, + username: str, + ban_minutes: int, + network_type: Optional[str] = None, + node_name: Optional[str] = None + ) -> Tuple[bool, str, Optional[int]]: + """ + Отправить уведомление о блокировке за использование мобильной сети + + Returns: + (success, message, telegram_id) + """ + if not self._bot: + return False, "Бот не инициализирован", None + + # Находим пользователя + user = await self._find_user_by_identifier(db, user_identifier) + if not user: + logger.warning(f"Пользователь {user_identifier} не найден в базе данных") + return False, f"Пользователь не найден: {user_identifier}", None + + # Формируем сообщение из настроек (заметно выделяем) + network_info = f"├ 🌐 Сеть: {network_type}\n" if network_type else "" + node_info = f"🖥 Нода: {node_name}" if node_name else "" + + # Безопасное форматирование + format_vars = { + "ban_minutes": ban_minutes, + "network_info": network_info, + "node_info": node_info + } + try: + message_text = settings.BAN_MSG_MOBILE.format(**format_vars) + except KeyError: + message_text = settings.BAN_MSG_MOBILE.format(ban_minutes=ban_minutes) + extra_info = (network_info + node_info).strip() + if extra_info: + message_text = message_text.rstrip() + f"\n\n{extra_info}" + + # Отправляем сообщение с кнопкой удаления + try: + await self._bot.send_message( + chat_id=user.telegram_id, + text=message_text, + parse_mode="HTML", + reply_markup=get_delete_keyboard() + ) + logger.info( + f"Уведомление о Mobile бане отправлено пользователю {username} " + f"(telegram_id: {user.telegram_id})" + ) + return True, "Уведомление отправлено", user.telegram_id + + except TelegramAPIError as e: + logger.error( + f"Ошибка отправки Mobile уведомления пользователю {username} " + f"(telegram_id: {user.telegram_id}): {e}" + ) + return False, f"Ошибка Telegram API: {str(e)}", user.telegram_id + + +# Глобальный экземпляр сервиса +ban_notification_service = BanNotificationService() diff --git a/app/services/daily_subscription_service.py b/app/services/daily_subscription_service.py new file mode 100644 index 00000000..f0f15063 --- /dev/null +++ b/app/services/daily_subscription_service.py @@ -0,0 +1,485 @@ +""" +Сервис для автоматического списания суточных подписок. +Проверяет подписки с суточным тарифом и списывает плату раз в сутки. +Также сбрасывает докупленный трафик по истечении 30 дней. +""" +import logging +import asyncio +from datetime import datetime +from typing import Optional + +from aiogram import Bot +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.database import get_db +from app.database.crud.subscription import ( + get_daily_subscriptions_for_charge, + update_daily_charge_time, + suspend_daily_subscription_insufficient_balance, +) +from app.database.crud.user import subtract_user_balance, get_user_by_id +from app.database.crud.transaction import create_transaction +from app.database.models import TransactionType, PaymentMethod, Subscription, User +from app.localization.texts import get_texts + + +logger = logging.getLogger(__name__) + + +class DailySubscriptionService: + """ + Сервис автоматического списания для суточных подписок. + """ + + def __init__(self): + self._running = False + self._bot: Optional[Bot] = None + self._check_interval_minutes = 30 # Проверка каждые 30 минут + + def set_bot(self, bot: Bot): + """Устанавливает бота для отправки уведомлений.""" + self._bot = bot + + def is_enabled(self) -> bool: + """Проверяет, включен ли сервис суточных подписок.""" + return getattr(settings, 'DAILY_SUBSCRIPTIONS_ENABLED', True) + + def get_check_interval_minutes(self) -> int: + """Возвращает интервал проверки в минутах.""" + return getattr(settings, 'DAILY_SUBSCRIPTIONS_CHECK_INTERVAL_MINUTES', 30) + + async def process_daily_charges(self) -> dict: + """ + Обрабатывает суточные списания. + + Returns: + dict: Статистика обработки + """ + stats = { + "checked": 0, + "charged": 0, + "suspended": 0, + "errors": 0, + } + + try: + async for db in get_db(): + subscriptions = await get_daily_subscriptions_for_charge(db) + stats["checked"] = len(subscriptions) + + for subscription in subscriptions: + try: + result = await self._process_single_charge(db, subscription) + if result == "charged": + stats["charged"] += 1 + elif result == "suspended": + stats["suspended"] += 1 + elif result == "error": + stats["errors"] += 1 + except Exception as e: + logger.error( + f"Ошибка обработки суточной подписки {subscription.id}: {e}", + exc_info=True + ) + stats["errors"] += 1 + + except Exception as e: + logger.error(f"Ошибка при получении подписок для списания: {e}", exc_info=True) + + return stats + + async def _process_single_charge(self, db, subscription) -> str: + """ + Обрабатывает списание для одной подписки. + + Returns: + str: "charged", "suspended", "error", "skipped" + """ + user = subscription.user + if not user: + user = await get_user_by_id(db, subscription.user_id) + + if not user: + logger.warning(f"Пользователь не найден для подписки {subscription.id}") + return "error" + + tariff = subscription.tariff + if not tariff: + logger.warning(f"Тариф не найден для подписки {subscription.id}") + return "error" + + daily_price = tariff.daily_price_kopeks + if daily_price <= 0: + logger.warning(f"Некорректная суточная цена для тарифа {tariff.id}") + return "error" + + # Проверяем баланс + if user.balance_kopeks < daily_price: + # Недостаточно средств - приостанавливаем подписку + await suspend_daily_subscription_insufficient_balance(db, subscription) + + # Уведомляем пользователя + if self._bot: + await self._notify_insufficient_balance(user, subscription, daily_price) + + logger.info( + f"Подписка {subscription.id} приостановлена: недостаточно средств " + f"(баланс: {user.balance_kopeks}, требуется: {daily_price})" + ) + return "suspended" + + # Списываем средства + description = f"Суточная оплата тарифа «{tariff.name}»" + + try: + deducted = await subtract_user_balance( + db, + user, + daily_price, + description, + ) + + if not deducted: + logger.warning(f"Не удалось списать средства для подписки {subscription.id}") + return "error" + + # Создаём транзакцию + await create_transaction( + db=db, + user_id=user.id, + type=TransactionType.SUBSCRIPTION_PAYMENT, + amount_kopeks=daily_price, + description=description, + payment_method=PaymentMethod.MANUAL, + ) + + # Обновляем время последнего списания и продлеваем подписку + subscription = await update_daily_charge_time(db, subscription) + + logger.info( + f"✅ Суточное списание: подписка {subscription.id}, " + f"сумма {daily_price} коп., пользователь {user.telegram_id}" + ) + + # Синхронизируем с Remnawave (обновляем срок подписки) + try: + from app.services.subscription_service import SubscriptionService + subscription_service = SubscriptionService() + await subscription_service.create_remnawave_user( + db, + subscription, + reset_traffic=False, + reset_reason=None, + ) + except Exception as e: + logger.warning(f"Не удалось обновить Remnawave: {e}") + + # Уведомляем пользователя + if self._bot: + await self._notify_daily_charge(user, subscription, daily_price) + + return "charged" + + except Exception as e: + logger.error( + f"Ошибка при списании средств для подписки {subscription.id}: {e}", + exc_info=True + ) + return "error" + + async def _notify_daily_charge(self, user, subscription, amount_kopeks: int): + """Уведомляет пользователя о суточном списании.""" + if not self._bot: + return + + try: + texts = get_texts(getattr(user, "language", "ru")) + amount_rubles = amount_kopeks / 100 + balance_rubles = user.balance_kopeks / 100 + + message = ( + f"💳 Суточное списание\n\n" + f"Списано: {amount_rubles:.2f} ₽\n" + f"Остаток баланса: {balance_rubles:.2f} ₽\n\n" + f"Следующее списание через 24 часа." + ) + + await self._bot.send_message( + chat_id=user.telegram_id, + text=message, + parse_mode="HTML", + ) + except Exception as e: + logger.warning(f"Не удалось отправить уведомление о списании: {e}") + + async def _notify_insufficient_balance(self, user, subscription, required_amount: int): + """Уведомляет пользователя о недостатке средств.""" + if not self._bot: + return + + try: + from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton + + texts = get_texts(getattr(user, "language", "ru")) + required_rubles = required_amount / 100 + balance_rubles = user.balance_kopeks / 100 + + message = ( + f"⚠️ Подписка приостановлена\n\n" + f"Недостаточно средств для суточной оплаты.\n\n" + f"Требуется: {required_rubles:.2f} ₽\n" + f"Баланс: {balance_rubles:.2f} ₽\n\n" + f"Пополните баланс, чтобы возобновить подписку." + ) + + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton( + text="💳 Пополнить баланс", + callback_data="menu_balance" + )], + [InlineKeyboardButton( + text="📱 Моя подписка", + callback_data="menu_subscription" + )], + ] + ) + + await self._bot.send_message( + chat_id=user.telegram_id, + text=message, + reply_markup=keyboard, + parse_mode="HTML", + ) + except Exception as e: + logger.warning(f"Не удалось отправить уведомление о недостатке средств: {e}") + + async def process_traffic_resets(self) -> dict: + """ + Сбрасывает докупленный трафик у подписок, у которых истёк срок. + + Returns: + dict: Статистика обработки + """ + stats = { + "checked": 0, + "reset": 0, + "errors": 0, + } + + from app.database.models import TrafficPurchase + + try: + async for db in get_db(): + # Находим все истекшие докупки + now = datetime.utcnow() + query = ( + select(TrafficPurchase) + .where(TrafficPurchase.expires_at <= now) + ) + result = await db.execute(query) + expired_purchases = result.scalars().all() + stats["checked"] = len(expired_purchases) + + # Группируем по подпискам для обновления + subscriptions_to_update = {} + for purchase in expired_purchases: + if purchase.subscription_id not in subscriptions_to_update: + subscriptions_to_update[purchase.subscription_id] = [] + subscriptions_to_update[purchase.subscription_id].append(purchase) + + # Удаляем истекшие докупки и обновляем подписки + for subscription_id, purchases in subscriptions_to_update.items(): + try: + await self._reset_subscription_traffic(db, subscription_id, purchases) + stats["reset"] += len(purchases) + except Exception as e: + logger.error( + f"Ошибка сброса трафика подписки {subscription_id}: {e}", + exc_info=True + ) + stats["errors"] += 1 + + except Exception as e: + logger.error(f"Ошибка при получении подписок для сброса трафика: {e}", exc_info=True) + + return stats + + async def _reset_subscription_traffic(self, db: AsyncSession, subscription_id: int, expired_purchases: list): + """Сбрасывает истекшие докупки трафика у подписки.""" + from app.database.models import TrafficPurchase + + # Получаем подписку + subscription_query = select(Subscription).where(Subscription.id == subscription_id) + subscription_result = await db.execute(subscription_query) + subscription = subscription_result.scalar_one_or_none() + + if not subscription: + return + + # Считаем сколько ГБ нужно убрать + total_expired_gb = sum(p.traffic_gb for p in expired_purchases) + old_limit = subscription.traffic_limit_gb + old_purchased = subscription.purchased_traffic_gb or 0 + + # КРИТИЧЕСКАЯ ПРОВЕРКА: защита от некорректных данных + if total_expired_gb > old_purchased: + logger.error( + f"⚠️ ОШИБКА ДАННЫХ: подписка {subscription.id}, " + f"истекает {total_expired_gb} ГБ, но purchased_traffic_gb = {old_purchased} ГБ. " + f"Сбрасываем только {old_purchased} ГБ." + ) + total_expired_gb = old_purchased + + # Рассчитываем базовый лимит тарифа (без докупок) + base_limit = old_limit - old_purchased + + # Получаем базовый лимит из тарифа для проверки + if subscription.tariff_id: + from app.database.crud.tariff import get_tariff_by_id + tariff = await get_tariff_by_id(db, subscription.tariff_id) + if tariff: + tariff_base_limit = tariff.traffic_limit_gb or 0 + # Проверяем, что базовый лимит не отрицательный + if base_limit < 0: + logger.warning( + f"⚠️ Базовый лимит отрицательный для подписки {subscription.id}: {base_limit} ГБ. " + f"Используем лимит из тарифа: {tariff_base_limit} ГБ" + ) + base_limit = tariff_base_limit + + # Защита от отрицательного базового лимита + base_limit = max(0, base_limit) + + # Удаляем истекшие записи + for purchase in expired_purchases: + await db.delete(purchase) + + # Рассчитываем новый лимит + new_purchased = old_purchased - total_expired_gb + new_limit = base_limit + new_purchased + + # Двойная защита: новый лимит не может быть меньше базового + if new_limit < base_limit: + logger.error( + f"⚠️ КРИТИЧЕСКАЯ ОШИБКА: новый лимит ({new_limit} ГБ) меньше базового ({base_limit} ГБ). " + f"Устанавливаем базовый лимит." + ) + new_limit = base_limit + new_purchased = 0 + + # Обновляем подписку + subscription.traffic_limit_gb = max(0, new_limit) + subscription.purchased_traffic_gb = max(0, new_purchased) + + # Проверяем, остались ли активные докупки + now = datetime.utcnow() + remaining_query = ( + select(TrafficPurchase) + .where(TrafficPurchase.subscription_id == subscription_id) + .where(TrafficPurchase.expires_at > now) + ) + remaining_result = await db.execute(remaining_query) + remaining_purchases = remaining_result.scalars().all() + + if not remaining_purchases: + # Нет больше активных докупок - сбрасываем дату + subscription.traffic_reset_at = None + else: + # Устанавливаем дату сброса по ближайшей истекающей докупке + next_expiry = min(p.expires_at for p in remaining_purchases) + subscription.traffic_reset_at = next_expiry + + subscription.updated_at = datetime.utcnow() + + await db.commit() + + logger.info( + f"🔄 Сброс истекших докупок: подписка {subscription.id}, " + f"было {old_limit} ГБ (базовый: {base_limit} ГБ, докуплено: {old_purchased} ГБ), " + f"стало {subscription.traffic_limit_gb} ГБ (базовый: {base_limit} ГБ, докуплено: {new_purchased} ГБ), " + f"убрано {total_expired_gb} ГБ из {len(expired_purchases)} покупок" + ) + + # Синхронизируем с RemnaWave + try: + from app.services.subscription_service import SubscriptionService + subscription_service = SubscriptionService() + await subscription_service.update_remnawave_user(db, subscription) + except Exception as e: + logger.warning(f"Не удалось синхронизировать с RemnaWave после сброса трафика: {e}") + + # Уведомляем пользователя + if self._bot and subscription.user_id: + user = await get_user_by_id(db, subscription.user_id) + if user: + await self._notify_traffic_reset(user, subscription, total_expired_gb) + + async def _notify_traffic_reset(self, user: User, subscription: Subscription, reset_gb: int): + """Уведомляет пользователя о сбросе докупленного трафика.""" + if not self._bot: + return + + try: + message = ( + f"ℹ️ Сброс докупленного трафика\n\n" + f"Ваш докупленный трафик ({reset_gb} ГБ) был сброшен, " + f"так как прошло 30 дней с момента первой докупки.\n\n" + f"Текущий лимит трафика: {subscription.traffic_limit_gb} ГБ\n\n" + f"Вы можете докупить трафик снова в любое время." + ) + + await self._bot.send_message( + chat_id=user.telegram_id, + text=message, + parse_mode="HTML", + ) + except Exception as e: + logger.warning(f"Не удалось отправить уведомление о сбросе трафика: {e}") + + async def start_monitoring(self): + """Запускает периодическую проверку суточных подписок и сброса трафика.""" + self._running = True + interval_minutes = self.get_check_interval_minutes() + + logger.info( + f"🔄 Запуск сервиса суточных подписок (интервал: {interval_minutes} мин)" + ) + + while self._running: + try: + # Обработка суточных списаний + stats = await self.process_daily_charges() + + if stats["charged"] > 0 or stats["suspended"] > 0: + logger.info( + f"📊 Суточные списания: проверено={stats['checked']}, " + f"списано={stats['charged']}, приостановлено={stats['suspended']}, " + f"ошибок={stats['errors']}" + ) + + # Обработка сброса докупленного трафика + traffic_stats = await self.process_traffic_resets() + if traffic_stats["reset"] > 0: + logger.info( + f"📊 Сброс трафика: проверено={traffic_stats['checked']}, " + f"сброшено={traffic_stats['reset']}, ошибок={traffic_stats['errors']}" + ) + except Exception as e: + logger.error(f"Ошибка в цикле проверки суточных подписок: {e}", exc_info=True) + + await asyncio.sleep(interval_minutes * 60) + + def stop_monitoring(self): + """Останавливает периодическую проверку.""" + self._running = False + logger.info("⏹️ Сервис суточных подписок остановлен") + + +# Глобальный экземпляр сервиса +daily_subscription_service = DailySubscriptionService() + + +__all__ = ["DailySubscriptionService", "daily_subscription_service"] diff --git a/app/services/freekassa_service.py b/app/services/freekassa_service.py index 6a94d91b..c609132c 100644 --- a/app/services/freekassa_service.py +++ b/app/services/freekassa_service.py @@ -4,6 +4,9 @@ import hashlib import hmac import time import logging +import asyncio +import json +import urllib.request from typing import Optional, Dict, Any, Set import aiohttp @@ -12,6 +15,10 @@ from app.config import settings logger = logging.getLogger(__name__) +# Кэш для публичного IP +_cached_public_ip: Optional[str] = None +_ip_fetch_lock = asyncio.Lock() + # IP-адреса Freekassa для проверки webhook FREEKASSA_IPS: Set[str] = { "168.119.157.136", @@ -22,6 +29,62 @@ FREEKASSA_IPS: Set[str] = { API_BASE_URL = "https://api.fk.life/v1" +# Сервисы для определения публичного IP (в порядке приоритета) +IP_SERVICES = [ + "https://api.ipify.org", + "https://ifconfig.me/ip", + "https://icanhazip.com", + "https://ipinfo.io/ip", +] + + +async def get_public_ip() -> str: + """ + Получает публичный IP сервера. + 1. Сначала проверяет переменную окружения SERVER_PUBLIC_IP + 2. Если нет - запрашивает через внешние сервисы и кэширует + """ + global _cached_public_ip + + # Проверяем переменную окружения + env_ip = getattr(settings, 'SERVER_PUBLIC_IP', None) + if env_ip: + return env_ip + + # Возвращаем кэшированный IP если есть + if _cached_public_ip: + return _cached_public_ip + + async with _ip_fetch_lock: + # Повторная проверка после получения блокировки + if _cached_public_ip: + return _cached_public_ip + + # Пробуем получить IP от внешних сервисов + async with aiohttp.ClientSession() as session: + for service_url in IP_SERVICES: + try: + async with session.get( + service_url, + timeout=aiohttp.ClientTimeout(total=5) + ) as response: + if response.status == 200: + ip = (await response.text()).strip() + # Простая валидация IPv4 + if ip and len(ip.split('.')) == 4: + _cached_public_ip = ip + logger.info(f"Определён публичный IP сервера: {ip}") + return ip + except Exception as e: + logger.debug(f"Не удалось получить IP от {service_url}: {e}") + continue + + # Fallback на известный рабочий IP если ничего не получилось + fallback_ip = "185.92.183.173" + logger.warning(f"Не удалось определить публичный IP, используем fallback: {fallback_ip}") + _cached_public_ip = fallback_ip + return fallback_ip + class FreekassaService: """Сервис для работы с API Freekassa.""" @@ -121,6 +184,7 @@ class FreekassaService: phone: Optional[str] = None, payment_system_id: Optional[int] = None, lang: str = "ru", + ip: Optional[str] = None, ) -> str: """ Формирует URL для перенаправления на оплату (форма выбора). @@ -128,6 +192,60 @@ class FreekassaService: """ # Приводим amount к int, если это целое число final_amount = int(amount) if float(amount).is_integer() else amount + + # Используем payment_system_id из настроек, если не передан явно + ps_id = payment_system_id or settings.FREEKASSA_PAYMENT_SYSTEM_ID + + # Специальная обработка для метода оплаты 44 (NSPK), чтобы работало как в старой версии + if ps_id == 44: + try: + # Определяем IP (важно для API запроса) - здесь синхронно, поэтому лучше иметь передачу IP + # Если IP не передан, используем fallback + target_ip = ip or "185.92.183.173" + target_email = email or "test@example.com" + + params = { + "shopId": self.shop_id, + "nonce": int(time.time_ns()), + "paymentId": str(order_id), + "i": 44, + "email": target_email, + "ip": target_ip, + "amount": final_amount, + "currency": "RUB" + } + + # Генерация подписи + params["signature"] = self._generate_api_signature(params) + + logger.info(f"Freekassa synchronous build_payment_url for 44: {params}") + + data_json = json.dumps(params).encode('utf-8') + req = urllib.request.Request( + f"{API_BASE_URL}/orders/create", + data=data_json, + headers={"Content-Type": "application/json"} + ) + + with urllib.request.urlopen(req, timeout=30) as response: + resp_body = response.read().decode('utf-8') + data = json.loads(resp_body) + + if data.get("type") == "error": + logger.error(f"Freekassa build_payment_url error: {data}") + # Fallback to standard flow if error? Or raise? + # User wants it to work. Raise to see error is safer. + # raise Exception(f"Freekassa API Error: {data.get('message')}") + # Но чтобы не ломать полностью, можно попробовать вернуть обычную ссылку, + # если API не сработал? Нет, вернем ошибку или ссылку из data. + + if data.get("location"): + return data.get("location") + except Exception as e: + logger.error(f"Failed to create order 44 via sync API: {e}") + # Если не получилось, попробуем сгенерировать обычную ссылку как fallback + pass + signature = self.generate_form_signature(final_amount, currency, order_id) params = { @@ -144,8 +262,6 @@ class FreekassaService: if phone: params["phone"] = phone - # Используем payment_system_id из настроек, если не передан явно - ps_id = payment_system_id or settings.FREEKASSA_PAYMENT_SYSTEM_ID if ps_id: params["i"] = ps_id @@ -177,13 +293,18 @@ class FreekassaService: # Используем payment_system_id из настроек, если не передан явно ps_id = payment_system_id or settings.FREEKASSA_PAYMENT_SYSTEM_ID or 1 + target_email = email or "test@example.com" + + # Определяем публичный IP сервера + server_ip = ip or await get_public_ip() + params = { "shopId": self.shop_id, "nonce": int(time.time_ns()), # Наносекунды для уникальности "paymentId": str(order_id), "i": ps_id, - "email": email or "user@example.com", - "ip": ip or "127.0.0.1", + "email": target_email, + "ip": server_ip, "amount": final_amount, "currency": currency, } @@ -206,10 +327,12 @@ class FreekassaService: data = await response.json() - if response.status != 200 or data.get("type") == "error": + # Проверяем на ошибку - API может вернуть error или type=error + error_msg = data.get("error") or data.get("message") + if response.status != 200 or data.get("type") == "error" or error_msg: logger.error(f"Freekassa create_order error: {data}") raise Exception( - f"Freekassa API error: {data.get('message', 'Unknown error')}" + f"Freekassa API error: {error_msg or 'Unknown error'}" ) return data @@ -255,7 +378,7 @@ class FreekassaService: } params["signature"] = self._generate_api_signature(params) - logger.info(f"Freekassa get_order_status params: {params}") + logger.debug(f"Freekassa get_order_status params: {params}") try: async with aiohttp.ClientSession() as session: @@ -266,7 +389,7 @@ class FreekassaService: timeout=aiohttp.ClientTimeout(total=30), ) as response: text = await response.text() - logger.info(f"Freekassa get_order_status response: {text}") + logger.debug(f"Freekassa get_order_status response: {text}") return await response.json() except aiohttp.ClientError as e: logger.exception(f"Freekassa API connection error: {e}") diff --git a/app/services/menu_layout/stats_service.py b/app/services/menu_layout/stats_service.py index f85a5006..7ca23b79 100644 --- a/app/services/menu_layout/stats_service.py +++ b/app/services/menu_layout/stats_service.py @@ -66,18 +66,36 @@ class MenuLayoutStatsService: callback_data: Optional[str] = None, button_type: Optional[str] = None, button_text: Optional[str] = None, - ) -> ButtonClickLog: + ) -> Optional[ButtonClickLog]: """Записать клик по кнопке.""" - click_log = ButtonClickLog( - button_id=button_id, - user_id=user_id, - callback_data=callback_data, - button_type=button_type, - button_text=button_text, - ) - db.add(click_log) - await db.commit() - return click_log + try: + click_log = ButtonClickLog( + button_id=button_id, + user_id=user_id, + callback_data=callback_data, + button_type=button_type, + button_text=button_text, + ) + db.add(click_log) + await db.commit() + return click_log + except Exception: + # If user doesn't exist (foreign key violation), try without user_id + await db.rollback() + try: + click_log = ButtonClickLog( + button_id=button_id, + user_id=None, # Log without user reference + callback_data=callback_data, + button_type=button_type, + button_text=button_text, + ) + db.add(click_log) + await db.commit() + return click_log + except Exception: + await db.rollback() + return None @classmethod async def get_button_stats( diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py index 44b6b5b9..fffcfc9a 100644 --- a/app/services/monitoring_service.py +++ b/app/services/monitoring_service.py @@ -46,6 +46,7 @@ from app.database.models import ( MonitoringLog, SubscriptionStatus, Subscription, + Tariff, User, Ticket, TicketStatus, @@ -530,7 +531,10 @@ class MonitoringService: ) result = await db.execute( select(Subscription) - .options(selectinload(Subscription.user)) + .options( + selectinload(Subscription.user), + selectinload(Subscription.tariff), + ) .where( and_( Subscription.is_trial.is_(True), @@ -694,7 +698,10 @@ class MonitoringService: result = await db.execute( select(Subscription) - .options(selectinload(Subscription.user)) + .options( + selectinload(Subscription.user), + selectinload(Subscription.tariff), + ) .where( and_( Subscription.is_trial == False, @@ -703,7 +710,14 @@ class MonitoringService: ) ) - subscriptions = result.scalars().all() + all_subscriptions = result.scalars().all() + + # Исключаем суточные тарифы - для них отдельная логика + subscriptions = [ + sub for sub in all_subscriptions + if not (sub.tariff and getattr(sub.tariff, 'is_daily', False)) + ] + sent_day1 = 0 sent_wave2 = 0 sent_wave3 = 0 @@ -811,27 +825,41 @@ class MonitoringService: async def _get_expiring_paid_subscriptions(self, db: AsyncSession, days_before: int) -> List[Subscription]: current_time = datetime.utcnow() threshold_date = current_time + timedelta(days=days_before) - + result = await db.execute( select(Subscription) - .options(selectinload(Subscription.user)) + .options( + selectinload(Subscription.user), + selectinload(Subscription.tariff), + ) .where( and_( Subscription.status == SubscriptionStatus.ACTIVE.value, - Subscription.is_trial == False, + Subscription.is_trial == False, Subscription.end_date > current_time, Subscription.end_date <= threshold_date ) ) ) - + logger.debug(f"🔍 Поиск платных подписок, истекающих в ближайшие {days_before} дней") logger.debug(f"📅 Текущее время: {current_time}") logger.debug(f"📅 Пороговая дата: {threshold_date}") - - subscriptions = result.scalars().all() + + all_subscriptions = result.scalars().all() + + # Исключаем суточные тарифы - для них отдельная логика списания + subscriptions = [ + sub for sub in all_subscriptions + if not (sub.tariff and getattr(sub.tariff, 'is_daily', False)) + ] + + excluded_count = len(all_subscriptions) - len(subscriptions) + if excluded_count > 0: + logger.debug(f"🔄 Исключено {excluded_count} суточных подписок из уведомлений") + logger.info(f"📊 Найдено {len(subscriptions)} платных подписок для уведомлений") - + return subscriptions @staticmethod diff --git a/app/services/payment/freekassa.py b/app/services/payment/freekassa.py index 95ad19d0..1dafabbf 100644 --- a/app/services/payment/freekassa.py +++ b/app/services/payment/freekassa.py @@ -509,17 +509,7 @@ class FreekassaPaymentMixin: local_payment_id: int, ) -> Optional[Dict[str, Any]]: """ - Проверяет статус платежа Freekassa по локальному ID. - - Freekassa не предоставляет API для проверки статуса платежа, - поэтому возвращаем текущее состояние из БД. - - Args: - db: Сессия БД - local_payment_id: Внутренний ID платежа - - Returns: - Dict с информацией о платеже или None если не найден + Проверяет статус платежа Freekassa по локальному ID через API. """ freekassa_crud = import_module("app.database.crud.freekassa") @@ -528,8 +518,73 @@ class FreekassaPaymentMixin: logger.warning("Freekassa payment not found: id=%s", local_payment_id) return None - # Freekassa не имеет API для проверки статуса, - # информация приходит только через webhook + if payment.is_paid: + return { + "payment": payment, + "status": "success", + "is_paid": True, + } + + if not settings.FREEKASSA_API_KEY: + return { + "payment": payment, + "status": payment.status or "pending", + "is_paid": payment.is_paid, + } + + try: + # Запрашиваем статус заказа в Freekassa + response = await freekassa_service.get_order_status(payment.order_id) + + # Freekassa возвращает список заказов + orders = response.get("orders", []) + target_order = None + + # Ищем наш заказ в списке + for order in orders: + # В ответе API поле называется merchant_order_id, а не paymentId + # Поддерживаем оба варианта на всякий случай + order_key = str(order.get("merchant_order_id") or order.get("paymentId")) + if order_key == str(payment.order_id): + target_order = order + break + + if target_order: + # Статус 1 = Оплачен + fk_status = int(target_order.get("status", 0)) + + if fk_status == 1: + logger.info("Freekassa payment %s confirmed via API", payment.order_id) + + callback_payload = { + "check_source": "api", + "fk_order_data": target_order, + } + + # ID заказа на стороне FK (fk_order_id или id) + fk_intid = str(target_order.get("fk_order_id") or target_order.get("id")) + + # Обновляем статус + payment = await freekassa_crud.update_freekassa_payment_status( + db=db, + payment=payment, + status="success", + is_paid=True, + freekassa_order_id=fk_intid, + payment_system_id=int(target_order.get("curID")) if target_order.get("curID") else None, + callback_payload=callback_payload, + ) + + # Финализируем + await self._finalize_freekassa_payment( + db, + payment, + intid=fk_intid, + trigger="api_check", + ) + except Exception as e: + logger.error("Error checking Freekassa payment status: %s", e) + return { "payment": payment, "status": payment.status or "pending", diff --git a/app/services/payment/yookassa.py b/app/services/payment/yookassa.py index 6f25c28c..4f2efeb4 100644 --- a/app/services/payment/yookassa.py +++ b/app/services/payment/yookassa.py @@ -110,6 +110,18 @@ class YooKassaPaymentMixin: amount_rubles = amount_kopeks / 100 payment_metadata = metadata.copy() if metadata else {} + + # Всегда добавляем telegram_id в метаданные для возможности возврата платежа + if "user_telegram_id" not in payment_metadata: + try: + from app.database.crud.user import get_user_by_id + user = await get_user_by_id(db, user_id) + if user and user.telegram_id: + payment_metadata["user_telegram_id"] = str(user.telegram_id) + payment_metadata["user_username"] = user.username or "" + except Exception as e: + logger.warning(f"Не удалось получить telegram_id для user_id={user_id}: {e}") + payment_metadata.update( { "user_id": str(user_id), @@ -201,6 +213,18 @@ class YooKassaPaymentMixin: amount_rubles = amount_kopeks / 100 payment_metadata = metadata.copy() if metadata else {} + + # Всегда добавляем telegram_id в метаданные для возможности возврата платежа + if "user_telegram_id" not in payment_metadata: + try: + from app.database.crud.user import get_user_by_id + user = await get_user_by_id(db, user_id) + if user and user.telegram_id: + payment_metadata["user_telegram_id"] = str(user.telegram_id) + payment_metadata["user_username"] = user.username or "" + except Exception as e: + logger.warning(f"Не удалось получить telegram_id для user_id={user_id}: {e}") + payment_metadata.update( { "user_id": str(user_id), @@ -548,16 +572,20 @@ class YooKassaPaymentMixin: payment_description = getattr(payment, "description", "YooKassa платеж") payment_purpose = payment_metadata.get("payment_purpose", "") + payment_type = payment_metadata.get("type", "") is_simple_subscription = payment_purpose == "simple_subscription_purchase" + is_trial_payment = payment_type == "trial" transaction_type = ( TransactionType.SUBSCRIPTION_PAYMENT - if is_simple_subscription + if is_simple_subscription or is_trial_payment else TransactionType.DEPOSIT ) transaction_description = ( f"Оплата подписки через YooKassa: {payment_description}" if is_simple_subscription + else f"Оплата пробной подписки через YooKassa: {payment_description}" + if is_trial_payment else f"Пополнение через YooKassa: {payment_description}" ) @@ -590,7 +618,74 @@ class YooKassaPaymentMixin: user = await payment_module.get_user_by_id(db, payment.user_id) if user: - if is_simple_subscription: + if is_trial_payment: + # Обработка платного триала + logger.info( + "YooKassa платеж %s обработан как оплата триала. Баланс пользователя %s не изменяется.", + payment.yookassa_payment_id, + user.id, + ) + try: + subscription_id = payment_metadata.get("subscription_id") + if subscription_id: + from app.database.crud.subscription import activate_pending_trial_subscription + from app.services.subscription_service import SubscriptionService + from app.services.admin_notification_service import AdminNotificationService + + subscription = await activate_pending_trial_subscription( + db=db, + subscription_id=int(subscription_id), + user_id=user.id, + ) + + if subscription: + logger.info(f"Триальная подписка {subscription_id} активирована для пользователя {user.id}") + + # Создаем пользователя в RemnaWave + subscription_service = SubscriptionService() + try: + await subscription_service.create_remnawave_user(db, subscription) + except Exception as rw_error: + logger.error(f"Ошибка создания RemnaWave для триала: {rw_error}") + + # Уведомление админам + if getattr(self, "bot", None): + try: + admin_notification_service = AdminNotificationService(self.bot) + await admin_notification_service.send_trial_activation_notification( + user=user, + subscription=subscription, + paid_amount=payment.amount_kopeks, + payment_method="YooKassa", + ) + except Exception as admin_error: + logger.warning(f"Ошибка уведомления админов о триале: {admin_error}") + + # Уведомление пользователю + if getattr(self, "bot", None): + try: + from app.config import settings + await self.bot.send_message( + chat_id=user.telegram_id, + text=( + f"🎉 Пробная подписка активирована!\n\n" + f"💳 Оплачено: {settings.format_price(payment.amount_kopeks)}\n" + f"📅 Период: {settings.TRIAL_DURATION_DAYS} дней\n" + f"📱 Устройств: {subscription.device_limit}\n\n" + f"Используйте меню для подключения к VPN." + ), + parse_mode="HTML", + ) + except Exception as notify_error: + logger.warning(f"Ошибка уведомления пользователя о триале: {notify_error}") + else: + logger.error(f"Не удалось активировать триал {subscription_id} для {user.id}") + else: + logger.error(f"Отсутствует subscription_id в metadata триального платежа YooKassa") + except Exception as trial_error: + logger.error(f"Ошибка обработки триального платежа YooKassa: {trial_error}", exc_info=True) + + elif is_simple_subscription: logger.info( "YooKassa платеж %s обработан как покупка подписки. Баланс пользователя %s не изменяется.", payment.yookassa_payment_id, diff --git a/app/services/remnawave_service.py b/app/services/remnawave_service.py index 4231dd45..f5eaef5d 100644 --- a/app/services/remnawave_service.py +++ b/app/services/remnawave_service.py @@ -1893,7 +1893,60 @@ class RemnaWaveService: except Exception as e: logger.error(f"Ошибка получения статистики трафика для пользователя {telegram_id}: {e}") return None - + + async def get_telegram_id_by_email(self, user_identifier: str) -> Optional[int]: + """ + Получить telegram_id пользователя по email или username из панели RemnaWave. + + Args: + user_identifier: Email или username пользователя + + Returns: + telegram_id если найден, иначе None + """ + if not self.is_configured: + logger.warning("RemnaWave API не настроен для поиска пользователя") + return None + + try: + async with self.get_api_client() as api: + # Сначала пробуем найти по username (часто username == email) + try: + user = await api.get_user_by_username(user_identifier) + if user and user.telegram_id: + logger.info( + f"Найден пользователь по username '{user_identifier}': " + f"telegram_id={user.telegram_id}" + ) + return user.telegram_id + except Exception as e: + logger.debug(f"Пользователь не найден по username '{user_identifier}': {e}") + + # Если не нашли по username, ищем по email среди всех пользователей + try: + all_users_response = await api.get_all_users(start=0, size=10000) + users_list = all_users_response.get('users', []) + + for panel_user in users_list: + panel_email = panel_user.email if hasattr(panel_user, 'email') else None + if panel_email and panel_email.lower() == user_identifier.lower(): + panel_telegram_id = panel_user.telegram_id if hasattr(panel_user, 'telegram_id') else None + if panel_telegram_id: + logger.info( + f"Найден пользователь по email '{user_identifier}': " + f"telegram_id={panel_telegram_id}" + ) + return panel_telegram_id + except Exception as e: + logger.warning(f"Ошибка поиска пользователя по email '{user_identifier}': {e}") + + logger.warning(f"Пользователь с идентификатором '{user_identifier}' не найден в панели") + return None + + except Exception as e: + logger.error(f"Ошибка получения telegram_id для '{user_identifier}': {e}") + return None + async def test_api_connection(self) -> Dict[str, Any]: if not self.is_configured: return { @@ -2689,3 +2742,7 @@ class RemnaWaveService: "api_url": settings.REMNAWAVE_API_URL, "attempts_used": attempts, } + + +# Singleton instance for backward compatibility +remnawave_service = RemnaWaveService() diff --git a/app/services/reporting_service.py b/app/services/reporting_service.py index cf6376ae..80afd083 100644 --- a/app/services/reporting_service.py +++ b/app/services/reporting_service.py @@ -558,7 +558,8 @@ class ReportingService: select(func.count(func.distinct(Subscription.user_id))).where( or_( Subscription.connected_squads.is_(None), - func.jsonb_array_length(cast(Subscription.connected_squads, JSONB)) == 0, + cast(Subscription.connected_squads, JSONB) == cast('[]', JSONB), + func.jsonb_typeof(cast(Subscription.connected_squads, JSONB)) != 'array', ) ) ) diff --git a/app/services/subscription_auto_purchase_service.py b/app/services/subscription_auto_purchase_service.py index 10fcec90..e2a22243 100644 --- a/app/services/subscription_auto_purchase_service.py +++ b/app/services/subscription_auto_purchase_service.py @@ -1160,6 +1160,8 @@ async def auto_activate_subscription_after_topup( exc_info=True, ) return (False, False) + await db.rollback() + return False __all__ = ["auto_purchase_saved_cart_after_topup", "auto_activate_subscription_after_topup"] diff --git a/app/services/subscription_purchase_service.py b/app/services/subscription_purchase_service.py index 5a9dcbc3..00987a2c 100644 --- a/app/services/subscription_purchase_service.py +++ b/app/services/subscription_purchase_service.py @@ -336,9 +336,11 @@ class MiniAppSubscriptionPurchaseService: currency = (getattr(user, "balance_currency", None) or "RUB").upper() texts = get_texts(getattr(user, "language", None)) + # Exclude trial-only servers from purchase options available_servers = await get_available_server_squads( db, promo_group_id=getattr(user, "promo_group_id", None), + exclude_trial_only=True, ) server_catalog: Dict[str, ServerSquad] = {server.squad_uuid: server for server in available_servers} diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py index 990e963d..b9938cdd 100644 --- a/app/services/subscription_service.py +++ b/app/services/subscription_service.py @@ -62,17 +62,35 @@ def _resolve_addon_discount_percent( period_days=period_days, ) -def get_traffic_reset_strategy(): +def get_traffic_reset_strategy(tariff=None): + """Получает стратегию сброса трафика. + + Args: + tariff: Объект тарифа. Если у тарифа задан traffic_reset_mode, + используется он, иначе глобальная настройка из конфига. + + Returns: + TrafficLimitStrategy: Стратегия сброса трафика для RemnaWave API. + """ from app.config import settings - strategy = settings.DEFAULT_TRAFFIC_RESET_STRATEGY.upper() - + strategy_mapping = { 'NO_RESET': 'NO_RESET', - 'DAY': 'DAY', + 'DAY': 'DAY', 'WEEK': 'WEEK', 'MONTH': 'MONTH' } - + + # Проверяем настройку тарифа + if tariff is not None: + tariff_mode = getattr(tariff, 'traffic_reset_mode', None) + if tariff_mode is not None: + mapped_strategy = strategy_mapping.get(tariff_mode.upper(), 'NO_RESET') + logger.info(f"🔄 Стратегия сброса трафика из тарифа '{getattr(tariff, 'name', 'N/A')}': {tariff_mode} -> {mapped_strategy}") + return getattr(TrafficLimitStrategy, mapped_strategy) + + # Используем глобальную настройку + strategy = settings.DEFAULT_TRAFFIC_RESET_STRATEGY.upper() mapped_strategy = strategy_mapping.get(strategy, 'NO_RESET') logger.info(f"🔄 Стратегия сброса трафика из конфига: {strategy} -> {mapped_strategy}") return getattr(TrafficLimitStrategy, mapped_strategy) @@ -205,7 +223,7 @@ class SubscriptionService: status=UserStatus.ACTIVE, expire_at=subscription.end_date, traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb), - traffic_limit_strategy=get_traffic_reset_strategy(), + traffic_limit_strategy=get_traffic_reset_strategy(subscription.tariff), description=settings.format_remnawave_user_description( full_name=user.full_name, username=user.username, @@ -242,7 +260,7 @@ class SubscriptionService: expire_at=subscription.end_date, status=UserStatus.ACTIVE, traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb), - traffic_limit_strategy=get_traffic_reset_strategy(), + traffic_limit_strategy=get_traffic_reset_strategy(subscription.tariff), telegram_id=user.telegram_id, description=settings.format_remnawave_user_description( full_name=user.full_name, @@ -326,7 +344,7 @@ class SubscriptionService: status=UserStatus.ACTIVE if is_actually_active else UserStatus.EXPIRED, expire_at=subscription.end_date, traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb), - traffic_limit_strategy=get_traffic_reset_strategy(), + traffic_limit_strategy=get_traffic_reset_strategy(subscription.tariff), description=settings.format_remnawave_user_description( full_name=user.full_name, username=user.username, @@ -413,6 +431,25 @@ class SubscriptionService: logger.error(f"Ошибка включения RemnaWave пользователя: {e}") return False + async def get_remnawave_squads(self) -> Optional[List[dict]]: + """Получить список internal squads из RemnaWave.""" + try: + async with self.get_api_client() as api: + squads = await api.get_internal_squads() + # Преобразуем в формат для sync_with_remnawave + result = [] + for squad in squads: + result.append({ + 'uuid': squad.uuid, + 'name': squad.name, + }) + logger.info(f"✅ Получено {len(result)} серверов из RemnaWave") + return result + + except Exception as e: + logger.error(f"Ошибка получения серверов из RemnaWave: {e}") + return None + async def revoke_subscription( self, db: AsyncSession, diff --git a/app/services/system_settings_service.py b/app/services/system_settings_service.py index d7b8b787..31d39da3 100644 --- a/app/services/system_settings_service.py +++ b/app/services/system_settings_service.py @@ -127,6 +127,7 @@ class BotConfigurationService: "LOG": "📝 Логирование", "DEBUG": "🧪 Режим разработки", "MODERATION": "🛡️ Модерация и фильтры", + "BAN_NOTIFICATIONS": "🚫 Тексты уведомлений о блокировках", } CATEGORY_DESCRIPTIONS: Dict[str, str] = { @@ -184,6 +185,7 @@ class BotConfigurationService: "LOG": "Уровни логирования и ротация.", "DEBUG": "Отладочные функции и безопасный режим.", "MODERATION": "Настройки фильтров отображаемых имен и защиты от фишинга.", + "BAN_NOTIFICATIONS": "Тексты уведомлений о блокировках, которые отправляются пользователям.", } @staticmethod @@ -214,6 +216,7 @@ class BotConfigurationService: "DEVICES_SELECTION_ENABLED": "SUBSCRIPTIONS_CORE", "DEVICES_SELECTION_DISABLED_AMOUNT": "SUBSCRIPTIONS_CORE", "BASE_SUBSCRIPTION_PRICE": "SUBSCRIPTIONS_CORE", + "SALES_MODE": "SUBSCRIPTIONS_CORE", "DEFAULT_TRAFFIC_RESET_STRATEGY": "TRAFFIC", "RESET_TRAFFIC_ON_PAYMENT": "TRAFFIC", "TRAFFIC_SELECTION_MODE": "TRAFFIC", @@ -292,6 +295,7 @@ class BotConfigurationService: "REMNAWAVE_USER_USERNAME_TEMPLATE": "REMNAWAVE", "REMNAWAVE_AUTO_SYNC_ENABLED": "REMNAWAVE", "REMNAWAVE_AUTO_SYNC_TIMES": "REMNAWAVE", + "CABINET_REMNA_SUB_CONFIG": "MINIAPP", } CATEGORY_PREFIX_OVERRIDES: Dict[str, str] = { @@ -339,6 +343,7 @@ class BotConfigurationService: "WEB_API_": "WEB_API", "DEBUG": "DEBUG", "DISPLAY_NAME_": "MODERATION", + "BAN_MSG_": "BAN_NOTIFICATIONS", } CHOICES: Dict[str, List[ChoiceOption]] = { @@ -382,6 +387,10 @@ class BotConfigurationService: ChoiceOption("default", "📋 Полное меню"), ChoiceOption("text", "📝 Текстовое меню"), ], + "SALES_MODE": [ + ChoiceOption("classic", "📋 Классический (периоды из .env)"), + ChoiceOption("tariffs", "📦 Тарифы (из кабинета)"), + ], "SERVER_STATUS_MODE": [ ChoiceOption("disabled", "🚫 Отключено"), ChoiceOption("external_link", "🌐 Внешняя ссылка"), @@ -440,6 +449,19 @@ class BotConfigurationService: } SETTING_HINTS: Dict[str, Dict[str, str]] = { + "SALES_MODE": { + "description": ( + "Режим продажи подписок. " + "«Классический» — выбор периода из .env (PRICE_14_DAYS и т.д.). " + "«Тарифы» — готовые тарифные планы из кабинета с серверами и лимитами." + ), + "format": "Выберите один из доступных режимов.", + "example": "tariffs", + "warning": ( + "При смене режима логика покупки подписки полностью меняется. " + "В режиме «Тарифы» пользователи выбирают готовый тарифный план." + ), + }, "YOOKASSA_ENABLED": { "description": ( "Включает оплату через YooKassa. " @@ -678,6 +700,16 @@ class BotConfigurationService: "warning": "Если тег не задан или невалиден, существующий тег не будет изменён.", "dependencies": "Оплата подписки и интеграция с RemnaWave", }, + "CABINET_REMNA_SUB_CONFIG": { + "description": ( + "UUID конфигурации страницы подписки из RemnaWave. " + "Позволяет синхронизировать список приложений напрямую из панели." + ), + "format": "UUID конфигурации из раздела Subscription Page Configs в RemnaWave.", + "example": "d4aa2b8c-9a36-4f31-93a2-6f07dad05fba", + "warning": "Убедитесь, что конфигурация существует в панели и содержит нужные приложения.", + "dependencies": "Настроенное подключение к RemnaWave API", + }, } @classmethod diff --git a/app/services/user_service.py b/app/services/user_service.py index 404c650d..52474dc3 100644 --- a/app/services/user_service.py +++ b/app/services/user_service.py @@ -23,7 +23,7 @@ from app.database.models import ( ReferralEarning, SubscriptionServer, YooKassaPayment, BroadcastHistory, CryptoBotPayment, PlategaPayment, SubscriptionConversion, UserMessage, WelcomeText, SentNotification, PromoGroup, MulenPayPayment, Pal24Payment, HeleketPayment, - AdvertisingCampaign, AdvertisingCampaignRegistration, PaymentMethod, + FreekassaPayment, AdvertisingCampaign, AdvertisingCampaignRegistration, PaymentMethod, TransactionType ) from app.config import settings @@ -960,6 +960,28 @@ class UserService: except Exception as e: logger.error(f"❌ Ошибка удаления Heleket платежей: {e}") + # Удаляем Freekassa платежи + try: + freekassa_payments_result = await db.execute( + select(FreekassaPayment).where(FreekassaPayment.user_id == user_id) + ) + freekassa_payments = freekassa_payments_result.scalars().all() + + if freekassa_payments: + logger.info(f"🔄 Удаляем {len(freekassa_payments)} Freekassa платежей") + await db.execute( + update(FreekassaPayment) + .where(FreekassaPayment.user_id == user_id) + .values(transaction_id=None) + ) + await db.flush() + await db.execute( + delete(FreekassaPayment).where(FreekassaPayment.user_id == user_id) + ) + await db.flush() + except Exception as e: + logger.error(f"❌ Ошибка удаления Freekassa платежей: {e}") + try: transactions_result = await db.execute( select(Transaction).where(Transaction.user_id == user_id) diff --git a/app/services/wheel_service.py b/app/services/wheel_service.py new file mode 100644 index 00000000..2c3d3294 --- /dev/null +++ b/app/services/wheel_service.py @@ -0,0 +1,632 @@ +""" +Сервис колеса удачи (Fortune Wheel) с RTP алгоритмом. +""" +import logging +import random +import secrets +from dataclasses import dataclass +from datetime import datetime, timedelta +from decimal import Decimal, ROUND_HALF_UP +from typing import Optional, List, Tuple, Dict, Any + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.models import ( + User, + Subscription, + WheelConfig, + WheelPrize, + WheelSpin, + WheelPrizeType, + WheelSpinPaymentType, + PromoCode, + PromoCodeType, +) +from app.database.crud.wheel import ( + get_or_create_wheel_config, + get_wheel_prizes, + get_user_spins_today, + create_wheel_spin, + mark_spin_applied, + get_wheel_statistics, +) +from app.database.crud.user import add_user_balance +from app.database.crud.subscription import get_subscription_by_user_id + +logger = logging.getLogger(__name__) + + +@dataclass +class SpinResult: + """Результат спина колеса.""" + success: bool + prize_id: Optional[int] = None + prize_type: Optional[str] = None + prize_value: int = 0 + prize_display_name: str = "" + emoji: str = "🎁" + color: str = "#3B82F6" + rotation_degrees: float = 0.0 + message: str = "" + promocode: Optional[str] = None + error: Optional[str] = None + + +@dataclass +class SpinAvailability: + """Доступность спина для пользователя.""" + can_spin: bool + reason: Optional[str] = None + spins_remaining_today: int = 0 + can_pay_stars: bool = False + can_pay_days: bool = False + min_subscription_days: int = 0 + user_subscription_days: int = 0 + user_balance_kopeks: int = 0 + required_balance_kopeks: int = 0 + + +class FortuneWheelService: + """Сервис колеса удачи с RTP механикой.""" + + def __init__(self): + pass + + async def check_availability( + self, + db: AsyncSession, + user: User + ) -> SpinAvailability: + """Проверить доступность спина для пользователя.""" + config = await get_or_create_wheel_config(db) + + # Колесо выключено + if not config.is_enabled: + return SpinAvailability( + can_spin=False, + reason="wheel_disabled", + ) + + # Проверяем лимит спинов + spins_today = await get_user_spins_today(db, user.id) + spins_remaining = config.daily_spin_limit - spins_today if config.daily_spin_limit > 0 else 999 + + if config.daily_spin_limit > 0 and spins_today >= config.daily_spin_limit: + return SpinAvailability( + can_spin=False, + reason="daily_limit_reached", + spins_remaining_today=0, + ) + + # Проверяем доступные способы оплаты + can_pay_stars = False + can_pay_days = False + user_subscription_days = 0 + required_balance_kopeks = 0 + + # Проверяем оплату Stars (конвертируется в рубли из баланса) + if config.spin_cost_stars_enabled and config.spin_cost_stars > 0: + stars_rate = Decimal(str(settings.get_stars_rate())) + rubles = Decimal(config.spin_cost_stars) * stars_rate + required_balance_kopeks = int(rubles * 100) + # Проверяем достаточно ли средств на балансе + if user.balance_kopeks >= required_balance_kopeks: + can_pay_stars = True + + if config.spin_cost_days_enabled: + subscription = await get_subscription_by_user_id(db, user.id) + if subscription and subscription.is_active: + user_subscription_days = subscription.days_left + # Нужно оставить минимум min_subscription_days_for_day_payment дней после оплаты + if user_subscription_days >= config.min_subscription_days_for_day_payment + config.spin_cost_days: + can_pay_days = True + + if not can_pay_stars and not can_pay_days: + # Определяем причину + reason = "no_payment_method_available" + if config.spin_cost_stars_enabled and user.balance_kopeks < required_balance_kopeks: + reason = "insufficient_balance" + + return SpinAvailability( + can_spin=False, + reason=reason, + spins_remaining_today=spins_remaining, + can_pay_stars=can_pay_stars, + can_pay_days=can_pay_days, + min_subscription_days=config.min_subscription_days_for_day_payment, + user_subscription_days=user_subscription_days, + user_balance_kopeks=user.balance_kopeks, + required_balance_kopeks=required_balance_kopeks, + ) + + # Проверяем наличие призов + prizes = await get_wheel_prizes(db, config.id, active_only=True) + if not prizes: + return SpinAvailability( + can_spin=False, + reason="no_prizes_configured", + ) + + return SpinAvailability( + can_spin=True, + spins_remaining_today=spins_remaining, + can_pay_stars=can_pay_stars, + can_pay_days=can_pay_days, + min_subscription_days=config.min_subscription_days_for_day_payment, + user_subscription_days=user_subscription_days, + user_balance_kopeks=user.balance_kopeks, + required_balance_kopeks=required_balance_kopeks, + ) + + def calculate_prize_probabilities( + self, + config: WheelConfig, + prizes: List[WheelPrize], + spin_cost_kopeks: int + ) -> List[Tuple[WheelPrize, float]]: + """ + Рассчитать вероятности выпадения призов на основе RTP. + + Алгоритм: + 1. Целевая средняя выплата = spin_cost * (RTP / 100) + 2. Для призов с manual_probability - используем его напрямую + 3. Для остальных - рассчитываем веса обратно пропорционально стоимости приза + 4. "Nothing" сектор балансирует систему + """ + if not prizes: + return [] + + target_payout = spin_cost_kopeks * (config.rtp_percent / 100) + + # Разделяем призы с ручной вероятностью и автоматической + manual_prizes = [] + auto_prizes = [] + manual_prob_sum = 0.0 + + for prize in prizes: + if prize.manual_probability is not None and prize.manual_probability > 0: + manual_prizes.append((prize, prize.manual_probability)) + manual_prob_sum += prize.manual_probability + else: + auto_prizes.append(prize) + + # Оставшаяся вероятность для авто-призов + remaining_prob = max(0, 1.0 - manual_prob_sum) + + if not auto_prizes or remaining_prob <= 0: + # Только ручные призы, нормализуем их + if manual_prizes: + total = sum(p[1] for p in manual_prizes) + return [(p[0], p[1] / total) for p in manual_prizes] + return [] + + # Рассчитываем веса для авто-призов + # Вес обратно пропорционален стоимости приза (более дорогие выпадают реже) + weights = [] + for prize in auto_prizes: + if prize.prize_value_kopeks > 0: + # Чем дороже приз, тем меньше вес + weight = target_payout / prize.prize_value_kopeks + else: + # "Nothing" или нулевой приз - даем базовый вес + weight = 1.0 + weights.append((prize, max(weight, 0.01))) # Минимальный вес 1% + + # Нормализуем веса авто-призов до remaining_prob + total_weight = sum(w[1] for w in weights) + auto_probabilities = [ + (prize, (weight / total_weight) * remaining_prob) + for prize, weight in weights + ] + + # Объединяем + result = manual_prizes + auto_probabilities + + # Финальная нормализация (на случай погрешностей) + total = sum(p[1] for p in result) + if total > 0: + result = [(p[0], p[1] / total) for p in result] + + return result + + def _select_prize( + self, + prizes_with_probabilities: List[Tuple[WheelPrize, float]] + ) -> WheelPrize: + """Выбрать приз на основе вероятностей.""" + if not prizes_with_probabilities: + raise ValueError("No prizes to select from") + + rand = random.random() + cumulative = 0.0 + + for prize, probability in prizes_with_probabilities: + cumulative += probability + if rand <= cumulative: + return prize + + # Fallback на последний приз + return prizes_with_probabilities[-1][0] + + def _calculate_rotation( + self, + prizes: List[WheelPrize], + selected_prize: WheelPrize + ) -> float: + """ + Рассчитать угол поворота колеса для анимации. + Возвращает градусы для CSS transform. + """ + if not prizes: + return 0.0 + + # Находим индекс выбранного приза + prize_index = next( + (i for i, p in enumerate(prizes) if p.id == selected_prize.id), + 0 + ) + + # Угол одного сектора + sector_angle = 360 / len(prizes) + + # Базовый угол до центра сектора (от 12 часов по часовой) + base_angle = prize_index * sector_angle + sector_angle / 2 + + # Добавляем случайное смещение внутри сектора (не по краям) + offset = random.uniform(-sector_angle * 0.3, sector_angle * 0.3) + + # Угол остановки (стрелка сверху, поэтому инвертируем) + stop_angle = 360 - base_angle + offset + + # Добавляем несколько полных оборотов для эффекта + full_rotations = random.randint(5, 8) * 360 + + return full_rotations + stop_angle + + async def _process_stars_payment( + self, + db: AsyncSession, + user: User, + config: WheelConfig + ) -> int: + """ + Обработать оплату Stars (списание эквивалента с баланса). + Возвращает стоимость в копейках. + """ + # Конвертируем Stars в рубли + stars_rate = Decimal(str(settings.get_stars_rate())) + rubles = Decimal(config.spin_cost_stars) * stars_rate + kopeks = int(rubles * 100) + + if user.balance_kopeks < kopeks: + raise ValueError("Недостаточно средств на балансе") + + # Списываем с баланса + user.balance_kopeks -= kopeks + logger.info(f"💫 Списано {kopeks/100:.2f}₽ ({config.spin_cost_stars}⭐) с баланса user_id={user.id}") + + return kopeks + + async def _process_days_payment( + self, + db: AsyncSession, + user: User, + config: WheelConfig + ) -> int: + """ + Обработать оплату днями подписки. + Возвращает эквивалент в копейках. + """ + subscription = await get_subscription_by_user_id(db, user.id) + + if not subscription or not subscription.is_active: + raise ValueError("Нет активной подписки") + + if subscription.days_left < config.min_subscription_days_for_day_payment + config.spin_cost_days: + raise ValueError("Недостаточно дней подписки") + + # Уменьшаем end_date + subscription.end_date -= timedelta(days=config.spin_cost_days) + subscription.updated_at = datetime.utcnow() + + # Оцениваем стоимость в копейках (для статистики) + # Берем цену 30-дневного периода и делим на 30 + from app.config import PERIOD_PRICES + price_30_days = PERIOD_PRICES.get(30, settings.PRICE_30_DAYS) or 19900 + daily_price = price_30_days / 30 + kopeks = int(daily_price * config.spin_cost_days) + + logger.info(f"📅 Списано {config.spin_cost_days} дней подписки у user_id={user.id}") + + return kopeks + + async def _apply_prize( + self, + db: AsyncSession, + user: User, + prize: WheelPrize, + config: WheelConfig + ) -> Optional[str]: + """ + Применить приз к пользователю. + Возвращает промокод (если приз - промокод), иначе None. + """ + prize_type = prize.prize_type + + if prize_type == WheelPrizeType.NOTHING.value: + logger.info(f"🎰 Пустой приз для user_id={user.id}") + return None + + if prize_type == WheelPrizeType.BALANCE_BONUS.value: + # Пополнение баланса + await add_user_balance( + db, user, prize.prize_value, + description=f"Выигрыш в колесе удачи: {prize.prize_value/100:.2f}₽", + create_transaction=True, + ) + logger.info(f"💰 Начислено {prize.prize_value/100:.2f}₽ на баланс user_id={user.id}") + return None + + if prize_type == WheelPrizeType.SUBSCRIPTION_DAYS.value: + # Дни подписки + subscription = await get_subscription_by_user_id(db, user.id) + if subscription: + subscription.end_date += timedelta(days=prize.prize_value) + subscription.updated_at = datetime.utcnow() + logger.info(f"📅 Начислено {prize.prize_value} дней подписки user_id={user.id}") + else: + # Если нет подписки - начисляем на баланс эквивалент + await add_user_balance( + db, user, prize.prize_value_kopeks, + description=f"Выигрыш в колесе удачи: {prize.prize_value} дней (на баланс)", + create_transaction=True, + ) + logger.info(f"💰 Дни конвертированы в баланс для user_id={user.id}") + return None + + if prize_type == WheelPrizeType.TRAFFIC_GB.value: + # Бонусный трафик + subscription = await get_subscription_by_user_id(db, user.id) + if subscription and subscription.traffic_limit_gb > 0: + subscription.traffic_limit_gb += prize.prize_value + subscription.updated_at = datetime.utcnow() + logger.info(f"📊 Начислено {prize.prize_value}GB трафика user_id={user.id}") + else: + # Если безлимит или нет подписки - на баланс + await add_user_balance( + db, user, prize.prize_value_kopeks, + description=f"Выигрыш в колесе удачи: {prize.prize_value}GB (на баланс)", + create_transaction=True, + ) + return None + + if prize_type == WheelPrizeType.PROMOCODE.value: + # Генерация промокода + promocode = await self._generate_prize_promocode(db, user, prize, config) + logger.info(f"🎟️ Сгенерирован промокод {promocode.code} для user_id={user.id}") + return promocode.code + + return None + + async def _generate_prize_promocode( + self, + db: AsyncSession, + user: User, + prize: WheelPrize, + config: WheelConfig + ) -> PromoCode: + """Сгенерировать уникальный промокод для приза.""" + # Генерируем уникальный код + code = f"{config.promo_prefix}{secrets.token_hex(4).upper()}" + + # Определяем тип промокода + if prize.promo_subscription_days > 0: + promo_type = PromoCodeType.SUBSCRIPTION_DAYS.value + else: + promo_type = PromoCodeType.BALANCE.value + + promocode = PromoCode( + code=code, + type=promo_type, + balance_bonus_kopeks=prize.promo_balance_bonus_kopeks, + subscription_days=prize.promo_subscription_days, + max_uses=1, + valid_until=datetime.utcnow() + timedelta(days=config.promo_validity_days), + is_active=True, + created_by=user.id, + ) + + db.add(promocode) + await db.flush() + + return promocode + + async def spin( + self, + db: AsyncSession, + user: User, + payment_type: str + ) -> SpinResult: + """ + Выполнить спин колеса. + + Шаги: + 1. Проверить доступность + 2. Обработать оплату + 3. Рассчитать вероятности и выбрать приз + 4. Применить приз + 5. Создать запись WheelSpin + 6. Вернуть результат + """ + try: + # 1. Проверяем доступность + availability = await self.check_availability(db, user) + if not availability.can_spin: + return SpinResult( + success=False, + error=availability.reason, + message=self._get_error_message(availability.reason), + ) + + config = await get_or_create_wheel_config(db) + prizes = await get_wheel_prizes(db, config.id, active_only=True) + + if not prizes: + return SpinResult( + success=False, + error="no_prizes", + message="Призы не настроены", + ) + + # 2. Обрабатываем оплату + if payment_type == WheelSpinPaymentType.TELEGRAM_STARS.value: + if not availability.can_pay_stars: + return SpinResult( + success=False, + error="cannot_pay_stars", + message="Оплата Stars недоступна", + ) + payment_amount = config.spin_cost_stars + payment_value_kopeks = await self._process_stars_payment(db, user, config) + elif payment_type == WheelSpinPaymentType.SUBSCRIPTION_DAYS.value: + if not availability.can_pay_days: + return SpinResult( + success=False, + error="cannot_pay_days", + message="Оплата днями подписки недоступна", + ) + payment_amount = config.spin_cost_days + payment_value_kopeks = await self._process_days_payment(db, user, config) + else: + return SpinResult( + success=False, + error="invalid_payment_type", + message="Неверный способ оплаты", + ) + + # 3. Рассчитываем вероятности и выбираем приз + prizes_with_probs = self.calculate_prize_probabilities(config, prizes, payment_value_kopeks) + selected_prize = self._select_prize(prizes_with_probs) + + # 4. Рассчитываем угол для анимации + rotation = self._calculate_rotation(prizes, selected_prize) + + # 5. Применяем приз + generated_promocode = await self._apply_prize(db, user, selected_prize, config) + promocode_id = None + if generated_promocode: + # Получаем ID промокода + result = await db.execute( + f"SELECT id FROM promocodes WHERE code = '{generated_promocode}'" + ) + row = result.fetchone() + if row: + promocode_id = row[0] + + # 6. Создаем запись спина + spin = await create_wheel_spin( + db=db, + user_id=user.id, + prize_id=selected_prize.id, + payment_type=payment_type, + payment_amount=payment_amount, + payment_value_kopeks=payment_value_kopeks, + prize_type=selected_prize.prize_type, + prize_value=selected_prize.prize_value, + prize_display_name=selected_prize.display_name, + prize_value_kopeks=selected_prize.prize_value_kopeks, + generated_promocode_id=promocode_id, + is_applied=True, + ) + + await db.commit() + + # 7. Формируем результат + message = self._get_prize_message(selected_prize, generated_promocode) + + return SpinResult( + success=True, + prize_id=selected_prize.id, + prize_type=selected_prize.prize_type, + prize_value=selected_prize.prize_value, + prize_display_name=selected_prize.display_name, + emoji=selected_prize.emoji, + color=selected_prize.color, + rotation_degrees=rotation, + message=message, + promocode=generated_promocode, + ) + + except ValueError as e: + await db.rollback() + return SpinResult( + success=False, + error="payment_error", + message=str(e), + ) + except Exception as e: + await db.rollback() + logger.exception(f"Ошибка спина колеса для user_id={user.id}: {e}") + return SpinResult( + success=False, + error="internal_error", + message="Произошла ошибка, попробуйте позже", + ) + + def _get_error_message(self, reason: Optional[str]) -> str: + """Получить человекочитаемое сообщение об ошибке.""" + messages = { + "wheel_disabled": "Колесо удачи временно недоступно", + "daily_limit_reached": "Вы достигли лимита спинов на сегодня", + "no_payment_method_available": "Нет доступных способов оплаты", + "no_prizes_configured": "Призы еще не настроены", + "insufficient_balance": "Недостаточно средств на балансе. Пополните баланс для оплаты спина.", + } + return messages.get(reason, "Произошла ошибка") + + def _get_prize_message(self, prize: WheelPrize, promocode: Optional[str]) -> str: + """Сформировать сообщение о выигрыше.""" + prize_type = prize.prize_type + + if prize_type == WheelPrizeType.NOTHING.value: + return "К сожалению, в этот раз не повезло. Попробуйте еще!" + + if prize_type == WheelPrizeType.BALANCE_BONUS.value: + return f"Поздравляем! Вы выиграли {prize.prize_value/100:.0f}₽ на баланс!" + + if prize_type == WheelPrizeType.SUBSCRIPTION_DAYS.value: + days_word = self._pluralize_days(prize.prize_value) + return f"Поздравляем! Вы выиграли {prize.prize_value} {days_word} подписки!" + + if prize_type == WheelPrizeType.TRAFFIC_GB.value: + return f"Поздравляем! Вы выиграли {prize.prize_value}GB трафика!" + + if prize_type == WheelPrizeType.PROMOCODE.value: + return f"Поздравляем! Ваш промокод: {promocode}" + + return "Поздравляем с выигрышем!" + + def _pluralize_days(self, n: int) -> str: + """Склонение слова 'день'.""" + if 11 <= n % 100 <= 19: + return "дней" + elif n % 10 == 1: + return "день" + elif 2 <= n % 10 <= 4: + return "дня" + else: + return "дней" + + async def get_statistics( + self, + db: AsyncSession, + date_from: Optional[datetime] = None, + date_to: Optional[datetime] = None + ) -> Dict[str, Any]: + """Получить статистику колеса.""" + return await get_wheel_statistics(db, date_from, date_to) + + +# Глобальный экземпляр сервиса +wheel_service = FortuneWheelService() diff --git a/app/states.py b/app/states.py index 33d74fd4..e2c28178 100644 --- a/app/states.py +++ b/app/states.py @@ -12,17 +12,22 @@ class SubscriptionStates(StatesGroup): selecting_countries = State() selecting_devices = State() confirming_purchase = State() - + adding_countries = State() adding_traffic = State() adding_devices = State() extending_subscription = State() confirming_traffic_reset = State() cart_saved_for_topup = State() - + # Состояния для простой подписки waiting_for_simple_subscription_payment_method = State() + # Состояния для кастомных дней/трафика при покупке тарифа + selecting_custom_days = State() + selecting_custom_traffic = State() + confirming_custom_purchase = State() + class BalanceStates(StatesGroup): waiting_for_amount = State() waiting_for_pal24_method = State() @@ -177,9 +182,13 @@ class AdminStates(StatesGroup): editing_tariff_tier = State() editing_tariff_prices = State() editing_tariff_device_price = State() + editing_tariff_max_devices = State() editing_tariff_trial_days = State() editing_tariff_squads = State() editing_tariff_promo_groups = State() + editing_tariff_traffic_topup_packages = State() + editing_tariff_max_topup_traffic = State() + editing_tariff_daily_price = State() class SupportStates(StatesGroup): diff --git a/app/webapi/app.py b/app/webapi/app.py index 7da19989..d1eba6e5 100644 --- a/app/webapi/app.py +++ b/app/webapi/app.py @@ -10,6 +10,7 @@ from .middleware import RequestLoggingMiddleware from .routes import ( broadcasts, backups, + ban_notifications, campaigns, config, health, @@ -165,6 +166,13 @@ OPENAPI_TAGS = [ "настройка показа при /start." ), }, + { + "name": "ban-notifications", + "description": ( + "Эндпоинты для приема уведомлений от системы мониторинга ban (Banhammer). " + "Позволяет отправлять уведомления пользователям о блокировке и разблокировке." + ), + }, ] @@ -258,6 +266,11 @@ def create_web_api_app() -> FastAPI: ) app.include_router(webhooks.router, prefix="/webhooks", tags=["webhooks"]) app.include_router(websocket.router, tags=["websocket"]) + app.include_router( + ban_notifications.router, + prefix="/ban-notifications", + tags=["ban-notifications"], + ) # Cabinet (Personal Account) routes if settings.is_cabinet_enabled(): diff --git a/app/webapi/routes/ban_notifications.py b/app/webapi/routes/ban_notifications.py new file mode 100644 index 00000000..20960a58 --- /dev/null +++ b/app/webapi/routes/ban_notifications.py @@ -0,0 +1,144 @@ +""" +API эндпоинты для приема уведомлений от ban системы +""" +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.webapi.dependencies import get_db_session, require_api_token +from app.webapi.schemas.ban_notifications import ( + BanNotificationRequest, + BanNotificationResponse, +) +from app.services.ban_notification_service import ban_notification_service + + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +@router.post( + "/send", + response_model=BanNotificationResponse, + summary="Отправить уведомление от ban системы", + description=( + "Эндпоинт для отправки уведомлений пользователям от системы мониторинга ban. " + "Поддерживает уведомления о блокировке, разблокировке и предупреждения." + ), +) +async def send_ban_notification( + request: BanNotificationRequest, + db: AsyncSession = Depends(get_db_session), + _token=Depends(require_api_token), +) -> BanNotificationResponse: + """ + Отправить уведомление пользователю от ban системы + + - **punishment**: Уведомление о блокировке за превышение лимита устройств + - **enabled**: Уведомление о снятии блокировки + - **warning**: Предупреждение пользователю + + Требует API ключ в заголовке X-API-Key или Authorization: Bearer + """ + logger.info( + f"Получен запрос на отправку уведомления типа '{request.notification_type}' " + f"для пользователя {request.username} ({request.user_identifier}), " + f"node_name={request.node_name!r}" + ) + + try: + if request.notification_type == "punishment": + if request.ip_count is None or request.limit is None or request.ban_minutes is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Для типа 'punishment' требуются поля: ip_count, limit, ban_minutes" + ) + + success, message, telegram_id = await ban_notification_service.send_punishment_notification( + db=db, + user_identifier=request.user_identifier, + username=request.username, + ip_count=request.ip_count, + limit=request.limit, + ban_minutes=request.ban_minutes, + node_name=request.node_name, + ) + + elif request.notification_type == "enabled": + success, message, telegram_id = await ban_notification_service.send_enabled_notification( + db=db, + user_identifier=request.user_identifier, + username=request.username, + ) + + elif request.notification_type == "warning": + if not request.warning_message: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Для типа 'warning' требуется поле: warning_message" + ) + + success, message, telegram_id = await ban_notification_service.send_warning_notification( + db=db, + user_identifier=request.user_identifier, + username=request.username, + warning_message=request.warning_message, + ) + + elif request.notification_type == "network_wifi": + if request.ban_minutes is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Для типа 'network_wifi' требуется поле: ban_minutes" + ) + + success, message, telegram_id = await ban_notification_service.send_network_wifi_notification( + db=db, + user_identifier=request.user_identifier, + username=request.username, + ban_minutes=request.ban_minutes, + network_type=request.network_type, + node_name=request.node_name, + ) + + elif request.notification_type == "network_mobile": + if request.ban_minutes is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Для типа 'network_mobile' требуется поле: ban_minutes" + ) + + success, message, telegram_id = await ban_notification_service.send_network_mobile_notification( + db=db, + user_identifier=request.user_identifier, + username=request.username, + ban_minutes=request.ban_minutes, + network_type=request.network_type, + node_name=request.node_name, + ) + + else: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Неизвестный тип уведомления: {request.notification_type}" + ) + + return BanNotificationResponse( + success=success, + message=message, + telegram_id=telegram_id, + sent=success + ) + + except HTTPException: + raise + except Exception as e: + logger.exception(f"Ошибка при отправке уведомления: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Внутренняя ошибка сервера: {str(e)}" + ) diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index 2f6acade..1a4e73c3 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -190,8 +190,15 @@ from ..schemas.miniapp import ( MiniAppTariffsResponse, MiniAppTariffPurchaseRequest, MiniAppTariffPurchaseResponse, + MiniAppTariffSwitchRequest, + MiniAppTariffSwitchPreviewResponse, + MiniAppTariffSwitchResponse, MiniAppCurrentTariff, MiniAppConnectedServer, + MiniAppTrafficTopupRequest, + MiniAppTrafficTopupResponse, + MiniAppDailySubscriptionToggleRequest, + MiniAppDailySubscriptionToggleResponse, ) @@ -208,6 +215,23 @@ _CRYPTOBOT_MAX_USD = 1000.0 _CRYPTOBOT_FALLBACK_RATE = 95.0 +def _get_tariff_monthly_price(tariff) -> int: + """Получает месячную цену тарифа (30 дней) с fallback на пропорциональный расчёт.""" + price = tariff.get_price_for_period(30) + if price is not None: + return price + + # Fallback: пропорционально пересчитываем из первого доступного периода + periods = tariff.get_available_periods() + if periods: + first_period = periods[0] + first_price = tariff.get_price_for_period(first_period) + if first_price: + return int(first_price * 30 / first_period) + + return 0 + + @router.get("/app-config.json") async def get_app_config() -> Dict[str, Any]: data = _load_app_config_data() @@ -985,7 +1009,7 @@ async def create_payment_link( payment_service = PaymentService(bot) invoice_link = await payment_service.create_stars_invoice( amount_kopeks=amount_kopeks, - description=settings.get_balance_payment_description(amount_kopeks), + description=settings.get_balance_payment_description(amount_kopeks, telegram_user_id=user.telegram_id), payload=invoice_payload, stars_amount=stars_amount, ) @@ -1022,7 +1046,7 @@ async def create_payment_link( db=db, user_id=user.id, amount_kopeks=amount_kopeks, - description=settings.get_balance_payment_description(amount_kopeks), + description=settings.get_balance_payment_description(amount_kopeks, telegram_user_id=user.telegram_id), ) confirmation_url = result.get("confirmation_url") if result else None if not result or not confirmation_url: @@ -1060,7 +1084,7 @@ async def create_payment_link( db=db, user_id=user.id, amount_kopeks=amount_kopeks, - description=settings.get_balance_payment_description(amount_kopeks), + description=settings.get_balance_payment_description(amount_kopeks, telegram_user_id=user.telegram_id), ) if not result or not result.get("confirmation_url"): raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to create payment") @@ -1092,7 +1116,7 @@ async def create_payment_link( db=db, user_id=user.id, amount_kopeks=amount_kopeks, - description=settings.get_balance_payment_description(amount_kopeks), + description=settings.get_balance_payment_description(amount_kopeks, telegram_user_id=user.telegram_id), language=user.language, ) if not result or not result.get("payment_url"): @@ -1134,7 +1158,7 @@ async def create_payment_link( db=db, user_id=user.id, amount_kopeks=amount_kopeks, - description=settings.get_balance_payment_description(amount_kopeks), + description=settings.get_balance_payment_description(amount_kopeks, telegram_user_id=user.telegram_id), language=user.language or settings.DEFAULT_LANGUAGE, payment_method_code=method_code, ) @@ -1172,7 +1196,7 @@ async def create_payment_link( db=db, user_id=user.id, amount_kopeks=amount_kopeks, - description=settings.get_balance_payment_description(amount_kopeks), + description=settings.get_balance_payment_description(amount_kopeks, telegram_user_id=user.telegram_id), language=user.language, ) payment_url = result.get("payment_url") if result else None @@ -1213,7 +1237,7 @@ async def create_payment_link( db=db, user_id=user.id, amount_kopeks=amount_kopeks, - description=settings.get_balance_payment_description(amount_kopeks), + description=settings.get_balance_payment_description(amount_kopeks, telegram_user_id=user.telegram_id), language=user.language or settings.DEFAULT_LANGUAGE, payment_method=provider_method, ) @@ -1291,16 +1315,17 @@ async def create_payment_link( user_id=user.id, amount_usd=amount_usd, asset=settings.CRYPTOBOT_DEFAULT_ASSET, - description=settings.get_balance_payment_description(amount_kopeks), + description=settings.get_balance_payment_description(amount_kopeks, telegram_user_id=user.telegram_id), payload=f"balance_{user.id}_{amount_kopeks}", ) if not result: raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to create payment") + # Priority: web_app for desktop/browser, mini_app for mobile, bot as fallback payment_url = ( - result.get("bot_invoice_url") + result.get("web_app_invoice_url") or result.get("mini_app_invoice_url") - or result.get("web_app_invoice_url") + or result.get("bot_invoice_url") ) if not payment_url: raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to obtain payment url") @@ -1342,7 +1367,7 @@ async def create_payment_link( db=db, user_id=user.id, amount_kopeks=amount_kopeks, - description=settings.get_balance_payment_description(amount_kopeks), + description=settings.get_balance_payment_description(amount_kopeks, telegram_user_id=user.telegram_id), language=user.language or settings.DEFAULT_LANGUAGE, ) @@ -1387,7 +1412,7 @@ async def create_payment_link( db=db, user_id=user.id, amount_kopeks=amount_kopeks, - description=settings.get_balance_payment_description(amount_kopeks), + description=settings.get_balance_payment_description(amount_kopeks, telegram_user_id=user.telegram_id), telegram_id=user.telegram_id, language=user.language or settings.DEFAULT_LANGUAGE, ) @@ -1428,7 +1453,7 @@ async def create_payment_link( db=db, user_id=user.id, amount_kopeks=amount_kopeks, - description=settings.get_balance_payment_description(amount_kopeks), + description=settings.get_balance_payment_description(amount_kopeks, telegram_user_id=user.telegram_id), email=getattr(user, "email", None), language=user.language or settings.DEFAULT_LANGUAGE, ) @@ -3392,6 +3417,26 @@ async def get_subscription_details( devices_count, devices = await _load_devices_info(user) + # Загружаем данные суточного тарифа + is_daily_tariff = False + is_daily_paused = False + daily_tariff_name = None + daily_price_kopeks = None + daily_price_label = None + daily_next_charge_at = None + + if subscription and getattr(subscription, "tariff_id", None): + tariff = await get_tariff_by_id(db, subscription.tariff_id) + if tariff and getattr(tariff, 'is_daily', False): + is_daily_tariff = True + is_daily_paused = getattr(subscription, 'is_daily_paused', False) + daily_tariff_name = tariff.name + daily_price_kopeks = getattr(tariff, 'daily_price_kopeks', 0) + daily_price_label = settings.format_price(daily_price_kopeks) + "/день" if daily_price_kopeks > 0 else None + # Оставшееся время подписки (показываем даже при паузе) + if subscription.end_date: + daily_next_charge_at = subscription.end_date + response_user = MiniAppSubscriptionUser( telegram_id=user.telegram_id, username=user.username, @@ -3421,6 +3466,12 @@ async def get_subscription_details( promo_offer_discount_percent=active_discount_percent, promo_offer_discount_expires_at=active_discount_expires_at, promo_offer_discount_source=promo_offer_source, + is_daily_tariff=is_daily_tariff, + is_daily_paused=is_daily_paused, + daily_tariff_name=daily_tariff_name, + daily_price_kopeks=daily_price_kopeks, + daily_price_label=daily_price_label, + daily_next_charge_at=daily_next_charge_at, ) referral_info = await _build_referral_info(db, user) @@ -3444,7 +3495,40 @@ async def get_subscription_details( else: subscription_missing_reason = "not_found" + # Получаем докупки трафика + traffic_purchases_data = [] + if subscription: + from app.database.models import TrafficPurchase + from sqlalchemy import select as sql_select + + now = datetime.utcnow() + purchases_query = ( + sql_select(TrafficPurchase) + .where(TrafficPurchase.subscription_id == subscription.id) + .where(TrafficPurchase.expires_at > now) + .order_by(TrafficPurchase.expires_at.asc()) + ) + purchases_result = await db.execute(purchases_query) + purchases = purchases_result.scalars().all() + + for purchase in purchases: + time_remaining = purchase.expires_at - now + days_remaining = max(0, int(time_remaining.total_seconds() / 86400)) + total_duration_seconds = (purchase.expires_at - purchase.created_at).total_seconds() + elapsed_seconds = (now - purchase.created_at).total_seconds() + progress_percent = min(100.0, max(0.0, (elapsed_seconds / total_duration_seconds * 100) if total_duration_seconds > 0 else 0)) + + traffic_purchases_data.append({ + "id": purchase.id, + "traffic_gb": purchase.traffic_gb, + "expires_at": purchase.expires_at, + "created_at": purchase.created_at, + "days_remaining": days_remaining, + "progress_percent": round(progress_percent, 1) + }) + return MiniAppSubscriptionResponse( + traffic_purchases=traffic_purchases_data, subscription_id=getattr(subscription, "id", None), remnawave_short_uuid=remnawave_short_uuid, user=response_user, @@ -3503,13 +3587,15 @@ async def get_subscription_details( trial_price_kopeks=trial_price_kopeks if trial_payment_required else None, trial_price_label=trial_price_label, sales_mode=settings.get_sales_mode(), - current_tariff=await _get_current_tariff_model(db, subscription) if subscription else None, + current_tariff=await _get_current_tariff_model(db, subscription, user) if subscription else None, **autopay_extras, ) -async def _get_current_tariff_model(db: AsyncSession, subscription) -> Optional[MiniAppCurrentTariff]: +async def _get_current_tariff_model(db: AsyncSession, subscription, user=None) -> Optional[MiniAppCurrentTariff]: """Возвращает модель текущего тарифа пользователя.""" + from app.webapi.schemas.miniapp import MiniAppTrafficTopupPackage + if not subscription or not getattr(subscription, "tariff_id", None): return None @@ -3519,6 +3605,71 @@ async def _get_current_tariff_model(db: AsyncSession, subscription) -> Optional[ servers_count = len(tariff.allowed_squads) if tariff.allowed_squads else 0 + # Получаем скидку на трафик из промогруппы + traffic_discount_percent = 0 + promo_group = (user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else getattr(user, "promo_group", None)) if user else None + if promo_group: + apply_to_addons = getattr(promo_group, 'apply_discounts_to_addons', True) + if apply_to_addons: + traffic_discount_percent = max(0, min(100, int(getattr(promo_group, 'traffic_discount_percent', 0) or 0))) + + # Лимит докупки трафика + max_topup_traffic_gb = getattr(tariff, 'max_topup_traffic_gb', 0) or 0 + current_subscription_traffic = subscription.traffic_limit_gb or 0 + + # Рассчитываем доступный лимит докупки + available_topup_gb = None + if max_topup_traffic_gb > 0: + available_topup_gb = max(0, max_topup_traffic_gb - current_subscription_traffic) + + # Пакеты докупки трафика + traffic_topup_enabled = getattr(tariff, 'traffic_topup_enabled', False) and tariff.traffic_limit_gb > 0 + traffic_topup_packages = [] + + if traffic_topup_enabled and hasattr(tariff, 'get_traffic_topup_packages'): + packages = tariff.get_traffic_topup_packages() + for gb in sorted(packages.keys()): + # Фильтруем пакеты, которые превышают доступный лимит + if available_topup_gb is not None and gb > available_topup_gb: + continue + + base_price = packages[gb] + # Применяем скидку + if traffic_discount_percent > 0: + discounted_price = int(base_price * (100 - traffic_discount_percent) / 100) + traffic_topup_packages.append(MiniAppTrafficTopupPackage( + gb=gb, + price_kopeks=discounted_price, + price_label=settings.format_price(discounted_price), + original_price_kopeks=base_price, + original_price_label=settings.format_price(base_price), + discount_percent=traffic_discount_percent, + )) + else: + traffic_topup_packages.append(MiniAppTrafficTopupPackage( + gb=gb, + price_kopeks=base_price, + price_label=settings.format_price(base_price), + )) + + # Если нет доступных пакетов из-за лимита - отключаем докупку + if traffic_topup_enabled and not traffic_topup_packages and available_topup_gb == 0: + traffic_topup_enabled = False + + monthly_price = _get_tariff_monthly_price(tariff) + + # Применяем скидку промогруппы для 30-дневного периода + if promo_group: + raw_discounts = getattr(promo_group, 'period_discounts', None) or {} + for k, v in raw_discounts.items(): + try: + if int(k) == 30: + discount = max(0, min(100, int(v))) + monthly_price = int(monthly_price * (100 - discount) / 100) + break + except (TypeError, ValueError): + pass + return MiniAppCurrentTariff( id=tariff.id, name=tariff.name, @@ -3529,6 +3680,11 @@ async def _get_current_tariff_model(db: AsyncSession, subscription) -> Optional[ is_unlimited_traffic=tariff.traffic_limit_gb == 0, device_limit=tariff.device_limit, servers_count=servers_count, + monthly_price_kopeks=monthly_price, + traffic_topup_enabled=traffic_topup_enabled, + traffic_topup_packages=traffic_topup_packages, + max_topup_traffic_gb=max_topup_traffic_gb, + available_topup_gb=available_topup_gb, ) @@ -4289,15 +4445,15 @@ def _safe_int(value: Any) -> int: def _normalize_period_discounts( raw: Optional[Dict[Any, Any]] -) -> Dict[int, int]: +) -> Dict[str, int]: if not isinstance(raw, dict): return {} - normalized: Dict[int, int] = {} + normalized: Dict[str, int] = {} for key, value in raw.items(): try: period = int(key) - normalized[period] = int(value) + normalized[str(period)] = int(value) except (TypeError, ValueError): continue @@ -4477,57 +4633,128 @@ async def _prepare_subscription_renewal_options( user: User, subscription: Subscription, ) -> Tuple[List[MiniAppSubscriptionRenewalPeriod], Dict[Union[str, int], Dict[str, Any]], Optional[str]]: - available_periods = [ - period for period in settings.get_available_renewal_periods() if period > 0 - ] - option_payloads: List[Tuple[MiniAppSubscriptionRenewalPeriod, Dict[str, Any]]] = [] - for period_days in available_periods: - try: - pricing_model = await _calculate_subscription_renewal_pricing( - db, - user, - subscription, + # Проверяем, есть ли у подписки тариф (режим тарифов) + tariff_id = getattr(subscription, 'tariff_id', None) + tariff = None + if tariff_id: + from app.database.crud.tariff import get_tariff_by_id + tariff = await get_tariff_by_id(db, tariff_id) + + if tariff and tariff.period_prices: + # Режим тарифов: используем периоды и цены из тарифа + promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else getattr(user, "promo_group", None) + + # Получаем скидки промогруппы по периодам + period_discounts = {} + if promo_group: + raw_discounts = getattr(promo_group, 'period_discounts', None) or {} + for k, v in raw_discounts.items(): + try: + period_discounts[int(k)] = max(0, min(100, int(v))) + except (TypeError, ValueError): + pass + + for period_str, original_price_kopeks in sorted(tariff.period_prices.items(), key=lambda x: int(x[0])): + period_days = int(period_str) + + # Применяем скидку промогруппы + discount_percent = period_discounts.get(period_days, 0) + if discount_percent > 0: + price_kopeks = int(original_price_kopeks * (100 - discount_percent) / 100) + else: + price_kopeks = original_price_kopeks + + months = max(1, period_days // 30) + per_month = price_kopeks // months if months > 0 else price_kopeks + + label = format_period_description( period_days, + getattr(user, "language", settings.DEFAULT_LANGUAGE), ) - pricing = pricing_model.to_payload() - except Exception as error: # pragma: no cover - defensive logging - logger.warning( - "Failed to calculate renewal pricing for subscription %s (period %s): %s", - subscription.id, + + price_label = settings.format_price(price_kopeks) + original_label = settings.format_price(original_price_kopeks) if discount_percent > 0 else None + per_month_label = settings.format_price(per_month) + + option_model = MiniAppSubscriptionRenewalPeriod( + id=f"tariff_{tariff.id}_{period_days}", + days=period_days, + months=months, + price_kopeks=price_kopeks, + price_label=price_label, + original_price_kopeks=original_price_kopeks if discount_percent > 0 else None, + original_price_label=original_label, + discount_percent=discount_percent, + price_per_month_kopeks=per_month, + price_per_month_label=per_month_label, + title=label, + ) + + pricing = { + "period_id": option_model.id, + "period_days": period_days, + "months": months, + "final_total": price_kopeks, + "base_original_total": original_price_kopeks if discount_percent > 0 else price_kopeks, + "overall_discount_percent": discount_percent, + "per_month": per_month, + "tariff_id": tariff.id, + } + + option_payloads.append((option_model, pricing)) + else: + # Классический режим: используем периоды из настроек + available_periods = [ + period for period in settings.get_available_renewal_periods() if period > 0 + ] + + for period_days in available_periods: + try: + pricing_model = await _calculate_subscription_renewal_pricing( + db, + user, + subscription, + period_days, + ) + pricing = pricing_model.to_payload() + except Exception as error: # pragma: no cover - defensive logging + logger.warning( + "Failed to calculate renewal pricing for subscription %s (period %s): %s", + subscription.id, + period_days, + error, + ) + continue + + label = format_period_description( period_days, - error, + getattr(user, "language", settings.DEFAULT_LANGUAGE), ) - continue - label = format_period_description( - period_days, - getattr(user, "language", settings.DEFAULT_LANGUAGE), - ) + price_label = settings.format_price(pricing["final_total"]) + original_label = None + if pricing["base_original_total"] and pricing["base_original_total"] != pricing["final_total"]: + original_label = settings.format_price(pricing["base_original_total"]) - price_label = settings.format_price(pricing["final_total"]) - original_label = None - if pricing["base_original_total"] and pricing["base_original_total"] != pricing["final_total"]: - original_label = settings.format_price(pricing["base_original_total"]) + per_month_label = settings.format_price(pricing["per_month"]) - per_month_label = settings.format_price(pricing["per_month"]) + option_model = MiniAppSubscriptionRenewalPeriod( + id=pricing["period_id"], + days=period_days, + months=pricing["months"], + price_kopeks=pricing["final_total"], + price_label=price_label, + original_price_kopeks=pricing["base_original_total"], + original_price_label=original_label, + discount_percent=pricing["overall_discount_percent"], + price_per_month_kopeks=pricing["per_month"], + price_per_month_label=per_month_label, + title=label, + ) - option_model = MiniAppSubscriptionRenewalPeriod( - id=pricing["period_id"], - days=period_days, - months=pricing["months"], - price_kopeks=pricing["final_total"], - price_label=price_label, - original_price_kopeks=pricing["base_original_total"], - original_price_label=original_label, - discount_percent=pricing["overall_discount_percent"], - price_per_month_kopeks=pricing["per_month"], - price_per_month_label=per_month_label, - title=label, - ) - - option_payloads.append((option_model, pricing)) + option_payloads.append((option_model, pricing)) if not option_payloads: return [], {}, None @@ -5092,79 +5319,200 @@ async def submit_subscription_renewal_endpoint( detail={"code": "invalid_period", "message": "Invalid renewal period"}, ) - available_periods = [ - period for period in settings.get_available_renewal_periods() if period > 0 - ] - if period_days not in available_periods: - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail={"code": "period_unavailable", "message": "Selected renewal period is not available"}, - ) + # Проверяем, есть ли у подписки тариф (режим тарифов) + tariff_id = getattr(subscription, 'tariff_id', None) + tariff = None + tariff_pricing = None + + if tariff_id: + from app.database.crud.tariff import get_tariff_by_id + tariff = await get_tariff_by_id(db, tariff_id) + + if tariff and tariff.period_prices: + # Режим тарифов: проверяем периоды из тарифа + available_periods = [int(p) for p in tariff.period_prices.keys()] + if period_days not in available_periods: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail={"code": "period_unavailable", "message": "Selected renewal period is not available for this tariff"}, + ) + + # Рассчитываем цену из тарифа + original_price_kopeks = tariff.period_prices.get(str(period_days), tariff.period_prices.get(period_days, 0)) + + # Применяем скидку промогруппы + promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else getattr(user, "promo_group", None) + discount_percent = 0 + if promo_group: + raw_discounts = getattr(promo_group, 'period_discounts', None) or {} + for k, v in raw_discounts.items(): + try: + if int(k) == period_days: + discount_percent = max(0, min(100, int(v))) + break + except (TypeError, ValueError): + pass + + if discount_percent > 0: + final_total = int(original_price_kopeks * (100 - discount_percent) / 100) + else: + final_total = original_price_kopeks + + tariff_pricing = { + "period_days": period_days, + "original_price_kopeks": original_price_kopeks, + "discount_percent": discount_percent, + "final_total": final_total, + "tariff_id": tariff.id, + } + else: + # Классический режим + available_periods = [ + period for period in settings.get_available_renewal_periods() if period > 0 + ] + if period_days not in available_periods: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail={"code": "period_unavailable", "message": "Selected renewal period is not available"}, + ) method = (payload.method or "").strip().lower() - try: - pricing_model = await _calculate_subscription_renewal_pricing( - db, - user, - subscription, - period_days, - ) - except HTTPException: - raise - except Exception as error: - logger.error( - "Failed to calculate renewal pricing for subscription %s (period %s): %s", - subscription.id, - period_days, - error, - ) - raise HTTPException( - status.HTTP_502_BAD_GATEWAY, - detail={"code": "pricing_failed", "message": "Failed to calculate renewal pricing"}, - ) from error + # Для тарифного режима используем упрощённый расчёт + if tariff_pricing: + final_total = tariff_pricing["final_total"] + pricing = tariff_pricing + else: + try: + pricing_model = await _calculate_subscription_renewal_pricing( + db, + user, + subscription, + period_days, + ) + except HTTPException: + raise + except Exception as error: + logger.error( + "Failed to calculate renewal pricing for subscription %s (period %s): %s", + subscription.id, + period_days, + error, + ) + raise HTTPException( + status.HTTP_502_BAD_GATEWAY, + detail={"code": "pricing_failed", "message": "Failed to calculate renewal pricing"}, + ) from error - pricing = pricing_model.to_payload() - final_total = int(pricing_model.final_total) + pricing = pricing_model.to_payload() + final_total = int(pricing_model.final_total) balance_kopeks = getattr(user, "balance_kopeks", 0) missing_amount = calculate_missing_amount(balance_kopeks, final_total) description = f"Продление подписки на {period_days} дней" if missing_amount <= 0: - try: - result = await renewal_service.finalize( - db, + if tariff_pricing: + # Тарифный режим: простое продление + from app.database.crud.user import subtract_user_balance + from app.database.crud.subscription import extend_subscription + from app.database.crud.transaction import create_transaction + + try: + # Списываем баланс (subtract_user_balance делает commit и обновляет user.balance_kopeks) + success = await subtract_user_balance(db, user, final_total, description) + if not success: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={"code": "balance_error", "message": "Failed to subtract balance"}, + ) + + # Продлеваем подписку + subscription = await extend_subscription(db, subscription, period_days) + new_end_date = subscription.end_date + + # Записываем транзакцию + from app.database.models import TransactionType + await create_transaction( + db, + user_id=user.id, + type=TransactionType.SUBSCRIPTION_PAYMENT, + amount_kopeks=-final_total, + description=description, + ) + + # Синхронизируем с RemnaWave (сброс трафика по настройке) + try: + from app.services.subscription_service import SubscriptionService + service = SubscriptionService() + await service.update_remnawave_user( + db, + subscription, + reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT, + reset_reason="subscription renewal (miniapp)", + ) + except Exception as e: + logger.error(f"Ошибка синхронизации с RemnaWave при продлении (miniapp): {e}") + + lang = getattr(user, "language", settings.DEFAULT_LANGUAGE) + if lang == "ru": + message = f"Подписка продлена до {new_end_date.strftime('%d.%m.%Y')}" + else: + message = f"Subscription extended until {new_end_date.strftime('%Y-%m-%d')}" + + return MiniAppSubscriptionRenewalResponse( + message=message, + balance_kopeks=user.balance_kopeks, + balance_label=settings.format_price(user.balance_kopeks), + subscription_id=subscription.id, + renewed_until=new_end_date, + ) + except Exception as error: + await db.rollback() + logger.error( + "Failed to renew tariff subscription %s: %s", + subscription.id, + error, + ) + raise HTTPException( + status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={"code": "renewal_failed", "message": "Failed to renew subscription"}, + ) from error + else: + # Классический режим + try: + result = await renewal_service.finalize( + db, + user, + subscription, + pricing_model, + description=description, + ) + except SubscriptionRenewalChargeError as error: + logger.error( + "Failed to charge balance for subscription renewal %s: %s", + subscription.id, + error, + ) + raise HTTPException( + status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={"code": "charge_failed", "message": "Failed to charge balance"}, + ) from error + + updated_subscription = result.subscription + message = _build_renewal_success_message( user, - subscription, - pricing_model, - description=description, + updated_subscription, + result.total_amount_kopeks, + pricing_model.promo_discount_value, ) - except SubscriptionRenewalChargeError as error: - logger.error( - "Failed to charge balance for subscription renewal %s: %s", - subscription.id, - error, + + return MiniAppSubscriptionRenewalResponse( + message=message, + balance_kopeks=user.balance_kopeks, + balance_label=settings.format_price(user.balance_kopeks), + subscription_id=updated_subscription.id, + renewed_until=updated_subscription.end_date, ) - raise HTTPException( - status.HTTP_500_INTERNAL_SERVER_ERROR, - detail={"code": "charge_failed", "message": "Failed to charge balance"}, - ) from error - - updated_subscription = result.subscription - message = _build_renewal_success_message( - user, - updated_subscription, - result.total_amount_kopeks, - pricing_model.promo_discount_value, - ) - - return MiniAppSubscriptionRenewalResponse( - message=message, - balance_kopeks=user.balance_kopeks, - balance_label=settings.format_price(user.balance_kopeks), - subscription_id=updated_subscription.id, - renewed_until=updated_subscription.end_date, - ) if not method: if final_total > 0 and balance_kopeks < final_total: @@ -5257,10 +5605,11 @@ async def submit_subscription_renewal_endpoint( detail={"code": "payment_creation_failed", "message": "Failed to create payment"}, ) + # Priority: web_app for desktop/browser, mini_app for mobile, bot as fallback payment_url = ( - result.get("mini_app_invoice_url") + result.get("web_app_invoice_url") + or 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( @@ -5995,6 +6344,10 @@ async def _build_tariff_model( db: AsyncSession, tariff, current_tariff_id: Optional[int] = None, + promo_group=None, + current_tariff=None, + remaining_days: int = 0, + user=None, ) -> MiniAppTariff: """Преобразует объект тарифа в модель для API.""" servers: List[MiniAppConnectedServer] = [] @@ -6010,10 +6363,28 @@ async def _build_tariff_model( name=server.display_name or squad_uuid[:8], )) + # Получаем скидки промогруппы по периодам + period_discounts = {} + if promo_group: + raw_discounts = getattr(promo_group, 'period_discounts', None) or {} + for k, v in raw_discounts.items(): + try: + period_discounts[int(k)] = max(0, min(100, int(v))) + except (TypeError, ValueError): + pass + periods: List[MiniAppTariffPeriod] = [] if tariff.period_prices: - for period_str, price_kopeks in sorted(tariff.period_prices.items(), key=lambda x: int(x[0])): + for period_str, original_price_kopeks in sorted(tariff.period_prices.items(), key=lambda x: int(x[0])): period_days = int(period_str) + + # Применяем скидку промогруппы + discount_percent = period_discounts.get(period_days, 0) + if discount_percent > 0: + price_kopeks = int(original_price_kopeks * (100 - discount_percent) / 100) + else: + price_kopeks = original_price_kopeks + months = max(1, period_days // 30) per_month = price_kopeks // months if months > 0 else price_kopeks @@ -6025,8 +6396,47 @@ async def _build_tariff_model( price_label=settings.format_price(price_kopeks), price_per_month_kopeks=per_month, price_per_month_label=settings.format_price(per_month), + original_price_kopeks=original_price_kopeks if discount_percent > 0 else None, + original_price_label=settings.format_price(original_price_kopeks) if discount_percent > 0 else None, + discount_percent=discount_percent, )) + # Расчёт стоимости переключения тарифа (если есть текущий тариф и это не он же) + switch_cost_kopeks = None + switch_cost_label = None + is_upgrade = None + is_switch_free = None + + if current_tariff and current_tariff.id != tariff.id: + current_is_daily = getattr(current_tariff, 'is_daily', False) + new_is_daily = getattr(tariff, 'is_daily', False) + + if current_is_daily and not new_is_daily: + # Переключение С суточного НА периодный - полная оплата нового тарифа + # Берём минимальную цену из периодов нового тарифа + min_period_price = None + if periods: + min_period_price = min(p.price_kopeks for p in periods) + if min_period_price and min_period_price > 0: + switch_cost_kopeks = min_period_price + switch_cost_label = settings.format_price(min_period_price) + is_upgrade = True # Показываем как платный переход + is_switch_free = False + elif remaining_days > 0: + # Обычный расчёт для периодных тарифов + cost, upgrade = _calculate_tariff_switch_cost( + current_tariff, tariff, remaining_days, promo_group, user + ) + switch_cost_kopeks = cost + switch_cost_label = settings.format_price(cost) if cost > 0 else None + is_upgrade = upgrade + is_switch_free = cost == 0 + + # Суточный тариф + is_daily = getattr(tariff, 'is_daily', False) + daily_price_kopeks = getattr(tariff, 'daily_price_kopeks', 0) if is_daily else 0 + daily_price_label = settings.format_price(daily_price_kopeks) + "/день" if is_daily and daily_price_kopeks > 0 else None + return MiniAppTariff( id=tariff.id, name=tariff.name, @@ -6041,12 +6451,37 @@ async def _build_tariff_model( periods=periods, is_current=current_tariff_id == tariff.id if current_tariff_id else False, is_available=tariff.is_active, + switch_cost_kopeks=switch_cost_kopeks, + switch_cost_label=switch_cost_label, + is_upgrade=is_upgrade, + is_switch_free=is_switch_free, + is_daily=is_daily, + daily_price_kopeks=daily_price_kopeks, + daily_price_label=daily_price_label, ) -async def _build_current_tariff_model(db: AsyncSession, tariff) -> MiniAppCurrentTariff: +async def _build_current_tariff_model(db: AsyncSession, tariff, promo_group=None) -> MiniAppCurrentTariff: """Создаёт модель текущего тарифа.""" servers_count = len(tariff.allowed_squads) if tariff.allowed_squads else 0 + monthly_price = _get_tariff_monthly_price(tariff) + + # Применяем скидку промогруппы для 30-дневного периода + if promo_group: + raw_discounts = getattr(promo_group, 'period_discounts', None) or {} + for k, v in raw_discounts.items(): + try: + if int(k) == 30: + discount = max(0, min(100, int(v))) + monthly_price = int(monthly_price * (100 - discount) / 100) + break + except (TypeError, ValueError): + pass + + # Суточный тариф + is_daily = getattr(tariff, 'is_daily', False) + daily_price_kopeks = getattr(tariff, 'daily_price_kopeks', 0) if is_daily else 0 + daily_price_label = settings.format_price(daily_price_kopeks) + "/день" if is_daily and daily_price_kopeks > 0 else None return MiniAppCurrentTariff( id=tariff.id, @@ -6058,6 +6493,10 @@ async def _build_current_tariff_model(db: AsyncSession, tariff) -> MiniAppCurren is_unlimited_traffic=tariff.traffic_limit_gb == 0, device_limit=tariff.device_limit, servers_count=servers_count, + monthly_price_kopeks=monthly_price, + is_daily=is_daily, + daily_price_kopeks=daily_price_kopeks, + daily_price_label=daily_price_label, ) @@ -6079,8 +6518,8 @@ async def get_tariffs_endpoint( }, ) - # Получаем промогруппу пользователя - promo_group = getattr(user, "promo_group", None) + # Получаем промогруппу пользователя (с приоритетом) + promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else getattr(user, "promo_group", None) promo_group_id = promo_group.id if promo_group else None # Получаем тарифы, доступные пользователю @@ -6090,18 +6529,39 @@ async def get_tariffs_endpoint( subscription = getattr(user, "subscription", None) current_tariff_id = subscription.tariff_id if subscription else None current_tariff_model: Optional[MiniAppCurrentTariff] = None + current_tariff = None + + # Вычисляем оставшиеся дни подписки + remaining_days = 0 + if subscription and subscription.end_date: + delta = subscription.end_date - datetime.utcnow() + remaining_days = max(0, delta.days) if current_tariff_id: current_tariff = await get_tariff_by_id(db, current_tariff_id) if current_tariff: - current_tariff_model = await _build_current_tariff_model(db, current_tariff) + current_tariff_model = await _build_current_tariff_model(db, current_tariff, promo_group) # Формируем список тарифов tariff_models: List[MiniAppTariff] = [] for tariff in tariffs: - model = await _build_tariff_model(db, tariff, current_tariff_id) + model = await _build_tariff_model( + db, tariff, current_tariff_id, promo_group, + current_tariff=current_tariff, + remaining_days=remaining_days, + user=user, + ) tariff_models.append(model) + # Формируем модель промогруппы для ответа + promo_group_model = None + if promo_group: + promo_group_model = MiniAppPromoGroup( + id=promo_group.id, + name=promo_group.name, + **_extract_promo_discounts(promo_group), + ) + return MiniAppTariffsResponse( success=True, sales_mode="tariffs", @@ -6109,6 +6569,7 @@ async def get_tariffs_endpoint( current_tariff=current_tariff_model, balance_kopeks=user.balance_kopeks, balance_label=settings.format_price(user.balance_kopeks), + promo_group=promo_group_model, ) @@ -6140,7 +6601,7 @@ async def purchase_tariff_endpoint( ) # Проверяем доступность тарифа для пользователя - promo_group = getattr(user, "promo_group", None) + promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else getattr(user, "promo_group", None) promo_group_id = promo_group.id if promo_group else None if not tariff.is_available_for_promo_group(promo_group_id): raise HTTPException( @@ -6151,16 +6612,47 @@ async def purchase_tariff_endpoint( }, ) - # Получаем цену за выбранный период - price_kopeks = tariff.get_price_for_period(payload.period_days) - if price_kopeks is None: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail={ - "code": "invalid_period", - "message": "Invalid period for this tariff", - }, - ) + # Получаем цену + is_daily_tariff = getattr(tariff, 'is_daily', False) + if is_daily_tariff: + # Для суточного тарифа принудительно 1 день (защита от манипуляций с period_days) + payload.period_days = 1 + # Для суточного тарифа берём daily_price_kopeks (первый день) + base_price_kopeks = getattr(tariff, 'daily_price_kopeks', 0) + if base_price_kopeks <= 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "code": "invalid_daily_price", + "message": "Daily tariff has no price configured", + }, + ) + else: + # Для обычного тарифа получаем цену за выбранный период + base_price_kopeks = tariff.get_price_for_period(payload.period_days) + if base_price_kopeks is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "code": "invalid_period", + "message": "Invalid period for this tariff", + }, + ) + + # Применяем скидку промогруппы (только для обычных тарифов, не для суточных) + price_kopeks = base_price_kopeks + discount_percent = 0 + if not is_daily_tariff and promo_group: + raw_discounts = getattr(promo_group, 'period_discounts', None) or {} + for k, v in raw_discounts.items(): + try: + if int(k) == payload.period_days: + discount_percent = max(0, min(100, int(v))) + break + except (TypeError, ValueError): + pass + if discount_percent > 0: + price_kopeks = int(base_price_kopeks * (100 - discount_percent) / 100) # Проверяем баланс if user.balance_kopeks < price_kopeks: @@ -6177,7 +6669,12 @@ async def purchase_tariff_endpoint( subscription = getattr(user, "subscription", None) # Списываем баланс - description = f"Покупка тарифа '{tariff.name}' на {payload.period_days} дней" + if is_daily_tariff: + description = f"Активация суточного тарифа '{tariff.name}' (первый день)" + elif discount_percent > 0: + description = f"Покупка тарифа '{tariff.name}' на {payload.period_days} дней (скидка {discount_percent}%)" + else: + description = f"Покупка тарифа '{tariff.name}' на {payload.period_days} дней" success = await subtract_user_balance(db, user, price_kopeks, description) if not success: raise HTTPException( @@ -6197,6 +6694,15 @@ async def purchase_tariff_endpoint( description=description, ) + # Получаем список серверов из тарифа + squads = tariff.allowed_squads or [] + + # Если allowed_squads пустой - значит "все серверы", получаем их + if not squads: + from app.database.crud.server_squad import get_all_server_squads + all_servers, _ = await get_all_server_squads(db, available_only=True) + squads = [s.squad_uuid for s in all_servers if s.squad_uuid] + if subscription: # Смена/продление тарифа subscription = await extend_subscription( @@ -6206,7 +6712,7 @@ async def purchase_tariff_endpoint( tariff_id=tariff.id, traffic_limit_gb=tariff.traffic_limit_gb, device_limit=tariff.device_limit, - connected_squads=tariff.allowed_squads or [], + connected_squads=squads, ) else: # Создание новой подписки @@ -6214,13 +6720,23 @@ async def purchase_tariff_endpoint( subscription = await create_paid_subscription( db=db, user_id=user.id, - days=payload.period_days, + duration_days=payload.period_days, traffic_limit_gb=tariff.traffic_limit_gb, device_limit=tariff.device_limit, - connected_squads=tariff.allowed_squads or [], + connected_squads=squads, tariff_id=tariff.id, ) + # Инициализация daily полей при покупке суточного тарифа + is_daily_tariff = getattr(tariff, 'is_daily', False) + if is_daily_tariff: + subscription.is_daily_paused = False + subscription.last_daily_charge_at = datetime.utcnow() + # Для суточного тарифа end_date = сейчас + 1 день (первый день уже оплачен) + subscription.end_date = datetime.utcnow() + timedelta(days=1) + await db.commit() + await db.refresh(subscription) + # Синхронизируем с RemnaWave service = SubscriptionService() await service.update_remnawave_user(db, subscription) @@ -6253,3 +6769,626 @@ async def purchase_tariff_endpoint( balance_kopeks=user.balance_kopeks, balance_label=settings.format_price(user.balance_kopeks), ) + + +def _get_user_period_discount(user, period_days: int) -> int: + """Получает скидку пользователя на период (унифицировано с ботом).""" + promo_group = getattr(user, 'promo_group', None) if user else None + + if promo_group: + discount = promo_group.get_discount_percent("period", period_days) + if discount > 0: + return discount + + personal_discount = get_user_active_promo_discount_percent(user) if user else 0 + return personal_discount + + +def _apply_promo_discount(price: int, discount_percent: int) -> int: + """Применяет скидку к цене.""" + if discount_percent <= 0: + return price + discount = int(price * discount_percent / 100) + return max(0, price - discount) + + +def _calculate_tariff_switch_cost( + current_tariff, + new_tariff, + remaining_days: int, + promo_group=None, + user=None, +) -> tuple[int, bool]: + """ + Рассчитывает стоимость переключения тарифа. + Логика унифицирована с ботом (tariff_purchase.py). + + Формула: (new_monthly - current_monthly) * remaining_days / 30 + Скидка применяется к обоим тарифам одинаково. + + Returns: + (cost_kopeks, is_upgrade) - стоимость доплаты и флаг апгрейда + """ + current_monthly = _get_tariff_monthly_price(current_tariff) + new_monthly = _get_tariff_monthly_price(new_tariff) + + discount_percent = _get_user_period_discount(user, 30) if user else 0 + + # Fallback на promo_group.period_discounts если user не передан + if discount_percent == 0 and promo_group: + raw_discounts = getattr(promo_group, 'period_discounts', None) or {} + for k, v in raw_discounts.items(): + try: + if int(k) == 30: + discount_percent = max(0, min(100, int(v))) + break + except (TypeError, ValueError): + pass + + if discount_percent > 0: + current_monthly = _apply_promo_discount(current_monthly, discount_percent) + new_monthly = _apply_promo_discount(new_monthly, discount_percent) + + price_diff = new_monthly - current_monthly + + if price_diff <= 0: + return 0, False + + upgrade_cost = int(price_diff * remaining_days / 30) + return upgrade_cost, True + + +@router.post("/subscription/tariff/switch/preview") +async def preview_tariff_switch_endpoint( + payload: MiniAppTariffSwitchRequest, + db: AsyncSession = Depends(get_db_session), +): + """Предпросмотр переключения тарифа - показывает стоимость.""" + + user = await _authorize_miniapp_user(payload.init_data, db) + + if not settings.is_tariffs_mode(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"code": "tariffs_mode_disabled", "message": "Tariffs mode is not enabled"}, + ) + + subscription = getattr(user, "subscription", None) + if not subscription or not subscription.tariff_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"code": "no_subscription", "message": "No active subscription with tariff"}, + ) + + if subscription.status not in ("active", "trial"): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"code": "subscription_inactive", "message": "Subscription is not active"}, + ) + + current_tariff = await get_tariff_by_id(db, subscription.tariff_id) + new_tariff = await get_tariff_by_id(db, payload.tariff_id) + + if not new_tariff or not new_tariff.is_active: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={"code": "tariff_not_found", "message": "Tariff not found or inactive"}, + ) + + if subscription.tariff_id == payload.tariff_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"code": "same_tariff", "message": "Already on this tariff"}, + ) + + # Проверяем доступность тарифа для пользователя + promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else getattr(user, "promo_group", None) + promo_group_id = promo_group.id if promo_group else None + if not new_tariff.is_available_for_promo_group(promo_group_id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={"code": "tariff_not_available", "message": "Tariff not available for your promo group"}, + ) + + # Рассчитываем оставшиеся дни + remaining_days = 0 + if subscription.end_date and subscription.end_date > datetime.utcnow(): + delta = subscription.end_date - datetime.utcnow() + remaining_days = max(0, delta.days) + + # Рассчитываем стоимость переключения + current_is_daily = getattr(current_tariff, 'is_daily', False) if current_tariff else False + new_is_daily = getattr(new_tariff, 'is_daily', False) + + if current_is_daily and not new_is_daily: + # Переключение С суточного НА периодный - полная оплата нового тарифа + # Берём минимальную цену из периодов нового тарифа + min_period_price = 0 + if new_tariff.period_prices: + min_period_price = min(new_tariff.period_prices.values()) + upgrade_cost = min_period_price + is_upgrade = min_period_price > 0 + else: + upgrade_cost, is_upgrade = _calculate_tariff_switch_cost( + current_tariff, new_tariff, remaining_days, promo_group, user + ) + + balance = user.balance_kopeks or 0 + has_enough = balance >= upgrade_cost + missing = max(0, upgrade_cost - balance) if not has_enough else 0 + + return MiniAppTariffSwitchPreviewResponse( + can_switch=has_enough, + current_tariff_id=current_tariff.id if current_tariff else None, + current_tariff_name=current_tariff.name if current_tariff else None, + new_tariff_id=new_tariff.id, + new_tariff_name=new_tariff.name, + remaining_days=remaining_days, + upgrade_cost_kopeks=upgrade_cost, + upgrade_cost_label=settings.format_price(upgrade_cost) if upgrade_cost > 0 else "Бесплатно", + balance_kopeks=balance, + balance_label=settings.format_price(balance), + has_enough_balance=has_enough, + missing_amount_kopeks=missing, + missing_amount_label=settings.format_price(missing) if missing > 0 else "", + is_upgrade=is_upgrade, + message=None, + ) + + +@router.post("/subscription/tariff/switch") +async def switch_tariff_endpoint( + payload: MiniAppTariffSwitchRequest, + db: AsyncSession = Depends(get_db_session), +): + """Переключение тарифа без изменения даты окончания.""" + user = await _authorize_miniapp_user(payload.init_data, db) + + if not settings.is_tariffs_mode(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"code": "tariffs_mode_disabled", "message": "Tariffs mode is not enabled"}, + ) + + subscription = getattr(user, "subscription", None) + if not subscription or not subscription.tariff_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"code": "no_subscription", "message": "No active subscription with tariff"}, + ) + + if subscription.status not in ("active", "trial"): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"code": "subscription_inactive", "message": "Subscription is not active"}, + ) + + current_tariff = await get_tariff_by_id(db, subscription.tariff_id) + new_tariff = await get_tariff_by_id(db, payload.tariff_id) + + if not new_tariff or not new_tariff.is_active: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={"code": "tariff_not_found", "message": "Tariff not found or inactive"}, + ) + + if subscription.tariff_id == payload.tariff_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"code": "same_tariff", "message": "Already on this tariff"}, + ) + + # Проверяем доступность тарифа + promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else getattr(user, "promo_group", None) + promo_group_id = promo_group.id if promo_group else None + if not new_tariff.is_available_for_promo_group(promo_group_id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={"code": "tariff_not_available", "message": "Tariff not available"}, + ) + + # Рассчитываем оставшиеся дни + remaining_days = 0 + if subscription.end_date and subscription.end_date > datetime.utcnow(): + delta = subscription.end_date - datetime.utcnow() + remaining_days = max(0, delta.days) + + # Рассчитываем стоимость + current_is_daily = getattr(current_tariff, 'is_daily', False) if current_tariff else False + new_is_daily = getattr(new_tariff, 'is_daily', False) + switching_from_daily = current_is_daily and not new_is_daily + + if switching_from_daily: + # Переключение С суточного НА периодный - полная оплата нового тарифа (минимальный период) + min_period_days = 30 # По умолчанию месяц + min_period_price = 0 + if new_tariff.period_prices: + # Находим минимальный период и его цену + min_period_days = min(int(k) for k in new_tariff.period_prices.keys()) + min_period_price = new_tariff.period_prices.get(str(min_period_days), 0) + upgrade_cost = min_period_price + is_upgrade = min_period_price > 0 + # remaining_days для нового тарифа будет равен min_period_days после покупки + new_period_days = min_period_days + else: + upgrade_cost, is_upgrade = _calculate_tariff_switch_cost( + current_tariff, new_tariff, remaining_days, promo_group, user + ) + new_period_days = 0 # Не меняем дату окончания + + # Списываем доплату если апгрейд + if upgrade_cost > 0: + if user.balance_kopeks < upgrade_cost: + missing = upgrade_cost - user.balance_kopeks + raise HTTPException( + status_code=status.HTTP_402_PAYMENT_REQUIRED, + detail={ + "code": "insufficient_funds", + "message": f"Недостаточно средств. Не хватает {settings.format_price(missing)}", + "missing_amount": missing, + }, + ) + + if switching_from_daily: + description = f"Переход с суточного на тариф '{new_tariff.name}' ({new_period_days} дней)" + else: + description = f"Переход на тариф '{new_tariff.name}' (доплата за {remaining_days} дней)" + success = await subtract_user_balance(db, user, upgrade_cost, description) + if not success: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={"code": "balance_error", "message": "Failed to charge balance"}, + ) + + # Записываем транзакцию + await create_transaction( + db=db, + user_id=user.id, + type=TransactionType.SUBSCRIPTION_PAYMENT, + amount_kopeks=upgrade_cost, + description=description, + ) + + # Получаем список серверов из тарифа + squads = new_tariff.allowed_squads or [] + + # Если allowed_squads пустой - значит "все серверы", получаем их + if not squads: + from app.database.crud.server_squad import get_all_server_squads + all_servers, _ = await get_all_server_squads(db, available_only=True) + squads = [s.squad_uuid for s in all_servers if s.squad_uuid] + + # Обновляем подписку - меняем тариф без изменения даты + subscription.tariff_id = new_tariff.id + subscription.traffic_limit_gb = new_tariff.traffic_limit_gb + subscription.device_limit = new_tariff.device_limit + subscription.connected_squads = squads + # Сбрасываем докупленный трафик при смене тарифа + subscription.purchased_traffic_gb = 0 + subscription.traffic_reset_at = None # Сбрасываем дату сброса трафика + + # Обработка daily полей при смене тарифа + new_is_daily = getattr(new_tariff, 'is_daily', False) + old_is_daily = getattr(current_tariff, 'is_daily', False) + + if new_is_daily: + # Переход на суточный тариф + subscription.is_daily_paused = False + subscription.last_daily_charge_at = datetime.utcnow() + # Для суточного тарифа end_date = сейчас + 1 день + subscription.end_date = datetime.utcnow() + timedelta(days=1) + logger.info(f"🔄 Смена на суточный тариф: установлены daily поля, end_date={subscription.end_date}") + elif old_is_daily and not new_is_daily: + # Переход с суточного на обычный тариф - очищаем daily поля + subscription.is_daily_paused = False + subscription.last_daily_charge_at = None + # Устанавливаем дату окончания для периодного тарифа + if new_period_days > 0: + subscription.end_date = datetime.utcnow() + timedelta(days=new_period_days) + logger.info(f"🔄 Смена с суточного на периодный тариф: end_date={subscription.end_date} ({new_period_days} дней)") + else: + logger.info(f"🔄 Смена с суточного на обычный тариф: очищены daily поля") + + await db.commit() + await db.refresh(subscription) + await db.refresh(user) + + # Синхронизируем с RemnaWave + try: + service = SubscriptionService() + await service.update_remnawave_user(db, subscription) + except Exception as e: + logger.error(f"Ошибка синхронизации с RemnaWave при смене тарифа: {e}") + + lang = getattr(user, "language", settings.DEFAULT_LANGUAGE) + if upgrade_cost > 0: + if lang == "ru": + message = f"Тариф изменён на '{new_tariff.name}'. Списано {settings.format_price(upgrade_cost)}" + else: + message = f"Switched to '{new_tariff.name}'. Charged {settings.format_price(upgrade_cost)}" + else: + if lang == "ru": + message = f"Тариф изменён на '{new_tariff.name}'" + else: + message = f"Switched to '{new_tariff.name}'" + + return MiniAppTariffSwitchResponse( + success=True, + message=message, + tariff_id=new_tariff.id, + tariff_name=new_tariff.name, + charged_kopeks=upgrade_cost, + balance_kopeks=user.balance_kopeks, + balance_label=settings.format_price(user.balance_kopeks), + ) + + +@router.post("/subscription/traffic-topup") +async def purchase_traffic_topup_endpoint( + payload: MiniAppTrafficTopupRequest, + db: AsyncSession = Depends(get_db_session), +): + """Докупка трафика для подписки.""" + from app.webapi.schemas.miniapp import MiniAppTrafficTopupRequest, MiniAppTrafficTopupResponse + from app.database.crud.subscription import add_subscription_traffic + from app.database.crud.user import subtract_user_balance + from app.database.crud.transaction import create_transaction + from app.database.models import TransactionType + from app.utils.pricing_utils import calculate_prorated_price + + user = await _authorize_miniapp_user(payload.init_data, db) + subscription = _ensure_paid_subscription(user) + _validate_subscription_id(payload.subscription_id, subscription) + + # Проверяем режим тарифов + if not settings.is_tariffs_mode(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "code": "tariffs_mode_disabled", + "message": "Traffic top-up is only available in tariffs mode", + }, + ) + + # Проверяем наличие тарифа + tariff_id = getattr(subscription, 'tariff_id', None) + if not tariff_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "code": "no_tariff", + "message": "Subscription has no tariff", + }, + ) + + tariff = await get_tariff_by_id(db, tariff_id) + if not tariff: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={ + "code": "tariff_not_found", + "message": "Tariff not found", + }, + ) + + # Проверяем, разрешена ли докупка трафика + if not getattr(tariff, 'traffic_topup_enabled', False): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={ + "code": "traffic_topup_disabled", + "message": "Traffic top-up is disabled for this tariff", + }, + ) + + # Проверяем безлимит + if tariff.traffic_limit_gb == 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "code": "unlimited_traffic", + "message": "Cannot add traffic to unlimited subscription", + }, + ) + + # Проверяем лимит докупки трафика + max_topup_limit = getattr(tariff, 'max_topup_traffic_gb', 0) or 0 + if max_topup_limit > 0: + current_traffic = subscription.traffic_limit_gb or 0 + new_traffic = current_traffic + payload.gb + if new_traffic > max_topup_limit: + available_gb = max(0, max_topup_limit - current_traffic) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "code": "topup_limit_exceeded", + "message": f"Traffic top-up limit exceeded. Maximum allowed: {max_topup_limit} GB, current: {current_traffic} GB, available: {available_gb} GB", + "max_limit_gb": max_topup_limit, + "current_gb": current_traffic, + "available_gb": available_gb, + }, + ) + + # Получаем цену пакета + packages = tariff.get_traffic_topup_packages() if hasattr(tariff, 'get_traffic_topup_packages') else {} + if payload.gb not in packages: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "code": "invalid_package", + "message": f"Traffic package {payload.gb}GB is not available", + }, + ) + + base_price_kopeks = packages[payload.gb] + + # Применяем скидку промогруппы на трафик + traffic_discount_percent = 0 + promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else getattr(user, "promo_group", None) + if promo_group: + apply_to_addons = getattr(promo_group, 'apply_discounts_to_addons', True) + if apply_to_addons: + traffic_discount_percent = max(0, min(100, int(getattr(promo_group, 'traffic_discount_percent', 0) or 0))) + + if traffic_discount_percent > 0: + base_price_kopeks = int(base_price_kopeks * (100 - traffic_discount_percent) / 100) + + # Пропорциональный расчет цены с учетом оставшегося времени подписки + final_price, months_charged = calculate_prorated_price( + base_price_kopeks, + subscription.end_date, + ) + + # Проверяем баланс + if user.balance_kopeks < final_price: + raise HTTPException( + status_code=status.HTTP_402_PAYMENT_REQUIRED, + detail={ + "code": "insufficient_balance", + "message": "Insufficient balance", + "required": final_price, + "balance": user.balance_kopeks, + }, + ) + + # Списываем баланс + if traffic_discount_percent > 0: + traffic_description = f"Докупка {payload.gb} ГБ трафика (скидка {traffic_discount_percent}%)" + else: + traffic_description = f"Докупка {payload.gb} ГБ трафика" + success = await subtract_user_balance( + db, user, final_price, + traffic_description + ) + if not success: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={ + "code": "balance_error", + "message": "Failed to subtract balance", + }, + ) + + # Добавляем трафик (add_subscription_traffic уже создаёт TrafficPurchase и обновляет все необходимые поля) + await add_subscription_traffic(db, subscription, payload.gb) + + # Синхронизируем с RemnaWave + try: + service = SubscriptionService() + await service.update_remnawave_user(db, subscription) + except Exception as e: + logger.error(f"Ошибка синхронизации с RemnaWave при докупке трафика: {e}") + + # Создаем транзакцию + await create_transaction( + db, + user_id=user.id, + type=TransactionType.SUBSCRIPTION_PAYMENT, + amount_kopeks=-final_price, + description=traffic_description, + ) + + await db.refresh(user) + await db.refresh(subscription) + + return MiniAppTrafficTopupResponse( + success=True, + message=f"Добавлено {payload.gb} ГБ трафика", + new_traffic_limit_gb=subscription.traffic_limit_gb, + new_balance_kopeks=user.balance_kopeks, + charged_kopeks=final_price, + ) + + +@router.post("/subscription/daily/toggle-pause") +async def toggle_daily_subscription_pause_endpoint( + payload: MiniAppDailySubscriptionToggleRequest, + db: AsyncSession = Depends(get_db_session), +): + """Переключает паузу/активацию суточной подписки.""" + from app.webapi.schemas.miniapp import MiniAppDailySubscriptionToggleResponse + from app.services.subscription_service import SubscriptionService + + user = await _authorize_miniapp_user(payload.init_data, db) + subscription = user.subscription + + if not subscription: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={"code": "no_subscription", "message": "No subscription found"}, + ) + + # Проверяем наличие тарифа + tariff_id = getattr(subscription, 'tariff_id', None) + if not tariff_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"code": "no_tariff", "message": "Subscription has no tariff"}, + ) + + tariff = await get_tariff_by_id(db, tariff_id) + if not tariff or not getattr(tariff, 'is_daily', False): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"code": "not_daily_tariff", "message": "Subscription is not on a daily tariff"}, + ) + + # Переключаем состояние паузы + is_currently_paused = getattr(subscription, 'is_daily_paused', False) + new_paused_state = not is_currently_paused + subscription.is_daily_paused = new_paused_state + + # Если снимаем с паузы, нужно проверить баланс для активации + if not new_paused_state: + daily_price = getattr(tariff, 'daily_price_kopeks', 0) + if daily_price > 0 and user.balance_kopeks < daily_price: + raise HTTPException( + status_code=status.HTTP_402_PAYMENT_REQUIRED, + detail={ + "code": "insufficient_balance", + "message": "Insufficient balance to resume daily subscription", + "required": daily_price, + "balance": user.balance_kopeks, + }, + ) + + # Восстанавливаем статус ACTIVE если подписка была DISABLED (недостаток средств) + from app.database.models import SubscriptionStatus + if subscription.status == SubscriptionStatus.DISABLED.value: + subscription.status = SubscriptionStatus.ACTIVE.value + # Обновляем время последнего списания для корректного расчёта следующего + subscription.last_daily_charge_at = datetime.utcnow() + subscription.end_date = datetime.utcnow() + timedelta(days=1) + logger.info( + f"✅ Суточная подписка {subscription.id} восстановлена из DISABLED в ACTIVE" + ) + + await db.commit() + await db.refresh(subscription) + await db.refresh(user) + + # Синхронизация с RemnaWave + # При паузе VPN продолжает работать до конца оплаченного времени, + # поэтому НЕ отключаем пользователя в RemnaWave + # При возобновлении включаем если был отключен (например, из-за истечения срока) + if not new_paused_state: + try: + service = SubscriptionService() + if user.remnawave_uuid: + await service.enable_remnawave_user(user.remnawave_uuid) + except Exception as e: + logger.error(f"Ошибка синхронизации с RemnaWave при возобновлении: {e}") + + lang = getattr(user, "language", settings.DEFAULT_LANGUAGE) + if new_paused_state: + message = "Суточная подписка приостановлена" if lang == "ru" else "Daily subscription paused" + else: + message = "Суточная подписка возобновлена" if lang == "ru" else "Daily subscription resumed" + + return MiniAppDailySubscriptionToggleResponse( + success=True, + message=message, + is_paused=new_paused_state, + balance_kopeks=user.balance_kopeks, + balance_label=settings.format_price(user.balance_kopeks), + ) diff --git a/app/webapi/schemas/ban_notifications.py b/app/webapi/schemas/ban_notifications.py new file mode 100644 index 00000000..3e7b5031 --- /dev/null +++ b/app/webapi/schemas/ban_notifications.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +from typing import Literal, Optional +from pydantic import BaseModel, Field + + +class BanNotificationRequest(BaseModel): + """Запрос на отправку уведомления о бане пользователю""" + + notification_type: Literal["punishment", "enabled", "warning", "network_wifi", "network_mobile"] = Field( + description="Тип уведомления: punishment (бан за устройства), enabled (разбан), warning (предупреждение), network_wifi (бан за WiFi), network_mobile (бан за мобильную сеть)" + ) + user_identifier: str = Field( + description="Email или user_id пользователя из Remnawave Panel" + ) + username: str = Field( + description="Имя пользователя для отображения" + ) + + # Данные для punishment + ip_count: Optional[int] = Field(None, description="Количество устройств") + limit: Optional[int] = Field(None, description="Лимит устройств") + ban_minutes: Optional[int] = Field(None, description="Длительность бана в минутах") + + # Данные для warning + warning_message: Optional[str] = Field(None, description="Текст предупреждения") + + # Данные для network_wifi/network_mobile и punishment + network_type: Optional[str] = Field(None, description="Тип сети (WiFi/Mobile)") + node_name: Optional[str] = Field(None, description="Название ноды/сервера с которой пришел бан") + + class Config: + json_schema_extra = { + "example": { + "notification_type": "punishment", + "user_identifier": "user@example.com", + "username": "john_doe", + "ip_count": 5, + "limit": 3, + "ban_minutes": 30, + "node_name": "DE-Server-1" + } + } + + +class BanNotificationResponse(BaseModel): + """Ответ на запрос отправки уведомления""" + + success: bool = Field(description="Успешно ли отправлено уведомление") + message: str = Field(description="Сообщение о результате") + telegram_id: Optional[int] = Field(None, description="Telegram ID получателя") + sent: bool = Field(False, description="Было ли фактически отправлено сообщение") + + class Config: + json_schema_extra = { + "example": { + "success": True, + "message": "Уведомление отправлено", + "telegram_id": 123456789, + "sent": True + } + } diff --git a/app/webapi/schemas/miniapp.py b/app/webapi/schemas/miniapp.py index e33ad8b1..0bac786e 100644 --- a/app/webapi/schemas/miniapp.py +++ b/app/webapi/schemas/miniapp.py @@ -47,6 +47,13 @@ class MiniAppSubscriptionUser(BaseModel): promo_offer_discount_percent: int = 0 promo_offer_discount_expires_at: Optional[datetime] = None promo_offer_discount_source: Optional[str] = None + # Суточные тарифы + is_daily_tariff: bool = False + is_daily_paused: bool = False + daily_tariff_name: Optional[str] = None + daily_price_kopeks: Optional[int] = None + daily_price_label: Optional[str] = None + daily_next_charge_at: Optional[datetime] = None # Время следующего списания class MiniAppPromoGroup(BaseModel): @@ -55,7 +62,7 @@ class MiniAppPromoGroup(BaseModel): server_discount_percent: int = 0 traffic_discount_percent: int = 0 device_discount_percent: int = 0 - period_discounts: Dict[int, int] = Field(default_factory=dict) + period_discounts: Dict[str, int] = Field(default_factory=dict) apply_discounts_to_addons: bool = True @@ -70,7 +77,7 @@ class MiniAppAutoPromoGroupLevel(BaseModel): server_discount_percent: int = 0 traffic_discount_percent: int = 0 device_discount_percent: int = 0 - period_discounts: Dict[int, int] = Field(default_factory=dict) + period_discounts: Dict[str, int] = Field(default_factory=dict) apply_discounts_to_addons: bool = True @@ -503,6 +510,10 @@ class MiniAppTariffPeriod(BaseModel): price_label: str price_per_month_kopeks: Optional[int] = None price_per_month_label: Optional[str] = None + # Скидка промогруппы + original_price_kopeks: Optional[int] = None # Цена без скидки + original_price_label: Optional[str] = None + discount_percent: int = 0 # Процент скидки class MiniAppTariff(BaseModel): @@ -520,6 +531,26 @@ class MiniAppTariff(BaseModel): periods: List[MiniAppTariffPeriod] = Field(default_factory=list) is_current: bool = False is_available: bool = True + # Для режима мгновенного переключения тарифа + switch_cost_kopeks: Optional[int] = None # Стоимость переключения (None если не в режиме switch) + switch_cost_label: Optional[str] = None # Форматированная стоимость + is_upgrade: Optional[bool] = None # True = повышение, False = понижение + is_switch_free: Optional[bool] = None # True = бесплатное переключение + # Суточные тарифы + is_daily: bool = False + daily_price_kopeks: int = 0 + daily_price_label: Optional[str] = None + + +class MiniAppTrafficTopupPackage(BaseModel): + """Пакет докупки трафика.""" + gb: int + price_kopeks: int + price_label: str + # Скидка промогруппы на трафик + original_price_kopeks: Optional[int] = None + original_price_label: Optional[str] = None + discount_percent: int = 0 class MiniAppCurrentTariff(BaseModel): @@ -533,6 +564,34 @@ class MiniAppCurrentTariff(BaseModel): is_unlimited_traffic: bool = False device_limit: int servers_count: int + # Месячная цена для расчёта стоимости переключения тарифа + monthly_price_kopeks: int = 0 + # Докупка трафика + traffic_topup_enabled: bool = False + traffic_topup_packages: List[MiniAppTrafficTopupPackage] = Field(default_factory=list) + # Лимит докупки трафика (0 = без лимита) + max_topup_traffic_gb: int = 0 + available_topup_gb: Optional[int] = None # Сколько еще можно докупить (None = без лимита) + # Суточные тарифы + is_daily: bool = False + daily_price_kopeks: int = 0 + daily_price_label: Optional[str] = None + + +class MiniAppTrafficTopupRequest(BaseModel): + """Запрос на докупку трафика.""" + init_data: str = Field(..., alias="initData") + subscription_id: Optional[int] = Field(None, alias="subscriptionId") + gb: int + + +class MiniAppTrafficTopupResponse(BaseModel): + """Ответ на докупку трафика.""" + success: bool = True + message: str = "" + new_traffic_limit_gb: int = 0 + new_balance_kopeks: int = 0 + charged_kopeks: int = 0 class MiniAppTariffsRequest(BaseModel): @@ -548,6 +607,7 @@ class MiniAppTariffsResponse(BaseModel): current_tariff: Optional[MiniAppCurrentTariff] = None balance_kopeks: int = 0 balance_label: Optional[str] = None + promo_group: Optional[MiniAppPromoGroup] = None # Промогруппа пользователя для отображения скидок class MiniAppTariffPurchaseRequest(BaseModel): @@ -569,11 +629,72 @@ class MiniAppTariffPurchaseResponse(BaseModel): balance_label: Optional[str] = None +class MiniAppTariffSwitchRequest(BaseModel): + """Запрос на переключение тарифа (без выбора периода).""" + init_data: str = Field(...) + tariff_id: int = Field(...) + + +class MiniAppTariffSwitchPreviewResponse(BaseModel): + """Предпросмотр переключения тарифа.""" + can_switch: bool = True + current_tariff_id: Optional[int] = None + current_tariff_name: Optional[str] = None + new_tariff_id: int + new_tariff_name: str + remaining_days: int = 0 + upgrade_cost_kopeks: int = 0 # 0 если даунгрейд или равная цена + upgrade_cost_label: str = "" + balance_kopeks: int = 0 + balance_label: str = "" + has_enough_balance: bool = True + missing_amount_kopeks: int = 0 + missing_amount_label: str = "" + is_upgrade: bool = False # True если новый тариф дороже + message: Optional[str] = None + + +class MiniAppTariffSwitchResponse(BaseModel): + """Ответ на переключение тарифа.""" + success: bool = True + message: Optional[str] = None + tariff_id: int + tariff_name: str + charged_kopeks: int = 0 + balance_kopeks: int = 0 + balance_label: str = "" + + +class MiniAppDailySubscriptionToggleRequest(BaseModel): + """Запрос на паузу/возобновление суточной подписки.""" + init_data: str = Field(...) + + +class MiniAppDailySubscriptionToggleResponse(BaseModel): + """Ответ на паузу/возобновление суточной подписки.""" + success: bool = True + message: Optional[str] = None + is_paused: bool = False + balance_kopeks: int = 0 + balance_label: str = "" + + +class MiniAppTrafficPurchase(BaseModel): + """Докупка трафика с индивидуальной датой истечения.""" + id: int + traffic_gb: int + expires_at: datetime + created_at: datetime + days_remaining: int + progress_percent: float + + class MiniAppSubscriptionResponse(BaseModel): success: bool = True subscription_id: Optional[int] = None remnawave_short_uuid: Optional[str] = None user: MiniAppSubscriptionUser + traffic_purchases: List[MiniAppTrafficPurchase] = Field(default_factory=list) subscription_url: Optional[str] = None subscription_crypto_link: Optional[str] = None subscription_purchase_url: Optional[str] = None diff --git a/app/webserver/payments.py b/app/webserver/payments.py index 113b1ac5..fd10c53f 100644 --- a/app/webserver/payments.py +++ b/app/webserver/payments.py @@ -798,6 +798,100 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute routes_registered = True + if settings.is_freekassa_enabled(): + @router.options(settings.FREEKASSA_WEBHOOK_PATH) + async def freekassa_options() -> Response: + return Response( + status_code=status.HTTP_200_OK, + headers={ + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "POST, GET, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + }, + ) + + @router.get(settings.FREEKASSA_WEBHOOK_PATH) + async def freekassa_health() -> JSONResponse: + return JSONResponse( + { + "status": "ok", + "service": "freekassa_webhook", + "enabled": settings.is_freekassa_enabled(), + } + ) + + @router.post(settings.FREEKASSA_WEBHOOK_PATH) + async def freekassa_webhook(request: Request) -> Response: + # Получаем IP клиента с учетом прокси + x_forwarded_for = request.headers.get("X-Forwarded-For") + if x_forwarded_for: + client_ip = x_forwarded_for.split(",")[0].strip() + else: + real_ip = request.headers.get("X-Real-IP") + if real_ip: + client_ip = real_ip.strip() + else: + client_ip = request.client.host if request.client else "127.0.0.1" + + # Получаем данные формы + try: + form_data = await request.form() + except Exception: + logger.error("Freekassa webhook: не удалось прочитать данные формы") + return Response("Error reading form data", status_code=status.HTTP_400_BAD_REQUEST) + + # Извлекаем параметры + merchant_id = form_data.get("MERCHANT_ID") + amount = form_data.get("AMOUNT") + order_id = form_data.get("MERCHANT_ORDER_ID") + sign = form_data.get("SIGN") + intid = form_data.get("intid") + cur_id = form_data.get("CUR_ID") + + if not all([merchant_id, amount, order_id, sign, intid]): + logger.warning("Freekassa webhook: отсутствуют обязательные параметры") + return Response("Missing parameters", status_code=status.HTTP_400_BAD_REQUEST) + + # Преобразуем типы + try: + merchant_id_int = int(merchant_id) + amount_float = float(amount) + cur_id_int = int(cur_id) if cur_id else None + except ValueError: + logger.warning("Freekassa webhook: неверный формат параметров") + return Response("Invalid parameters format", status_code=status.HTTP_400_BAD_REQUEST) + + # Обрабатываем callback + db_generator = get_db() + try: + db = await db_generator.__anext__() + except StopAsyncIteration: + return Response("DB Error", status_code=status.HTTP_500_INTERNAL_SERVER_ERROR) + + try: + success = await payment_service.process_freekassa_webhook( + db, + merchant_id=merchant_id_int, + amount=amount_float, + order_id=order_id, + sign=sign, + intid=intid, + cur_id=cur_id_int, + client_ip=client_ip, + ) + finally: + try: + await db_generator.__anext__() + except StopAsyncIteration: + pass + + if success: + return Response("YES", status_code=status.HTTP_200_OK) + + return Response("Error", status_code=status.HTTP_400_BAD_REQUEST) + + routes_registered = True + if routes_registered: @router.get("/health/payment-webhooks") async def payment_webhooks_health() -> JSONResponse: @@ -813,6 +907,7 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute "pal24_enabled": settings.is_pal24_enabled(), "platega_enabled": settings.is_platega_enabled(), "cloudpayments_enabled": settings.is_cloudpayments_enabled(), + "freekassa_enabled": settings.is_freekassa_enabled(), } ) diff --git a/app/webserver/unified_app.py b/app/webserver/unified_app.py index 50375e50..d93a52b7 100644 --- a/app/webserver/unified_app.py +++ b/app/webserver/unified_app.py @@ -120,6 +120,7 @@ def create_unified_app( "pal24": settings.is_pal24_enabled(), "wata": settings.is_wata_enabled(), "heleket": settings.is_heleket_enabled(), + "freekassa": settings.is_freekassa_enabled(), } if enable_telegram_webhook: diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 26893b7c..61bb6c5f 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -7,14 +7,18 @@ services: POSTGRES_DB: ${POSTGRES_DB:-remnawave_bot} POSTGRES_USER: ${POSTGRES_USER:-remnawave_user} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-secure_password_123} - POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C" + POSTGRES_INITDB_ARGS: '--encoding=UTF8 --locale=C' volumes: - postgres_data:/var/lib/postgresql/data networks: - bot_network - - remnawave-network # Подключаем к сети панели + - remnawave-network # Подключаем к сети панели healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-remnawave_user} -d ${POSTGRES_DB:-remnawave_bot}"] + test: + [ + 'CMD-SHELL', + 'pg_isready -U ${POSTGRES_USER:-remnawave_user} -d ${POSTGRES_DB:-remnawave_bot}', + ] interval: 30s timeout: 5s retries: 5 @@ -29,9 +33,9 @@ services: - redis_data:/data networks: - bot_network - - remnawave-network # Подключаем к сети панели + - remnawave-network # Подключаем к сети панели healthcheck: - test: ["CMD", "redis-cli", "ping"] + test: ['CMD', 'redis-cli', 'ping'] interval: 30s timeout: 10s retries: 3 @@ -48,16 +52,16 @@ services: env_file: - .env environment: - DOCKER_ENV: "true" - DATABASE_MODE: "auto" - POSTGRES_HOST: "postgres" - POSTGRES_PORT: "5432" - POSTGRES_DB: "${POSTGRES_DB:-remnawave_bot}" - POSTGRES_USER: "${POSTGRES_USER:-remnawave_user}" - POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:-secure_password_123}" - REDIS_URL: "redis://redis:6379/0" - TZ: "Europe/Moscow" - LOCALES_PATH: "${LOCALES_PATH:-/app/locales}" + DOCKER_ENV: 'true' + DATABASE_MODE: 'auto' + POSTGRES_HOST: 'postgres' + POSTGRES_PORT: '5432' + POSTGRES_DB: '${POSTGRES_DB:-remnawave_bot}' + POSTGRES_USER: '${POSTGRES_USER:-remnawave_user}' + POSTGRES_PASSWORD: '${POSTGRES_PASSWORD:-secure_password_123}' + REDIS_URL: 'redis://redis:6379/0' + TZ: 'Europe/Moscow' + LOCALES_PATH: '${LOCALES_PATH:-/app/locales}' volumes: - ./logs:/app/logs:rw - ./data:/app/data:rw @@ -66,12 +70,16 @@ services: - /etc/localtime:/etc/localtime:ro - ./vpn_logo.png:/app/vpn_logo.png:ro ports: - - "${WEB_API_PORT:-8080}:8080" + - '${WEB_API_PORT:-8080}:8080' networks: - bot_network - - remnawave-network # Подключаем к сети панели + - remnawave-network # Подключаем к сети панели healthcheck: - test: ["CMD-SHELL", "python -c \"import requests, os; requests.get('http://localhost:8080/health', headers={'X-API-Key': os.environ.get('WEB_API_DEFAULT_TOKEN')}, timeout=5) or exit(1)\""] + test: + [ + 'CMD-SHELL', + 'python -c "import requests, os; requests.get(''http://localhost:8080/health'', headers={''X-API-Key'': os.environ.get(''WEB_API_DEFAULT_TOKEN'')}, timeout=5) or exit(1)"', + ] interval: 60s timeout: 10s retries: 3 @@ -90,7 +98,9 @@ networks: config: - subnet: 172.20.0.0/16 gateway: 172.20.0.1 - + driver_opts: + com.docker.network.driver.mtu: 1350 + remnawave-network: name: remnawave-network - external: true # Используем существующую сеть панели + external: true # Используем существующую сеть панели diff --git a/docker-compose.yml b/docker-compose.yml index c62611f2..1d015da6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,13 +7,17 @@ services: POSTGRES_DB: ${POSTGRES_DB:-remnawave_bot} POSTGRES_USER: ${POSTGRES_USER:-remnawave_user} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-secure_password_123} - POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C" + POSTGRES_INITDB_ARGS: '--encoding=UTF8 --locale=C' volumes: - postgres_data:/var/lib/postgresql/data networks: - bot_network healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-remnawave_user} -d ${POSTGRES_DB:-remnawave_bot}"] + test: + [ + 'CMD-SHELL', + 'pg_isready -U ${POSTGRES_USER:-remnawave_user} -d ${POSTGRES_DB:-remnawave_bot}', + ] interval: 30s timeout: 5s retries: 5 @@ -29,7 +33,7 @@ services: networks: - bot_network healthcheck: - test: ["CMD", "redis-cli", "ping"] + test: ['CMD', 'redis-cli', 'ping'] interval: 30s timeout: 10s retries: 3 @@ -46,18 +50,18 @@ services: env_file: - .env environment: - DOCKER_ENV: "true" - DATABASE_MODE: "auto" - POSTGRES_HOST: "postgres" - POSTGRES_PORT: "5432" - POSTGRES_DB: "${POSTGRES_DB:-remnawave_bot}" - POSTGRES_USER: "${POSTGRES_USER:-remnawave_user}" - POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:-secure_password_123}" - - REDIS_URL: "redis://redis:6379/0" - - TZ: "Europe/Moscow" - LOCALES_PATH: "${LOCALES_PATH:-/app/locales}" + DOCKER_ENV: 'true' + DATABASE_MODE: 'auto' + POSTGRES_HOST: 'postgres' + POSTGRES_PORT: '5432' + POSTGRES_DB: '${POSTGRES_DB:-remnawave_bot}' + POSTGRES_USER: '${POSTGRES_USER:-remnawave_user}' + POSTGRES_PASSWORD: '${POSTGRES_PASSWORD:-secure_password_123}' + + REDIS_URL: 'redis://redis:6379/0' + + TZ: 'Europe/Moscow' + LOCALES_PATH: '${LOCALES_PATH:-/app/locales}' volumes: # Логи - ./logs:/app/logs:rw @@ -72,11 +76,15 @@ services: # Логотип для сообщений - ./vpn_logo.png:/app/vpn_logo.png:ro ports: - - "${WEB_API_PORT:-8080}:8080" + - '${WEB_API_PORT:-8080}:8080' networks: - bot_network healthcheck: - test: ["CMD-SHELL", "python -c \"import requests, os; requests.get('http://localhost:8080/health', headers={'X-API-Key': os.environ.get('WEB_API_DEFAULT_TOKEN')}, timeout=5) or exit(1)\""] + test: + [ + 'CMD-SHELL', + 'python -c "import requests, os; requests.get(''http://localhost:8080/health'', headers={''X-API-Key'': os.environ.get(''WEB_API_DEFAULT_TOKEN'')}, timeout=5) or exit(1)"', + ] interval: 60s timeout: 10s retries: 3 @@ -95,3 +103,5 @@ networks: config: - subnet: 172.20.0.0/16 gateway: 172.20.0.1 + driver_opts: + com.docker.network.driver.mtu: 1350 diff --git a/main.py b/main.py index d20cbcd4..a6be92b6 100644 --- a/main.py +++ b/main.py @@ -36,11 +36,13 @@ from app.services.referral_contest_service import referral_contest_service from app.services.contest_rotation_service import contest_rotation_service from app.services.nalogo_queue_service import nalogo_queue_service from app.services.traffic_monitoring_service import traffic_monitoring_scheduler +from app.services.daily_subscription_service import daily_subscription_service from app.utils.startup_timeline import StartupTimeline from app.utils.timezone import TimezoneAwareFormatter from app.utils.log_handlers import LevelFilterHandler, ExcludePaymentFilter from app.utils.payment_logger import payment_logger, configure_payment_logger from app.services.log_rotation_service import log_rotation_service +from app.services.ban_notification_service import ban_notification_service class GracefulExit: @@ -174,6 +176,7 @@ async def main(): maintenance_task = None version_check_task = None traffic_monitoring_task = None + daily_subscription_task = None polling_task = None web_api_server = None telegram_webhook_enabled = False @@ -219,6 +222,34 @@ async def main(): "SKIP_MIGRATION=true", ) + async with timeline.stage( + "Синхронизация тарифов из конфига", + "💰", + success_message="Тарифы синхронизированы", + ) as stage: + try: + from app.database.crud.tariff import ensure_tariffs_synced + from app.database.database import AsyncSessionLocal + async with AsyncSessionLocal() as db: + await ensure_tariffs_synced(db) + except Exception as error: + stage.warning(f"Не удалось синхронизировать тарифы: {error}") + logger.error(f"❌ Не удалось синхронизировать тарифы: {error}") + + async with timeline.stage( + "Синхронизация серверов из RemnaWave", + "🖥️", + success_message="Серверы синхронизированы", + ) as stage: + try: + from app.database.crud.server_squad import ensure_servers_synced + from app.database.database import AsyncSessionLocal + async with AsyncSessionLocal() as db: + await ensure_servers_synced(db) + except Exception as error: + stage.warning(f"Не удалось синхронизировать серверы: {error}") + logger.error(f"❌ Не удалось синхронизировать серверы: {error}") + async with timeline.stage( "Загрузка конфигурации из БД", "⚙️", @@ -239,7 +270,9 @@ async def main(): monitoring_service.bot = bot maintenance_service.set_bot(bot) broadcast_service.set_bot(bot) + ban_notification_service.set_bot(bot) traffic_monitoring_scheduler.set_bot(bot) + daily_subscription_service.set_bot(bot) from app.services.admin_notification_service import AdminNotificationService @@ -596,6 +629,21 @@ async def main(): traffic_monitoring_task = None stage.skip("Мониторинг трафика отключен настройками") + async with timeline.stage( + "Суточные подписки", + "💳", + success_message="Сервис суточных подписок запущен", + ) as stage: + if daily_subscription_service.is_enabled(): + daily_subscription_task = asyncio.create_task( + daily_subscription_service.start_monitoring() + ) + interval_minutes = daily_subscription_service.get_check_interval_minutes() + stage.log(f"Интервал проверки: {interval_minutes} мин") + else: + daily_subscription_task = None + stage.skip("Суточные подписки отключены настройками") + async with timeline.stage( "Сервис проверки версий", "📄", @@ -647,6 +695,8 @@ async def main(): webhook_lines.append(f"WATA: {_fmt(settings.WATA_WEBHOOK_PATH)}") if settings.is_heleket_enabled(): webhook_lines.append(f"Heleket: {_fmt(settings.HELEKET_WEBHOOK_PATH)}") + if settings.is_freekassa_enabled(): + webhook_lines.append(f"Freekassa: {_fmt(settings.FREEKASSA_WEBHOOK_PATH)}") timeline.log_section( "Активные webhook endpoints", @@ -658,6 +708,7 @@ async def main(): f"Мониторинг: {'Включен' if monitoring_task else 'Отключен'}", f"Техработы: {'Включен' if maintenance_task else 'Отключен'}", f"Мониторинг трафика: {'Включен' if traffic_monitoring_task else 'Отключен'}", + f"Суточные подписки: {'Включен' if daily_subscription_task else 'Отключен'}", f"Проверка версий: {'Включен' if version_check_task else 'Отключен'}", f"Отчеты: {'Включен' if reporting_service.is_running() else 'Отключен'}", ] @@ -712,6 +763,16 @@ async def main(): traffic_monitoring_scheduler.start_monitoring() ) + if daily_subscription_task and daily_subscription_task.done(): + exception = daily_subscription_task.exception() + if exception: + logger.error(f"Сервис суточных подписок завершился с ошибкой: {exception}") + if daily_subscription_service.is_enabled(): + logger.info("🔄 Перезапуск сервиса суточных подписок...") + daily_subscription_task = asyncio.create_task( + daily_subscription_service.start_monitoring() + ) + if auto_verification_active and not auto_payment_verification_service.is_running(): logger.warning( "Сервис автопроверки пополнений остановился, пробуем перезапустить..." @@ -781,6 +842,15 @@ async def main(): except asyncio.CancelledError: pass + if daily_subscription_task and not daily_subscription_task.done(): + logger.info("ℹ️ Остановка сервиса суточных подписок...") + daily_subscription_service.stop_monitoring() + daily_subscription_task.cancel() + try: + await daily_subscription_task + except asyncio.CancelledError: + pass + logger.info("ℹ️ Остановка сервиса отчетов...") try: await reporting_service.stop() diff --git a/miniapp/index.html b/miniapp/index.html index acab2673..596889c7 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -725,6 +725,80 @@ font-size: 14px; } + /* Бейдж скидки промогруппы на карточке тарифа */ + .tariff-discount-badge { + display: inline-block; + padding: 3px 8px; + background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%); + color: #fff; + font-size: 11px; + font-weight: 700; + border-radius: 8px; + margin-top: 4px; + box-shadow: 0 2px 4px rgba(34, 197, 94, 0.3); + animation: discount-pulse 2s ease-in-out infinite; + } + + @keyframes discount-pulse { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.05); } + } + + :root[data-theme="dark"] .tariff-discount-badge { + background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%); + box-shadow: 0 2px 8px rgba(34, 197, 94, 0.4); + } + + /* Баннер скидки промогруппы */ + .promo-group-discount-banner { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 16px; + margin-bottom: 16px; + background: linear-gradient(135deg, rgba(34, 197, 94, 0.12) 0%, rgba(16, 163, 74, 0.08) 100%); + border: 1px solid rgba(34, 197, 94, 0.25); + border-radius: 14px; + animation: banner-glow 3s ease-in-out infinite; + } + + @keyframes banner-glow { + 0%, 100% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0); } + 50% { box-shadow: 0 0 15px 2px rgba(34, 197, 94, 0.15); } + } + + .promo-group-discount-icon { + font-size: 28px; + filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.1)); + } + + .promo-group-discount-content { + flex: 1; + min-width: 0; + } + + .promo-group-discount-title { + font-weight: 700; + font-size: 14px; + color: #22c55e; + line-height: 1.3; + } + + .promo-group-discount-subtitle { + font-size: 12px; + color: var(--text-secondary); + margin-top: 2px; + } + + :root[data-theme="dark"] .promo-group-discount-banner { + background: linear-gradient(135deg, rgba(34, 197, 94, 0.18) 0%, rgba(16, 163, 74, 0.1) 100%); + border-color: rgba(34, 197, 94, 0.35); + } + + :root[data-theme="dark"] .promo-group-discount-title { + color: #4ade80; + } + :root[data-theme="dark"] .tariff-name-badge { background: linear-gradient(135deg, rgba(var(--primary-rgb), 0.25), rgba(var(--primary-rgb), 0.1)); } @@ -742,6 +816,313 @@ background: linear-gradient(135deg, rgba(34, 197, 94, 0.2), rgba(34, 197, 94, 0.08)); } + /* ============================================ + Instant Tariff Switch Styles + ============================================ */ + .instant-switch-current-info { + display: flex; + gap: 16px; + padding: 16px; + background: var(--bg-secondary); + border-radius: 12px; + margin-bottom: 12px; + } + + .instant-switch-current-tariff, + .instant-switch-remaining { + flex: 1; + } + + .instant-switch-label { + font-size: 11px; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 4px; + } + + .instant-switch-value { + font-size: 15px; + font-weight: 600; + color: var(--text-primary); + } + + .instant-switch-hint { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 12px 14px; + background: linear-gradient(135deg, rgba(59, 130, 246, 0.08) 0%, rgba(59, 130, 246, 0.04) 100%); + border: 1px solid rgba(59, 130, 246, 0.15); + border-radius: 10px; + margin-bottom: 4px; + } + + .instant-switch-hint-icon { + font-size: 18px; + flex-shrink: 0; + } + + .instant-switch-hint-text { + font-size: 12px; + color: var(--text-secondary); + line-height: 1.5; + } + + /* Tariff item in instant switch */ + .instant-switch-tariff-item { + display: flex; + align-items: flex-start; + justify-content: space-between; + padding: 16px; + margin-bottom: 12px; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 14px; + cursor: pointer; + transition: all 0.2s ease; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); + } + + .instant-switch-tariff-item:last-child { + margin-bottom: 0; + } + + .instant-switch-tariff-item:hover { + border-color: var(--primary); + background: rgba(var(--primary-rgb), 0.03); + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(var(--primary-rgb), 0.12); + } + + .instant-switch-tariff-item:active { + transform: translateY(0); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); + } + + .instant-switch-tariff-item.current { + opacity: 0.5; + cursor: default; + pointer-events: none; + } + + .instant-switch-tariff-info { + flex: 1; + min-width: 0; + } + + .instant-switch-tariff-name { + font-weight: 600; + font-size: 15px; + color: var(--text-primary); + margin-bottom: 6px; + display: flex; + align-items: center; + gap: 6px; + } + + .instant-switch-tariff-details { + display: flex; + gap: 14px; + font-size: 13px; + color: var(--text-secondary); + } + + .instant-switch-tariff-details span { + display: inline-flex; + align-items: center; + gap: 4px; + } + + .instant-switch-tariff-cost { + text-align: right; + flex-shrink: 0; + margin-left: 12px; + } + + .instant-switch-cost-badge { + display: inline-flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 8px 14px; + border-radius: 12px; + font-size: 14px; + font-weight: 600; + min-width: 80px; + text-align: center; + } + + .instant-switch-cost-badge.upgrade { + background: linear-gradient(135deg, rgba(249, 115, 22, 0.12) 0%, rgba(251, 146, 60, 0.08) 100%); + color: #ea580c; + border: 1px solid rgba(249, 115, 22, 0.2); + } + + .instant-switch-cost-badge.free { + background: linear-gradient(135deg, rgba(34, 197, 94, 0.12) 0%, rgba(74, 222, 128, 0.08) 100%); + color: #16a34a; + border: 1px solid rgba(34, 197, 94, 0.2); + } + + /* Confirmation panel */ + .instant-switch-confirm { + margin-top: 20px; + padding: 20px; + background: var(--bg-secondary); + border-radius: 16px; + border: 2px solid var(--primary); + animation: slideUp 0.3s ease; + } + + @keyframes slideUp { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + + .instant-switch-confirm-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; + } + + .instant-switch-confirm-title { + font-size: 16px; + font-weight: 700; + color: var(--text-primary); + } + + .instant-switch-confirm-close { + width: 28px; + height: 28px; + border-radius: 50%; + border: none; + background: var(--bg-tertiary, var(--bg-primary)); + color: var(--text-secondary); + font-size: 18px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + } + + .instant-switch-confirm-close:hover { + background: var(--bg-primary); + color: var(--text-primary); + } + + .instant-switch-compare { + display: flex; + align-items: center; + justify-content: center; + gap: 16px; + margin-bottom: 16px; + } + + .instant-switch-compare-item { + text-align: center; + flex: 1; + } + + .instant-switch-compare-label { + font-size: 11px; + color: var(--text-secondary); + text-transform: uppercase; + margin-bottom: 4px; + } + + .instant-switch-compare-value { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + } + + .instant-switch-compare-arrow { + font-size: 20px; + color: var(--primary); + flex-shrink: 0; + } + + .instant-switch-cost { + text-align: center; + padding: 12px; + background: var(--bg-primary); + border-radius: 10px; + margin-bottom: 12px; + } + + .instant-switch-cost-label { + font-size: 13px; + color: var(--text-secondary); + } + + .instant-switch-cost-value { + font-size: 18px; + font-weight: 700; + color: var(--primary); + margin-left: 8px; + } + + .instant-switch-cost-value.free { + color: #22c55e; + } + + .instant-switch-balance { + text-align: center; + font-size: 13px; + color: var(--text-secondary); + margin-bottom: 8px; + } + + .instant-switch-insufficient { + text-align: center; + font-size: 13px; + color: #ef4444; + padding: 10px; + background: rgba(239, 68, 68, 0.1); + border-radius: 8px; + margin-bottom: 12px; + } + + .instant-switch-confirm-actions { + display: flex; + gap: 10px; + margin-top: 16px; + } + + .instant-switch-confirm-actions .btn { + flex: 1; + } + + .instant-switch-confirm-actions .btn-secondary { + background: var(--bg-tertiary, var(--bg-primary)); + color: var(--text-primary); + border: 1px solid var(--border-color); + } + + :root[data-theme="dark"] .instant-switch-hint { + background: linear-gradient(135deg, rgba(59, 130, 246, 0.12) 0%, rgba(59, 130, 246, 0.06) 100%); + border-color: rgba(59, 130, 246, 0.2); + } + + :root[data-theme="dark"] .instant-switch-cost-badge.upgrade { + background: linear-gradient(135deg, rgba(249, 115, 22, 0.2) 0%, rgba(249, 115, 22, 0.1) 100%); + color: #fb923c; + } + + :root[data-theme="dark"] .instant-switch-cost-badge.free { + background: linear-gradient(135deg, rgba(34, 197, 94, 0.2) 0%, rgba(34, 197, 94, 0.1) 100%); + color: #4ade80; + } + .subscription-settings-toggle.active .tariff-name-badge { background: linear-gradient(135deg, rgba(var(--primary-rgb), 0.25), rgba(var(--primary-rgb), 0.12)); box-shadow: 0 2px 6px rgba(var(--primary-rgb), 0.15); @@ -2326,6 +2707,185 @@ color: #41464b; } + .status-paused { + background: linear-gradient(135deg, #e2e3e5, #eeeff1); + color: #41464b; + } + + /* Daily Subscription Status Bar */ + .daily-subscription-status { + margin-top: 16px; + padding: 16px; + background: linear-gradient(135deg, rgba(var(--primary-rgb), 0.08), rgba(var(--primary-rgb), 0.04)); + border-radius: var(--radius-lg); + border: 1px solid rgba(var(--primary-rgb), 0.15); + } + + .daily-subscription-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; + } + + .daily-subscription-title { + font-size: 14px; + font-weight: 700; + color: var(--text-primary); + display: flex; + align-items: center; + gap: 8px; + } + + .daily-subscription-title .daily-icon { + font-size: 18px; + } + + .daily-subscription-price { + font-size: 14px; + font-weight: 600; + color: var(--primary); + } + + .daily-subscription-progress { + margin-bottom: 12px; + transition: opacity 0.3s ease; + } + + .daily-subscription-progress.paused { + opacity: 0.5; + } + + .daily-subscription-progress.paused .daily-progress-fill { + animation: none; + background: var(--hint-color); + } + + .daily-progress-bar { + height: 8px; + background: rgba(var(--primary-rgb), 0.15); + border-radius: 4px; + overflow: hidden; + position: relative; + } + + .daily-progress-fill { + height: 100%; + background: linear-gradient(90deg, var(--primary), rgba(var(--primary-rgb), 0.7)); + border-radius: 4px; + transition: width 0.5s ease; + position: relative; + } + + .daily-progress-fill::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent); + animation: shimmer 2s infinite; + } + + .daily-subscription-info { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 13px; + } + + .daily-time-remaining { + color: var(--text-secondary); + } + + .daily-time-remaining strong { + color: var(--text-primary); + font-weight: 600; + } + + .daily-next-charge { + color: var(--text-secondary); + } + + .daily-subscription-actions { + margin-top: 12px; + display: flex; + gap: 8px; + } + + .daily-pause-btn { + flex: 1; + padding: 10px 16px; + border-radius: var(--radius); + border: 2px solid var(--border-color); + background: var(--bg-secondary); + color: var(--text-primary); + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + } + + .daily-pause-btn:hover { + border-color: var(--primary); + background: rgba(var(--primary-rgb), 0.05); + } + + .daily-pause-btn.paused { + background: linear-gradient(135deg, var(--primary), rgba(var(--primary-rgb), 0.8)); + color: white; + border-color: transparent; + } + + .daily-pause-btn.paused:hover { + opacity: 0.9; + } + + .daily-pause-btn:disabled { + opacity: 0.6; + cursor: not-allowed; + } + + .daily-subscription-paused-notice { + margin: 16px; + padding: 14px 16px; + background: linear-gradient(135deg, rgba(var(--warning-rgb), 0.12), rgba(var(--warning-rgb), 0.06)); + border-radius: 12px; + border: 1px solid rgba(var(--warning-rgb), 0.25); + font-size: 13px; + line-height: 1.5; + color: var(--text-primary); + display: flex; + align-items: flex-start; + gap: 12px; + box-shadow: 0 2px 8px rgba(var(--warning-rgb), 0.08); + } + + .daily-subscription-paused-notice .notice-icon { + font-size: 20px; + line-height: 1; + flex-shrink: 0; + } + + .daily-subscription-paused-notice span:last-child { + flex: 1; + } + + :root[data-theme="dark"] .daily-subscription-status { + background: linear-gradient(135deg, rgba(var(--primary-rgb), 0.12), rgba(var(--primary-rgb), 0.06)); + border-color: rgba(var(--primary-rgb), 0.25); + } + + :root[data-theme="dark"] .status-paused { + background: linear-gradient(135deg, rgba(100, 116, 139, 0.3), rgba(100, 116, 139, 0.2)); + color: #94a3b8; + } + .status-missing { background: linear-gradient(135deg, #e0e7ff, #eef2ff); color: #1e3a8a; @@ -2418,6 +2978,167 @@ text-align: right; } + .info-action-btn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 50%; + border: none; + background: var(--primary); + color: white; + cursor: pointer; + margin-left: 8px; + transition: all 0.2s ease; + flex-shrink: 0; + } + + .info-action-btn:hover { + transform: scale(1.1); + box-shadow: 0 2px 8px rgba(var(--primary-rgb), 0.4); + } + + .info-action-btn:active { + transform: scale(0.95); + } + + .info-action-btn svg { + width: 16px; + height: 16px; + } + + /* Traffic Topup Modal */ + .traffic-topup-packages { + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 16px; + } + + .traffic-topup-package { + display: flex; + justify-content: space-between; + align-items: center; + padding: 14px 16px; + border-radius: var(--radius); + border: 1px solid var(--border-color); + background: var(--card-bg); + cursor: pointer; + transition: all 0.2s ease; + } + + .traffic-topup-package:hover { + border-color: var(--primary); + background: rgba(var(--primary-rgb), 0.05); + } + + .traffic-topup-package.disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .traffic-topup-package.disabled:hover { + border-color: var(--border-color); + background: var(--card-bg); + } + + .traffic-topup-package-info { + display: flex; + flex-direction: column; + gap: 2px; + } + + .traffic-topup-package-gb { + font-size: 16px; + font-weight: 700; + color: var(--text-primary); + } + + .traffic-topup-package-price { + font-size: 14px; + font-weight: 600; + color: var(--primary); + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + } + + .traffic-topup-price-original { + font-size: 12px; + color: var(--text-secondary); + text-decoration: line-through; + font-weight: 400; + } + + .traffic-topup-price-current { + color: var(--primary); + } + + .traffic-topup-discount-badge { + display: inline-block; + padding: 2px 6px; + background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%); + color: #fff; + font-size: 10px; + font-weight: 700; + border-radius: 6px; + box-shadow: 0 1px 3px rgba(34, 197, 94, 0.3); + } + + .traffic-topup-package-btn { + padding: 8px 16px; + border-radius: var(--radius); + border: none; + background: var(--primary); + color: white; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + } + + .traffic-topup-package-btn:hover { + box-shadow: 0 2px 8px rgba(var(--primary-rgb), 0.4); + } + + .traffic-topup-package-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .traffic-topup-balance { + text-align: center; + font-size: 13px; + color: var(--text-secondary); + padding: 8px; + border-radius: var(--radius); + background: var(--surface); + } + + .traffic-topup-success { + text-align: center; + padding: 20px; + } + + .traffic-topup-success-icon { + font-size: 48px; + margin-bottom: 12px; + } + + .traffic-topup-success-title { + font-size: 18px; + font-weight: 700; + color: var(--text-primary); + margin-bottom: 8px; + } + + .traffic-topup-success-message { + font-size: 14px; + color: var(--text-secondary); + } + /* Promo card */ .promo-card .card-header { gap: 12px; @@ -2905,9 +3626,12 @@ .payment-method-info { display: flex; + flex-direction: column; align-items: center; - gap: 12px; + justify-content: center; + gap: 8px; flex: 1; + text-align: center; } .payment-method-icon { @@ -2917,17 +3641,20 @@ .payment-method-text { display: flex; flex-direction: column; + align-items: center; gap: 2px; } .payment-method-label { font-weight: 700; color: var(--text-primary); + text-align: center; } .payment-method-description { font-size: 13px; color: var(--text-secondary); + text-align: center; } .amount-form { @@ -4997,6 +5724,7 @@
Top up your balance after activation to stay connected.
+
@@ -5026,6 +5754,39 @@ + + +
@@ -5043,6 +5804,11 @@
Traffic Limit - +
Type @@ -5270,6 +6036,14 @@
Текущий тариф
+ +
Выберите тариф
@@ -5471,6 +6245,27 @@
+ +
Activate promo code
@@ -5856,6 +6651,17 @@ 'info.promo_group': 'Promo group', 'info.device_limit': 'Device limit', 'info.autopay': 'Auto-pay', + // Daily subscription + 'daily.tariff_name': 'Daily tariff', + 'daily.time_remaining': 'Time left:', + 'daily.next_charge': 'Next charge at', + 'daily.pause': 'Pause', + 'daily.resume': 'Resume', + 'daily.paused_notice': 'Subscription paused. Auto-renewal disabled. VPN works until the end of paid time.', + 'daily.paused_no_charge': 'Paused — no charge', + 'daily.status.active': 'Active', + 'daily.status.paused': 'Paused', + 'daily.hours_remaining': 'Hours left', 'button.connect.default': 'Connect to VPN', 'button.connect.happ': 'Connect', 'button.copy': 'Copy subscription link', @@ -5869,6 +6675,8 @@ 'topup.method.stars.adjusted': 'Requested {requested}. Actual charge: {amount} ({stars} ⭐).', 'topup.method.yookassa.title': 'Bank card (YooKassa)', 'topup.method.yookassa.description': 'Pay securely with a bank card', + 'topup.method.yookassa_sbp.title': 'SBP (YooKassa)', + 'topup.method.yookassa_sbp.description': 'Pay via Faster Payments System', 'topup.method.mulenpay.title': 'Bank card (Mulen Pay)', 'topup.method.mulenpay.description': 'Fast payment with bank card', 'topup.method.platega.title': 'Platega.io', @@ -5922,6 +6730,18 @@ 'topup.status.failed.description': 'We could not confirm the payment automatically. Please check later or contact support.', 'topup.status.retry': 'Try again', 'topup.done': 'Done', + 'traffic_topup.title': 'Buy extra traffic', + 'traffic_topup.subtitle': 'Choose a traffic package', + 'traffic_topup.cancel': 'Close', + 'traffic_topup.gb_suffix': 'GB', + 'traffic_topup.buy_button': 'Buy', + 'traffic_topup.your_balance': 'Your balance', + 'traffic_topup.empty': 'No packages available', + 'traffic_topup.error.generic': 'Failed to purchase traffic. Please try again.', + 'traffic_topup.success.title': 'Traffic added!', + 'traffic_topup.success.message': '+{gb} GB has been added to your subscription.', + 'traffic_topup.available_limit': 'Available to purchase: {gb} GB', + 'traffic_topup.limit_reached': 'Maximum traffic limit reached', 'button.buy_subscription': 'Buy Subscription', 'button.open_bot': 'Open Telegram bot', 'subscription_purchase.title': 'Purchase subscription', @@ -6088,6 +6908,20 @@ 'tariffs.select': 'Select tariff', 'tariffs.current': 'Current tariff', 'tariffs.no_tariffs': 'No tariffs available', + 'instant_switch.title': 'Switch tariff', + 'instant_switch.current': 'Current tariff', + 'instant_switch.remaining': 'Remaining', + 'instant_switch.hint': 'When switching, your remaining days are preserved. Upgrade = pay the difference, downgrade = free.', + 'instant_switch.select': 'Select new tariff', + 'instant_switch.confirm': 'Confirmation', + 'instant_switch.from': 'Current', + 'instant_switch.to': 'New', + 'instant_switch.cost': 'Cost:', + 'instant_switch.free': 'Free', + 'instant_switch.balance': 'Your balance:', + 'instant_switch.insufficient': 'Insufficient funds. Missing:', + 'instant_switch.cancel': 'Cancel', + 'instant_switch.confirm_btn': 'Confirm', 'card.referral.title': 'Referral Program', 'card.history.title': 'Transaction History', 'card.servers.title': 'Connected Servers', @@ -6202,6 +7036,7 @@ 'subscription_missing.hint': 'After activation you can top up your balance here to stay connected.', 'subscription_missing.action.trial': 'Activate trial', 'subscription_missing.action.trial.loading': 'Activating…', + 'subscription_missing.action.buy': 'Buy subscription', 'trial.activation.title': 'Trial activation', 'trial.activation.success': 'Trial activated! Enjoy {days} days of access.', 'trial.activation.success.short': 'Trial activated successfully.', @@ -6292,6 +7127,17 @@ 'info.promo_group': 'Уровень', 'info.device_limit': 'Лимит устройств', 'info.autopay': 'Автоплатеж', + // Суточная подписка + 'daily.tariff_name': 'Суточный тариф', + 'daily.time_remaining': 'Осталось:', + 'daily.next_charge': 'Следующее списание', + 'daily.pause': 'Приостановить', + 'daily.resume': 'Возобновить', + 'daily.paused_notice': 'Подписка приостановлена. Автопродление отключено. VPN работает до конца оплаченного времени.', + 'daily.paused_no_charge': 'Пауза — без списания', + 'daily.status.active': 'Активна', + 'daily.status.paused': 'Приостановлена', + 'daily.hours_remaining': 'Часов осталось', 'button.connect.default': 'Подключиться к VPN', 'button.connect.happ': 'Подключиться', 'button.copy': 'Скопировать ссылку подписки', @@ -6305,6 +7151,8 @@ 'topup.method.stars.adjusted': 'Вы запросили {requested}. Итог к оплате: {amount} ({stars} ⭐).', 'topup.method.yookassa.title': 'Банковская карта (YooKassa)', 'topup.method.yookassa.description': 'Безопасная оплата банковской картой', + 'topup.method.yookassa_sbp.title': 'СБП (YooKassa)', + 'topup.method.yookassa_sbp.description': 'Оплата через Систему быстрых платежей', 'topup.method.mulenpay.title': 'Банковская карта (Mulen Pay)', 'topup.method.mulenpay.description': 'Мгновенное списание с карты', 'topup.method.platega.title': 'Platega.io', @@ -6358,6 +7206,18 @@ 'topup.status.failed.description': 'Не удалось подтвердить платеж автоматически. Проверьте позже или обратитесь в поддержку.', 'topup.status.retry': 'Повторить попытку', 'topup.done': 'Готово', + 'traffic_topup.title': 'Докупить трафик', + 'traffic_topup.subtitle': 'Выберите пакет трафика', + 'traffic_topup.cancel': 'Закрыть', + 'traffic_topup.gb_suffix': 'ГБ', + 'traffic_topup.buy_button': 'Купить', + 'traffic_topup.your_balance': 'Ваш баланс', + 'traffic_topup.empty': 'Нет доступных пакетов', + 'traffic_topup.error.generic': 'Не удалось докупить трафик. Попробуйте снова.', + 'traffic_topup.success.title': 'Трафик добавлен!', + 'traffic_topup.success.message': '+{gb} ГБ добавлено к вашей подписке.', + 'traffic_topup.available_limit': 'Доступно для покупки: {gb} ГБ', + 'traffic_topup.limit_reached': 'Достигнут максимальный лимит трафика', 'button.buy_subscription': 'Купить подписку', 'button.open_bot': 'Открыть бота', 'subscription_purchase.title': 'Оформление подписки', @@ -6524,6 +7384,20 @@ 'tariffs.select': 'Выбрать тариф', 'tariffs.current': 'Текущий тариф', 'tariffs.no_tariffs': 'Нет доступных тарифов', + 'instant_switch.title': 'Сменить тариф', + 'instant_switch.current': 'Текущий тариф', + 'instant_switch.remaining': 'Осталось', + 'instant_switch.hint': 'При смене тарифа остаток дней сохраняется. Повышение = доплата, понижение = бесплатно.', + 'instant_switch.select': 'Выберите новый тариф', + 'instant_switch.confirm': 'Подтверждение', + 'instant_switch.from': 'Текущий', + 'instant_switch.to': 'Новый', + 'instant_switch.cost': 'Стоимость:', + 'instant_switch.free': 'Бесплатно', + 'instant_switch.balance': 'Ваш баланс:', + 'instant_switch.insufficient': 'Недостаточно средств. Не хватает:', + 'instant_switch.cancel': 'Отмена', + 'instant_switch.confirm_btn': 'Подтвердить', 'card.referral.title': 'Реферальная программа', 'card.history.title': 'История операций', 'card.servers.title': 'Подключённые серверы', @@ -6638,6 +7512,7 @@ 'subscription_missing.hint': 'После активации вы сможете пополнить баланс здесь для бесперебойной работы.', 'subscription_missing.action.trial': 'Активировать триал', 'subscription_missing.action.trial.loading': 'Активация…', + 'subscription_missing.action.buy': 'Купить подписку', 'trial.activation.title': 'Активация триала', 'trial.activation.success': 'Триал активирован! Доступ открыт на {days} дн.', 'trial.activation.success.short': 'Триал успешно активирован.', @@ -7657,6 +8532,19 @@ return div.innerHTML; } + function getDaysWord(count, lang = 'ru') { + if (lang === 'en') { + return count === 1 ? 'day' : 'days'; + } + // Russian: 1 день, 2-4 дня, 5-20 дней, 21 день, 22-24 дня, etc. + const n = Math.abs(count) % 100; + const n1 = n % 10; + if (n > 10 && n < 20) return 'дней'; + if (n1 === 1) return 'день'; + if (n1 >= 2 && n1 <= 4) return 'дня'; + return 'дней'; + } + function normalizeUrl(value) { if (typeof value !== 'string') { return null; @@ -8615,6 +9503,27 @@ } else { currentTariffRow.classList.add('hidden'); } + + // Кнопка докупки трафика + const trafficTopupBtn = document.getElementById('trafficTopupBtn'); + if (trafficTopupBtn) { + const trafficTopupEnabled = currentTariff?.traffic_topup_enabled || currentTariff?.trafficTopupEnabled; + const trafficTopupPackages = currentTariff?.traffic_topup_packages || currentTariff?.trafficTopupPackages || []; + const isTrial = (user?.subscription_status || '').toLowerCase() === 'trial'; + + if (trafficTopupEnabled && trafficTopupPackages.length > 0 && !isTrial) { + trafficTopupBtn.classList.remove('hidden'); + // Сохраняем данные для модального окна + window._trafficTopupPackages = trafficTopupPackages; + window._subscriptionId = userData?.subscription_id ?? userData?.subscriptionId; + window._userBalance = userData?.balance_kopeks || userData?.balanceKopeks || 0; + // Лимит докупки трафика (null = без лимита) + window._availableTopupGb = currentTariff?.available_topup_gb ?? currentTariff?.availableTopupGb ?? null; + window._maxTopupTrafficGb = currentTariff?.max_topup_traffic_gb ?? currentTariff?.maxTopupTrafficGb ?? 0; + } else { + trafficTopupBtn.classList.add('hidden'); + } + } } const autopayElement = document.getElementById('autopayStatus'); @@ -8626,6 +9535,9 @@ : autopayLabel; } + // Отображение суточной подписки + renderDailySubscriptionStatus(); + renderSubscriptionMissingCard(); renderSubscriptionPurchaseCard(); renderSubscriptionRenewalCard(); @@ -8643,6 +9555,211 @@ updateActionButtons(); } + // ============================================================ + // Суточная подписка - отображение и управление + // ============================================================ + let dailyTimerInterval = null; + + function renderDailySubscriptionStatus() { + const container = document.getElementById('dailySubscriptionStatus'); + if (!container) return; + + const user = userData?.user; + if (!user) { + container.classList.add('hidden'); + return; + } + + const isDailyTariff = user.is_daily_tariff ?? user.isDailyTariff ?? false; + const isDailyPaused = user.is_daily_paused ?? user.isDailyPaused ?? false; + const dailyTariffName = user.daily_tariff_name ?? user.dailyTariffName ?? ''; + const dailyPriceLabel = user.daily_price_label ?? user.dailyPriceLabel ?? ''; + const dailyNextChargeAt = user.daily_next_charge_at ?? user.dailyNextChargeAt ?? null; + + if (!isDailyTariff) { + container.classList.add('hidden'); + if (dailyTimerInterval) { + clearInterval(dailyTimerInterval); + dailyTimerInterval = null; + } + return; + } + + container.classList.remove('hidden'); + + // Заполняем данные + const tariffNameEl = document.getElementById('dailyTariffName'); + if (tariffNameEl && dailyTariffName) { + tariffNameEl.textContent = dailyTariffName; + } + + const priceEl = document.getElementById('dailyPrice'); + if (priceEl && dailyPriceLabel) { + priceEl.textContent = dailyPriceLabel; + } + + // Настройка кнопки паузы + const pauseBtn = document.getElementById('dailyPauseBtn'); + const pauseBtnIcon = document.getElementById('dailyPauseBtnIcon'); + const pauseBtnText = document.getElementById('dailyPauseBtnText'); + const pausedNotice = document.getElementById('dailyPausedNotice'); + const progressSection = document.getElementById('dailyProgressSection'); + + if (isDailyPaused) { + pauseBtn?.classList.add('paused'); + if (pauseBtnIcon) pauseBtnIcon.textContent = '▶️'; + if (pauseBtnText) pauseBtnText.textContent = t('daily.resume'); + pausedNotice?.classList.remove('hidden'); + // Оставляем прогресс секцию видимой, но затемняем + progressSection?.classList.remove('hidden'); + progressSection?.classList.add('paused'); + + // Обновляем статус badge + const statusBadge = document.getElementById('statusBadge'); + if (statusBadge) { + statusBadge.textContent = t('daily.status.paused'); + statusBadge.className = 'status-badge status-paused'; + } + } else { + pauseBtn?.classList.remove('paused'); + if (pauseBtnIcon) pauseBtnIcon.textContent = '⏸️'; + if (pauseBtnText) pauseBtnText.textContent = t('daily.pause'); + pausedNotice?.classList.add('hidden'); + progressSection?.classList.remove('hidden'); + progressSection?.classList.remove('paused'); + } + + // Обработчик кнопки паузы + if (pauseBtn) { + pauseBtn.onclick = () => toggleDailyPause(); + } + + // Запуск таймера обратного отсчета + startDailyCountdownTimer(dailyNextChargeAt, isDailyPaused); + } + + function startDailyCountdownTimer(nextChargeAt, isPaused) { + if (dailyTimerInterval) { + clearInterval(dailyTimerInterval); + dailyTimerInterval = null; + } + + const timeRemainingEl = document.getElementById('dailyTimeRemaining'); + const progressFill = document.getElementById('dailyProgressFill'); + const nextChargeEl = document.getElementById('dailyNextCharge'); + + if (!nextChargeAt) { + if (timeRemainingEl) timeRemainingEl.textContent = '--:--:--'; + if (progressFill) progressFill.style.width = '0%'; + if (nextChargeEl) nextChargeEl.textContent = ''; + return; + } + + // При паузе показываем оставшееся время, но без обновления (статичное) + if (isPaused) { + // Сервер возвращает UTC время, добавляем 'Z' если отсутствует + const utcDateStr = String(nextChargeAt).endsWith('Z') ? nextChargeAt : nextChargeAt + 'Z'; + const nextChargeDate = new Date(utcDateStr); + const now = new Date(); + const remaining = nextChargeDate - now; + + if (remaining > 0) { + const hours = Math.floor(remaining / (1000 * 60 * 60)); + const minutes = Math.floor((remaining % (1000 * 60 * 60)) / (1000 * 60)); + const seconds = Math.floor((remaining % (1000 * 60)) / 1000); + const timeStr = `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; + if (timeRemainingEl) timeRemainingEl.textContent = timeStr; + const totalDuration = 24 * 60 * 60 * 1000; + const progressPercent = Math.max(0, Math.min(100, (remaining / totalDuration) * 100)); + if (progressFill) progressFill.style.width = `${progressPercent}%`; + } else { + if (timeRemainingEl) timeRemainingEl.textContent = '00:00:00'; + if (progressFill) progressFill.style.width = '0%'; + } + if (nextChargeEl) nextChargeEl.textContent = t('daily.paused_no_charge'); + return; + } + + // Сервер возвращает UTC время, добавляем 'Z' если отсутствует + const utcDateStr = String(nextChargeAt).endsWith('Z') ? nextChargeAt : nextChargeAt + 'Z'; + const nextChargeDate = new Date(utcDateStr); + const totalDuration = 24 * 60 * 60 * 1000; // 24 часа в миллисекундах + + function updateTimer() { + const now = new Date(); + const remaining = nextChargeDate - now; + + if (remaining <= 0) { + if (timeRemainingEl) timeRemainingEl.textContent = '00:00:00'; + if (progressFill) progressFill.style.width = '0%'; + clearInterval(dailyTimerInterval); + return; + } + + // Форматируем оставшееся время + const hours = Math.floor(remaining / (1000 * 60 * 60)); + const minutes = Math.floor((remaining % (1000 * 60 * 60)) / (1000 * 60)); + const seconds = Math.floor((remaining % (1000 * 60)) / 1000); + const timeStr = `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; + + if (timeRemainingEl) timeRemainingEl.textContent = timeStr; + + // Обновляем прогресс бар (от 100% до 0%) + const progressPercent = Math.max(0, Math.min(100, (remaining / totalDuration) * 100)); + if (progressFill) progressFill.style.width = `${progressPercent}%`; + + // Время следующего списания + if (nextChargeEl) { + const chargeTime = nextChargeDate.toLocaleTimeString(preferredLanguage === 'ru' ? 'ru-RU' : 'en-US', { + hour: '2-digit', + minute: '2-digit' + }); + nextChargeEl.textContent = `${t('daily.next_charge')} ${chargeTime}`; + } + } + + updateTimer(); + dailyTimerInterval = setInterval(updateTimer, 1000); + } + + async function toggleDailyPause() { + const pauseBtn = document.getElementById('dailyPauseBtn'); + if (!pauseBtn || pauseBtn.disabled) return; + + pauseBtn.disabled = true; + + try { + const initData = tg.initData || ''; + const response = await fetch('/miniapp/subscription/daily/toggle-pause', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ init_data: initData }) + }); + + const result = await response.json(); + + if (!response.ok) { + const errorCode = result?.detail?.code || 'unknown'; + const errorMsg = errorCode === 'insufficient_balance' + ? (t('daily.error.insufficient_balance') || 'Недостаточно средств для возобновления подписки') + : (result?.detail?.message || 'Error'); + showPopup(errorMsg); + return; + } + + // Полное обновление данных подписки для корректного отображения + await refreshSubscriptionData({ silent: true }); + + } catch (error) { + console.error('Error toggling daily pause:', error); + showPopup(error.message || 'Connection error'); + } finally { + if (pauseBtn) { + pauseBtn.disabled = false; + } + } + } + function renderSubscriptionMissingCard() { const card = document.getElementById('subscriptionMissingCard'); if (!card) { @@ -8734,6 +9851,16 @@ trialButton.classList.toggle('hidden', !trialAvailable && !trialActivationInProgress); trialButton.disabled = !trialAvailable || trialActivationInProgress; } + + const buyButton = document.getElementById('subscriptionMissingBuyBtn'); + if (buyButton) { + const buyLabel = t('subscription_missing.action.buy'); + buyButton.textContent = buyLabel && buyLabel !== 'subscription_missing.action.buy' + ? buyLabel + : (preferredLanguage === 'ru' ? 'Купить подписку' : 'Buy subscription'); + // Показываем кнопку покупки всегда когда subscription_missing + buyButton.classList.remove('hidden'); + } } function resolvePromoOfferIcon(offer) { @@ -11839,6 +12966,205 @@ clearTopupError(); } + // ============ Traffic Topup Modal ============ + function openTrafficTopupModal() { + const modal = document.getElementById('trafficTopupModal'); + if (!modal) return; + + modal.classList.remove('hidden'); + document.body.classList.add('modal-open'); + renderTrafficTopupPackages(); + } + + function closeTrafficTopupModal() { + const modal = document.getElementById('trafficTopupModal'); + if (modal) { + modal.classList.add('hidden'); + } + document.body.classList.remove('modal-open'); + clearTrafficTopupError(); + } + + function clearTrafficTopupError() { + const errorEl = document.getElementById('trafficTopupModalError'); + if (errorEl) { + errorEl.classList.add('hidden'); + errorEl.textContent = ''; + } + } + + function showTrafficTopupError(message) { + const errorEl = document.getElementById('trafficTopupModalError'); + if (errorEl) { + errorEl.textContent = message; + errorEl.classList.remove('hidden'); + } + } + + function renderTrafficTopupPackages() { + const packagesContainer = document.getElementById('trafficTopupPackages'); + const balanceContainer = document.getElementById('trafficTopupBalance'); + + if (!packagesContainer) return; + + const packages = window._trafficTopupPackages || []; + const balance = window._userBalance || 0; + const availableTopupGb = window._availableTopupGb; + + packagesContainer.innerHTML = ''; + + // Показываем доступный лимит, если он есть + if (availableTopupGb !== null && availableTopupGb !== undefined) { + const limitEl = document.createElement('div'); + limitEl.className = 'traffic-topup-limit-info'; + limitEl.style.cssText = 'text-align: center; padding: 8px 12px; margin-bottom: 12px; background: var(--bg-tertiary); border-radius: 8px; font-size: 13px; color: var(--text-secondary);'; + if (availableTopupGb <= 0) { + limitEl.textContent = t('traffic_topup.limit_reached'); + limitEl.style.color = 'var(--danger-color)'; + } else { + limitEl.textContent = t('traffic_topup.available_limit').replace('{gb}', availableTopupGb); + } + packagesContainer.appendChild(limitEl); + } + + if (packages.length === 0) { + const emptyEl = document.createElement('div'); + emptyEl.className = 'traffic-topup-empty'; + emptyEl.textContent = t('traffic_topup.empty'); + packagesContainer.appendChild(emptyEl); + return; + } + + packages.forEach(pkg => { + const gb = pkg.gb; + const priceKopeks = pkg.price_kopeks || pkg.priceKopeks; + const priceLabel = pkg.price_label || pkg.priceLabel || formatPrice(priceKopeks); + const originalPriceKopeks = pkg.original_price_kopeks || pkg.originalPriceKopeks; + const originalPriceLabel = pkg.original_price_label || pkg.originalPriceLabel; + const discountPercent = pkg.discount_percent || pkg.discountPercent || 0; + const hasDiscount = discountPercent > 0 && originalPriceKopeks; + const canAfford = balance >= priceKopeks; + + const packageEl = document.createElement('div'); + packageEl.className = 'traffic-topup-package' + (canAfford ? '' : ' disabled'); + + packageEl.innerHTML = ` +
+
+${gb} ${t('traffic_topup.gb_suffix')}
+
+ ${hasDiscount ? `${originalPriceLabel}` : ''} + ${priceLabel} + ${hasDiscount ? `-${discountPercent}%` : ''} +
+
+ + `; + + if (canAfford) { + const btn = packageEl.querySelector('.traffic-topup-package-btn'); + btn.addEventListener('click', () => purchaseTrafficPackage(gb, priceKopeks)); + } + + packagesContainer.appendChild(packageEl); + }); + + if (balanceContainer) { + balanceContainer.innerHTML = t('traffic_topup.your_balance') + ': ' + formatPrice(balance) + ''; + } + } + + async function purchaseTrafficPackage(gb, priceKopeks) { + const packagesContainer = document.getElementById('trafficTopupPackages'); + if (!packagesContainer) return; + + // Показываем загрузку + const buttons = packagesContainer.querySelectorAll('.traffic-topup-package-btn'); + buttons.forEach(btn => { + btn.disabled = true; + btn.textContent = '...'; + }); + + clearTrafficTopupError(); + + try { + const initData = tg.initData || ''; + const requestBody = { + initData: initData, + gb: gb + }; + if (window._subscriptionId) { + requestBody.subscriptionId = window._subscriptionId; + } + + const response = await fetch('/miniapp/subscription/traffic-topup', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(requestBody) + }); + + const data = await response.json(); + + if (!response.ok) { + const errorMessage = data?.detail?.message || data?.message || t('traffic_topup.error.generic'); + showTrafficTopupError(errorMessage); + renderTrafficTopupPackages(); + return; + } + + // Успех - показываем сообщение + packagesContainer.innerHTML = ` +
+
+
${t('traffic_topup.success.title')}
+
${t('traffic_topup.success.message').replace('{gb}', gb)}
+
+ `; + + // Обновляем баланс + window._userBalance = data.new_balance_kopeks || data.newBalanceKopeks || 0; + const balanceContainer = document.getElementById('trafficTopupBalance'); + if (balanceContainer) { + balanceContainer.innerHTML = t('traffic_topup.your_balance') + ': ' + formatPrice(window._userBalance) + ''; + } + + // Обновляем отображение баланса на странице + const balanceAmountEl = document.getElementById('balanceAmount'); + if (balanceAmountEl) { + balanceAmountEl.textContent = formatPrice(window._userBalance); + } + + // Обновляем лимит трафика + const trafficLimitEl = document.getElementById('trafficLimit'); + if (trafficLimitEl && data.new_traffic_limit_gb) { + trafficLimitEl.textContent = formatTrafficLimit(data.new_traffic_limit_gb); + } + + // Обновляем доступный лимит докупки + if (window._availableTopupGb !== null && window._availableTopupGb !== undefined) { + window._availableTopupGb = Math.max(0, window._availableTopupGb - gb); + } + + // Обновляем данные через 2 секунды + setTimeout(() => { + refreshSubscriptionData({ silent: true }); + }, 2000); + + } catch (error) { + console.error('Traffic topup error:', error); + showTrafficTopupError(t('traffic_topup.error.generic')); + renderTrafficTopupPackages(); + } + } + + function formatPrice(kopeks) { + const rubles = Math.floor(kopeks / 100); + return rubles + ' ₽'; + } + + // ============ End Traffic Topup Modal ============ + function renderTopupLoading(messageKey = 'topup.loading') { const { body } = getTopupElements(); if (!body) { @@ -13855,6 +15181,13 @@ return typeRaw === 'paid'; } + function isDailyTariffSubscription() { + if (!userData?.user) { + return false; + } + return Boolean(userData.user.is_daily_tariff ?? userData.user.isDailyTariff ?? false); + } + function isTrialSubscription() { if (!userData?.user) { return false; @@ -15067,8 +16400,10 @@ return; } - // Для триала НЕ показываем карточку продления — юзер должен купить подписку - const shouldShow = hasActiveSubscription() && !isTrialSubscription(); + // Для триала и суточных тарифов НЕ показываем карточку продления + // Триал — юзер должен купить подписку + // Суточный тариф — продление автоматическое + const shouldShow = hasActiveSubscription() && !isTrialSubscription() && !isDailyTariffSubscription(); card.classList.toggle('hidden', !shouldShow); if (!shouldShow) { return; @@ -15541,8 +16876,9 @@ return; } - // Для триала НЕ показываем настройки подписки — юзер должен сначала купить - const shouldShow = hasActiveSubscription() && !isTrialSubscription(); + // В режиме тарифов НЕ показываем настройки подписки — там своя логика + // Для триала тоже НЕ показываем — юзер должен сначала купить + const shouldShow = hasActiveSubscription() && !isTrialSubscription() && !isTariffsMode(); card.classList.toggle('hidden', !shouldShow); if (!shouldShow) { return; @@ -16235,6 +17571,13 @@ if (subscriptionPurchaseModalOpen) { return true; } + // Показываем для пользователей с subscription_missing (без активированного триала/подписки) + const subscriptionMissing = Boolean( + userData?.subscription_missing ?? userData?.subscriptionMissing + ); + if (subscriptionMissing) { + return true; + } // Показываем для пользователей без оплаченной подписки ИЛИ с триалом return Boolean(userData?.user) && (!hasPaidSubscription() || isTrialSubscription()); } @@ -19097,6 +20440,31 @@ }); } + // Кнопка докупки трафика + const trafficTopupBtn = document.getElementById('trafficTopupBtn'); + if (trafficTopupBtn) { + trafficTopupBtn.addEventListener('click', () => { + openTrafficTopupModal(); + }); + } + + // Модальное окно докупки трафика + const trafficTopupModal = document.getElementById('trafficTopupModal'); + if (trafficTopupModal) { + trafficTopupModal.addEventListener('click', event => { + if (event.target === trafficTopupModal) { + closeTrafficTopupModal(); + } + }); + } + + const trafficTopupCancelBtn = document.getElementById('trafficTopupModalCancelBtn'); + if (trafficTopupCancelBtn) { + trafficTopupCancelBtn.addEventListener('click', () => { + closeTrafficTopupModal(); + }); + } + const topupModal = document.getElementById('topupModal'); if (topupModal) { topupModal.addEventListener('click', event => { @@ -19202,6 +20570,7 @@ document.getElementById('purchaseBtn')?.addEventListener('click', handlePurchaseAction); document.getElementById('subscriptionMissingTrialBtn')?.addEventListener('click', handleTrialAction); + document.getElementById('subscriptionMissingBuyBtn')?.addEventListener('click', handlePurchaseAction); // ============================================ // Tariffs Mode Support @@ -19251,6 +20620,25 @@ } let selectedTariffData = null; + let isInstantSwitchMode = false; + let instantSwitchPreviewData = null; + + function getMonthlyPrice(tariff) { + if (!tariff) return 0; + const periods = tariff.periods || []; + const period30 = periods.find(p => (p.days || p.period_days || p.periodDays) === 30); + if (period30) { + return period30.price_kopeks || period30.priceKopeks || period30.final_price || period30.finalPrice || 0; + } + // Fallback - пропорционально пересчитываем + if (periods.length > 0) { + const firstPeriod = periods[0]; + const days = firstPeriod.days || firstPeriod.period_days || firstPeriod.periodDays || 30; + const price = firstPeriod.price_kopeks || firstPeriod.priceKopeks || 0; + return Math.round(price * 30 / days); + } + return 0; + } function renderTariffs() { const loading = document.getElementById('tariffsLoading'); @@ -19258,20 +20646,69 @@ const list = document.getElementById('tariffsList'); const currentBlock = document.getElementById('tariffsCurrentTariff'); const currentName = document.getElementById('tariffsCurrentTariffName'); + const periodsSection = document.getElementById('tariffPeriodsSection'); + const summary = document.getElementById('tariffsSummary'); + const selectBtn = document.getElementById('tariffsSelectBtn'); loading?.classList.add('hidden'); body?.classList.remove('hidden'); - // Текущий тариф - if (tariffsData?.current_tariff || tariffsData?.currentTariff) { - const current = tariffsData.current_tariff || tariffsData.currentTariff; + // Определяем режим: мгновенная смена или покупка + // userData - глобальная переменная с данными подписки пользователя + const userInfo = userData?.user || userData; + + // Проверяем тип подписки - триал или платная + // subscription_type хранит 'trial' | 'paid' | 'none' + const subscriptionType = ( + userData?.subscription_type || + userData?.user?.subscription_type || + userInfo?.subscription_type || + '' + ).toLowerCase(); + + const actualStatus = ( + userInfo?.subscription_actual_status || + userInfo?.subscriptionActualStatus || + userInfo?.subscription_status || + userInfo?.subscriptionStatus || + '' + ).toLowerCase(); + + // Триал определяется по subscription_type = 'trial' + const isTrial = subscriptionType === 'trial' || + actualStatus === 'trial' || + userInfo?.is_trial || + userInfo?.isTrial; + + console.log('🔍 Status check:', { subscriptionType, actualStatus, isTrial, userData }); + + const currentTariff = tariffsData?.current_tariff || tariffsData?.currentTariff; + + // Режим смены тарифа ТОЛЬКО для ОПЛАЧЕННЫХ подписок (subscription_type = 'paid') + // Триалу нужно КУПИТЬ подписку, а не менять тариф + const isPaid = subscriptionType === 'paid'; + isInstantSwitchMode = isPaid && !isTrial && currentTariff; + + console.log('🔍 Mode:', { isInstantSwitchMode, isPaid, isTrial, subscriptionType, hasTariff: !!currentTariff }); + + // Вычисляем оставшиеся дни из expires_at + let daysLeft = 0; + const expiresAt = userInfo?.expires_at || userInfo?.expiresAt; + if (expiresAt) { + const endDate = new Date(expiresAt); + const now = new Date(); + daysLeft = Math.max(0, Math.ceil((endDate - now) / (1000 * 60 * 60 * 24))); + } + + // Текущий тариф с информацией о режиме + if (currentTariff) { currentBlock?.classList.remove('hidden'); if (currentName) { - const trafficLabel = current.traffic_limit_label || current.trafficLimitLabel - || ((current.traffic_limit_gb || current.trafficLimitGb) === 0 ? '∞' : (current.traffic_limit_gb || current.trafficLimitGb) + ' ГБ'); - const deviceLimit = current.device_limit || current.deviceLimit || 1; - const servers = current.servers || []; - const serversCount = current.servers_count || current.serversCount || servers.length || 0; + const trafficLabel = currentTariff.traffic_limit_label || currentTariff.trafficLimitLabel + || ((currentTariff.traffic_limit_gb || currentTariff.trafficLimitGb) === 0 ? '∞' : (currentTariff.traffic_limit_gb || currentTariff.trafficLimitGb) + ' ГБ'); + const deviceLimit = currentTariff.device_limit || currentTariff.deviceLimit || 1; + const servers = currentTariff.servers || []; + const serversCount = currentTariff.servers_count || currentTariff.serversCount || servers.length || 0; let serverTags = ''; if (servers.length > 0) { @@ -19286,10 +20723,37 @@ serverTags = `🌍 Все серверы`; } + // Проверяем, суточный ли текущий тариф + const isCurrentDaily = currentTariff.is_daily ?? currentTariff.isDaily ?? false; + + // В режиме смены тарифа показываем остаток + let remainingInfo = ''; + if (isInstantSwitchMode) { + let remainingText = ''; + if (isCurrentDaily) { + // Для суточного тарифа показываем другой текст + remainingText = preferredLanguage === 'en' + ? 'Daily subscription — time is not transferred' + : 'Суточная подписка — время не переносится'; + } else if (daysLeft > 0) { + // Для обычных тарифов показываем дни с правильным склонением + const daysWord = getDaysWord(daysLeft, preferredLanguage); + remainingText = preferredLanguage === 'en' + ? `${daysLeft} ${daysWord} remaining — will be preserved on switch` + : `Осталось ${daysLeft} ${daysWord} — сохранятся при смене`; + } + if (remainingText) { + remainingInfo = `
+ ${isCurrentDaily ? '🔄' : '⏰'} + ${remainingText} +
`; + } + } + currentName.innerHTML = `
- ${escapeHtml(current.name)} + ${escapeHtml(currentTariff.name)}
@@ -19308,12 +20772,46 @@
${serverTags}
+ ${remainingInfo} `; } } else { currentBlock?.classList.add('hidden'); } + // Баннер скидки промогруппы + const promoGroupBanner = document.getElementById('tariffsPromoGroupBanner'); + const promoGroupNameEl = document.getElementById('tariffsPromoGroupName'); + const promoGroupDiscountEl = document.getElementById('tariffsPromoGroupDiscount'); + const promoGroup = tariffsData?.promo_group || tariffsData?.promoGroup; + + if (promoGroup && promoGroupBanner) { + const periodDiscounts = promoGroup.period_discounts || promoGroup.periodDiscounts || {}; + let maxDiscount = 0; + Object.values(periodDiscounts).forEach(discount => { + const d = parseInt(discount) || 0; + if (d > maxDiscount) maxDiscount = d; + }); + + if (maxDiscount > 0) { + promoGroupBanner.classList.remove('hidden'); + if (promoGroupNameEl) { + promoGroupNameEl.textContent = preferredLanguage === 'en' + ? `Promo group "${promoGroup.name}"` + : `Промогруппа «${promoGroup.name}»`; + } + if (promoGroupDiscountEl) { + promoGroupDiscountEl.textContent = preferredLanguage === 'en' + ? `Your discount: up to ${maxDiscount}%` + : `Ваша скидка: до ${maxDiscount}%`; + } + } else { + promoGroupBanner.classList.add('hidden'); + } + } else { + promoGroupBanner?.classList.add('hidden'); + } + // Список тарифов if (!list) return; list.innerHTML = ''; @@ -19324,117 +20822,439 @@ return; } + // Добавляем информационный блок в режиме мгновенной смены + if (isInstantSwitchMode) { + const hintDiv = document.createElement('div'); + hintDiv.className = 'instant-switch-mode-hint'; + hintDiv.innerHTML = ` +
+
+ 🔄 +
+
+
+ ${preferredLanguage === 'en' ? 'Instant tariff switch' : 'Мгновенная смена тарифа'} +
+
+ ${(() => { + const isCurrentDailyTariff = currentTariff?.is_daily ?? currentTariff?.isDaily ?? false; + if (isCurrentDailyTariff) { + // Переключение с суточного тарифа - оплачивается новый тариф + return preferredLanguage === 'en' + ? 'Switching from daily tariff requires full payment for the new tariff' + : 'Переключение с суточного тарифа — оплата нового тарифа полностью'; + } else { + const daysWord = getDaysWord(daysLeft, preferredLanguage); + return preferredLanguage === 'en' + ? '⬆️ Upgrade = pay the difference • ⬇️ Downgrade = free
Your remaining ' + daysLeft + ' ' + daysWord + ' will be preserved' + : '⬆️ Повышение = доплата • ⬇️ Понижение = бесплатно
Ваши ' + daysLeft + ' ' + daysWord + ' сохраняются'; + } + })()} +
+
+
+ `; + list.appendChild(hintDiv); + } + + // В режиме мгновенной смены скрываем периоды и summary, в обычном - показываем + if (isInstantSwitchMode) { + periodsSection?.classList.add('hidden'); + summary?.classList.add('hidden'); + if (selectBtn) selectBtn.classList.add('hidden'); + } else { + // В обычном режиме покупки - убедимся что элементы видны + if (selectBtn) selectBtn.classList.remove('hidden'); + } + + const currentTariffId = currentTariff?.id; + tariffs.forEach(tariff => { - const isCurrent = tariff.is_current || tariff.isCurrent; + const isCurrent = tariff.id === currentTariffId; const periods = tariff.periods || []; - const minPrice = periods.length > 0 - ? periods.reduce((min, p) => { - const price = p.price_kopeks || p.priceKopeks || 0; - return price < min ? price : min; - }, Infinity) - : null; - const minPriceLabel = minPrice !== null && minPrice !== Infinity - ? formatPriceFromKopeks(minPrice, tariffsData?.currency || 'RUB') - : null; + + // В режиме смены пропускаем текущий тариф + if (isInstantSwitchMode && isCurrent) { + return; + } const div = document.createElement('div'); - div.className = 'subscription-settings-toggle' + (selectedTariffId === tariff.id ? ' active' : ''); - const trafficLabel = tariff.traffic_limit_label || tariff.trafficLimitLabel - || ((tariff.traffic_limit_gb || tariff.trafficLimitGb) === 0 ? '∞' : (tariff.traffic_limit_gb || tariff.trafficLimitGb) + ' ГБ'); + // Разные стили для режима смены и покупки + if (isInstantSwitchMode) { + // Режим мгновенной смены тарифа - используем точные данные с сервера + const isDaily = tariff.is_daily ?? tariff.isDaily ?? false; + const dailyPriceLabel = tariff.daily_price_label ?? tariff.dailyPriceLabel ?? null; + const isUpgrade = tariff.is_upgrade ?? tariff.isUpgrade ?? false; + const isSwitchFree = tariff.is_switch_free ?? tariff.isSwitchFree ?? !isUpgrade; + const upgradeCost = tariff.switch_cost_kopeks ?? tariff.switchCostKopeks ?? 0; + const upgradeCostLabel = tariff.switch_cost_label ?? tariff.switchCostLabel ?? formatPriceFromKopeks(upgradeCost, tariffsData?.currency || 'RUB'); - const deviceLimit = tariff.device_limit || tariff.deviceLimit || 1; + const trafficLabel = tariff.traffic_limit_label || tariff.trafficLimitLabel + || ((tariff.traffic_limit_gb || tariff.trafficLimitGb) === 0 ? '∞' : (tariff.traffic_limit_gb || tariff.trafficLimitGb) + ' ГБ'); + const deviceLimit = tariff.device_limit || tariff.deviceLimit || 1; + const description = tariff.description || ''; - // Серверы - показываем названия или количество - const servers = tariff.servers || []; - const serversCount = tariff.servers_count || tariff.serversCount || servers.length || 0; - let serversText = ''; - if (servers.length > 0) { - const serverNames = servers.slice(0, 3).map(s => s.name || s.display_name || s.displayName).filter(Boolean); - if (serverNames.length > 0) { - serversText = serverNames.join(', '); + // Серверы тарифа + const servers = tariff.servers || []; + const serversCount = tariff.servers_count || tariff.serversCount || servers.length || 0; + let serverTags = ''; + if (servers.length > 0) { + const serverNames = servers.slice(0, 3).map(s => s.name || s.display_name || s.displayName).filter(Boolean); + serverTags = serverNames.map(name => + `${escapeHtml(name)}` + ).join(''); if (serversCount > serverNames.length) { - serversText += ` +${serversCount - serverNames.length}`; + serverTags += `+${serversCount - serverNames.length}`; } - } else { - serversText = serversCount + ' серв.'; + } else if (serversCount === 0) { + serverTags = `🌍 Все`; } - } else if (serversCount > 0) { - serversText = serversCount + ' серв.'; - } else { - serversText = 'Все серверы'; - } - const description = tariff.description || ''; + // Определяем тип тарифа + const tariffNameLower = (tariff.name || '').toLowerCase(); + const isPremium = tariffNameLower.includes('премиум') || tariffNameLower.includes('premium') || + tariffNameLower.includes('про') || tariffNameLower.includes('pro') || + tariffNameLower.includes('vip') || tariffNameLower.includes('ultimate') || + tariffNameLower.includes('макс') || tariffNameLower.includes('max'); - // Серверные теги - let serverTags = ''; - if (servers.length > 0) { - const serverNames = servers.slice(0, 4).map(s => s.name || s.display_name || s.displayName).filter(Boolean); - serverTags = serverNames.map(name => - `${escapeHtml(name)}` - ).join(''); - if (serversCount > serverNames.length) { - serverTags += `+${serversCount - serverNames.length}`; - } - } else if (serversCount === 0) { - serverTags = `🌍 Все серверы`; - } + const upgradeLabel = preferredLanguage === 'en' ? 'upgrade' : 'доплата'; + const freeLabel = preferredLanguage === 'en' ? '✓ Free' : '✓ Бесплатно'; + const dailyLabel = preferredLanguage === 'en' ? 'daily' : 'суточный'; - // Determine tariff type for styling - const tariffNameLower = (tariff.name || '').toLowerCase(); - const isPremium = tariffNameLower.includes('премиум') || tariffNameLower.includes('premium') || - tariffNameLower.includes('про') || tariffNameLower.includes('pro') || - tariffNameLower.includes('vip') || tariffNameLower.includes('ultimate') || - tariffNameLower.includes('макс') || tariffNameLower.includes('max'); - const tariffIcon = isPremium ? '👑' : (isCurrent ? '✓' : '⚡'); - const badgeClass = isPremium ? 'tariff-name-badge tariff-premium' : (isCurrent ? 'tariff-name-badge tariff-current' : 'tariff-name-badge'); - - div.innerHTML = ` -
-
-
-
- ${tariffIcon} - ${escapeHtml(tariff.name)} -
- ${description ? `
${escapeHtml(description)}
` : ''} + div.className = 'instant-switch-tariff-item'; + div.innerHTML = ` +
+
+ ${isDaily ? '🔄 ' : (isPremium ? '👑 ' : '⚡ ')}${escapeHtml(tariff.name)}
- ${minPriceLabel ? ` -
-
от
-
${minPriceLabel}
+ ${description ? `
${escapeHtml(description)}
` : ''} +
+ 📱 ${deviceLimit} + 📊 ${trafficLabel} +
+ ${serverTags ? `
${serverTags}
` : ''} +
+
+ ${(() => { + const currentIsDaily = currentTariff?.is_daily ?? currentTariff?.isDaily ?? false; + const switchingFromDaily = currentIsDaily && !isDaily; + + if (isDaily) { + // Переключение на суточный тариф + return ` + + ${dailyLabel} + ${dailyPriceLabel || ''} + + `; + } else if (switchingFromDaily) { + // Переключение С суточного НА периодный - показываем "от X₽ / N дн" + const fromLabel = preferredLanguage === 'en' ? 'from' : 'от'; + // Находим минимальный период тарифа + const tariffPeriods = tariff.periods || []; + let minPeriodDays = 30; + if (tariffPeriods.length > 0) { + minPeriodDays = Math.min(...tariffPeriods.map(p => p.days || 30)); + } + const periodSuffix = preferredLanguage === 'en' ? ` / ${minPeriodDays}d` : ` / ${minPeriodDays} дн`; + return ` + + ${fromLabel} + ${upgradeCostLabel}${periodSuffix} + + `; + } else if (isUpgrade) { + // Повышение тарифа - доплата + return ` + + ${upgradeLabel} + +${upgradeCostLabel} + + `; + } else { + // Понижение тарифа - бесплатно + return ` + + ${freeLabel} + + `; + } + })()} +
+ `; + + div.addEventListener('click', () => showInstantSwitchConfirm(tariff, isUpgrade, upgradeCost, currentTariff?.is_daily ?? currentTariff?.isDaily ?? false)); + } else { + // Обычный режим покупки + const isDaily = tariff.is_daily ?? tariff.isDaily ?? false; + const dailyPriceKopeks = tariff.daily_price_kopeks ?? tariff.dailyPriceKopeks ?? 0; + const dailyPriceLabel = tariff.daily_price_label ?? tariff.dailyPriceLabel ?? null; + + let minPrice = null; + let minPriceOriginal = null; + let maxDiscountPercent = 0; + let priceDisplayLabel = null; + let priceDisplayOriginalLabel = null; + let pricePrefix = preferredLanguage === 'en' ? 'from' : 'от'; + + if (isDaily && dailyPriceKopeks > 0) { + // Суточный тариф - показываем цену за день + priceDisplayLabel = dailyPriceLabel || formatPriceFromKopeks(dailyPriceKopeks, tariffsData?.currency || 'RUB') + '/день'; + pricePrefix = ''; + } else if (periods.length > 0) { + periods.forEach(p => { + const price = p.price_kopeks || p.priceKopeks || 0; + const originalPrice = p.original_price_kopeks || p.originalPriceKopeks || price; + const discountPct = p.discount_percent || p.discountPercent || 0; + + if (minPrice === null || price < minPrice) { + minPrice = price; + minPriceOriginal = originalPrice > price ? originalPrice : null; + } + if (discountPct > maxDiscountPercent) { + maxDiscountPercent = discountPct; + } + }); + priceDisplayLabel = minPrice !== null + ? formatPriceFromKopeks(minPrice, tariffsData?.currency || 'RUB') + : null; + priceDisplayOriginalLabel = minPriceOriginal !== null + ? formatPriceFromKopeks(minPriceOriginal, tariffsData?.currency || 'RUB') + : null; + } + + const minPriceLabel = priceDisplayLabel; + const minPriceOriginalLabel = priceDisplayOriginalLabel; + + div.className = 'subscription-settings-toggle' + (selectedTariffId === tariff.id ? ' active' : ''); + + const trafficLabel = tariff.traffic_limit_label || tariff.trafficLimitLabel + || ((tariff.traffic_limit_gb || tariff.trafficLimitGb) === 0 ? '∞' : (tariff.traffic_limit_gb || tariff.trafficLimitGb) + ' ГБ'); + const deviceLimit = tariff.device_limit || tariff.deviceLimit || 1; + const description = tariff.description || ''; + + const servers = tariff.servers || []; + const serversCount = tariff.servers_count || tariff.serversCount || servers.length || 0; + let serverTags = ''; + if (servers.length > 0) { + const serverNames = servers.slice(0, 4).map(s => s.name || s.display_name || s.displayName).filter(Boolean); + serverTags = serverNames.map(name => + `${escapeHtml(name)}` + ).join(''); + if (serversCount > serverNames.length) { + serverTags += `+${serversCount - serverNames.length}`; + } + } else if (serversCount === 0) { + serverTags = `🌍 Все серверы`; + } + + const tariffNameLower = (tariff.name || '').toLowerCase(); + const isPremium = tariffNameLower.includes('премиум') || tariffNameLower.includes('premium') || + tariffNameLower.includes('про') || tariffNameLower.includes('pro') || + tariffNameLower.includes('vip') || tariffNameLower.includes('ultimate') || + tariffNameLower.includes('макс') || tariffNameLower.includes('max'); + const tariffIcon = isPremium ? '👑' : (isCurrent ? '✓' : '⚡'); + const badgeClass = isPremium ? 'tariff-name-badge tariff-premium' : (isCurrent ? 'tariff-name-badge tariff-current' : 'tariff-name-badge'); + + div.innerHTML = ` +
+
+
+
+ ${tariffIcon} + ${escapeHtml(tariff.name)} +
+ ${description ? `
${escapeHtml(description)}
` : ''} +
+ ${minPriceLabel ? ` +
+ ${pricePrefix ? `
${pricePrefix}
` : ''} + ${minPriceOriginalLabel ? `
${minPriceOriginalLabel}
` : ''} +
${minPriceLabel}
+ ${maxDiscountPercent > 0 ? `
-${maxDiscountPercent}%
` : ''} + ${isDaily ? `
🔄 суточный
` : ''} +
+ ` : ''} +
+
+ + + + + ${deviceLimit} + + + + + + ${trafficLabel} + +
+ ${serverTags ? ` +
+ ${serverTags}
` : ''}
-
- - - - - ${deviceLimit} - - - - - - ${trafficLabel} - -
- ${serverTags ? ` -
- ${serverTags} -
- ` : ''} -
- `; + `; + + div.addEventListener('click', () => selectTariff(tariff)); + } - div.addEventListener('click', () => selectTariff(tariff)); list.appendChild(div); }); - // Обновляем периоды если тариф выбран - renderTariffPeriods(); + // Обновляем периоды если обычный режим и тариф выбран + if (!isInstantSwitchMode) { + renderTariffPeriods(); + } + } + + // Показать подтверждение мгновенной смены тарифа + async function showInstantSwitchConfirm(tariff, isUpgrade, estimatedCost, currentIsDaily = false) { + const currentTariff = tariffsData?.current_tariff || tariffsData?.currentTariff; + const switchingFromDaily = currentIsDaily && !(tariff.is_daily ?? tariff.isDaily ?? false); + + // Запрашиваем точную стоимость с сервера + try { + const initData = tg.initData || ''; + const tariffId = parseInt(tariff.id, 10); + + if (!initData) { + throw new Error(preferredLanguage === 'en' ? 'Authorization error' : 'Ошибка авторизации'); + } + if (isNaN(tariffId)) { + throw new Error(preferredLanguage === 'en' ? 'Invalid tariff' : 'Неверный тариф'); + } + + const requestBody = { init_data: initData, tariff_id: tariffId }; + + const response = await fetch('/miniapp/subscription/tariff/switch/preview', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(requestBody) + }); + + if (!response.ok) { + const error = await response.json(); + console.error('Switch preview error:', error); + // Показываем детали ошибки валидации + let errorMsg = error?.detail?.message || error?.message || 'Ошибка'; + if (Array.isArray(error?.detail)) { + errorMsg = error.detail.map(e => e.msg || e.message).join('; '); + } + throw new Error(errorMsg); + } + + instantSwitchPreviewData = await response.json(); + + const actualCost = instantSwitchPreviewData.upgrade_cost_kopeks || instantSwitchPreviewData.upgradeCostKopeks || 0; + const costLabel = instantSwitchPreviewData.upgrade_cost_label || instantSwitchPreviewData.upgradeCostLabel || formatPriceFromKopeks(actualCost, tariffsData?.currency || 'RUB'); + const balanceLabel = instantSwitchPreviewData.balance_label || instantSwitchPreviewData.balanceLabel || '—'; + const hasEnough = instantSwitchPreviewData.has_enough_balance ?? instantSwitchPreviewData.hasEnoughBalance ?? true; + const missingLabel = instantSwitchPreviewData.missing_amount_label || instantSwitchPreviewData.missingAmountLabel || ''; + const actualIsUpgrade = instantSwitchPreviewData.is_upgrade || instantSwitchPreviewData.isUpgrade || false; + + // Тексты для подтверждения + const txt = { + current: preferredLanguage === 'en' ? 'Current' : 'Текущий', + new: preferredLanguage === 'en' ? 'New' : 'Новый', + cost: preferredLanguage === 'en' ? 'Cost' : 'Стоимость', + free: preferredLanguage === 'en' ? '✓ Free' : '✓ Бесплатно', + balance: preferredLanguage === 'en' ? 'Your balance:' : 'Ваш баланс:', + insufficient: preferredLanguage === 'en' ? '⚠️ Insufficient funds. Missing:' : '⚠️ Недостаточно средств. Не хватает:', + confirmUpgrade: preferredLanguage === 'en' + ? `Switch to "${tariff.name}"?\n\nUpgrade cost: ${costLabel}\nYour balance: ${balanceLabel}` + : `Сменить тариф на «${tariff.name}»?\n\nДоплата: ${costLabel}\nВаш баланс: ${balanceLabel}`, + confirmFromDaily: (() => { + // Получаем минимальный период для отображения + const tariffPeriods = tariff.periods || []; + let minPeriodDays = 30; + if (tariffPeriods.length > 0) { + minPeriodDays = Math.min(...tariffPeriods.map(p => p.days || 30)); + } + return preferredLanguage === 'en' + ? `Switch to "${tariff.name}"?\n\nCost: ${costLabel} for ${minPeriodDays} days\n(Daily subscription time is not transferred)\nYour balance: ${balanceLabel}` + : `Сменить тариф на «${tariff.name}»?\n\nСтоимость: ${costLabel} за ${minPeriodDays} дн.\n(Время суточной подписки не переносится)\nВаш баланс: ${balanceLabel}`; + })(), + confirmDowngrade: preferredLanguage === 'en' + ? `Switch to "${tariff.name}"?\n\nFree (downgrade)` + : `Сменить тариф на «${tariff.name}»?\n\nБесплатно (понижение тарифа)`, + error: preferredLanguage === 'en' ? 'Error' : 'Ошибка', + insufficientError: preferredLanguage === 'en' + ? `Insufficient funds to switch tariff. Missing: ${missingLabel}` + : `Недостаточно средств для смены тарифа. Не хватает: ${missingLabel}`, + }; + + // Используем кастомный попап или showConfirm + // Выбираем нужное сообщение в зависимости от типа перехода + let message; + if (switchingFromDaily) { + // Переход с суточного на периодный - особый текст + message = txt.confirmFromDaily; + } else if (actualIsUpgrade) { + message = txt.confirmUpgrade; + } else { + message = txt.confirmDowngrade; + } + + if (typeof tg.showConfirm === 'function' && hasEnough) { + tg.showConfirm(message, async (confirmed) => { + if (confirmed) { + await executeInstantSwitch(tariff); + } + }); + } else if (hasEnough) { + // Fallback на простой confirm + if (confirm(message)) { + await executeInstantSwitch(tariff); + } + } else { + showPopup(txt.insufficientError, txt.error); + } + + } catch (err) { + console.error('Preview failed:', err); + const errTitle = preferredLanguage === 'en' ? 'Error' : 'Ошибка'; + const errMsg = preferredLanguage === 'en' ? 'Failed to load info' : 'Не удалось получить информацию'; + showPopup(err.message || errMsg, errTitle); + } + } + + // Выполнить мгновенную смену тарифа + async function executeInstantSwitch(tariff) { + try { + const initData = tg.initData || ''; + const tariffId = parseInt(tariff.id, 10); + + if (!initData || isNaN(tariffId)) { + throw new Error(preferredLanguage === 'en' ? 'Invalid data' : 'Неверные данные'); + } + + const response = await fetch('/miniapp/subscription/tariff/switch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ init_data: initData, tariff_id: tariffId }) + }); + + const result = await response.json(); + + if (!response.ok) { + const defaultErr = preferredLanguage === 'en' ? 'Switch failed' : 'Ошибка переключения'; + throw new Error(result?.detail?.message || result?.message || defaultErr); + } + + const successTitle = preferredLanguage === 'en' ? 'Success' : 'Успех'; + const successMsg = preferredLanguage === 'en' ? 'Tariff changed successfully!' : 'Тариф успешно изменён!'; + showPopup(result.message || successMsg, successTitle); + await refreshSubscriptionData(); + } catch (err) { + console.error('Switch failed:', err); + const errTitle = preferredLanguage === 'en' ? 'Error' : 'Ошибка'; + const errMsg = preferredLanguage === 'en' ? 'Failed to switch tariff' : 'Не удалось сменить тариф'; + showPopup(err.message || errMsg, errTitle); + } } function selectTariff(tariff) { @@ -19463,6 +21283,34 @@ return; } + // Для суточных тарифов показываем специальную информацию + const isDaily = selectedTariffData.is_daily ?? selectedTariffData.isDaily ?? false; + if (isDaily) { + const dailyPriceLabel = selectedTariffData.daily_price_label ?? selectedTariffData.dailyPriceLabel ?? ''; + section.classList.remove('hidden'); + list.innerHTML = ` +
+
+ 🔄 +
+
Суточная подписка
+
Ежедневное списание с баланса
+
+
+
+ Стоимость в день: + ${dailyPriceLabel} +
+
+ 💡 Списание происходит автоматически каждые 24 часа. Можно приостановить в любой момент. +
+
+ `; + summary?.classList.add('hidden'); + updateTariffButton(); + return; + } + const periods = selectedTariffData.periods || []; if (periods.length === 0) { section.classList.add('hidden'); @@ -19476,9 +21324,9 @@ periods.forEach((period, index) => { const days = period.days || period.period_days || period.periodDays; const priceKopeks = period.price_kopeks || period.priceKopeks || period.final_price || period.finalPrice || 0; - const originalKopeks = period.original_price || period.originalPrice || period.base_price || period.basePrice || priceKopeks; + const originalKopeks = period.original_price_kopeks || period.originalPriceKopeks || period.original_price || period.originalPrice || period.base_price || period.basePrice || priceKopeks; const hasDiscount = originalKopeks > priceKopeks; - const discountPercent = hasDiscount ? Math.round((1 - priceKopeks / originalKopeks) * 100) : 0; + const discountPercent = period.discount_percent || period.discountPercent || (hasDiscount ? Math.round((1 - priceKopeks / originalKopeks) * 100) : 0); const priceLabel = formatPriceFromKopeks(priceKopeks, tariffsData?.currency || 'RUB'); const originalLabel = hasDiscount ? formatPriceFromKopeks(originalKopeks, tariffsData?.currency || 'RUB') : null; @@ -19582,12 +21430,24 @@ const btn = document.getElementById('tariffsSelectBtn'); if (!btn) return; - if (selectedTariffData && selectedTariffPeriod) { + // Для суточных тарифов не требуем выбора периода + const isDaily = selectedTariffData?.is_daily ?? selectedTariffData?.isDaily ?? false; + + if (selectedTariffData && (selectedTariffPeriod || isDaily)) { btn.disabled = false; - const priceKopeks = selectedTariffPeriod.price_kopeks || selectedTariffPeriod.priceKopeks || - selectedTariffPeriod.final_price || selectedTariffPeriod.finalPrice || 0; - const priceLabel = formatPriceFromKopeks(priceKopeks, tariffsData?.currency || 'RUB'); - btn.textContent = `Купить за ${priceLabel}`; + let priceKopeks, priceLabel; + + if (isDaily) { + // Суточный тариф - используем daily_price + priceKopeks = selectedTariffData.daily_price_kopeks ?? selectedTariffData.dailyPriceKopeks ?? 0; + priceLabel = selectedTariffData.daily_price_label ?? selectedTariffData.dailyPriceLabel ?? formatPriceFromKopeks(priceKopeks, tariffsData?.currency || 'RUB'); + btn.textContent = `Активировать за ${priceLabel}`; + } else { + priceKopeks = selectedTariffPeriod.price_kopeks || selectedTariffPeriod.priceKopeks || + selectedTariffPeriod.final_price || selectedTariffPeriod.finalPrice || 0; + priceLabel = formatPriceFromKopeks(priceKopeks, tariffsData?.currency || 'RUB'); + btn.textContent = `Купить за ${priceLabel}`; + } } else { btn.disabled = true; btn.textContent = t('tariffs.select'); @@ -19595,7 +21455,10 @@ } async function purchaseTariff() { - if (!selectedTariffId || !selectedTariffPeriod) { + const isDaily = selectedTariffData?.is_daily ?? selectedTariffData?.isDaily ?? false; + + // Для суточных тарифов не требуем выбора периода + if (!selectedTariffId || (!selectedTariffPeriod && !isDaily)) { showPopup('Выберите тариф', 'Ошибка'); return; } @@ -19608,7 +21471,10 @@ try { const initData = tg.initData || ''; - const periodDays = selectedTariffPeriod.days || selectedTariffPeriod.period_days || selectedTariffPeriod.periodDays; + // Для суточных тарифов используем period_days=1 + const periodDays = isDaily + ? 1 + : (selectedTariffPeriod.days || selectedTariffPeriod.period_days || selectedTariffPeriod.periodDays); const response = await fetch('/miniapp/subscription/tariff/purchase', { method: 'POST', @@ -19626,7 +21492,10 @@ throw new Error(result?.detail?.message || result?.message || 'Ошибка покупки тарифа'); } - showPopup(result.message || 'Тариф успешно активирован!', 'Успех'); + const successMsg = isDaily + ? (result.message || 'Суточный тариф активирован!') + : (result.message || 'Тариф успешно активирован!'); + showPopup(successMsg, 'Успех'); await refreshSubscriptionData(); } catch (err) { console.error('Tariff purchase failed:', err); @@ -19639,6 +21508,10 @@ document.getElementById('tariffsRetry')?.addEventListener('click', loadTariffs); document.getElementById('tariffsSelectBtn')?.addEventListener('click', purchaseTariff); + // ============================================ + // Instant Tariff Switch + // ============================================ + // Загружаем тарифы после загрузки данных подписки const originalApplySubscriptionData = applySubscriptionData; applySubscriptionData = function(payload) {